@yeaft/webchat-agent 1.0.500 → 1.0.502
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 +57 -9
- package/connection/index.js +1 -0
- package/connection/message-router.js +1 -0
- package/context.js +1 -0
- package/index.js +1 -1
- package/local-runtime/server/file-content-assembly.js +130 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +111 -11
- package/local-runtime/server/preview-files.js +33 -0
- package/local-runtime/server/routes/upload-routes.js +14 -9
- package/local-runtime/server/workbench-correlation.js +12 -0
- package/local-runtime/server/workbench-preview.js +117 -0
- package/local-runtime/server/ws-agent.js +4 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +118 -90
- 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 +53 -10
- package/workbench/request-routing.js +1 -1
package/connection/buffer.js
CHANGED
|
@@ -57,14 +57,62 @@ function bufferMessage(msg, reason) {
|
|
|
57
57
|
return 'buffered';
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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();
|
package/connection/index.js
CHANGED
|
@@ -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
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,
|
|
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
|
|
112
|
+
const stableToken = createWorkbenchPreview(fileId, pending, msg.filePath);
|
|
113
|
+
const token = stableToken || randomUUID();
|
|
96
114
|
const filename = msg.filePath.split('/').pop() || 'file';
|
|
97
|
-
|
|
98
|
-
buffer
|
|
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)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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;
|