@walkhi/code-relax 0.1.0-beta.26 → 0.1.0-beta.28
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 +80 -67
- package/dist/bin/codex-remote.mjs +286 -239
- package/dist/shared/app-server-events.cjs +102 -16
- package/dist/shared/code-browser.cjs +159 -0
- package/dist/shared/file-changes.cjs +31 -0
- package/dist/shared/file-citations.cjs +117 -0
- package/dist/shared/file-mentions.cjs +80 -35
- package/dist/shared/message-delivery.cjs +38 -0
- package/dist/shared/p2p-file-channel.cjs +74 -0
- package/dist/shared/server-requests.cjs +149 -41
- package/dist/shared/service-endpoints.cjs +11 -11
- package/dist/shared/timeline-sync.cjs +173 -156
- package/dist/skills/code-relax/SKILL.md +2 -1
- package/dist/src/bounded-process-log.mjs +63 -0
- package/dist/src/celsius-client.mjs +107 -0
- package/dist/src/code-browser.mjs +101 -0
- package/dist/src/desktop-launcher.mjs +30 -30
- package/dist/src/dots-approval-timeline.mjs +99 -0
- package/dist/src/dots-avatar.mjs +42 -0
- package/dist/src/dots-chat-projection.mjs +50 -0
- package/dist/src/dots-chat-server.mjs +412 -0
- package/dist/src/dots-message-images.mjs +319 -0
- package/dist/src/dots-profile.mjs +77 -0
- package/dist/src/dots-room-activity.mjs +94 -0
- package/dist/src/dots-room-client.mjs +135 -0
- package/dist/src/dots-room-projection.mjs +94 -0
- package/dist/src/dots-schedules.mjs +66 -0
- package/dist/src/dots-summary-tasks.mjs +32 -0
- package/dist/src/dots-task-catalog.mjs +411 -0
- package/dist/src/file-transfer-range.mjs +22 -0
- package/dist/src/file-upload-session.mjs +130 -124
- package/dist/src/hosted-history-app-server.mjs +103 -0
- package/dist/src/hosted-history-cache.mjs +113 -0
- package/dist/src/lan-socket-server.mjs +205 -160
- package/dist/src/managed-app-server.mjs +241 -238
- package/dist/src/message-upload-files.mjs +74 -0
- package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
- package/dist/src/platform/windows/codex-runtime.mjs +253 -251
- package/dist/src/platform/windows/desktop-host.mjs +38 -32
- package/dist/src/platform/windows/desktop-info.ps1 +2 -2
- package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
- package/dist/src/platform/windows/dots-profile.mjs +49 -0
- package/dist/src/platform/windows/launch-worker.mjs +41 -42
- package/dist/src/platform/windows/project-locations.mjs +16 -0
- package/dist/src/project-create.mjs +119 -0
- package/dist/src/project-management.mjs +69 -0
- package/dist/src/self-relay/account-connections.mjs +52 -0
- package/dist/src/self-relay/admin-page.mjs +80 -80
- package/dist/src/self-relay/admin-style.mjs +14 -9
- package/dist/src/self-relay/connector.mjs +22 -4
- package/dist/src/self-relay/file-wire.mjs +13 -0
- package/dist/src/self-relay/p2p-probe.mjs +152 -123
- package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
- package/dist/src/self-relay/server.mjs +894 -849
- package/dist/src/server.mjs +463 -148
- package/dist/src/service-doctor.mjs +72 -61
- package/dist/src/service-lifecycle.mjs +51 -43
- package/dist/src/shared-app-server.mjs +166 -107
- package/dist/src/shared-catalog.mjs +132 -40
- package/dist/src/shared-thread-stream.mjs +103 -25
- package/dist/src/usage-reset.mjs +17 -0
- package/dist/src/workstation-downloads.mjs +120 -0
- package/package.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import protocol from '../shared/code-browser.cjs';
|
|
5
|
+
import { fileRange, fileRevision } from './file-transfer-range.mjs';
|
|
6
|
+
|
|
7
|
+
async function checkedPath(root, relative) {
|
|
8
|
+
const target = protocol.child(root, relative);
|
|
9
|
+
const [base, real] = await Promise.all([fs.realpath(root), fs.realpath(target)]);
|
|
10
|
+
if (!protocol.inside(base, real)) protocol.fail('此链接指向项目目录之外,无法浏览。', 403);
|
|
11
|
+
return real;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function git(cwd, args) {
|
|
15
|
+
return new Promise((resolve, reject) => execFile('git', ['--no-optional-locks', '--literal-pathspecs', '-c', 'core.quotePath=false', ...args],
|
|
16
|
+
{ cwd, encoding: 'utf8', maxBuffer: protocol.maxBytes, timeout: 15000, windowsHide: true },
|
|
17
|
+
(error, stdout, stderr) => {
|
|
18
|
+
if (error) reject(Object.assign(new Error(error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
|
19
|
+
? 'Git 输出超过预览上限(1 MiB)。' : String(stderr || error.message).trim()), { statusCode: 422 }));
|
|
20
|
+
else resolve(stdout);
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function browseCode(rpc, operation, query) {
|
|
25
|
+
if (operation === 'change') return protocol.change(rpc, query);
|
|
26
|
+
const context = await protocol.context(rpc, query);
|
|
27
|
+
if (operation === 'roots') return { ...context, workspaceDiff: true, sizeCheckedBeforeRead: true };
|
|
28
|
+
if (operation === 'changes') return protocol.changes(rpc, query);
|
|
29
|
+
const root = protocol.selectedRoot(context, query);
|
|
30
|
+
if (operation === 'workspace' || operation === 'workspace-file') {
|
|
31
|
+
const realRoot = await checkedPath(root, '');
|
|
32
|
+
const repository = (await git(realRoot, ['rev-parse', '--show-toplevel'])).trim();
|
|
33
|
+
const scope = path.relative(repository, realRoot).replaceAll('\\', '/') || '.';
|
|
34
|
+
if (scope.startsWith('..') || path.isAbsolute(scope)) protocol.fail('无法确认 Git 仓库范围。', 403);
|
|
35
|
+
const toRelative = name => protocol.relative(realRoot, path.resolve(repository, name));
|
|
36
|
+
if (operation === 'workspace-file') {
|
|
37
|
+
const group = query.get('group');
|
|
38
|
+
if (!['staged', 'unstaged'].includes(group)) protocol.fail('未知的变更来源。');
|
|
39
|
+
const names = [query.get('path'), query.get('oldPath')].filter(Boolean);
|
|
40
|
+
if (!names.length) protocol.fail('请选择变更文件。');
|
|
41
|
+
const paths = names.map(name => path.relative(repository, protocol.child(realRoot, name)).replaceAll('\\', '/'));
|
|
42
|
+
const diff = await git(repository, ['diff', ...(group === 'staged' ? ['--cached'] : []), '--no-ext-diff', '--no-textconv', '--no-color', names.length > 1 ? '--find-renames' : '--no-renames', '--', ...paths]);
|
|
43
|
+
return { diff, scope: group === 'staged' ? '当前工作区 · 已暂存' : '当前工作区 · 未暂存' };
|
|
44
|
+
}
|
|
45
|
+
const entries = [];
|
|
46
|
+
for (const group of ['unstaged', 'staged']) {
|
|
47
|
+
const fields = (await git(repository, ['diff', ...(group === 'staged' ? ['--cached'] : []), '--name-status', '-z', '--find-renames', '--no-ext-diff', '--no-textconv', '--', scope])).split('\0');
|
|
48
|
+
for (let i = 0; i < fields.length && fields[i];) {
|
|
49
|
+
const status = fields[i++], first = fields[i++];
|
|
50
|
+
const renamed = /^[RC]/.test(status), last = renamed ? fields[i++] : first;
|
|
51
|
+
const name = toRelative(last), oldPath = renamed ? toRelative(first) : null;
|
|
52
|
+
if (name == null) { if (oldPath != null) entries.push({ path: oldPath, oldPath: null, status: 'D', group }); continue; }
|
|
53
|
+
entries.push({ path: name, oldPath, status: renamed && oldPath == null ? 'A' : status, group });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const name of (await git(repository, ['ls-files', '--others', '--exclude-standard', '-z', '--', scope])).split('\0').filter(Boolean)) {
|
|
57
|
+
const relative = toRelative(name);
|
|
58
|
+
if (relative != null) entries.push({ path: relative, status: '?', group: 'untracked' });
|
|
59
|
+
}
|
|
60
|
+
if (entries.length > protocol.maxEntries) protocol.fail('变更文件超过 4000 项,暂不支持展示。', 413);
|
|
61
|
+
return { root, entries, scope: '当前工作区快照 · 非本次任务归属' };
|
|
62
|
+
}
|
|
63
|
+
const relative = query.get('path') || '';
|
|
64
|
+
const target = await checkedPath(root, relative);
|
|
65
|
+
if ((operation === 'image' || operation === 'binary')) {
|
|
66
|
+
const handle = await fs.open(target, 'r');
|
|
67
|
+
try {
|
|
68
|
+
const before = await handle.stat();
|
|
69
|
+
if (!before.isFile()) protocol.fail('请选择普通文件。', 415);
|
|
70
|
+
if (before.size > protocol.maxFileBytes) protocol.fail('文件超过预览上限(20 MiB)。', 413);
|
|
71
|
+
if (!before.size) protocol.fail('文件为空。', 415);
|
|
72
|
+
const revision = fileRevision(before);
|
|
73
|
+
const { offset, length } = fileRange(query, before.size, revision);
|
|
74
|
+
const header = Buffer.alloc(12);
|
|
75
|
+
await handle.read(header, 0, header.length, 0);
|
|
76
|
+
const mimeType = operation === 'binary' ? 'application/octet-stream' : protocol.imageMime(header);
|
|
77
|
+
const data = Buffer.alloc(length);
|
|
78
|
+
const { bytesRead } = await handle.read(data, 0, data.length, offset);
|
|
79
|
+
const real = await checkedPath(root, relative), after = await handle.stat(), onDisk = await fs.stat(real);
|
|
80
|
+
if (real !== target || bytesRead !== data.length || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || after.size !== before.size || onDisk.ino !== before.ino || onDisk.dev !== before.dev || onDisk.mtimeMs !== before.mtimeMs || onDisk.size !== before.size) protocol.fail('读取期间文件发生变化,请重新打开。', 409);
|
|
81
|
+
return { name: path.basename(target), size: before.size, offset, revision, mimeType, data };
|
|
82
|
+
} finally { await handle.close(); }
|
|
83
|
+
}
|
|
84
|
+
if (operation === 'directory') {
|
|
85
|
+
const result = await rpc('fs/readDirectory', { path: target });
|
|
86
|
+
await checkedPath(root, relative);
|
|
87
|
+
return protocol.directory(result);
|
|
88
|
+
}
|
|
89
|
+
if (operation === 'file') {
|
|
90
|
+
const before = await fs.stat(target);
|
|
91
|
+
if (!before.isFile()) protocol.fail('请选择普通文件。', 415);
|
|
92
|
+
if (before.size > protocol.maxBytes) protocol.fail('文件超过只读预览上限(1 MiB)。', 413);
|
|
93
|
+
const result = await rpc('fs/readFile', { path: target });
|
|
94
|
+
const real = await checkedPath(root, relative), after = await fs.stat(real);
|
|
95
|
+
if (real !== target || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== after.ino) {
|
|
96
|
+
protocol.fail('读取期间文件发生变化,请刷新。', 409);
|
|
97
|
+
}
|
|
98
|
+
return { ...protocol.fromBase64(result.dataBase64), modifiedAtMs: after.mtimeMs };
|
|
99
|
+
}
|
|
100
|
+
protocol.fail('未知的代码浏览操作。', 404);
|
|
101
|
+
}
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
-
import { readRecord } from './service-lifecycle.mjs';
|
|
5
|
-
import { ManagedAppServer, managedAppServerUrl as sharedUrl, managedPortAvailable as portAvailable } from './managed-app-server.mjs';
|
|
6
|
-
export { portAvailable };
|
|
7
|
-
export async function launchDesktop({ stateRoot, host, environment = process.env, available = portAvailable, wait = delay, timeout = 30000, recoveryId, progress = () => {} }) {
|
|
8
|
-
fs.mkdirSync(stateRoot, { recursive: true });
|
|
9
|
-
const recovery = readRecord(path.join(stateRoot, 'shared-recovery.lock'));
|
|
10
|
-
if (recovery && recovery.operationId !== recoveryId) throw new Error('Codex 内核正在启动,请用 relax doctor 查看恢复状态。');
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { readRecord } from './service-lifecycle.mjs';
|
|
5
|
+
import { ManagedAppServer, managedAppServerUrl as sharedUrl, managedPortAvailable as portAvailable } from './managed-app-server.mjs';
|
|
6
|
+
export { portAvailable };
|
|
7
|
+
export async function launchDesktop({ stateRoot, host, environment = process.env, available = portAvailable, wait = delay, timeout = 30000, recoveryId, progress = () => {} }) {
|
|
8
|
+
fs.mkdirSync(stateRoot, { recursive: true });
|
|
9
|
+
const recovery = readRecord(path.join(stateRoot, 'shared-recovery.lock'));
|
|
10
|
+
if (recovery && recovery.operationId !== recoveryId) throw new Error('Codex 内核正在启动,请用 relax doctor 查看恢复状态。');
|
|
11
11
|
const lock = path.join(stateRoot, 'desktop-launch.lock');
|
|
12
|
-
let descriptor;
|
|
13
|
-
let managed;
|
|
12
|
+
let descriptor;
|
|
13
|
+
let managed;
|
|
14
14
|
try { descriptor = fs.openSync(lock, 'wx'); }
|
|
15
15
|
catch (error) {
|
|
16
16
|
if (error.code === 'EEXIST') throw new Error('另一个 Desktop 启动操作正在进行;若上次异常退出,请确认没有启动器运行后移除 desktop-launch.lock。');
|
|
17
17
|
throw error;
|
|
18
18
|
}
|
|
19
19
|
try {
|
|
20
|
-
const info = await host.inspect();
|
|
21
|
-
if (!info.desktop) throw new Error('未安装 Codex Desktop,请使用 relax start 启动工作站。');
|
|
20
|
+
const info = await host.inspect();
|
|
21
|
+
if (!info.desktop) throw new Error('未安装 Codex Desktop,请使用 relax start 启动工作站。');
|
|
22
22
|
const connected = await host.sharedServiceUrl();
|
|
23
23
|
if (info.running) {
|
|
24
|
-
if (connected) return { reused: true };
|
|
25
|
-
throw new Error('请先保存任务并完全退出 Codex Desktop,再接入 Codex 内核;不会自动结束正在运行的任务。');
|
|
24
|
+
if (connected) return { reused: true };
|
|
25
|
+
throw new Error('请先保存任务并完全退出 Codex Desktop,再接入 Codex 内核;不会自动结束正在运行的任务。');
|
|
26
26
|
}
|
|
27
|
-
const childEnv = { ...environment };
|
|
27
|
+
const childEnv = { ...environment };
|
|
28
28
|
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnv[key];
|
|
29
|
-
progress('starting-app-server');
|
|
30
|
-
managed = new ManagedAppServer({ stateRoot, host, environment, available, wait, timeout });
|
|
31
|
-
if (!await managed.start()) throw new Error(managed.snapshot().error || 'Codex 内核未就绪。');
|
|
32
|
-
const runtime = readRecord(path.join(stateRoot, 'codex-runtime.json'));
|
|
33
|
-
if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
|
|
34
|
-
childEnv.CODEX_APP_SERVER_WS_URL = sharedUrl;
|
|
35
|
-
progress('starting-desktop');
|
|
36
|
-
await host.
|
|
29
|
+
progress('starting-app-server');
|
|
30
|
+
managed = new ManagedAppServer({ stateRoot, host, environment, available, wait, timeout });
|
|
31
|
+
if (!await managed.start()) throw new Error(managed.snapshot().error || 'Codex 内核未就绪。');
|
|
32
|
+
const runtime = readRecord(path.join(stateRoot, 'codex-runtime.json'));
|
|
33
|
+
if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
|
|
34
|
+
childEnv.CODEX_APP_SERVER_WS_URL = sharedUrl;
|
|
35
|
+
progress('starting-desktop');
|
|
36
|
+
await host.startDesktop(info.desktop, childEnv);
|
|
37
37
|
const deadline = Date.now() + timeout;
|
|
38
38
|
while (true) {
|
|
39
|
-
const ready = await host.
|
|
39
|
+
const ready = await host.desktopReady();
|
|
40
40
|
if (ready) break;
|
|
41
|
-
if (Date.now() >= deadline) throw new Error('Desktop 尚未接入 Codex 内核。请查看 Desktop 窗口后重试。');
|
|
41
|
+
if (Date.now() >= deadline) throw new Error('Desktop 尚未接入 Codex 内核。请查看 Desktop 窗口后重试。');
|
|
42
42
|
await wait(500);
|
|
43
43
|
}
|
|
44
|
-
return { reused: false };
|
|
45
|
-
} finally { managed?.close(); fs.closeSync(descriptor); fs.rmSync(lock, { force: true }); }
|
|
46
|
-
}
|
|
44
|
+
return { reused: false };
|
|
45
|
+
} finally { managed?.close(); fs.closeSync(descriptor); fs.rmSync(lock, { force: true }); }
|
|
46
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const itemTime = item => item.dotsReceipt?.sentAt ? Date.parse(item.dotsReceipt.sentAt)
|
|
2
|
+
: item.startedAtMs ?? item.receivedAtMs;
|
|
3
|
+
const terminal = status => ['resolved', 'expired'].includes(status);
|
|
4
|
+
|
|
5
|
+
// Approval records have their own official room source. Keep them with the
|
|
6
|
+
// account-scoped hosted cache, then place them in the chat's observed positions.
|
|
7
|
+
export class DotsApprovalTimeline {
|
|
8
|
+
constructor(server) { this.server = server; this.states = new Map(); }
|
|
9
|
+
|
|
10
|
+
async state(threadId) {
|
|
11
|
+
const scope = this.server.historyScope(threadId);
|
|
12
|
+
if (!scope) throw new Error('读取点点审批历史需要账号身份。');
|
|
13
|
+
let state = this.states.get(scope);
|
|
14
|
+
if (!state) {
|
|
15
|
+
state = { scope, threadId, epoch: this.server.epoch, records: new Map() };
|
|
16
|
+
state.ready = (async () => {
|
|
17
|
+
const saved = await this.server.historyCache?.get(scope, 'approval-records');
|
|
18
|
+
for (const record of saved?.data || []) {
|
|
19
|
+
// A display record never reactivates an old pending request.
|
|
20
|
+
state.records.set(this.key(record), terminal(record.status) ? record : { ...record, status: 'expired' });
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
23
|
+
this.states.set(scope, state);
|
|
24
|
+
}
|
|
25
|
+
await state.ready;
|
|
26
|
+
this.check(threadId, state);
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
check(threadId, state) {
|
|
31
|
+
if (state.scope !== this.server.historyScope(threadId) || state.epoch !== this.server.epoch) throw new Error('点点审批历史的账号连接已变化。');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
key(item) { return JSON.stringify([typeof item.requestId, item.requestId]); }
|
|
35
|
+
|
|
36
|
+
merge(state, incoming) {
|
|
37
|
+
let changed = false;
|
|
38
|
+
for (const item of incoming) {
|
|
39
|
+
const key = this.key(item), previous = state.records.get(key);
|
|
40
|
+
const merged = { ...previous, ...item, id: previous?.id || item.id,
|
|
41
|
+
receivedAtMs: previous?.receivedAtMs ?? item.receivedAtMs,
|
|
42
|
+
...(previous?.result && item.result == null ? { result: previous.result } : {}),
|
|
43
|
+
...(previous?.afterItemId ? { afterItemId: previous.afterItemId } : {}) };
|
|
44
|
+
if (JSON.stringify(previous) !== JSON.stringify(merged)) { state.records.set(key, merged); changed = true; }
|
|
45
|
+
}
|
|
46
|
+
return changed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
save(state) {
|
|
50
|
+
return this.server.historyCache?.put(state.scope, 'approval-records', { data: [...state.records.values()] });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
targets(sourceThreadId) {
|
|
54
|
+
return [...this.states.values()].filter(state => state.scope === this.server.historyScope(state.threadId)
|
|
55
|
+
&& [...state.records.values()].some(record => record.params.threadId === sourceThreadId)).map(state => state.threadId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async observe(threadId, records, turns) {
|
|
59
|
+
const state = await this.state(threadId);
|
|
60
|
+
const incoming = records.map(record => {
|
|
61
|
+
const turn = turns?.find(turn => turn.id === record.turnId);
|
|
62
|
+
const afterItemId = turn?.items.filter(item => item.type !== 'approval').at(-1)?.id;
|
|
63
|
+
return { ...record, ...(afterItemId ? { afterItemId } : {}) };
|
|
64
|
+
});
|
|
65
|
+
if (this.merge(state, incoming)) await this.save(state);
|
|
66
|
+
this.check(threadId, state);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
overlay(threadId, turns, range = {}) {
|
|
70
|
+
const state = this.states.get(this.server.historyScope(threadId));
|
|
71
|
+
if (!state) return turns;
|
|
72
|
+
const ids = new Set([...state.records.values()].map(item => item.id));
|
|
73
|
+
const result = turns.map(turn => ({ ...turn, items: turn.items.filter(item => item.type !== 'approval' || !ids.has(item.id)) }));
|
|
74
|
+
for (const approval of [...state.records.values()].sort((a, b) => a.receivedAtMs - b.receivedAtMs)) {
|
|
75
|
+
if (Number.isFinite(range.fromMs) && approval.receivedAtMs < range.fromMs
|
|
76
|
+
|| Number.isFinite(range.toMs) && approval.receivedAtMs > range.toMs) continue;
|
|
77
|
+
let turn = result.find(turn => turn.id === approval.roomMessageId || turn.id === approval.turnId);
|
|
78
|
+
if (!turn) {
|
|
79
|
+
if (!Number.isFinite(range.fromMs)) continue;
|
|
80
|
+
const positions = result.flatMap(turn => turn.items.map(item => ({ turn, item, time: itemTime(item) })))
|
|
81
|
+
.filter(position => Number.isFinite(position.time));
|
|
82
|
+
turn = positions.filter(position => position.time <= approval.receivedAtMs).at(-1)?.turn || positions[0]?.turn;
|
|
83
|
+
}
|
|
84
|
+
if (!turn) continue;
|
|
85
|
+
const after = turn.items.findIndex(item => item.id === approval.afterItemId);
|
|
86
|
+
if (after >= 0) {
|
|
87
|
+
let index = after + 1;
|
|
88
|
+
while (turn.items[index]?.type === 'approval' && turn.items[index].receivedAtMs <= approval.receivedAtMs) index++;
|
|
89
|
+
turn.items.splice(index, 0, approval);
|
|
90
|
+
} else {
|
|
91
|
+
const next = turn.items.findIndex(item => Number.isFinite(itemTime(item)) && itemTime(item) > approval.receivedAtMs);
|
|
92
|
+
turn.items.splice(next < 0 ? turn.items.length : next, 0, approval);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clear() { this.states.clear(); }
|
|
99
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
// Resolve Desktop's saved snapshot on the workstation. Signed URLs never go to the phone.
|
|
4
|
+
export function createDotsAvatarReader(localServer) {
|
|
5
|
+
const cache = new Map();
|
|
6
|
+
return async (profile, identity) => {
|
|
7
|
+
if (!profile.avatarFileId) return '';
|
|
8
|
+
const key = JSON.stringify([identity.accountId, identity.userId, profile.avatarFileId]);
|
|
9
|
+
if (!cache.has(key)) {
|
|
10
|
+
const work = (async () => {
|
|
11
|
+
const auth = await localServer.request('getAuthStatus', { includeToken: true, refreshToken: false });
|
|
12
|
+
const principal = JSON.parse(Buffer.from(auth.authToken.split('.')[1], 'base64url'))['https://api.openai.com/auth'];
|
|
13
|
+
if (principal?.chatgpt_account_id !== identity.accountId || principal.chatgpt_user_id !== identity.userId) {
|
|
14
|
+
throw new Error('头像账号已变化,请刷新对话。');
|
|
15
|
+
}
|
|
16
|
+
const response = await fetch(`https://chatgpt.com/backend-api/files/download/${encodeURIComponent(profile.avatarFileId)}?download_intent=false&inline=true`, {
|
|
17
|
+
headers: { Authorization: `Bearer ${auth.authToken}`, 'ChatGPT-Account-Id': identity.accountId },
|
|
18
|
+
redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
19
|
+
});
|
|
20
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error(`点点头像资料读取失败(${response.status})`); }
|
|
21
|
+
const { download_url: downloadUrl } = await response.json();
|
|
22
|
+
const url = new URL(downloadUrl);
|
|
23
|
+
const authenticated = url.origin === 'https://chatgpt.com' && url.pathname === '/backend-api/estuary/content';
|
|
24
|
+
if (url.protocol !== 'https:' || !(authenticated || url.hostname.endsWith('.oaiusercontent.com'))) throw new Error('点点头像下载地址无效。');
|
|
25
|
+
const image = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
26
|
+
...(authenticated ? { headers: { Authorization: `Bearer ${auth.authToken}`, 'ChatGPT-Account-Id': identity.accountId } } : {}) });
|
|
27
|
+
if (!image.ok) { await image.body?.cancel(); throw new Error(`点点头像下载失败(${image.status})`); }
|
|
28
|
+
const chunks = []; let size = 0;
|
|
29
|
+
for await (const chunk of image.body) {
|
|
30
|
+
size += chunk.length;
|
|
31
|
+
if (size > 4 * 1024 * 1024) throw new Error('点点头像超过 4 MiB。');
|
|
32
|
+
chunks.push(chunk);
|
|
33
|
+
}
|
|
34
|
+
const thumbnail = await sharp(Buffer.concat(chunks)).resize(96, 96, { fit: 'inside', withoutEnlargement: true }).webp().toBuffer();
|
|
35
|
+
return `data:image/webp;base64,${thumbnail.toString('base64')}`;
|
|
36
|
+
})();
|
|
37
|
+
cache.set(key, work);
|
|
38
|
+
work.catch(() => { if (cache.get(key) === work) cache.delete(key); });
|
|
39
|
+
}
|
|
40
|
+
return cache.get(key);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { dotsImageReference } from './dots-message-images.mjs';
|
|
3
|
+
|
|
4
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0;
|
|
5
|
+
|
|
6
|
+
export function dotsChatRevision(turns) {
|
|
7
|
+
const latest = dotsChatTailRevision(turns);
|
|
8
|
+
const message = dotsChatMessageRevision(turns);
|
|
9
|
+
// A message can keep streaming while a later agent action is already visible.
|
|
10
|
+
return !latest || latest === message ? latest
|
|
11
|
+
: createHash('sha256').update(JSON.stringify([latest, message])).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A bounded disk-head probe compares the same last visible item on both sides.
|
|
15
|
+
// The full transcript revision also tracks an earlier streaming message.
|
|
16
|
+
export function dotsChatTailRevision(turns) {
|
|
17
|
+
return chatItemRevision(turns.at(-1)?.items.at(-1));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function dotsChatMessageRevision(turns) {
|
|
21
|
+
for (let turnIndex = turns.length - 1; turnIndex >= 0; turnIndex--) {
|
|
22
|
+
const items = turns[turnIndex].items;
|
|
23
|
+
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex--) {
|
|
24
|
+
const item = items[itemIndex];
|
|
25
|
+
if (item.type === 'userMessage' || item.type === 'agentMessage') return chatItemRevision(item);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// A recent window containing only agent actions cannot acknowledge a chat message.
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function chatItemRevision(item) {
|
|
33
|
+
if (!item) return '';
|
|
34
|
+
const content = item.type === 'userMessage' ? item.content.map(part => {
|
|
35
|
+
const reference = dotsImageReference(part);
|
|
36
|
+
if (!reference?.url) return part;
|
|
37
|
+
// Renewing an official storage signature does not create a new message.
|
|
38
|
+
const url = new URL(reference.url);
|
|
39
|
+
return { ...part, url: url.origin + url.pathname };
|
|
40
|
+
}) : item.type === 'subAgentActivity' ? [item.kind, item.agentThreadId, item.agentPath]
|
|
41
|
+
: [item.text || '', item.attachments || []];
|
|
42
|
+
// Identity and displayed content survive reconnects; internal work does not affect this marker.
|
|
43
|
+
return createHash('sha256').update(JSON.stringify([item.id, item.type, content])).digest('hex');
|
|
44
|
+
}
|
|
45
|
+
// Execution events remain separate from room user/agent messages.
|
|
46
|
+
export function projectDotsChatItem(item) {
|
|
47
|
+
if (item?.type !== 'subAgentActivity' || !nonempty(item.id) || !nonempty(item.agentThreadId)
|
|
48
|
+
|| !['started', 'interacted', 'completed', 'interrupted'].includes(item.kind)) return null;
|
|
49
|
+
return { type: item.type, id: item.id, kind: item.kind, agentThreadId: item.agentThreadId, agentPath: item.agentPath || '' };
|
|
50
|
+
}
|