@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.
@@ -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.1007.9446.0: room-live activity and responding heartbeats are
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
- watch.attempts = 0; watch.unavailable = false; watch.liveActivity = null;
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, ![401, 403, 404].includes(error.status)); }
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 { CelsiusClient } from './celsius-client.mjs';
3
-
4
- const topic = 'calpico-chatgpt-messaging';
5
- const identityKey = identity => JSON.stringify([identity?.accountId, identity?.userId]);
6
-
7
- // Desktop 26.1002.7124.0: the DM API and Celsius share the kernel account,
8
- // but are independent of the hosted execution socket. Never replay a POST.
9
- export class DotsRoomClient extends CelsiusClient {
10
- constructor(transport) {
11
- super({ topics: [topic] }); this.transport = transport;
12
- this.getConnection = () => this.request('/celsius/ws/user');
13
- this.controllers = new Set(); this.routes = new Map();
14
- }
15
-
16
- async authorization() {
17
- const identity = this.transport.profileIdentity, generation = this.generation;
18
- const auth = await this.transport.localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
19
- const principal = auth.authToken && JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url'))['https://api.openai.com/auth'];
20
- if (!identity?.accountId || principal?.chatgpt_account_id !== identity.accountId
21
- || principal.chatgpt_user_id !== identity.userId || generation !== this.generation
22
- || identityKey(identity) !== identityKey(this.transport.profileIdentity)) throw new Error('点点账号已变化,请重新连接。');
23
- return { identity, generation, headers: { authorization: `Bearer ${auth.authToken}`,
24
- 'chatgpt-account-id': identity.accountId, 'user-agent': 'Code-Relax/0.1.0' } };
25
- }
26
-
27
- async request(route, { method = 'GET', body, contentType, signal, onEvent, rawResponse = false } = {}) {
28
- const auth = await this.authorization();
29
- const controller = new AbortController(); this.controllers.add(controller);
30
- const cancelled = () => controller.abort(signal.reason);
31
- signal?.addEventListener('abort', cancelled, { once: true });
32
- if (signal?.aborted) cancelled();
33
- let sent = false, status, timer;
34
- try {
35
- return await new Promise((resolve, reject) => {
36
- const session = http2.connect('https://chatgpt.com');
37
- let request, settled = false, bytes = 0, text = '';
38
- const current = () => auth.generation === this.generation
39
- && identityKey(auth.identity) === identityKey(this.transport.profileIdentity);
40
- const finish = (error, value) => {
41
- if (settled) return; settled = true;
42
- clearTimeout(timer); controller.signal.removeEventListener('abort', abort);
43
- request?.destroy(); session.destroy();
44
- if (error) {
45
- if (method !== 'GET' && !onEvent) error.deliveryUnknown = sent && !(status >= 400 && status < 500 && status !== 408);
46
- reject(error);
47
- } else resolve(value);
48
- };
49
- const abort = () => finish(controller.signal.reason || new Error('点点请求已取消。'));
50
- controller.signal.addEventListener('abort', abort, { once: true });
51
- session.on('error', error => finish(error));
52
- if (controller.signal.aborted) { abort(); return; }
53
- const roomRoute = /^\/messaging\/rooms\/([^/]+)\/messages$/.exec(route)?.[1];
54
- request = session.request({ ':method': method, ':path': `/backend-api${route}`, ...auth.headers,
55
- accept: onEvent ? 'text/event-stream' : 'application/json',
56
- ...(body != null ? { 'content-type': contentType || 'application/json' } : {}),
57
- ...(roomRoute && this.routes.has(roomRoute) ? { 'x-openai-thread-route': this.routes.get(roomRoute) } : {}) });
58
- timer = setTimeout(() => finish(new Error('点点请求超时,请重新核对状态。')), 30000);
59
- request.setEncoding('utf8');
60
- request.on('response', headers => {
61
- status = headers[':status'];
62
- if (roomRoute && typeof headers['x-openai-thread-route'] === 'string' && headers['x-openai-thread-route'].length <= 1024 && current()) this.routes.set(roomRoute, headers['x-openai-thread-route']);
63
- if (status < 200 || status >= 300) {
64
- console.error(JSON.stringify({ event: 'dots-room-request', method, status, route: route.split('?')[0] }));
65
- const message = status === 401 ? '点点登录状态需要恢复,请重新连接。'
66
- : status === 403 || status === 404 ? '点点消息暂不可用,请稍后重新同步。'
67
- : status === 429 ? '点点暂时繁忙,请稍后再试。' : '点点请求尚未完成,请稍后核对。';
68
- finish(Object.assign(new Error(message), { status }));
69
- }
70
- });
71
- request.on('data', chunk => {
72
- if (!current()) { finish(new Error('点点账号已变化,请重新连接。')); return; }
73
- bytes += Buffer.byteLength(chunk); text += chunk;
74
- if (onEvent) {
75
- text = text.replace(/\r\n/g, '\n');
76
- let end;
77
- while ((end = text.indexOf('\n\n')) >= 0) {
78
- const frame = text.slice(0, end); text = text.slice(end + 2);
79
- const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
80
- // Desktop's ChatGPT SSE treats [DONE] as completion, not JSON.
81
- if (data.trim() === '[DONE]') { finish(null, null); return; }
82
- if (data) try { onEvent(JSON.parse(data)); clearTimeout(timer); } catch (error) { finish(error); return; }
83
- }
84
- bytes = Buffer.byteLength(text);
85
- }
86
- if (bytes > 8 * 1024 * 1024) finish(new Error('点点响应超出单次读取上限。'));
87
- });
88
- request.on('error', error => finish(error));
89
- request.on('end', () => {
90
- if (!current()) { finish(new Error('点点账号已变化,请重新连接。')); return; }
91
- try { finish(null, onEvent ? null : rawResponse ? text : text ? JSON.parse(text) : null); } catch (error) { finish(error); }
92
- });
93
- request.on('close', () => { if (!settled) finish(new Error('点点请求连接已断开。')); });
94
- // end() can queue data before TLS connects. Only a ready HTTP/2 stream
95
- // has been assigned an ID and may have submitted the request.
96
- sent = !request.pending;
97
- request.once('ready', () => { sent = true; });
98
- request.end(body == null ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body));
99
- });
100
- } finally { this.controllers.delete(controller); signal?.removeEventListener('abort', cancelled); }
101
- }
102
-
103
- async roomId(threadId) {
104
- let profile = this.transport.liveProfiles.get(threadId);
105
- if (!profile?.messagingRoomId) {
106
- const value = await this.request(`/tbo/by-thread/${encodeURIComponent(threadId)}`);
107
- profile = { messagingRoomId: value?.messaging_room_id };
108
- }
109
- if (typeof profile.messagingRoomId !== 'string' || !profile.messagingRoomId) throw new Error('点点消息房间暂不可用。');
110
- return profile.messagingRoomId;
111
- }
112
-
113
- async room(id) {
114
- const room = await this.request(`/messaging/rooms/${encodeURIComponent(id)}`);
115
- if (room?.id !== id || room.type !== 'DM' || room.app_source !== 'chatgpt:messaging' || !Array.isArray(room.members)) {
116
- throw new Error('点点消息房间身份不匹配。');
117
- }
118
- return room;
119
- }
120
-
121
- async messages(roomId, { before, after, around, limit = 20 } = {}) {
122
- const query = new URLSearchParams({ limit: String(limit), ...(before ? { before } : {}),
123
- ...(after ? { after } : {}), ...(around ? { around } : {}) });
124
- const page = await this.request(`/messaging/rooms/${encodeURIComponent(roomId)}/messages?${query}`);
125
- if (!Array.isArray(page?.items) || page.items.some(message => typeof message.id !== 'string' || !message.id)) throw new Error('点点消息页格式无效。');
126
- // Null prev_cursor is also returned for nonempty middle pages. Desktop uses
127
- // the first message ID; only an empty page proves that history is exhausted.
128
- return { items: page.items, before: page.prev_cursor ?? page.items[0]?.id ?? null,
129
- after: page.next_cursor ?? (after && page.items.length >= limit ? page.items.at(-1)?.id : null) };
130
- }
131
-
132
- close() {
133
- super.close();
134
- for (const controller of this.controllers) controller.abort(new Error('点点连接已结束。'));
135
- this.controllers.clear(); this.routes.clear();
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) this.profileNames = names;
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 = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${file.mimeType}\r\n\r\n`),
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 || message.size > MAX_FILE_BYTES) {
90
- throw new Error('单个文件不得超过 20 MiB');
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 && (completed.size >= MAX_FILE_COUNT || completedBytes + message.size > MAX_TOTAL_BYTES)) {
95
- throw new Error('每条消息最多 4 个文件,合计不超过 40 MiB');
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: 3, purpose, maxDownloadBytes: FILE_READ_LIMIT, maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
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() {