@yeaft/webchat-agent 1.0.501 → 1.0.503

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.
@@ -57,14 +57,62 @@ function bufferMessage(msg, reason) {
57
57
  return 'buffered';
58
58
  }
59
59
 
60
- async function sendNow(msg) {
61
- if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) return bufferMessage(msg, 'Disconnected');
62
- if (ctx.serverEncryptionRequired && ctx.sessionKey) {
63
- const encrypted = await encrypt(msg, ctx.sessionKey);
64
- ctx.ws.send(JSON.stringify(encrypted));
65
- } else {
66
- ctx.ws.send(JSON.stringify(msg));
60
+ const FILE_CHUNK_SEND_TIMEOUT_MS = 10_000;
61
+
62
+ function sendFileChunk(socket, data) {
63
+ // Production uses Node ws; retain compatibility with synchronous, one-argument
64
+ // transport stubs without treating a real ws.send() return as a flushed write.
65
+ if (!(socket instanceof WebSocket) && socket.send.length < 2) {
66
+ socket.send(data);
67
+ return 'sent';
68
+ }
69
+ return new Promise((resolve, reject) => {
70
+ let settled = false;
71
+ const finish = (error) => {
72
+ if (settled) return;
73
+ settled = true;
74
+ clearTimeout(timeout);
75
+ clearInterval(connectionCheck);
76
+ socket.removeListener?.('close', onClose);
77
+ socket.removeListener?.('error', onError);
78
+ if (error) reject(error);
79
+ else resolve('sent');
80
+ };
81
+ const onClose = () => finish(new Error('WebSocket closed during file chunk send'));
82
+ const onError = error => finish(error);
83
+ const connectionChanged = () => socket !== ctx.ws || socket.readyState !== WebSocket.OPEN;
84
+ const timeout = setTimeout(() => finish(new Error('File chunk send timed out')), FILE_CHUNK_SEND_TIMEOUT_MS);
85
+ // Replacing ctx.ws need not emit close on the old socket.
86
+ const connectionCheck = setInterval(() => {
87
+ if (connectionChanged()) onClose();
88
+ }, 100);
89
+ socket.once?.('close', onClose);
90
+ socket.once?.('error', onError);
91
+ try {
92
+ socket.send(data, error => {
93
+ if (error) finish(error);
94
+ else if (connectionChanged()) onClose();
95
+ else finish();
96
+ });
97
+ } catch (error) {
98
+ finish(error);
99
+ }
100
+ });
101
+ }
102
+
103
+ async function sendNow(msg, queuedSocket) {
104
+ const socket = ctx.ws;
105
+ if (!socket || socket.readyState !== WebSocket.OPEN) return bufferMessage(msg, 'Disconnected');
106
+ // File transfers are request/connection scoped, unlike replayable chat output.
107
+ if (msg.type === 'file_content_chunk' && queuedSocket !== socket) return 'dropped';
108
+ const payload = ctx.serverEncryptionRequired && ctx.sessionKey
109
+ ? await encrypt(msg, ctx.sessionKey) : msg;
110
+ if (socket !== ctx.ws || socket.readyState !== WebSocket.OPEN) {
111
+ return bufferMessage(msg, 'Connection changed');
67
112
  }
113
+ const data = JSON.stringify(payload);
114
+ if (msg.type === 'file_content_chunk') return sendFileChunk(socket, data);
115
+ socket.send(data);
68
116
  return 'sent';
69
117
  }
70
118
 
@@ -78,7 +126,7 @@ function scheduleOutboundDrain() {
78
126
  ctx.outboundSendQueueBytes = Math.max(0, Number(ctx.outboundSendQueueBytes || 0) - Number(item?.bytes || 0));
79
127
  const msg = item?.msg ?? item;
80
128
  try {
81
- const outcome = await sendNow(msg);
129
+ const outcome = await sendNow(msg, item?.socket);
82
130
  item?.resolve?.(outcome);
83
131
  } catch (e) {
84
132
  console.error(`[WS] Error sending message ${msg?.type}:`, e.message);
@@ -113,7 +161,7 @@ export async function sendToServer(msg) {
113
161
  return 'dropped';
114
162
  }
115
163
  const promise = new Promise((resolve, reject) => {
116
- ctx.outboundSendQueue.push({ msg, bytes, resolve, reject });
164
+ ctx.outboundSendQueue.push({ msg, bytes, resolve, reject, socket: msg.type === 'file_content_chunk' ? ctx.ws : null });
117
165
  ctx.outboundSendQueueBytes = Number(ctx.outboundSendQueueBytes || 0) + bytes;
118
166
  });
119
167
  scheduleOutboundDrain();
@@ -8,6 +8,7 @@ import { cleanupTerminalsForDisconnect } from '../terminal.js';
8
8
  export function resetConnectionTransport() {
9
9
  ctx.sessionKey = null;
10
10
  ctx.serverEncryptionRequired = true;
11
+ ctx.serverCapabilities = new Set();
11
12
  ctx.pendingAuthTempId = null;
12
13
  }
13
14
 
@@ -82,6 +82,7 @@ export async function applyLlmConfigUpdate(msg, dependencies = {}) {
82
82
  }
83
83
 
84
84
  export function applyRegisteredTransport(msg) {
85
+ ctx.serverCapabilities = new Set(Array.isArray(msg.serverCapabilities) ? msg.serverCapabilities : []);
85
86
  if (msg.sessionKey) {
86
87
  ctx.sessionKey = decodeKey(msg.sessionKey);
87
88
  console.log('Encryption enabled');
package/context.js CHANGED
@@ -20,6 +20,7 @@ export default {
20
20
  CONFIG: null,
21
21
  managedCliReady: Promise.resolve([]),
22
22
  agentCapabilities: [],
23
+ serverCapabilities: new Set(),
23
24
  browserRuntime: null,
24
25
  // Agent 级别的 slash commands 缓存(所有 conversation 共用)
25
26
  slashCommands: [],
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', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', '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', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', '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');
@@ -0,0 +1,130 @@
1
+ import { MAX_PREVIEW_FILE_BYTES } from './preview-files.js';
2
+
3
+ const MAX_CHUNK_BYTES = 1024 * 1024;
4
+ const MAX_ASSEMBLIES = 64;
5
+ const MAX_ASSEMBLY_BYTES = 64 * 1024 * 1024;
6
+ const ASSEMBLY_TTL_MS = 2 * 60 * 1000;
7
+ const assemblies = new Map();
8
+ let assemblyBytes = 0;
9
+
10
+ function key(agentId, requestId) {
11
+ return `${String(agentId)}\u0000${String(requestId)}`;
12
+ }
13
+
14
+ function remove(keyValue) {
15
+ const existing = assemblies.get(keyValue);
16
+ if (!existing) return;
17
+ if (existing.timeout) clearTimeout(existing.timeout);
18
+ assemblyBytes = Math.max(0, assemblyBytes - existing.receivedBytes);
19
+ assemblies.delete(keyValue);
20
+ }
21
+
22
+ function invalid(keyValue, status = 'invalid') {
23
+ remove(keyValue);
24
+ return { status };
25
+ }
26
+
27
+ function prune(now = Date.now()) {
28
+ for (const [keyValue, assembly] of assemblies) {
29
+ if (assembly.expiresAt <= now) remove(keyValue);
30
+ }
31
+ }
32
+
33
+ function decodeChunk(content) {
34
+ if (typeof content !== 'string' || content.length === 0 || content.length > 1_398_104) return null;
35
+ if (content.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(content)) return null;
36
+ const buffer = Buffer.from(content, 'base64');
37
+ if (buffer.length === 0 || buffer.length > MAX_CHUNK_BYTES) return null;
38
+ if (buffer.toString('base64') !== content) return null;
39
+ return buffer;
40
+ }
41
+
42
+ export function appendFileContentChunk(agentId, msg) {
43
+ prune();
44
+ const requestId = msg?._workbenchRequestId;
45
+ const chunkIndex = Number(msg?.chunkIndex);
46
+ const chunkCount = Number(msg?.chunkCount);
47
+ const totalBytes = Number(msg?.totalBytes);
48
+ if (!agentId || !requestId
49
+ || !Number.isSafeInteger(chunkIndex) || chunkIndex < 0
50
+ || !Number.isSafeInteger(chunkCount) || chunkCount < 2 || chunkCount > 20
51
+ || !Number.isSafeInteger(totalBytes) || totalBytes <= MAX_CHUNK_BYTES
52
+ || totalBytes > MAX_PREVIEW_FILE_BYTES
53
+ || chunkIndex >= chunkCount) return { status: 'invalid' };
54
+ const buffer = decodeChunk(msg.content);
55
+ if (!buffer) return { status: 'invalid' };
56
+ const expectedCount = Math.ceil(totalBytes / MAX_CHUNK_BYTES);
57
+ const expectedBytes = chunkIndex === chunkCount - 1
58
+ ? totalBytes - (chunkCount - 1) * MAX_CHUNK_BYTES
59
+ : MAX_CHUNK_BYTES;
60
+ if (chunkCount !== expectedCount || buffer.length !== expectedBytes) return { status: 'invalid' };
61
+
62
+ const keyValue = key(agentId, requestId);
63
+ let assembly = assemblies.get(keyValue);
64
+ if (!assembly) {
65
+ if (chunkIndex !== 0) return { status: 'out_of_order' };
66
+ if (assemblies.size >= MAX_ASSEMBLIES || assemblyBytes + buffer.length > MAX_ASSEMBLY_BYTES) {
67
+ return { status: 'capacity' };
68
+ }
69
+ assembly = {
70
+ chunkCount,
71
+ totalBytes,
72
+ mimeType: msg.mimeType,
73
+ filePath: msg.filePath,
74
+ requestedFilePath: msg.requestedFilePath,
75
+ conversationId: msg.conversationId,
76
+ routeKey: msg.workbenchRouteKey,
77
+ workspaceGeneration: msg.workbenchWorkspaceGeneration,
78
+ nextIndex: 0,
79
+ receivedBytes: 0,
80
+ chunks: [],
81
+ expiresAt: Date.now() + ASSEMBLY_TTL_MS,
82
+ timeout: null,
83
+ };
84
+ assembly.timeout = setTimeout(() => remove(keyValue), ASSEMBLY_TTL_MS);
85
+ assembly.timeout.unref?.();
86
+ assemblies.set(keyValue, assembly);
87
+ }
88
+ if (assembly.nextIndex !== chunkIndex) return invalid(keyValue, 'out_of_order');
89
+ if (assembly.chunkCount !== chunkCount || assembly.totalBytes !== totalBytes
90
+ || assembly.mimeType !== msg.mimeType || assembly.filePath !== msg.filePath
91
+ || assembly.requestedFilePath !== msg.requestedFilePath
92
+ || assembly.conversationId !== msg.conversationId || assembly.routeKey !== msg.workbenchRouteKey
93
+ || assembly.workspaceGeneration !== msg.workbenchWorkspaceGeneration) {
94
+ return invalid(keyValue);
95
+ }
96
+ if (assemblyBytes + buffer.length > MAX_ASSEMBLY_BYTES) return invalid(keyValue, 'capacity');
97
+
98
+ assembly.chunks.push(buffer);
99
+ assembly.nextIndex += 1;
100
+ assembly.receivedBytes += buffer.length;
101
+ assemblyBytes += buffer.length;
102
+ assembly.expiresAt = Date.now() + ASSEMBLY_TTL_MS;
103
+ if (assembly.timeout) clearTimeout(assembly.timeout);
104
+ assembly.timeout = setTimeout(() => remove(keyValue), ASSEMBLY_TTL_MS);
105
+ assembly.timeout.unref?.();
106
+ if (assembly.nextIndex !== assembly.chunkCount) return { status: 'pending' };
107
+ if (assembly.receivedBytes !== assembly.totalBytes) {
108
+ remove(keyValue);
109
+ return { status: 'invalid' };
110
+ }
111
+ const completed = Buffer.concat(assembly.chunks, assembly.totalBytes);
112
+ remove(keyValue);
113
+ return { status: 'complete', buffer: completed };
114
+ }
115
+
116
+ export function discardFileContentAssembly(agentId, requestId) {
117
+ remove(key(agentId, requestId));
118
+ }
119
+
120
+ export function clearFileContentAssembliesForAgent(agentId) {
121
+ const prefix = `${String(agentId)}\u0000`;
122
+ for (const keyValue of assemblies.keys()) {
123
+ if (keyValue.startsWith(prefix)) remove(keyValue);
124
+ }
125
+ }
126
+
127
+ export function __testResetFileContentAssemblies() {
128
+ assemblies.clear();
129
+ assemblyBytes = 0;
130
+ }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { CONFIG } from '../config.js';
3
- import { agents, previewFiles, webClients } from '../context.js';
3
+ import { agents, webClients } from '../context.js';
4
4
  import {
5
5
  sendToAgent,
6
6
  sendToWebClient,
@@ -17,12 +17,16 @@ import {
17
17
  import {
18
18
  consumeLegacyWorkbenchRequest,
19
19
  consumeWorkbenchRequest,
20
+ peekWorkbenchRequest,
20
21
  deleteWorkbenchTerminalOwner,
21
22
  getWorkbenchTerminalOwner,
22
23
  isLegacyWorkbenchRequestQuarantined,
23
24
  registerWorkbenchTerminalOwner,
24
25
  workbenchTerminalCleanupMessage,
25
26
  } from '../workbench-correlation.js';
27
+ import { appendFileContentChunk, discardFileContentAssembly } from '../file-content-assembly.js';
28
+ import { cachePreviewFile, MAX_PREVIEW_FILE_BYTES } from '../preview-files.js';
29
+ import { createWorkbenchPreview } from '../workbench-preview.js';
26
30
 
27
31
  function stripAgentRouting(msg) {
28
32
  const {
@@ -49,6 +53,10 @@ function pendingResponse(agentId, msg, pending) {
49
53
  }
50
54
 
51
55
  async function sendToPendingClient(agentId, msg, pending) {
56
+ if (pending?.onResponse) {
57
+ await pending.onResponse(msg);
58
+ return true;
59
+ }
52
60
  const client = webClients.get(pending?.clientId);
53
61
  if (!client?.authenticated || client.userId !== pending?.userId) return false;
54
62
  await sendToWebClient(client, pendingResponse(agentId, msg, pending));
@@ -90,23 +98,40 @@ async function forwardLegacyResponse(agentId, msg) {
90
98
  }
91
99
  }
92
100
 
93
- function cacheBinaryPreview(msg) {
101
+ function cacheBinaryPreview(msg, suppliedBuffer = null, pending = null) {
102
+ const buffer = suppliedBuffer || Buffer.from(msg.content || '', 'base64');
103
+ if (buffer.length > MAX_PREVIEW_FILE_BYTES) return {
104
+ ...msg,
105
+ binary: false,
106
+ content: '',
107
+ error: 'File exceeds the 20 MB transfer limit.',
108
+ errorCode: 'FILE_PREVIEW_TOO_LARGE',
109
+ errorDetails: { sizeBytes: buffer.length, limitBytes: MAX_PREVIEW_FILE_BYTES },
110
+ };
94
111
  const fileId = randomUUID();
95
- const token = randomUUID();
112
+ const stableToken = createWorkbenchPreview(fileId, pending, msg.filePath);
113
+ const token = stableToken || randomUUID();
96
114
  const filename = msg.filePath.split('/').pop() || 'file';
97
- previewFiles.set(fileId, {
98
- buffer: Buffer.from(msg.content, 'base64'),
115
+ if (!cachePreviewFile(fileId, {
116
+ buffer,
99
117
  mimeType: msg.mimeType,
100
118
  filename,
101
119
  createdAt: Date.now(),
102
120
  token,
103
- });
121
+ }) && !stableToken) return {
122
+ ...msg,
123
+ binary: false,
124
+ content: '',
125
+ error: 'Preview cache is at capacity.',
126
+ errorCode: 'FILE_PREVIEW_CACHE_FULL',
127
+ };
104
128
  const { content: _binaryContent, ...projected } = msg;
105
129
  return {
106
130
  ...projected,
107
131
  binary: true,
108
132
  fileId,
109
133
  previewToken: token,
134
+ previewUrl: `/api/preview/${fileId}?token=${encodeURIComponent(token)}`,
110
135
  };
111
136
  }
112
137
 
@@ -177,7 +202,79 @@ async function handleAgentDirectoryPickerResponse(agentId, msg) {
177
202
  await sendToPendingClient(agentId, msg, pending);
178
203
  }
179
204
 
205
+ function hasCurrentWorkspaceGeneration(pending) {
206
+ const currentGeneration = currentWorkbenchWorkspaceGeneration({
207
+ route: pending.route,
208
+ userId: pending.userId,
209
+ role: pending.role,
210
+ });
211
+ return currentGeneration !== null
212
+ && (!currentGeneration || currentGeneration === pending.workspaceGeneration);
213
+ }
214
+
215
+ async function handleFileContentChunk(agentId, agent, msg, routeKey) {
216
+ if (!msg._workbenchRequestId) return;
217
+ const pending = peekWorkbenchRequest({
218
+ agentId,
219
+ requestId: msg._workbenchRequestId,
220
+ responseType: 'file_content',
221
+ routeKey,
222
+ });
223
+ if (!pending || pending.requestType !== 'read_file') return;
224
+ if (!hasCurrentWorkspaceGeneration(pending)) {
225
+ await pending.onResponse?.({ error: 'Preview workspace is no longer available' });
226
+ discardFileContentAssembly(agentId, msg._workbenchRequestId);
227
+ consumeWorkbenchRequest({
228
+ agentId,
229
+ requestId: msg._workbenchRequestId,
230
+ responseType: 'file_content',
231
+ routeKey,
232
+ });
233
+ return;
234
+ }
235
+ const result = appendFileContentChunk(agentId, msg);
236
+ if (result.status === 'pending') return;
237
+ const consumed = consumeWorkbenchRequest({
238
+ agentId,
239
+ requestId: msg._workbenchRequestId,
240
+ responseType: 'file_content',
241
+ routeKey,
242
+ });
243
+ if (!consumed) return;
244
+ if (result.status !== 'complete') {
245
+ discardFileContentAssembly(agentId, msg._workbenchRequestId);
246
+ const errorCode = result.status === 'capacity'
247
+ ? 'FILE_PREVIEW_CACHE_FULL'
248
+ : 'FILE_TRANSFER_INVALID';
249
+ await sendToPendingClient(agentId, {
250
+ ...msg,
251
+ type: 'file_content',
252
+ binary: false,
253
+ content: '',
254
+ error: result.status === 'capacity'
255
+ ? 'Preview transfer capacity was exceeded.'
256
+ : 'Preview transfer was incomplete or invalid.',
257
+ errorCode,
258
+ }, consumed);
259
+ return;
260
+ }
261
+ const completed = {
262
+ ...msg,
263
+ type: 'file_content',
264
+ content: '',
265
+ };
266
+ delete completed.chunkIndex;
267
+ delete completed.chunkCount;
268
+ delete completed.totalBytes;
269
+ await sendToPendingClient(agentId, consumed.onResponse
270
+ ? { ...completed, buffer: result.buffer }
271
+ : cacheBinaryPreview(completed, result.buffer, consumed), consumed);
272
+ }
273
+
180
274
  async function handleOneShotResponse(agentId, agent, msg, routeKey) {
275
+ if (msg.type === 'file_content' && msg._workbenchRequestId) {
276
+ discardFileContentAssembly(agentId, msg._workbenchRequestId);
277
+ }
181
278
  const pending = consumeRouteResponse(agentId, agent, msg, routeKey);
182
279
  if (!pending) return;
183
280
  const currentGeneration = currentWorkbenchWorkspaceGeneration({
@@ -185,10 +282,12 @@ async function handleOneShotResponse(agentId, agent, msg, routeKey) {
185
282
  userId: pending.userId,
186
283
  role: pending.role,
187
284
  });
188
- if (currentGeneration === null) return;
189
- if (currentGeneration && currentGeneration !== pending.workspaceGeneration) return;
190
- const projected = msg.type === 'file_content' && msg.binary
191
- ? cacheBinaryPreview(msg)
285
+ if (currentGeneration === null || (currentGeneration && currentGeneration !== pending.workspaceGeneration)) {
286
+ await pending.onResponse?.({ error: 'Preview workspace is no longer available' });
287
+ return;
288
+ }
289
+ const projected = msg.type === 'file_content' && msg.binary && !pending.onResponse
290
+ ? cacheBinaryPreview(msg, null, pending)
192
291
  : msg;
193
292
  await sendToPendingClient(agentId, projected, pending);
194
293
  }
@@ -205,7 +304,7 @@ export async function handleAgentFileTerminal(agentId, agent, rawMsg) {
205
304
  'terminal_created', 'terminal_output', 'terminal_closed', 'terminal_error',
206
305
  ]);
207
306
  const oneShotTypes = new Set([
208
- 'file_content', 'file_references_resolved', 'file_saved', 'directory_listing', 'file_op_result',
307
+ 'file_content', 'file_content_chunk', 'file_references_resolved', 'file_saved', 'directory_listing', 'file_op_result',
209
308
  'git_status_result', 'git_diff_result', 'git_op_result', 'file_search_result',
210
309
  ]);
211
310
  if (!terminalTypes.has(msg.type) && !oneShotTypes.has(msg.type)) return false;
@@ -218,6 +317,7 @@ export async function handleAgentFileTerminal(agentId, agent, rawMsg) {
218
317
 
219
318
  if (routeKey) {
220
319
  if (terminalTypes.has(msg.type)) await handleTerminalResponse(agentId, agent, msg, routeKey);
320
+ else if (msg.type === 'file_content_chunk') await handleFileContentChunk(agentId, agent, msg, routeKey);
221
321
  else await handleOneShotResponse(agentId, agent, msg, routeKey);
222
322
  return true;
223
323
  }
@@ -0,0 +1,33 @@
1
+ import { previewFiles } from './context.js';
2
+
3
+ export const MAX_PREVIEW_FILE_BYTES = 20 * 1024 * 1024;
4
+ export const PREVIEW_FILE_TTL_MS = 10 * 60 * 1000;
5
+ export const MAX_PREVIEW_CACHE_BYTES = 128 * 1024 * 1024;
6
+ export const MAX_PREVIEW_CACHE_FILES = 256;
7
+
8
+ function entryBytes(entry) {
9
+ return Buffer.isBuffer(entry?.buffer) ? entry.buffer.length : 0;
10
+ }
11
+
12
+ export function prunePreviewFiles(now = Date.now()) {
13
+ const cutoff = now - PREVIEW_FILE_TTL_MS;
14
+ for (const [id, file] of previewFiles) {
15
+ if (!file || file.createdAt < cutoff) previewFiles.delete(id);
16
+ }
17
+ }
18
+
19
+ export function cachePreviewFile(fileId, file) {
20
+ if (!fileId || !Buffer.isBuffer(file?.buffer)) return false;
21
+ if (file.buffer.length > MAX_PREVIEW_FILE_BYTES) return false;
22
+ prunePreviewFiles();
23
+ let totalBytes = 0;
24
+ for (const value of previewFiles.values()) totalBytes += entryBytes(value);
25
+ const existingBytes = entryBytes(previewFiles.get(fileId));
26
+ const isNewEntry = !previewFiles.has(fileId);
27
+ if ((isNewEntry && previewFiles.size >= MAX_PREVIEW_CACHE_FILES)
28
+ || totalBytes - existingBytes + file.buffer.length > MAX_PREVIEW_CACHE_BYTES) {
29
+ return false;
30
+ }
31
+ previewFiles.set(fileId, { ...file, createdAt: file.createdAt || Date.now() });
32
+ return true;
33
+ }
@@ -3,7 +3,9 @@ import multer from 'multer';
3
3
  import { CONFIG } from '../config.js';
4
4
  import { userDb } from '../database.js';
5
5
  import { pendingFiles, previewFiles } from '../context.js';
6
+ import { PREVIEW_FILE_TTL_MS, prunePreviewFiles } from '../preview-files.js';
6
7
  import { yeaftAssetStore } from '../yeaft-asset-store.js';
8
+ import { readWorkbenchPreview } from '../workbench-preview.js';
7
9
 
8
10
  // 文件上传配置 (存储在内存中)
9
11
  const upload = multer({
@@ -41,13 +43,8 @@ setInterval(() => {
41
43
  }
42
44
  }, 60 * 1000);
43
45
 
44
- // Cleanup expired preview files every 60s (10 min TTL)
45
- setInterval(() => {
46
- const cutoff = Date.now() - 10 * 60 * 1000;
47
- for (const [id, f] of previewFiles) {
48
- if (f.createdAt < cutoff) previewFiles.delete(id);
49
- }
50
- }, 60 * 1000);
46
+ // Cleanup expired preview files every 60s.
47
+ setInterval(() => prunePreviewFiles(), Math.min(60 * 1000, PREVIEW_FILE_TTL_MS));
51
48
 
52
49
  /**
53
50
  * Register file upload and preview routes.
@@ -82,8 +79,16 @@ export function registerUploadRoutes(app, { requireAuth }) {
82
79
  res.json({ files: uploaded });
83
80
  });
84
81
 
85
- app.get('/api/preview/:fileId', (req, res) => {
86
- const file = previewFiles.get(req.params.fileId);
82
+ app.get('/api/preview/:fileId', async (req, res) => {
83
+ let file;
84
+ try {
85
+ file = typeof req.query.token === 'string' && req.query.token.startsWith('wb1.')
86
+ ? await readWorkbenchPreview(req.params.fileId, req.query.token)
87
+ : previewFiles.get(req.params.fileId);
88
+ } catch (error) {
89
+ res.setHeader('Cache-Control', 'no-store');
90
+ return res.status(error.status || 502).send(error.status ? error.message : 'Preview read failed');
91
+ }
87
92
  if (!file) return res.status(404).send('File not found or expired');
88
93
  if (file.token && req.query.token !== file.token) {
89
94
  return res.status(403).send('Forbidden');
@@ -113,6 +113,7 @@ export function registerWorkbenchRequest({
113
113
  terminalId = null,
114
114
  allowLegacyCorrelation = false,
115
115
  onTimeout = null,
116
+ onResponse = null,
116
117
  }) {
117
118
  if (!agentId || !clientId || !userId || !routeKey || !conversationId
118
119
  || !workspaceGeneration || !requestType) return null;
@@ -144,6 +145,7 @@ export function registerWorkbenchRequest({
144
145
  terminalId,
145
146
  allowLegacyCorrelation: allowLegacyCorrelation === true,
146
147
  onTimeout,
148
+ onResponse,
147
149
  expiresAt: Date.now() + REQUEST_TTL_MS,
148
150
  timeout: null,
149
151
  };
@@ -162,6 +164,15 @@ function consumePending(key, pending) {
162
164
  return pending;
163
165
  }
164
166
 
167
+ export function peekWorkbenchRequest({ agentId, requestId, responseType, routeKey = null }) {
168
+ if (!agentId || !requestId || !responseType) return null;
169
+ prune();
170
+ const pending = pendingRequests.get(requestKey(agentId, requestId));
171
+ if (!pending || !pending.expectedResponseTypes.has(responseType)) return null;
172
+ if (routeKey && pending.routeKey !== routeKey) return null;
173
+ return pending;
174
+ }
175
+
165
176
  export function consumeWorkbenchRequest({ agentId, requestId, responseType, routeKey = null }) {
166
177
  if (!agentId || !requestId || !responseType) return null;
167
178
  prune();
@@ -317,6 +328,7 @@ export function clearWorkbenchCorrelationsForAgent(agentId) {
317
328
  if (!agentId) return;
318
329
  for (const [key, pending] of pendingRequests) {
319
330
  if (pending?.agentId !== agentId) continue;
331
+ if (pending.onResponse) pending.onTimeout?.(pending, 'disconnect');
320
332
  pendingRequests.delete(key);
321
333
  if (pending.timeout) clearTimeout(pending.timeout);
322
334
  pending.timeout = null;