@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.51

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.
Files changed (89) hide show
  1. package/README.md +105 -105
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +403 -403
  4. package/dist/bin/publish-android-release.mjs +63 -63
  5. package/dist/bin/release-upload-server.mjs +190 -190
  6. package/dist/build-platform.json +1 -1
  7. package/dist/get-shared-service-url.ps1 +14 -14
  8. package/dist/shared/code-browser.cjs +159 -159
  9. package/dist/shared/file-changes.cjs +31 -31
  10. package/dist/shared/file-citations.cjs +117 -117
  11. package/dist/shared/file-mentions.cjs +92 -92
  12. package/dist/shared/message-delivery.cjs +115 -115
  13. package/dist/shared/p2p-file-channel.cjs +87 -87
  14. package/dist/shared/server-requests.cjs +311 -311
  15. package/dist/shared/timeline-sync.cjs +173 -173
  16. package/dist/skills/code-relax/SKILL.md +22 -22
  17. package/dist/src/bounded-process-log.mjs +63 -63
  18. package/dist/src/celsius-client.mjs +107 -107
  19. package/dist/src/dots-approval-timeline.mjs +99 -99
  20. package/dist/src/dots-avatar.mjs +42 -42
  21. package/dist/src/dots-chat-projection.mjs +50 -50
  22. package/dist/src/dots-chat-server.mjs +483 -483
  23. package/dist/src/dots-message-images.mjs +319 -319
  24. package/dist/src/dots-profile.mjs +77 -77
  25. package/dist/src/dots-room-activity.mjs +172 -172
  26. package/dist/src/dots-room-client.mjs +135 -135
  27. package/dist/src/dots-room-projection.mjs +94 -94
  28. package/dist/src/dots-schedules.mjs +66 -66
  29. package/dist/src/dots-summary-tasks.mjs +32 -32
  30. package/dist/src/dots-task-catalog.mjs +430 -430
  31. package/dist/src/file-transfer-range.mjs +22 -22
  32. package/dist/src/file-upload-session.mjs +130 -130
  33. package/dist/src/hosted-history-app-server.mjs +103 -103
  34. package/dist/src/hosted-history-cache.mjs +113 -113
  35. package/dist/src/lan-socket-server.mjs +266 -266
  36. package/dist/src/managed-app-server.mjs +373 -373
  37. package/dist/src/managed-endpoint.mjs +25 -25
  38. package/dist/src/message-upload-files.mjs +109 -109
  39. package/dist/src/onboarding.mjs +12 -12
  40. package/dist/src/platform/README.md +1 -1
  41. package/dist/src/platform/codex-protocol.mjs +46 -46
  42. package/dist/src/platform/desktop-project-store.mjs +208 -208
  43. package/dist/src/platform/linux/desktop-host.mjs +87 -87
  44. package/dist/src/platform/linux/process.mjs +53 -53
  45. package/dist/src/platform/linux/system.mjs +50 -50
  46. package/dist/src/platform/linux/user-environment.mjs +9 -9
  47. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  48. package/dist/src/platform/macos/desktop-host.mjs +86 -86
  49. package/dist/src/platform/macos/user-environment.mjs +9 -9
  50. package/dist/src/platform/native-package.mjs +58 -58
  51. package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
  52. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  53. package/dist/src/platform/windows/credential-store.mjs +27 -27
  54. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  55. package/dist/src/platform/windows/desktop-host.mjs +65 -65
  56. package/dist/src/platform/windows/desktop-info.ps1 +38 -38
  57. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  58. package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
  59. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  60. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  61. package/dist/src/platform/windows/project-locations.mjs +16 -16
  62. package/dist/src/platform/windows/service-host.mjs +35 -35
  63. package/dist/src/platform/windows/user-environment.mjs +52 -52
  64. package/dist/src/project-management.mjs +58 -58
  65. package/dist/src/runtime-discovery.mjs +47 -47
  66. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  67. package/dist/src/self-relay/admin-page.mjs +80 -80
  68. package/dist/src/self-relay/admin-style.mjs +14 -14
  69. package/dist/src/self-relay/connector.mjs +439 -439
  70. package/dist/src/self-relay/demo.mjs +31 -31
  71. package/dist/src/self-relay/diagnostics.mjs +54 -54
  72. package/dist/src/self-relay/email-accounts.mjs +183 -183
  73. package/dist/src/self-relay/file-wire.mjs +13 -13
  74. package/dist/src/self-relay/p2p-probe.mjs +168 -168
  75. package/dist/src/self-relay/privacy-page.mjs +26 -26
  76. package/dist/src/self-relay/public-releases.mjs +67 -67
  77. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
  78. package/dist/src/self-relay/usage-meter.mjs +141 -141
  79. package/dist/src/server.mjs +5 -4
  80. package/dist/src/service-doctor.mjs +148 -148
  81. package/dist/src/service-install.mjs +97 -97
  82. package/dist/src/service-lifecycle.mjs +139 -139
  83. package/dist/src/shared-app-server.mjs +666 -666
  84. package/dist/src/shared-catalog.mjs +132 -132
  85. package/dist/src/shared-thread-stream.mjs +165 -165
  86. package/dist/src/usage-reset.mjs +17 -17
  87. package/dist/src/workstation-downloads.mjs +120 -120
  88. package/package.json +4 -4
  89. package/tools/postinstall.mjs +186 -186
