@walkhi/code-relax 0.1.0-beta.71 → 0.1.0-beta.78
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/dist/bin/codex-remote.mjs +23 -5
- package/dist/build-platform.json +1 -1
- package/dist/src/code-browser.mjs +0 -1
- package/dist/src/desktop-launcher.mjs +15 -6
- package/dist/src/dots-chat-server.mjs +531 -498
- package/dist/src/dots-message-images.mjs +0 -1
- package/dist/src/dots-room-activity.mjs +25 -4
- package/dist/src/dots-room-client.mjs +163 -137
- package/dist/src/dots-task-catalog.mjs +4 -1
- package/dist/src/dots-task-files.mjs +2 -2
- package/dist/src/file-upload-session.mjs +13 -6
- package/dist/src/lan-socket-server.mjs +8 -5
- package/dist/src/managed-app-server.mjs +26 -1
- package/dist/src/message-upload-files.mjs +115 -109
- package/dist/src/runtime-discovery.mjs +25 -5
- package/dist/src/self-relay/connector.mjs +9 -6
- package/dist/src/self-relay/file-wire.mjs +63 -0
- package/dist/src/self-relay/server.mjs +47 -20
- package/dist/src/server.mjs +1981 -1969
- package/dist/src/shared-app-server.mjs +4 -3
- package/dist/src/workstation-downloads.mjs +120 -120
- package/package.json +4 -4
|
@@ -254,7 +254,6 @@ export class DotsMessageImages {
|
|
|
254
254
|
}
|
|
255
255
|
const headers = await this.authorization(image.identity);
|
|
256
256
|
await this.metadata(image, headers);
|
|
257
|
-
if (image.size > this.maxBytes) throw new Error('点点附件超过 20 MiB 下载上限。');
|
|
258
257
|
const revision = image.id;
|
|
259
258
|
const { offset, length } = fileRange(query, image.size, revision);
|
|
260
259
|
if (!offset || !image.downloadUrl) image.downloadUrl = await this.contentUrl(image, headers, true);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
|
|
3
|
-
// Desktop 26.
|
|
3
|
+
// Desktop 26.1016.6573.0: room-live activity and responding heartbeats are
|
|
4
4
|
// independent. Heartbeats expire after 10s; Dot reply handoff lasts at most 5s.
|
|
5
5
|
const typingTimeout = 10000, handoffTimeout = 5000;
|
|
6
6
|
const statusLabels = {
|
|
@@ -8,6 +8,7 @@ const statusLabels = {
|
|
|
8
8
|
payment: '等待支付', 'needs input': '等待你的输入', paused: '已暂停',
|
|
9
9
|
};
|
|
10
10
|
const waitingStates = new Set(['payment', 'needs input', 'paused']);
|
|
11
|
+
const retryableStatus = status => status == null || status === 408 || status === 429 || status >= 500;
|
|
11
12
|
|
|
12
13
|
export class DotsRoomActivity extends EventEmitter {
|
|
13
14
|
constructor(server) {
|
|
@@ -41,6 +42,13 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
41
42
|
if (!watch.listeners.size) { this.watches.delete(threadId); this.stop(watch); }
|
|
42
43
|
};
|
|
43
44
|
}
|
|
45
|
+
retryUnavailable(threadId) {
|
|
46
|
+
const watch = this.watches.get(threadId);
|
|
47
|
+
if (!watch?.unavailable || !watch.listeners.size || watch.controller || watch.timer) return false;
|
|
48
|
+
watch.attempts = 0;
|
|
49
|
+
void this.start(watch);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
44
52
|
publish(watch, activity) {
|
|
45
53
|
if (JSON.stringify(watch.activity) === JSON.stringify(activity)) return;
|
|
46
54
|
watch.activity = activity;
|
|
@@ -100,14 +108,17 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
100
108
|
}
|
|
101
109
|
apply(watch, value) {
|
|
102
110
|
if (value.subscription_id !== watch.subscriptionId || !Number.isSafeInteger(value.revision) || value.revision <= watch.revision) return;
|
|
103
|
-
watch.revision = value.revision;
|
|
104
111
|
if (value.type === 'calpico-room-live-error') {
|
|
112
|
+
watch.revision = value.revision;
|
|
105
113
|
this.fail(watch, new Error('点点活动暂不可用。'), value.code === 'temporarily_unavailable'); return;
|
|
106
114
|
}
|
|
107
115
|
if (value.type !== 'calpico-room-live-state' || !Array.isArray(value.threads)) return;
|
|
116
|
+
watch.revision = value.revision;
|
|
117
|
+
clearTimeout(watch.initialTimer); watch.initialTimer = null;
|
|
108
118
|
const current = value.threads[0];
|
|
109
119
|
watch.idle = value.threads.length === 0;
|
|
110
|
-
|
|
120
|
+
if (value.threads.some(thread => thread.stream_state === 'attached')) watch.attempts = 0;
|
|
121
|
+
watch.unavailable = false; watch.liveActivity = null;
|
|
111
122
|
if (current?.stream_state !== 'attached') { this.refresh(watch); return; }
|
|
112
123
|
if (current.turn_status === 'failed') {
|
|
113
124
|
this.clearReply(watch); watch.liveActivity = { kind: 'error', label: '回复出错', spinning: false };
|
|
@@ -133,6 +144,11 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
133
144
|
const generation = ++watch.generation, controller = new AbortController();
|
|
134
145
|
watch.controller = controller; watch.pending = new Map(); watch.subscriptionId = null; watch.revision = -1;
|
|
135
146
|
const current = () => this.watches.get(watch.threadId) === watch && generation === watch.generation && !controller.signal.aborted;
|
|
147
|
+
// Ready/keepalive on SSE does not establish delivery on the Celsius topic.
|
|
148
|
+
watch.initialTimer = setTimeout(() => {
|
|
149
|
+
if (current()) this.fail(watch, new Error('点点活动首次状态等待超时。'), true);
|
|
150
|
+
}, 30000);
|
|
151
|
+
watch.initialTimer.unref();
|
|
136
152
|
try {
|
|
137
153
|
await this.server.connect();
|
|
138
154
|
const room = this.server.bindings.get(watch.threadId) || await this.server.room(watch.threadId);
|
|
@@ -155,9 +171,13 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
155
171
|
},
|
|
156
172
|
});
|
|
157
173
|
if (current()) this.fail(watch, new Error('点点活动连接已结束。'), true);
|
|
158
|
-
} catch (error) { if (current()) this.fail(watch, error,
|
|
174
|
+
} catch (error) { if (current()) this.fail(watch, error, retryableStatus(error.status)); }
|
|
159
175
|
}
|
|
160
176
|
fail(watch, error, retry) {
|
|
177
|
+
console.error(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-activity',
|
|
178
|
+
threadId: watch.threadId, roomId: watch.roomId, status: error.status || 0,
|
|
179
|
+
stage: watch.subscriptionId ? 'state' : 'subscribe', retry, attempts: watch.attempts,
|
|
180
|
+
responseKind: error.details?.responseKind || 'none' }));
|
|
161
181
|
this.stop(watch, true); watch.unavailable = true; this.refresh(watch); this.emit('unavailable', error);
|
|
162
182
|
if (retry && watch.listeners.size && this.server.ready && watch.attempts < 5) {
|
|
163
183
|
watch.timer = setTimeout(() => { watch.timer = null; void this.start(watch); }, Math.min(30000, 1000 * 2 ** watch.attempts++));
|
|
@@ -166,6 +186,7 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
166
186
|
}
|
|
167
187
|
stop(watch, keepReply = false) {
|
|
168
188
|
watch.generation++; clearTimeout(watch.timer); watch.timer = null;
|
|
189
|
+
clearTimeout(watch.initialTimer); watch.initialTimer = null;
|
|
169
190
|
watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); watch.liveActivity = null;
|
|
170
191
|
watch.subscriptionId = null; watch.revision = -1; watch.idle = false;
|
|
171
192
|
if (!keepReply) { this.clearReply(watch); watch.unavailable = false; watch.roomId = null; }
|
|
@@ -1,137 +1,163 @@
|
|
|
1
|
-
import http2 from 'node:http2';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
||
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
1
|
+
import http2 from 'node:http2';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { CelsiusClient } from './celsius-client.mjs';
|
|
4
|
+
|
|
5
|
+
const topic = 'calpico-chatgpt-messaging';
|
|
6
|
+
const identityKey = identity => JSON.stringify([identity?.accountId, identity?.userId]);
|
|
7
|
+
|
|
8
|
+
// Desktop 26.1016.6573.0: the DM API and Celsius share the kernel account,
|
|
9
|
+
// but are independent of the hosted execution socket. Never replay a POST.
|
|
10
|
+
export class DotsRoomClient extends CelsiusClient {
|
|
11
|
+
constructor(transport) {
|
|
12
|
+
super({ topics: [topic] }); this.transport = transport;
|
|
13
|
+
this.getConnection = () => this.request('/celsius/ws/user');
|
|
14
|
+
this.controllers = new Set(); this.routes = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async authorization() {
|
|
18
|
+
const identity = this.transport.profileIdentity, generation = this.generation;
|
|
19
|
+
const auth = await this.transport.localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
|
|
20
|
+
const principal = auth.authToken && JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url'))['https://api.openai.com/auth'];
|
|
21
|
+
if (!identity?.accountId || principal?.chatgpt_account_id !== identity.accountId
|
|
22
|
+
|| principal.chatgpt_user_id !== identity.userId || generation !== this.generation
|
|
23
|
+
|| identityKey(identity) !== identityKey(this.transport.profileIdentity)) throw new Error('点点账号已变化,请重新连接。');
|
|
24
|
+
return { identity, generation, headers: { authorization: `Bearer ${auth.authToken}`,
|
|
25
|
+
'chatgpt-account-id': identity.accountId, 'user-agent': 'Code-Relax/0.1.0' } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async request(route, { method = 'GET', body, contentType, signal, onEvent, rawResponse = false } = {}) {
|
|
29
|
+
const auth = await this.authorization();
|
|
30
|
+
const controller = new AbortController(); this.controllers.add(controller);
|
|
31
|
+
const cancelled = () => controller.abort(signal.reason);
|
|
32
|
+
signal?.addEventListener('abort', cancelled, { once: true });
|
|
33
|
+
if (signal?.aborted) cancelled();
|
|
34
|
+
let sent = false, status, timer, responseKind = 'none';
|
|
35
|
+
const started = Date.now();
|
|
36
|
+
const clientId = method === 'POST' && /^[a-f0-9-]{36}$/i.test(body?.request_id || '') ? body.request_id : undefined;
|
|
37
|
+
try {
|
|
38
|
+
return await new Promise((resolve, reject) => {
|
|
39
|
+
const session = http2.connect('https://chatgpt.com');
|
|
40
|
+
let request, settled = false, bytes = 0, text = '';
|
|
41
|
+
const current = () => auth.generation === this.generation
|
|
42
|
+
&& identityKey(auth.identity) === identityKey(this.transport.profileIdentity);
|
|
43
|
+
const finish = (error, value) => {
|
|
44
|
+
if (settled) return; settled = true;
|
|
45
|
+
clearTimeout(timer); controller.signal.removeEventListener('abort', abort);
|
|
46
|
+
request?.destroy(); session.destroy();
|
|
47
|
+
if (error) {
|
|
48
|
+
error.details = { ...error.details, ...(status ? { upstreamStatus: status } : {}), responseKind };
|
|
49
|
+
if (!controller.signal.aborted) console.error(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
|
|
50
|
+
route: route.split('?')[0], clientId, status: status || 0, responseKind,
|
|
51
|
+
ms: Date.now() - started, outcome: 'failed' }));
|
|
52
|
+
if (method !== 'GET' && !onEvent) error.deliveryUnknown = sent && !(status >= 400 && status < 500 && status !== 408);
|
|
53
|
+
reject(error);
|
|
54
|
+
} else resolve(value);
|
|
55
|
+
};
|
|
56
|
+
const abort = () => finish(controller.signal.reason || new Error('点点请求已取消。'));
|
|
57
|
+
controller.signal.addEventListener('abort', abort, { once: true });
|
|
58
|
+
session.on('error', error => finish(error));
|
|
59
|
+
if (controller.signal.aborted) { abort(); return; }
|
|
60
|
+
const roomRoute = method === 'POST' ? /^\/messaging\/rooms\/([^/]+)\/messages$/.exec(route)?.[1] : null;
|
|
61
|
+
request = session.request({ ':method': method, ':path': `/backend-api${route}`, ...auth.headers,
|
|
62
|
+
accept: onEvent ? 'text/event-stream' : 'application/json',
|
|
63
|
+
...(body != null ? { 'content-type': contentType || 'application/json' } : {}),
|
|
64
|
+
...(roomRoute && this.routes.has(roomRoute) ? { 'x-openai-thread-route': this.routes.get(roomRoute) } : {}) });
|
|
65
|
+
timer = setTimeout(() => finish(new Error('点点请求超时,请重新核对状态。')), 30000);
|
|
66
|
+
request.setEncoding('utf8');
|
|
67
|
+
request.on('response', headers => {
|
|
68
|
+
status = headers[':status'];
|
|
69
|
+
const type = String(headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
70
|
+
responseKind = headers['cf-mitigated'] === 'challenge' ? 'challenge'
|
|
71
|
+
: type === 'application/json' ? 'json' : type === 'text/html' ? 'html' : type === 'text/event-stream' ? 'sse' : 'other';
|
|
72
|
+
if (roomRoute && current()) {
|
|
73
|
+
// Desktop replaces the route on every send response, including removal.
|
|
74
|
+
this.routes.delete(roomRoute);
|
|
75
|
+
const routeHint = headers['x-openai-thread-route'];
|
|
76
|
+
if (typeof routeHint === 'string' && routeHint && routeHint.length <= 1024) {
|
|
77
|
+
if (this.routes.size >= 128) this.routes.clear();
|
|
78
|
+
this.routes.set(roomRoute, routeHint);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (status < 200 || status >= 300) {
|
|
82
|
+
const message = status === 401 ? '点点登录状态需要恢复,请重新连接。'
|
|
83
|
+
: status === 403 ? '点点服务拒绝访问(HTTP 403),请稍后重试。'
|
|
84
|
+
: status === 404 ? '点点消息暂不可用,请稍后重新同步。'
|
|
85
|
+
: status === 429 ? '点点暂时繁忙,请稍后再试。' : '点点请求尚未完成,请稍后核对。';
|
|
86
|
+
finish(Object.assign(new Error(message), { status }));
|
|
87
|
+
} else if (roomRoute || onEvent) {
|
|
88
|
+
console.log(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
|
|
89
|
+
route: route.split('?')[0], clientId, status, responseKind, ms: Date.now() - started, outcome: 'response' }));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
request.on('data', chunk => {
|
|
93
|
+
if (!current()) { finish(new Error('点点账号已变化,请重新连接。')); return; }
|
|
94
|
+
bytes += Buffer.byteLength(chunk); text += chunk;
|
|
95
|
+
if (onEvent) {
|
|
96
|
+
text = text.replace(/\r\n/g, '\n');
|
|
97
|
+
let end;
|
|
98
|
+
while ((end = text.indexOf('\n\n')) >= 0) {
|
|
99
|
+
const frame = text.slice(0, end); text = text.slice(end + 2);
|
|
100
|
+
const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
|
101
|
+
// Desktop's ChatGPT SSE treats [DONE] as completion, not JSON.
|
|
102
|
+
if (data.trim() === '[DONE]') { finish(null, null); return; }
|
|
103
|
+
if (data) try { onEvent(JSON.parse(data)); clearTimeout(timer); } catch (error) { finish(error); return; }
|
|
104
|
+
}
|
|
105
|
+
bytes = Buffer.byteLength(text);
|
|
106
|
+
}
|
|
107
|
+
if (bytes > 8 * 1024 * 1024) finish(new Error('点点响应超出单次读取上限。'));
|
|
108
|
+
});
|
|
109
|
+
request.on('error', error => finish(error));
|
|
110
|
+
request.on('end', () => {
|
|
111
|
+
if (!current()) { finish(new Error('点点账号已变化,请重新连接。')); return; }
|
|
112
|
+
try { finish(null, onEvent ? null : rawResponse ? text : text ? JSON.parse(text) : null); } catch (error) { finish(error); }
|
|
113
|
+
});
|
|
114
|
+
request.on('close', () => { if (!settled) finish(new Error('点点请求连接已断开。')); });
|
|
115
|
+
// end() can queue data before TLS connects. Only a ready HTTP/2 stream
|
|
116
|
+
// has been assigned an ID and may have submitted the request.
|
|
117
|
+
sent = !request.pending;
|
|
118
|
+
request.once('ready', () => { sent = true; });
|
|
119
|
+
if (body instanceof Blob) {
|
|
120
|
+
const source = Readable.fromWeb(body.stream());
|
|
121
|
+
source.on('error', error => finish(error));
|
|
122
|
+
request.on('close', () => source.destroy());
|
|
123
|
+
source.pipe(request);
|
|
124
|
+
} else request.end(body == null ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body));
|
|
125
|
+
});
|
|
126
|
+
} finally { this.controllers.delete(controller); signal?.removeEventListener('abort', cancelled); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async roomId(threadId) {
|
|
130
|
+
let profile = this.transport.liveProfiles.get(threadId);
|
|
131
|
+
if (!profile?.messagingRoomId) {
|
|
132
|
+
const value = await this.request(`/tbo/by-thread/${encodeURIComponent(threadId)}`);
|
|
133
|
+
profile = { messagingRoomId: value?.messaging_room_id };
|
|
134
|
+
}
|
|
135
|
+
if (typeof profile.messagingRoomId !== 'string' || !profile.messagingRoomId) throw new Error('点点消息房间暂不可用。');
|
|
136
|
+
return profile.messagingRoomId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async room(id) {
|
|
140
|
+
const room = await this.request(`/messaging/rooms/${encodeURIComponent(id)}`);
|
|
141
|
+
if (room?.id !== id || room.type !== 'DM' || room.app_source !== 'chatgpt:messaging' || !Array.isArray(room.members)) {
|
|
142
|
+
throw new Error('点点消息房间身份不匹配。');
|
|
143
|
+
}
|
|
144
|
+
return room;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async messages(roomId, { before, after, around, limit = 20 } = {}) {
|
|
148
|
+
const query = new URLSearchParams({ limit: String(limit), ...(before ? { before } : {}),
|
|
149
|
+
...(after ? { after } : {}), ...(around ? { around } : {}) });
|
|
150
|
+
const page = await this.request(`/messaging/rooms/${encodeURIComponent(roomId)}/messages?${query}`);
|
|
151
|
+
if (!Array.isArray(page?.items) || page.items.some(message => typeof message.id !== 'string' || !message.id)) throw new Error('点点消息页格式无效。');
|
|
152
|
+
// Null prev_cursor is also returned for nonempty middle pages. Desktop uses
|
|
153
|
+
// the first message ID; only an empty page proves that history is exhausted.
|
|
154
|
+
return { items: page.items, before: page.prev_cursor ?? page.items[0]?.id ?? null,
|
|
155
|
+
after: page.next_cursor ?? (after && page.items.length >= limit ? page.items.at(-1)?.id : null) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
close() {
|
|
159
|
+
super.close();
|
|
160
|
+
for (const controller of this.controllers) controller.abort(new Error('点点连接已结束。'));
|
|
161
|
+
this.controllers.clear(); this.routes.clear();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -346,7 +346,10 @@ export class DotsAppServer extends HostedHistoryAppServer {
|
|
|
346
346
|
const previous = this.profileNames.get(id);
|
|
347
347
|
if (previous?.avatar && previous.avatarFileId === profile.avatarFileId) profile.avatar = previous.avatar;
|
|
348
348
|
}
|
|
349
|
-
if (identity === this.profileIdentity)
|
|
349
|
+
if (identity === this.profileIdentity) {
|
|
350
|
+
this.profileNames = names;
|
|
351
|
+
this.emit('profiles-changed');
|
|
352
|
+
}
|
|
350
353
|
}
|
|
351
354
|
|
|
352
355
|
refreshLiveProfiles() {
|
|
@@ -9,7 +9,7 @@ export async function uploadTaskFiles(files, { uploadsRoot, client, current, fet
|
|
|
9
9
|
for (const file of uploads) {
|
|
10
10
|
current();
|
|
11
11
|
const reserved = await client.request('/files', { method: 'POST', body: {
|
|
12
|
-
file_name: file.name, file_size: file.bytes.length, mime_type: file.mimeType,
|
|
12
|
+
file_name: file.name, file_size: file.bytes.size ?? file.bytes.length, mime_type: file.mimeType,
|
|
13
13
|
use_case: 'codex', store_in_library: false, timezone_offset_min: new Date().getTimezoneOffset(), reset_rate_limits: false,
|
|
14
14
|
} });
|
|
15
15
|
current();
|
|
@@ -22,7 +22,7 @@ export async function uploadTaskFiles(files, { uploadsRoot, client, current, fet
|
|
|
22
22
|
if (!uploadUrl) throw new Error('云端文件上传地址缺少参数。');
|
|
23
23
|
const boundary = `relax-${randomUUID()}`;
|
|
24
24
|
const name = file.name.replaceAll('"', '%22').replaceAll('\\', '%5C');
|
|
25
|
-
const body =
|
|
25
|
+
const body = new Blob([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${file.mimeType}\r\n\r\n`),
|
|
26
26
|
file.bytes, Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="upload_url"\r\n\r\n${uploadUrl}\r\n--${boundary}--\r\n`)]);
|
|
27
27
|
await client.request(url.pathname.slice('/backend-api'.length) + url.search, {
|
|
28
28
|
method: 'POST', body, contentType: `multipart/form-data; boundary=${boundary}`, rawResponse: true,
|
|
@@ -56,9 +56,16 @@ export function createFileUploadSession(root) {
|
|
|
56
56
|
completed.clear(); files.clear(); completedBytes = 0;
|
|
57
57
|
};
|
|
58
58
|
return {
|
|
59
|
-
async receive(message) {
|
|
59
|
+
async receive(message, { relayed = false } = {}) {
|
|
60
60
|
if (!message || typeof message !== 'object') throw new Error('无效的文件上传请求');
|
|
61
61
|
if (message.type === 'cancel') { await clear(); return { type: 'cancelled' }; }
|
|
62
|
+
// Recheck the complete batch on every relayed request, including resume
|
|
63
|
+
// and commit after a direct connection has been replaced.
|
|
64
|
+
if (relayed) {
|
|
65
|
+
const originals = [...files.values()].filter(file => !file.previewOf);
|
|
66
|
+
if (originals.some(file => file.size > MAX_FILE_BYTES)) throw new Error('服务器转发仅支持单个文件不超过 20 MiB,请使用局域网或 RTC 直连');
|
|
67
|
+
if (originals.reduce((sum, file) => sum + file.size, 0) > MAX_TOTAL_BYTES) throw new Error('服务器转发仅支持每批合计不超过 40 MiB,请使用局域网或 RTC 直连');
|
|
68
|
+
}
|
|
62
69
|
if (message.type === 'commit') {
|
|
63
70
|
if (upload) throw new Error('文件尚未上传完成');
|
|
64
71
|
committed = true;
|
|
@@ -86,14 +93,14 @@ export function createFileUploadSession(root) {
|
|
|
86
93
|
if (committed) throw new Error('文件批次已提交');
|
|
87
94
|
if (upload) throw new Error('已有文件正在上传');
|
|
88
95
|
if (!validName(message.name)) throw new Error('文件名无效');
|
|
89
|
-
if (!Number.isSafeInteger(message.size) || message.size < 1
|
|
90
|
-
throw new Error('
|
|
96
|
+
if (!Number.isSafeInteger(message.size) || message.size < 1) {
|
|
97
|
+
throw new Error('文件大小无效');
|
|
91
98
|
}
|
|
99
|
+
if (relayed && message.size > MAX_FILE_BYTES) throw new Error('服务器转发仅支持单个文件不超过 20 MiB,请使用局域网或 RTC 直连');
|
|
92
100
|
const parent = message.previewOf ? completed.get(message.previewOf) : null;
|
|
93
101
|
if (message.previewOf && (!parent || parent.preview || message.size > FILE_CHUNK_BYTES)) throw new Error('预览图关联无效或超过 256 KiB');
|
|
94
|
-
if (!parent &&
|
|
95
|
-
|
|
96
|
-
}
|
|
102
|
+
if (!parent && completed.size >= MAX_FILE_COUNT) throw new Error('每条消息最多 4 个文件');
|
|
103
|
+
if (!parent && relayed && completedBytes + message.size > MAX_TOTAL_BYTES) throw new Error('服务器转发仅支持每批合计不超过 40 MiB,请使用局域网或 RTC 直连');
|
|
97
104
|
if (!/^[a-f0-9]{64}$/i.test(message.sha256 || '')) throw new Error('文件校验值无效');
|
|
98
105
|
const id = crypto.randomUUID();
|
|
99
106
|
await fs.mkdir(directory, { recursive: true });
|
|
@@ -48,7 +48,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
48
48
|
target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
|
|
49
49
|
});
|
|
50
50
|
uploadWss.on('connection', ws => {
|
|
51
|
-
let authenticated = false, stopped = false, identity = '', upload, purpose, chunk, uploadEntry, uploadId;
|
|
51
|
+
let authenticated = false, stopped = false, identity = '', upload, purpose, chunk, uploadEntry, uploadId, fileRelay = false;
|
|
52
52
|
let release, discardUpload = false;
|
|
53
53
|
const released = new Promise(resolve => { release = resolve; });
|
|
54
54
|
let queued = 0;
|
|
@@ -84,7 +84,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
84
84
|
if (binary) {
|
|
85
85
|
if (!authenticated || purpose !== 'upload' || !chunk || bytes.length !== chunk.length) throw new Error('无效的文件分片');
|
|
86
86
|
const current = chunk; chunk = undefined;
|
|
87
|
-
reply(await upload.receive({ ...current, data: bytes })); return;
|
|
87
|
+
reply(await upload.receive({ ...current, data: bytes }, { relayed: fileRelay })); return;
|
|
88
88
|
}
|
|
89
89
|
if (bytes.length > 16 * 1024 || chunk) throw new Error('无效的文件请求');
|
|
90
90
|
const message = JSON.parse(bytes.toString());
|
|
@@ -98,6 +98,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
98
98
|
if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) {
|
|
99
99
|
identity = typeof message.relayClientId === 'string' && message.relayClientId.length <= 200
|
|
100
100
|
? `relay:${message.relayClientId}` : 'local-token';
|
|
101
|
+
fileRelay = message.fileRelay === true;
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
if (identity && purpose === 'upload') {
|
|
@@ -141,17 +142,19 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
141
142
|
Object.assign(uploadEntry, { ws, released });
|
|
142
143
|
upload = uploadEntry.session;
|
|
143
144
|
}
|
|
144
|
-
reply({ type: 'ready', version:
|
|
145
|
-
maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES, uploadSessionId: uploadId });
|
|
145
|
+
reply({ type: 'ready', version: 4, purpose, maxDownloadBytes: fileRelay ? FILE_READ_LIMIT : null, maxFileBytes: fileRelay ? MAX_FILE_BYTES : null, maxFileCount: MAX_FILE_COUNT,
|
|
146
|
+
maxTotalBytes: fileRelay ? MAX_TOTAL_BYTES : null, chunkBytes: FILE_CHUNK_BYTES, uploadSessionId: uploadId });
|
|
146
147
|
return;
|
|
147
148
|
}
|
|
148
149
|
if (purpose === 'download') {
|
|
150
|
+
if (fileRelay && /^\/api\/desktop\/frames\//.test(message.path || '')) throw new Error('桌面画面仅支持直连');
|
|
149
151
|
if (message.type !== 'read' || typeof message.path !== 'string' || message.path.length > 12000 ||
|
|
150
152
|
!/^\/api\/(?:images\/[a-f0-9]{64}|files\/(?:local|[a-f0-9]{64})|code\/(?:image|binary)|desktop\/frames\/[a-f0-9]{32})(?:\?|$)/.test(message.path) || message.path.includes('#')) throw new Error('无效的文件读取请求');
|
|
151
153
|
await dispatch({ method: 'GET', url: message.path, headers: {}, socketRequest: true, fileTransfer: true }, {
|
|
152
154
|
json(status, body) { reply({ type: 'error', status, error: body?.error || '文件读取失败' }); },
|
|
153
155
|
filePart(metadata, data) {
|
|
154
156
|
if (stopped || ws.readyState !== WebSocket.OPEN) return;
|
|
157
|
+
if (fileRelay && metadata.size > FILE_READ_LIMIT) throw new Error('服务器转发仅支持下载不超过 20 MiB 的文件,请使用局域网或 RTC 直连');
|
|
155
158
|
if (!Buffer.isBuffer(data) || data.length > FILE_CHUNK_BYTES || ws.bufferedAmount > 1024 * 1024) throw new Error('文件接收过慢或分片无效');
|
|
156
159
|
reply({ type: 'part', ...metadata, length: data.length });
|
|
157
160
|
ws.send(data);
|
|
@@ -163,7 +166,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
163
166
|
if (!Number.isSafeInteger(message.length) || message.length < 1 || message.length > FILE_CHUNK_BYTES) throw new Error('文件分片大小无效');
|
|
164
167
|
chunk = message; return;
|
|
165
168
|
}
|
|
166
|
-
reply(await upload.receive(message));
|
|
169
|
+
reply(await upload.receive(message, { relayed: fileRelay }));
|
|
167
170
|
if (message.type === 'commit') uploadEntry.committed = true;
|
|
168
171
|
if (message.type === 'cancel') {
|
|
169
172
|
await expireUpload(uploadId, uploadEntry);
|
|
@@ -35,7 +35,8 @@ export function availableManagedPort() {
|
|
|
35
35
|
export class ManagedAppServer extends EventEmitter {
|
|
36
36
|
constructor({ stateRoot, host, environment = process.env, available = managedPortAvailable,
|
|
37
37
|
wait = delay, timeout = 30_000, now = Date.now, client = new SharedAppServer(`ws://127.0.0.1:${defaultManagedPort}`),
|
|
38
|
-
selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime
|
|
38
|
+
selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime,
|
|
39
|
+
diagnose = () => {} }) {
|
|
39
40
|
super();
|
|
40
41
|
this.stateRoot = stateRoot;
|
|
41
42
|
this.host = host;
|
|
@@ -44,6 +45,7 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
44
45
|
this.selectPort = selectPort;
|
|
45
46
|
this.configureEnvironment = configureEnvironment;
|
|
46
47
|
this.resolveRuntime = resolveRuntime;
|
|
48
|
+
this.diagnose = diagnose;
|
|
47
49
|
this.wait = wait;
|
|
48
50
|
this.timeout = timeout;
|
|
49
51
|
this.now = now;
|
|
@@ -111,6 +113,13 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
111
113
|
throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable};请确认重启内核。`);
|
|
112
114
|
}
|
|
113
115
|
if (!actual) {
|
|
116
|
+
const selection = { reason: this.restarting ? 'confirmed-restart' : record?.pid ? 'previous-process-exited' : 'no-running-kernel',
|
|
117
|
+
version: runtime.version, source: runtime.source, executable: runtime.executable };
|
|
118
|
+
const selectionSignature = JSON.stringify(selection);
|
|
119
|
+
if (selectionSignature !== this.diagnosticSelectionSignature) {
|
|
120
|
+
this.diagnosticSelectionSignature = selectionSignature;
|
|
121
|
+
this.diagnose('kernel-start-selected', selection);
|
|
122
|
+
}
|
|
114
123
|
fs.rmSync(this.pendingPath, { force: true });
|
|
115
124
|
const childEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, RUST_LOG: 'warn' };
|
|
116
125
|
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
|
|
@@ -128,6 +137,8 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
128
137
|
writeRecord(this.pendingPath, { phase: 'spawning', url, executable: runtime.executable, startedAt: new Date().toISOString() });
|
|
129
138
|
const pid = await this.host.start(runtime.executable, ['app-server', '--listen', url], childEnvironment,
|
|
130
139
|
[path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
|
|
140
|
+
this.diagnose('kernel-spawned', { pid, executable: runtime.executable, selectedVersion: runtime.version,
|
|
141
|
+
source: runtime.source, attempt: attempt + 1 });
|
|
131
142
|
const started = await this.host.process(pid);
|
|
132
143
|
if (!started) {
|
|
133
144
|
fs.rmSync(this.pendingPath, { force: true });
|
|
@@ -355,6 +366,20 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
355
366
|
const changed = JSON.stringify(value) !== JSON.stringify(this.state);
|
|
356
367
|
this.state = value;
|
|
357
368
|
if (changed) this.emit('state', this.snapshot());
|
|
369
|
+
// Retry transitions remain visible to health consumers; diagnostic output
|
|
370
|
+
// records a repeated failure once until it changes or the kernel recovers.
|
|
371
|
+
if (value.state === 'starting' && this.diagnosticState?.state === 'failed') return;
|
|
372
|
+
const details = { state: value.state, ready: value.ready, pid: value.pid ?? null, created: value.created ?? null,
|
|
373
|
+
version: value.ready && this.client.ready ? this.client.serverVersion || null : null,
|
|
374
|
+
executable: value.executable || null, selectedVersion: value.selectedVersion || null,
|
|
375
|
+
preferredExecutable: value.preferredExecutable || null, source: value.source || null,
|
|
376
|
+
updatePending: value.updatePending === true, error: value.error || null };
|
|
377
|
+
const signature = JSON.stringify(details);
|
|
378
|
+
if (signature === this.diagnosticStateSignature) return;
|
|
379
|
+
this.diagnosticStateSignature = signature;
|
|
380
|
+
this.diagnosticState = details;
|
|
381
|
+
if (value.ready) this.diagnosticSelectionSignature = null;
|
|
382
|
+
this.diagnose('kernel-state', { ...details, retryAt: value.retryAt || null });
|
|
358
383
|
}
|
|
359
384
|
|
|
360
385
|
close() {
|