@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.59
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/README.md +105 -105
- package/dist/bin/cleanup-accounts.mjs +63 -63
- package/dist/bin/codex-remote.mjs +403 -403
- package/dist/bin/publish-android-release.mjs +63 -63
- package/dist/bin/release-upload-server.mjs +190 -190
- package/dist/bin/set-relay-admin-password.mjs +11 -0
- package/dist/build-platform.json +1 -1
- package/dist/get-shared-service-url.ps1 +14 -14
- package/dist/shared/code-browser.cjs +159 -159
- package/dist/shared/file-changes.cjs +31 -31
- package/dist/shared/file-citations.cjs +117 -117
- package/dist/shared/file-mentions.cjs +92 -92
- package/dist/shared/message-delivery.cjs +115 -115
- package/dist/shared/p2p-file-channel.cjs +87 -87
- package/dist/shared/release-version.cjs +31 -0
- package/dist/shared/server-requests.cjs +311 -311
- package/dist/shared/timeline-sync.cjs +173 -173
- package/dist/skills/code-relax/SKILL.md +22 -22
- package/dist/src/bounded-process-log.mjs +63 -63
- package/dist/src/celsius-client.mjs +107 -107
- package/dist/src/dots-approval-timeline.mjs +99 -99
- package/dist/src/dots-avatar.mjs +42 -42
- package/dist/src/dots-chat-projection.mjs +50 -50
- package/dist/src/dots-chat-server.mjs +483 -483
- package/dist/src/dots-message-images.mjs +319 -319
- package/dist/src/dots-profile.mjs +77 -77
- package/dist/src/dots-room-activity.mjs +172 -172
- package/dist/src/dots-room-client.mjs +137 -135
- package/dist/src/dots-room-projection.mjs +94 -94
- package/dist/src/dots-schedules.mjs +66 -66
- package/dist/src/dots-summary-tasks.mjs +32 -32
- package/dist/src/dots-task-catalog.mjs +430 -430
- package/dist/src/file-transfer-range.mjs +22 -22
- package/dist/src/file-upload-session.mjs +146 -130
- package/dist/src/hosted-history-app-server.mjs +103 -103
- package/dist/src/hosted-history-cache.mjs +113 -113
- package/dist/src/lan-socket-server.mjs +338 -266
- package/dist/src/managed-app-server.mjs +373 -373
- package/dist/src/managed-endpoint.mjs +25 -25
- package/dist/src/message-upload-files.mjs +109 -109
- package/dist/src/onboarding.mjs +12 -12
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/codex-protocol.mjs +46 -46
- package/dist/src/platform/desktop-project-store.mjs +219 -208
- package/dist/src/platform/linux/desktop-host.mjs +87 -87
- package/dist/src/platform/linux/process.mjs +53 -53
- package/dist/src/platform/linux/system.mjs +50 -50
- package/dist/src/platform/linux/user-environment.mjs +9 -9
- package/dist/src/platform/macos/codex-runtime.mjs +125 -125
- package/dist/src/platform/macos/desktop-host.mjs +86 -86
- package/dist/src/platform/macos/user-environment.mjs +9 -9
- package/dist/src/platform/native-package.mjs +58 -58
- package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
- package/dist/src/platform/windows/codex-runtime.mjs +215 -215
- package/dist/src/platform/windows/credential-store.mjs +27 -27
- package/dist/src/platform/windows/credential-store.ps1 +49 -49
- package/dist/src/platform/windows/desktop-host.mjs +65 -65
- package/dist/src/platform/windows/desktop-info.ps1 +38 -38
- package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
- package/dist/src/platform/windows/dots-profile.mjs +49 -49
- package/dist/src/platform/windows/launch-worker.mjs +41 -41
- package/dist/src/platform/windows/project-locations.mjs +16 -16
- package/dist/src/platform/windows/service-host.mjs +35 -35
- package/dist/src/platform/windows/user-environment.mjs +52 -52
- package/dist/src/project-management.mjs +58 -58
- package/dist/src/runtime-discovery.mjs +47 -47
- package/dist/src/self-relay/account-cleanup.mjs +30 -30
- package/dist/src/self-relay/admin-auth.mjs +59 -0
- package/dist/src/self-relay/admin-login-page.mjs +7 -0
- package/dist/src/self-relay/admin-page.mjs +87 -80
- package/dist/src/self-relay/admin-server.mjs +49 -18
- package/dist/src/self-relay/admin-style.mjs +58 -14
- package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
- package/dist/src/self-relay/admin-workstations.mjs +143 -0
- package/dist/src/self-relay/connection-reporting.mjs +38 -0
- package/dist/src/self-relay/connector.mjs +444 -439
- package/dist/src/self-relay/demo.mjs +31 -31
- package/dist/src/self-relay/diagnostics.mjs +54 -54
- package/dist/src/self-relay/email-accounts.mjs +183 -183
- package/dist/src/self-relay/file-wire.mjs +13 -13
- package/dist/src/self-relay/lifecycle.mjs +11 -0
- package/dist/src/self-relay/p2p-probe.mjs +168 -168
- package/dist/src/self-relay/phone-connections.mjs +55 -0
- package/dist/src/self-relay/privacy-page.mjs +26 -26
- package/dist/src/self-relay/public-releases.mjs +67 -67
- package/dist/src/self-relay/rtc-file-bridge.mjs +46 -41
- package/dist/src/self-relay/server.mjs +63 -3
- package/dist/src/self-relay/usage-meter.mjs +141 -141
- package/dist/src/server.mjs +23 -9
- package/dist/src/service-doctor.mjs +148 -148
- package/dist/src/service-install.mjs +97 -97
- package/dist/src/service-lifecycle.mjs +139 -139
- package/dist/src/service-update.mjs +6 -3
- package/dist/src/shared-app-server.mjs +683 -666
- package/dist/src/shared-catalog.mjs +132 -132
- package/dist/src/shared-thread-stream.mjs +165 -165
- package/dist/src/usage-reset.mjs +17 -17
- package/dist/src/workstation-downloads.mjs +120 -120
- package/package.json +4 -4
- 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
|
+
}
|