@@ -1,77 +1,77 @@
1
- import http2 from 'node:http2';
2
-
3
- const colors = new Set(['black', 'blue', 'green', 'orange', 'pink', 'purple', 'red', 'yellow']);
4
- export function dotsProfile(profile) {
5
- const name = typeof profile?.display_name === 'string' ? profile.display_name.trim() : '';
6
- const pointer = profile?.avatar_manifest?.snapshot?.asset_pointer;
7
- return { profileId: profile.id, activeRootThreadId: profile.active_root_thread_id || '',
8
- messagingRoomId: typeof profile.messaging_room_id === 'string' ? profile.messaging_room_id : '',
9
- name: name && !['O', 'o', 'Orbit', 'orbit', 'dot'].includes(name) ? name : 'Dots',
10
- avatarFileId: typeof pointer === 'string' && /^sediment:\/\/file_[a-f0-9]+$/.test(pointer)
11
- ? pointer.slice('sediment://'.length) : '', color: colors.has(profile.color) ? profile.color : '' };
12
- }
13
-
14
- // Desktop 26.930.7945.0 discovers roots through /tbo and its thread attachments.
15
- // Hosted thread/list can omit every aeon root while thread/read still resolves it.
16
- export async function readDotsCatalog(localServer, identity) {
17
- const auth = await localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
18
- const principal = JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url'))['https://api.openai.com/auth'];
19
- if (!identity || principal?.chatgpt_account_id !== identity.accountId || principal.chatgpt_user_id !== identity.userId) {
20
- throw new Error('点点账号已变化,请重新连接。');
21
- }
22
- const session = http2.connect('https://chatgpt.com');
23
- session.on('error', () => {}); // Individual requests report the transport error.
24
- try {
25
- const get = route => new Promise((resolve, reject) => {
26
- const request = session.request({ ':method': 'GET', ':path': `/backend-api${route}`,
27
- authorization: `Bearer ${auth.authToken}`, 'chatgpt-account-id': identity.accountId,
28
- 'user-agent': 'Code-Relax/0.1.0', accept: 'application/json' });
29
- let status, body = '', size = 0;
30
- const timer = setTimeout(() => request.destroy(new Error('读取点点资料超时。')), 15000);
31
- request.setEncoding('utf8');
32
- request.on('response', headers => { status = headers[':status']; });
33
- request.on('data', chunk => {
34
- size += Buffer.byteLength(chunk);
35
- if (size > 1024 * 1024) request.destroy(new Error('点点资料响应过大。'));
36
- else body += chunk;
37
- });
38
- request.on('error', reject);
39
- request.on('close', () => clearTimeout(timer));
40
- request.on('end', () => {
41
- try {
42
- if (status !== 200) throw new Error(`读取点点目录失败(HTTP ${status})。`);
43
- resolve(JSON.parse(body));
44
- } catch (error) { reject(error); }
45
- });
46
- request.end();
47
- });
48
- const profiles = new Map(), rootThreadIds = new Set();
49
- let cursor;
50
- do {
51
- const query = new URLSearchParams({ limit: '25', ...(cursor ? { cursor } : {}) });
52
- const page = await get(`/tbo?${query}`);
53
- for (const value of page.items) {
54
- if (typeof value.id !== 'string' || !value.id
55
- || value.active_root_thread_id != null && typeof value.active_root_thread_id !== 'string') {
56
- throw new Error('点点资料缺少有效身份。');
57
- }
58
- const profile = dotsProfile(value);
59
- let threadCursor;
60
- do {
61
- const threadQuery = new URLSearchParams({ limit: '100', include_hidden: 'true',
62
- ...(threadCursor ? { cursor: threadCursor } : {}) });
63
- const attachments = await get(`/tbo/${encodeURIComponent(value.id)}/threads?${threadQuery}`);
64
- for (const attachment of attachments.items) {
65
- const id = attachment.thread_id;
66
- profiles.set(id, profile);
67
- if (value.status !== 'deleting' && (!profile.activeRootThreadId || profile.activeRootThreadId === id)
68
- && (attachment.parent_thread_id === null || attachment.parent_thread_id === id)) rootThreadIds.add(id);
69
- }
70
- threadCursor = attachments.cursor;
71
- } while (threadCursor);
72
- }
73
- cursor = page.cursor;
74
- } while (cursor);
75
- return { profiles, rootThreadIds };
76
- } finally { session.destroy(); }
77
- }
1
+ import http2 from 'node:http2';
2
+
3
+ const colors = new Set(['black', 'blue', 'green', 'orange', 'pink', 'purple', 'red', 'yellow']);
4
+ export function dotsProfile(profile) {
5
+ const name = typeof profile?.display_name === 'string' ? profile.display_name.trim() : '';
6
+ const pointer = profile?.avatar_manifest?.snapshot?.asset_pointer;
7
+ return { profileId: profile.id, activeRootThreadId: profile.active_root_thread_id || '',
8
+ messagingRoomId: typeof profile.messaging_room_id === 'string' ? profile.messaging_room_id : '',
9
+ name: name && !['O', 'o', 'Orbit', 'orbit', 'dot'].includes(name) ? name : 'Dots',
10
+ avatarFileId: typeof pointer === 'string' && /^sediment:\/\/file_[a-f0-9]+$/.test(pointer)
11
+ ? pointer.slice('sediment://'.length) : '', color: colors.has(profile.color) ? profile.color : '' };
12
+ }
13
+
14
+ // Desktop 26.930.7945.0 discovers roots through /tbo and its thread attachments.
15
+ // Hosted thread/list can omit every aeon root while thread/read still resolves it.
16
+ export async function readDotsCatalog(localServer, identity) {
17
+ const auth = await localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
18
+ const principal = JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url'))['https://api.openai.com/auth'];
19
+ if (!identity || principal?.chatgpt_account_id !== identity.accountId || principal.chatgpt_user_id !== identity.userId) {
20
+ throw new Error('点点账号已变化,请重新连接。');
21
+ }
22
+ const session = http2.connect('https://chatgpt.com');
23
+ session.on('error', () => {}); // Individual requests report the transport error.
24
+ try {
25
+ const get = route => new Promise((resolve, reject) => {
26
+ const request = session.request({ ':method': 'GET', ':path': `/backend-api${route}`,
27
+ authorization: `Bearer ${auth.authToken}`, 'chatgpt-account-id': identity.accountId,
28
+ 'user-agent': 'Code-Relax/0.1.0', accept: 'application/json' });
29
+ let status, body = '', size = 0;
30
+ const timer = setTimeout(() => request.destroy(new Error('读取点点资料超时。')), 15000);
31
+ request.setEncoding('utf8');
32
+ request.on('response', headers => { status = headers[':status']; });
33
+ request.on('data', chunk => {
34
+ size += Buffer.byteLength(chunk);
35
+ if (size > 1024 * 1024) request.destroy(new Error('点点资料响应过大。'));
36
+ else body += chunk;
37
+ });
38
+ request.on('error', reject);
39
+ request.on('close', () => clearTimeout(timer));
40
+ request.on('end', () => {
41
+ try {
42
+ if (status !== 200) throw new Error(`读取点点目录失败(HTTP ${status})。`);
43
+ resolve(JSON.parse(body));
44
+ } catch (error) { reject(error); }
45
+ });
46
+ request.end();
47
+ });
48
+ const profiles = new Map(), rootThreadIds = new Set();
49
+ let cursor;
50
+ do {
51
+ const query = new URLSearchParams({ limit: '25', ...(cursor ? { cursor } : {}) });
52
+ const page = await get(`/tbo?${query}`);
53
+ for (const value of page.items) {
54
+ if (typeof value.id !== 'string' || !value.id
55
+ || value.active_root_thread_id != null && typeof value.active_root_thread_id !== 'string') {
56
+ throw new Error('点点资料缺少有效身份。');
57
+ }
58
+ const profile = dotsProfile(value);
59
+ let threadCursor;
60
+ do {
61
+ const threadQuery = new URLSearchParams({ limit: '100', include_hidden: 'true',
62
+ ...(threadCursor ? { cursor: threadCursor } : {}) });
63
+ const attachments = await get(`/tbo/${encodeURIComponent(value.id)}/threads?${threadQuery}`);
64
+ for (const attachment of attachments.items) {
65
+ const id = attachment.thread_id;
66
+ profiles.set(id, profile);
67
+ if (value.status !== 'deleting' && (!profile.activeRootThreadId || profile.activeRootThreadId === id)
68
+ && (attachment.parent_thread_id === null || attachment.parent_thread_id === id)) rootThreadIds.add(id);
69
+ }
70
+ threadCursor = attachments.cursor;
71
+ } while (threadCursor);
72
+ }
73
+ cursor = page.cursor;
74
+ } while (cursor);
75
+ return { profiles, rootThreadIds };
76
+ } finally { session.destroy(); }
77
+ }
@@ -1,172 +1,172 @@
1
- import { EventEmitter } from 'node:events';
2
-
3
- // Desktop 26.1007.9446.0: room-live activity and responding heartbeats are
4
- // independent. Heartbeats expire after 10s; Dot reply handoff lasts at most 5s.
5
- const typingTimeout = 10000, handoffTimeout = 5000;
6
- const statusLabels = {
7
- working: '正在处理…', searching: '正在搜索…', creating: '正在创建…',
8
- payment: '等待支付', 'needs input': '等待你的输入', paused: '已暂停',
9
- };
10
- const waitingStates = new Set(['payment', 'needs input', 'paused']);
11
-
12
- export class DotsRoomActivity extends EventEmitter {
13
- constructor(server) {
14
- super(); this.server = server; this.watches = new Map();
15
- this.message = (topic, event) => {
16
- for (const watch of this.watches.values()) {
17
- if (topic === 'calpico-chatgpt-messaging') { this.roomEvent(watch, event); continue; }
18
- if (event?.type !== 'calpico-room-live') continue;
19
- const value = event.payload;
20
- if (watch.topic !== topic || watch.roomId !== value?.room_id || value.version !== 1) continue;
21
- if (!watch.subscriptionId) {
22
- if (watch.pending.size >= 4) watch.pending.delete(watch.pending.keys().next().value);
23
- const old = watch.pending.get(value.subscription_id);
24
- if (!old || value.revision > old.revision) watch.pending.set(value.subscription_id, value);
25
- } else this.apply(watch, value);
26
- }
27
- };
28
- server.rooms.on('message', this.message);
29
- }
30
- acquire(threadId, listener) {
31
- let watch = this.watches.get(threadId);
32
- if (!watch) {
33
- watch = { threadId, listeners: new Set(), activity: null, generation: 0, attempts: 0 };
34
- this.watches.set(threadId, watch);
35
- }
36
- watch.listeners.add(listener); listener({ threadId, activity: watch.activity });
37
- if (!watch.controller && !watch.timer) void this.start(watch);
38
- let released = false;
39
- return () => {
40
- if (released) return; released = true; watch.listeners.delete(listener);
41
- if (!watch.listeners.size) { this.watches.delete(threadId); this.stop(watch); }
42
- };
43
- }
44
- publish(watch, activity) {
45
- if (JSON.stringify(watch.activity) === JSON.stringify(activity)) return;
46
- watch.activity = activity;
47
- for (const listener of watch.listeners) listener({ threadId: watch.threadId, activity });
48
- }
49
- refresh(watch) {
50
- const activity = watch.typing ? { kind: 'replying', label: '正在回复…', spinning: true }
51
- : watch.liveActivity || (watch.handoffTimer ? { kind: 'replying', label: '正在回复…', spinning: true }
52
- : watch.unavailable ? { kind: 'unavailable', label: '活动状态暂不可用', spinning: false } : null);
53
- this.publish(watch, activity);
54
- }
55
- clearReply(watch) {
56
- clearTimeout(watch.typingTimer); clearTimeout(watch.handoffTimer);
57
- watch.typingTimer = null; watch.handoffTimer = null; watch.typing = false; watch.handoffMessageId = null;
58
- }
59
- handoff(watch, messageId = null) {
60
- const wasTyping = watch.typing;
61
- clearTimeout(watch.typingTimer); watch.typingTimer = null; watch.typing = false;
62
- if (messageId && (wasTyping || watch.handoffTimer)) watch.handoffMessageId = messageId;
63
- if (wasTyping && !watch.handoffTimer) {
64
- watch.handoffTimer = setTimeout(() => {
65
- watch.handoffTimer = null; watch.handoffMessageId = null;
66
- if (this.watches.get(watch.threadId) === watch) this.refresh(watch);
67
- }, handoffTimeout);
68
- watch.handoffTimer.unref();
69
- }
70
- this.refresh(watch);
71
- }
72
- completeReply(threadId, turns) {
73
- const watch = this.watches.get(threadId);
74
- if (!watch?.handoffMessageId || !turns.some(turn => turn.id === watch.handoffMessageId
75
- && turn.items.some(item => item.type === 'agentMessage' && !item.dotsStreaming))) return;
76
- // The matching formal message has entered the existing chat stream.
77
- clearTimeout(watch.handoffTimer); watch.handoffTimer = null; watch.handoffMessageId = null;
78
- this.refresh(watch);
79
- }
80
- roomEvent(watch, event) {
81
- if (!this.server.rooms.ready || !watch.roomId || event?.payload?.room_id !== watch.roomId) return;
82
- const payload = event.payload, heartbeat = event.type === 'calpico-is-responding-heartbeat';
83
- if (!heartbeat && event.type !== 'calpico-message-add') return;
84
- const room = this.server.bindings.get(watch.threadId);
85
- const sender = heartbeat ? payload.source : payload.message;
86
- if (room?.id !== watch.roomId || !room.aeon_id || heartbeat && sender?.role !== 'user'
87
- || !sender?.account_user_id
88
- || !room.members.some(member => member.account_user_id === sender.account_user_id && member.aeon_id === room.aeon_id)) return;
89
- if (!heartbeat || payload.typing === false) {
90
- this.handoff(watch, heartbeat ? null : payload.message.id); return;
91
- }
92
- if (payload.typing != null && payload.typing !== true) return;
93
- this.clearReply(watch); watch.typing = true;
94
- watch.typingTimer = setTimeout(() => {
95
- watch.typingTimer = null;
96
- if (this.watches.get(watch.threadId) === watch) this.handoff(watch);
97
- }, typingTimeout);
98
- watch.typingTimer.unref(); this.refresh(watch);
99
- }
100
- apply(watch, value) {
101
- if (value.subscription_id !== watch.subscriptionId || !Number.isSafeInteger(value.revision) || value.revision <= watch.revision) return;
102
- watch.revision = value.revision;
103
- if (value.type === 'calpico-room-live-error') {
104
- this.fail(watch, new Error('点点活动暂不可用。'), value.code === 'temporarily_unavailable'); return;
105
- }
106
- if (value.type !== 'calpico-room-live-state' || !Array.isArray(value.threads)) return;
107
- const current = value.threads[0];
108
- watch.attempts = 0; watch.unavailable = false; watch.liveActivity = null;
109
- if (current?.stream_state !== 'attached') { this.refresh(watch); return; }
110
- if (current.turn_status === 'failed') {
111
- this.clearReply(watch); watch.liveActivity = { kind: 'error', label: '回复出错', spinning: false };
112
- this.refresh(watch); return;
113
- }
114
- if (current.turn_status !== 'in_progress') { this.refresh(watch); return; }
115
- const status = typeof current.activity_status?.status_type === 'string' ? current.activity_status.status_type.toLowerCase() : '';
116
- const label = current.activity_status?.status_label;
117
- if (status !== 'ready') {
118
- const waiting = waitingStates.has(status);
119
- const fallback = Object.hasOwn(statusLabels, status) ? statusLabels[status] : null;
120
- if (waiting) this.clearReply(watch);
121
- watch.liveActivity = { kind: waiting ? 'waiting' : fallback ? 'operation' : 'thinking',
122
- label: typeof label === 'string' && label.trim() ? label : fallback || '正在思考…', spinning: !waiting };
123
- }
124
- this.refresh(watch);
125
- }
126
- async start(watch) {
127
- const generation = ++watch.generation, controller = new AbortController();
128
- watch.controller = controller; watch.pending = new Map(); watch.subscriptionId = null; watch.revision = -1;
129
- const current = () => this.watches.get(watch.threadId) === watch && generation === watch.generation && !controller.signal.aborted;
130
- try {
131
- await this.server.connect();
132
- const room = this.server.bindings.get(watch.threadId) || await this.server.room(watch.threadId);
133
- if (!current()) return;
134
- watch.roomId = room.id; watch.topic = `calpico-room-live:${room.id}`;
135
- await this.server.rooms.subscribe(watch.topic);
136
- if (!current()) { if (!this.watches.has(watch.threadId)) await this.server.rooms.unsubscribe(watch.topic); return; }
137
- await this.server.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/live`, {
138
- method: 'POST', signal: controller.signal, onEvent: value => {
139
- if (!current()) return;
140
- // The live SSE sends {} keepalives after ready; they carry no identity.
141
- if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) return;
142
- if (value?.type !== 'calpico-room-live-ready' || value.version !== 1 || value.room_id !== room.id
143
- || typeof value.subscription_id !== 'string' || watch.subscriptionId && watch.subscriptionId !== value.subscription_id) {
144
- throw new Error('点点活动订阅身份无效。');
145
- }
146
- watch.subscriptionId = value.subscription_id;
147
- const pending = watch.pending.get(value.subscription_id); watch.pending.clear();
148
- if (pending) this.apply(watch, pending);
149
- },
150
- });
151
- if (current()) this.fail(watch, new Error('点点活动连接已结束。'), true);
152
- } catch (error) { if (current()) this.fail(watch, error, ![401, 403, 404].includes(error.status)); }
153
- }
154
- fail(watch, error, retry) {
155
- this.stop(watch, true); watch.unavailable = true; this.refresh(watch); this.emit('unavailable', error);
156
- if (retry && watch.listeners.size && this.server.ready && watch.attempts < 5) {
157
- watch.timer = setTimeout(() => { watch.timer = null; void this.start(watch); }, Math.min(30000, 1000 * 2 ** watch.attempts++));
158
- watch.timer.unref();
159
- }
160
- }
161
- stop(watch, keepReply = false) {
162
- watch.generation++; clearTimeout(watch.timer); watch.timer = null;
163
- watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); watch.liveActivity = null;
164
- watch.subscriptionId = null; watch.revision = -1;
165
- if (!keepReply) { this.clearReply(watch); watch.unavailable = false; watch.roomId = null; }
166
- if (!keepReply) this.publish(watch, null);
167
- // The SSE cancellation ends the server watch. The shared topic remains
168
- // subscribed while this account socket lives, avoiding unsubscribe races.
169
- }
170
- close() { for (const watch of this.watches.values()) this.stop(watch); this.watches.clear(); }
171
- dispose() { this.close(); this.server.rooms.off('message', this.message); }
172
- }
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ // Desktop 26.1007.9446.0: room-live activity and responding heartbeats are
4
+ // independent. Heartbeats expire after 10s; Dot reply handoff lasts at most 5s.
5
+ const typingTimeout = 10000, handoffTimeout = 5000;
6
+ const statusLabels = {
7
+ working: '正在处理…', searching: '正在搜索…', creating: '正在创建…',
8
+ payment: '等待支付', 'needs input': '等待你的输入', paused: '已暂停',
9
+ };
10
+ const waitingStates = new Set(['payment', 'needs input', 'paused']);
11
+
12
+ export class DotsRoomActivity extends EventEmitter {
13
+ constructor(server) {
14
+ super(); this.server = server; this.watches = new Map();
15
+ this.message = (topic, event) => {
16
+ for (const watch of this.watches.values()) {
17
+ if (topic === 'calpico-chatgpt-messaging') { this.roomEvent(watch, event); continue; }
18
+ if (event?.type !== 'calpico-room-live') continue;
19
+ const value = event.payload;
20
+ if (watch.topic !== topic || watch.roomId !== value?.room_id || value.version !== 1) continue;
21
+ if (!watch.subscriptionId) {
22
+ if (watch.pending.size >= 4) watch.pending.delete(watch.pending.keys().next().value);
23
+ const old = watch.pending.get(value.subscription_id);
24
+ if (!old || value.revision > old.revision) watch.pending.set(value.subscription_id, value);
25
+ } else this.apply(watch, value);
26
+ }
27
+ };
28
+ server.rooms.on('message', this.message);
29
+ }
30
+ acquire(threadId, listener) {
31
+ let watch = this.watches.get(threadId);
32
+ if (!watch) {
33
+ watch = { threadId, listeners: new Set(), activity: null, generation: 0, attempts: 0 };
34
+ this.watches.set(threadId, watch);
35
+ }
36
+ watch.listeners.add(listener); listener({ threadId, activity: watch.activity });
37
+ if (!watch.controller && !watch.timer) void this.start(watch);
38
+ let released = false;
39
+ return () => {
40
+ if (released) return; released = true; watch.listeners.delete(listener);
41
+ if (!watch.listeners.size) { this.watches.delete(threadId); this.stop(watch); }
42
+ };
43
+ }
44
+ publish(watch, activity) {
45
+ if (JSON.stringify(watch.activity) === JSON.stringify(activity)) return;
46
+ watch.activity = activity;
47
+ for (const listener of watch.listeners) listener({ threadId: watch.threadId, activity });
48
+ }
49
+ refresh(watch) {
50
+ const activity = watch.typing ? { kind: 'replying', label: '正在回复…', spinning: true }
51
+ : watch.liveActivity || (watch.handoffTimer ? { kind: 'replying', label: '正在回复…', spinning: true }
52
+ : watch.unavailable ? { kind: 'unavailable', label: '活动状态暂不可用', spinning: false } : null);
53
+ this.publish(watch, activity);
54
+ }
55
+ clearReply(watch) {
56
+ clearTimeout(watch.typingTimer); clearTimeout(watch.handoffTimer);
57
+ watch.typingTimer = null; watch.handoffTimer = null; watch.typing = false; watch.handoffMessageId = null;
58
+ }
59
+ handoff(watch, messageId = null) {
60
+ const wasTyping = watch.typing;
61
+ clearTimeout(watch.typingTimer); watch.typingTimer = null; watch.typing = false;
62
+ if (messageId && (wasTyping || watch.handoffTimer)) watch.handoffMessageId = messageId;
63
+ if (wasTyping && !watch.handoffTimer) {
64
+ watch.handoffTimer = setTimeout(() => {
65
+ watch.handoffTimer = null; watch.handoffMessageId = null;
66
+ if (this.watches.get(watch.threadId) === watch) this.refresh(watch);
67
+ }, handoffTimeout);
68
+ watch.handoffTimer.unref();
69
+ }
70
+ this.refresh(watch);
71
+ }
72
+ completeReply(threadId, turns) {
73
+ const watch = this.watches.get(threadId);
74
+ if (!watch?.handoffMessageId || !turns.some(turn => turn.id === watch.handoffMessageId
75
+ && turn.items.some(item => item.type === 'agentMessage' && !item.dotsStreaming))) return;
76
+ // The matching formal message has entered the existing chat stream.
77
+ clearTimeout(watch.handoffTimer); watch.handoffTimer = null; watch.handoffMessageId = null;
78
+ this.refresh(watch);
79
+ }
80
+ roomEvent(watch, event) {
81
+ if (!this.server.rooms.ready || !watch.roomId || event?.payload?.room_id !== watch.roomId) return;
82
+ const payload = event.payload, heartbeat = event.type === 'calpico-is-responding-heartbeat';
83
+ if (!heartbeat && event.type !== 'calpico-message-add') return;
84
+ const room = this.server.bindings.get(watch.threadId);
85
+ const sender = heartbeat ? payload.source : payload.message;
86
+ if (room?.id !== watch.roomId || !room.aeon_id || heartbeat && sender?.role !== 'user'
87
+ || !sender?.account_user_id
88
+ || !room.members.some(member => member.account_user_id === sender.account_user_id && member.aeon_id === room.aeon_id)) return;
89
+ if (!heartbeat || payload.typing === false) {
90
+ this.handoff(watch, heartbeat ? null : payload.message.id); return;
91
+ }
92
+ if (payload.typing != null && payload.typing !== true) return;
93
+ this.clearReply(watch); watch.typing = true;
94
+ watch.typingTimer = setTimeout(() => {
95
+ watch.typingTimer = null;
96
+ if (this.watches.get(watch.threadId) === watch) this.handoff(watch);
97
+ }, typingTimeout);
98
+ watch.typingTimer.unref(); this.refresh(watch);
99
+ }
100
+ apply(watch, value) {
101
+ if (value.subscription_id !== watch.subscriptionId || !Number.isSafeInteger(value.revision) || value.revision <= watch.revision) return;
102
+ watch.revision = value.revision;
103
+ if (value.type === 'calpico-room-live-error') {
104
+ this.fail(watch, new Error('点点活动暂不可用。'), value.code === 'temporarily_unavailable'); return;
105
+ }
106
+ if (value.type !== 'calpico-room-live-state' || !Array.isArray(value.threads)) return;
107
+ const current = value.threads[0];
108
+ watch.attempts = 0; watch.unavailable = false; watch.liveActivity = null;
109
+ if (current?.stream_state !== 'attached') { this.refresh(watch); return; }
110
+ if (current.turn_status === 'failed') {
111
+ this.clearReply(watch); watch.liveActivity = { kind: 'error', label: '回复出错', spinning: false };
112
+ this.refresh(watch); return;
113
+ }
114
+ if (current.turn_status !== 'in_progress') { this.refresh(watch); return; }
115
+ const status = typeof current.activity_status?.status_type === 'string' ? current.activity_status.status_type.toLowerCase() : '';
116
+ const label = current.activity_status?.status_label;
117
+ if (status !== 'ready') {
118
+ const waiting = waitingStates.has(status);
119
+ const fallback = Object.hasOwn(statusLabels, status) ? statusLabels[status] : null;
120
+ if (waiting) this.clearReply(watch);
121
+ watch.liveActivity = { kind: waiting ? 'waiting' : fallback ? 'operation' : 'thinking',
122
+ label: typeof label === 'string' && label.trim() ? label : fallback || '正在思考…', spinning: !waiting };
123
+ }
124
+ this.refresh(watch);
125
+ }
126
+ async start(watch) {
127
+ const generation = ++watch.generation, controller = new AbortController();
128
+ watch.controller = controller; watch.pending = new Map(); watch.subscriptionId = null; watch.revision = -1;
129
+ const current = () => this.watches.get(watch.threadId) === watch && generation === watch.generation && !controller.signal.aborted;
130
+ try {
131
+ await this.server.connect();
132
+ const room = this.server.bindings.get(watch.threadId) || await this.server.room(watch.threadId);
133
+ if (!current()) return;
134
+ watch.roomId = room.id; watch.topic = `calpico-room-live:${room.id}`;
135
+ await this.server.rooms.subscribe(watch.topic);
136
+ if (!current()) { if (!this.watches.has(watch.threadId)) await this.server.rooms.unsubscribe(watch.topic); return; }
137
+ await this.server.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/live`, {
138
+ method: 'POST', signal: controller.signal, onEvent: value => {
139
+ if (!current()) return;
140
+ // The live SSE sends {} keepalives after ready; they carry no identity.
141
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) return;
142
+ if (value?.type !== 'calpico-room-live-ready' || value.version !== 1 || value.room_id !== room.id
143
+ || typeof value.subscription_id !== 'string' || watch.subscriptionId && watch.subscriptionId !== value.subscription_id) {
144
+ throw new Error('点点活动订阅身份无效。');
145
+ }
146
+ watch.subscriptionId = value.subscription_id;
147
+ const pending = watch.pending.get(value.subscription_id); watch.pending.clear();
148
+ if (pending) this.apply(watch, pending);
149
+ },
150
+ });
151
+ if (current()) this.fail(watch, new Error('点点活动连接已结束。'), true);
152
+ } catch (error) { if (current()) this.fail(watch, error, ![401, 403, 404].includes(error.status)); }
153
+ }
154
+ fail(watch, error, retry) {
155
+ this.stop(watch, true); watch.unavailable = true; this.refresh(watch); this.emit('unavailable', error);
156
+ if (retry && watch.listeners.size && this.server.ready && watch.attempts < 5) {
157
+ watch.timer = setTimeout(() => { watch.timer = null; void this.start(watch); }, Math.min(30000, 1000 * 2 ** watch.attempts++));
158
+ watch.timer.unref();
159
+ }
160
+ }
161
+ stop(watch, keepReply = false) {
162
+ watch.generation++; clearTimeout(watch.timer); watch.timer = null;
163
+ watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); watch.liveActivity = null;
164
+ watch.subscriptionId = null; watch.revision = -1;
165
+ if (!keepReply) { this.clearReply(watch); watch.unavailable = false; watch.roomId = null; }
166
+ if (!keepReply) this.publish(watch, null);
167
+ // The SSE cancellation ends the server watch. The shared topic remains
168
+ // subscribed while this account socket lives, avoiding unsubscribe races.
169
+ }
170
+ close() { for (const watch of this.watches.values()) this.stop(watch); this.watches.clear(); }
171
+ dispose() { this.close(); this.server.rooms.off('message', this.message); }
172
+ }