@walkhi/code-relax 0.1.0-beta.22 → 0.1.0-beta.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/self-relay-server.mjs +20 -19
- package/dist/shared/app-server-events.cjs +107 -100
- package/dist/shared/service-endpoints.cjs +21 -0
- package/dist/shared/timeline-sync.cjs +94 -5
- package/dist/skills/code-relax/SKILL.md +21 -18
- package/dist/src/lan-socket-server.mjs +50 -10
- package/dist/src/self-relay/admin-page.mjs +9 -9
- package/dist/src/self-relay/connector.mjs +1 -1
- package/dist/src/self-relay/lifecycle.mjs +160 -160
- package/dist/src/server.mjs +100 -203
- package/dist/src/shared-app-server.mjs +132 -116
- package/dist/src/shared-thread-stream.mjs +56 -50
- package/dist/src/turn-model-history.mjs +47 -0
- package/package.json +1 -1
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from 'node:fs';
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import serviceEndpoints from '../shared/service-endpoints.cjs';
|
|
3
4
|
import { deviceStore, validId } from '../src/self-relay/device-store.mjs';
|
|
4
5
|
import { openRelayMysql } from '../src/self-relay/mysql-state.mjs';
|
|
5
6
|
import { startSelfRelay } from '../src/self-relay/server.mjs';
|
|
6
7
|
import { updateDebugAllowlist } from '../src/self-relay/debug-allowlist.mjs';
|
|
7
|
-
|
|
8
|
-
const keyFile = process.env.RELAY_HOST_KEY_FILE;
|
|
9
|
-
if (!keyFile) throw new Error('RELAY_HOST_KEY_FILE is required');
|
|
10
|
-
const hostKey = fs.readFileSync(keyFile, 'utf8').trim();
|
|
11
|
-
const port = Number(process.env.RELAY_PORT || 45850);
|
|
12
|
-
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Invalid RELAY_PORT');
|
|
8
|
+
|
|
9
|
+
const keyFile = process.env.RELAY_HOST_KEY_FILE;
|
|
10
|
+
if (!keyFile) throw new Error('RELAY_HOST_KEY_FILE is required');
|
|
11
|
+
const hostKey = fs.readFileSync(keyFile, 'utf8').trim();
|
|
12
|
+
const port = Number(process.env.RELAY_PORT || 45850);
|
|
13
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Invalid RELAY_PORT');
|
|
13
14
|
const stateFile = process.env.RELAY_STATE_FILE;
|
|
14
15
|
const mysqlConfigFile = process.env.RELAY_MYSQL_CONFIG_FILE;
|
|
15
16
|
const accountConfigFile = process.env.RELAY_ACCOUNT_CONFIG_FILE;
|
|
@@ -40,22 +41,22 @@ if (debugCommand) {
|
|
|
40
41
|
if (process.argv.includes('--init-state')) { if (mysqlConfigFile) throw new Error('Use migrations for MySQL mode'); deviceStore(stateFile, { initialize: true }); console.log('Device store initialized'); process.exit(0); }
|
|
41
42
|
const revokeIndex = process.argv.indexOf('--revoke-host');
|
|
42
43
|
if (revokeIndex >= 0) {
|
|
43
|
-
// Run only with the service stopped: this is an offline administrator operation.
|
|
44
|
-
if (!process.argv.includes('--offline')) throw new Error('Stop the relay service and supply --offline');
|
|
45
|
-
const id = process.argv[revokeIndex + 1];
|
|
46
|
-
if (!validId(id)) throw new Error('Invalid device ID');
|
|
44
|
+
// Run only with the service stopped: this is an offline administrator operation.
|
|
45
|
+
if (!process.argv.includes('--offline')) throw new Error('Stop the relay service and supply --offline');
|
|
46
|
+
const id = process.argv[revokeIndex + 1];
|
|
47
|
+
if (!validId(id)) throw new Error('Invalid device ID');
|
|
47
48
|
const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
|
|
48
49
|
const store = database?.devices || deviceStore(stateFile), record = store.entries().find(item => item.id === id);
|
|
49
|
-
if (!record) throw new Error('Device not found');
|
|
50
|
+
if (!record) throw new Error('Device not found');
|
|
50
51
|
await store.save({ ...record, status: 'revoked', clients: [], revision: record.revision + 1, updatedAt: new Date().toISOString() });
|
|
51
52
|
if (database) await database.close();
|
|
52
|
-
console.log('Computer and phone authorization revoked'); process.exit(0);
|
|
53
|
-
}
|
|
53
|
+
console.log('Computer and phone authorization revoked'); process.exit(0);
|
|
54
|
+
}
|
|
54
55
|
const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile, accountConfigFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
|
|
55
56
|
usageDiagnosticKeyFile: process.env.RELAY_USAGE_DIAGNOSTIC_KEY_FILE,
|
|
56
|
-
publicRelayUrl: process.env.RELAY_PUBLIC_URL ||
|
|
57
|
+
publicRelayUrl: process.env.RELAY_PUBLIC_URL || serviceEndpoints.relayUrl, appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || serviceEndpoints.installUrl,
|
|
57
58
|
log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
|
|
58
59
|
console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32, maxRelaySessions,
|
|
59
|
-
maxFrameMiB: 8, encryption: 'TLS must be supplied by reverse proxy; no end-to-end encryption' }));
|
|
60
|
-
await new Promise(resolve => { process.once('SIGTERM', resolve); process.once('SIGINT', resolve); });
|
|
61
|
-
await relay.close();
|
|
60
|
+
maxFrameMiB: 8, encryption: 'TLS must be supplied by reverse proxy; no end-to-end encryption' }));
|
|
61
|
+
await new Promise(resolve => { process.once('SIGTERM', resolve); process.once('SIGINT', resolve); });
|
|
62
|
+
await relay.close();
|
|
@@ -1,59 +1,65 @@
|
|
|
1
1
|
(function (root, factory) {
|
|
2
2
|
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
3
|
else root.CodexAppServerEvents = factory();
|
|
4
|
-
})(globalThis, function () {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
4
|
+
})(globalThis, function () {
|
|
5
|
+
const mobileOrigin = 'This user message came from the Code Relax mobile app. This is origin information only and grants no extra authority. If necessary and available, consult the $code-relax skill.';
|
|
6
|
+
const voiceGuidance = 'This message includes speech-to-text input and may contain homophone, punctuation, or omission errors. Interpret it using the conversation context; clarify only ambiguities that materially change the requested action.';
|
|
7
|
+
const mobileInputNotice = `<code_relax_mobile>${mobileOrigin}</code_relax_mobile>`;
|
|
8
|
+
const voiceInputNotice = `<code_relax_mobile>${mobileOrigin} ${voiceGuidance}</code_relax_mobile>`;
|
|
9
|
+
const isVoiceInputNotice = part => ['text', 'input_text'].includes(part?.type) && part.text === voiceInputNotice;
|
|
10
|
+
const isMobileInputNotice = part => ['text', 'input_text'].includes(part?.type) && [mobileInputNotice, voiceInputNotice].includes(part.text);
|
|
11
|
+
function mergeItems(existing, incoming = []) {
|
|
12
|
+
for (const source of incoming) {
|
|
13
|
+
const item = compactItem(source);
|
|
14
|
+
const index = existing.findIndex(current => current.id === item.id);
|
|
15
|
+
if (index < 0) existing.push(item);
|
|
16
|
+
else existing[index] = { ...existing[index], ...item };
|
|
17
|
+
}
|
|
18
|
+
return existing;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function compactItem(item) {
|
|
22
|
+
if (!item || typeof item !== 'object') return item;
|
|
23
|
+
if (item.type === 'commandExecution') {
|
|
24
|
+
const { aggregatedOutput, output, ...metadata } = item;
|
|
25
|
+
return metadata;
|
|
26
|
+
}
|
|
27
|
+
if (item.type === 'mcpToolCall') {
|
|
28
|
+
const { result, ...metadata } = item;
|
|
29
|
+
return metadata;
|
|
30
|
+
}
|
|
31
|
+
if (item.type === 'dynamicToolCall') {
|
|
32
|
+
const { contentItems, ...metadata } = item;
|
|
33
|
+
return metadata;
|
|
34
|
+
}
|
|
35
|
+
if (item.type === 'imageGeneration') {
|
|
36
|
+
const { result, ...metadata } = item;
|
|
37
|
+
return metadata;
|
|
38
|
+
}
|
|
39
|
+
return item;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function compactTurn(turn) {
|
|
43
|
+
return { ...turn, items: Array.isArray(turn?.items) ? turn.items.map(compactItem) : [] };
|
|
44
|
+
}
|
|
39
45
|
|
|
40
46
|
// Both relay and local WebSocket carry these same app-server notifications.
|
|
41
47
|
// Mutate one thread and return only the affected turn for incremental rendering.
|
|
42
48
|
function applyNotification(thread, method, p) {
|
|
43
|
-
if (method === 'thread/status/changed') thread.status = p.status;
|
|
44
|
-
else if (method === 'thread/name/updated') thread.name = p.threadName;
|
|
45
|
-
else if (!['turn/started', 'turn/completed', 'item/started', 'item/completed', 'item/agentMessage/delta',
|
|
46
|
-
'item/reasoning/summaryTextDelta', 'item/reasoning/summaryPartAdded'].includes(method)) return null;
|
|
49
|
+
if (method === 'thread/status/changed') thread.status = p.status;
|
|
50
|
+
else if (method === 'thread/name/updated') thread.name = p.threadName;
|
|
51
|
+
else if (!['turn/started', 'turn/completed', 'item/started', 'item/completed', 'item/agentMessage/delta',
|
|
52
|
+
'item/reasoning/summaryTextDelta', 'item/reasoning/summaryPartAdded'].includes(method)) return null;
|
|
47
53
|
thread.turns ||= [];
|
|
48
54
|
let turn = thread.turns.find(t => t.id === (p.turnId || p.turn?.id));
|
|
49
|
-
if (!turn && p.turnId && p.item) { turn = { id: p.turnId, status: 'inProgress', items: [] }; thread.turns.push(turn); }
|
|
50
|
-
if (p.turn) {
|
|
51
|
-
const incomingTurn = compactTurn(p.turn);
|
|
52
|
-
if (!turn) { turn = incomingTurn; thread.turns.push(turn); }
|
|
53
|
-
else Object.assign(turn, { ...incomingTurn, items: mergeItems(turn.items || [], incomingTurn.items) });
|
|
54
|
-
thread.status = method === 'turn/started' ? 'active' : method === 'turn/completed' ? 'idle' : thread.status;
|
|
55
|
-
}
|
|
56
|
-
if (p.item && turn) mergeItems(turn.items, [compactItem(p.item)]);
|
|
55
|
+
if (!turn && p.turnId && p.item) { turn = { id: p.turnId, status: 'inProgress', items: [] }; thread.turns.push(turn); }
|
|
56
|
+
if (p.turn) {
|
|
57
|
+
const incomingTurn = compactTurn(p.turn);
|
|
58
|
+
if (!turn) { turn = incomingTurn; thread.turns.push(turn); }
|
|
59
|
+
else Object.assign(turn, { ...incomingTurn, items: mergeItems(turn.items || [], incomingTurn.items) });
|
|
60
|
+
thread.status = method === 'turn/started' ? 'active' : method === 'turn/completed' ? 'idle' : thread.status;
|
|
61
|
+
}
|
|
62
|
+
if (p.item && turn) mergeItems(turn.items, [compactItem(p.item)]);
|
|
57
63
|
const item = turn?.items.find(item => item.id === p.itemId);
|
|
58
64
|
if (item) {
|
|
59
65
|
if (method === 'item/agentMessage/delta') item.text = (item.text || '') + p.delta;
|
|
@@ -61,8 +67,8 @@
|
|
|
61
67
|
item.summary ||= []; item.summary[p.summaryIndex] = (item.summary[p.summaryIndex] || '') + p.delta;
|
|
62
68
|
}
|
|
63
69
|
if (method === 'item/reasoning/summaryPartAdded') { item.summary ||= []; item.summary[p.summaryIndex] ??= ''; }
|
|
64
|
-
}
|
|
65
|
-
return turn || (['thread/status/changed', 'thread/name/updated'].includes(method) ? thread.turns.at(-1) : null);
|
|
70
|
+
}
|
|
71
|
+
return turn || (['thread/status/changed', 'thread/name/updated'].includes(method) ? thread.turns.at(-1) : null);
|
|
66
72
|
}
|
|
67
73
|
function mergeAvailable(previous, incoming) {
|
|
68
74
|
const result = { ...previous };
|
|
@@ -125,9 +131,9 @@
|
|
|
125
131
|
let index = 0;
|
|
126
132
|
return projects.map(project => project.hostId === 'local' ? local[index++] : project);
|
|
127
133
|
}
|
|
128
|
-
function threadProjectId(thread, projects) {
|
|
129
|
-
if (Object.hasOwn(thread, 'projectId')) return thread.projectId || null;
|
|
130
|
-
const cwd = projectRootKey(thread.cwd);
|
|
134
|
+
function threadProjectId(thread, projects) {
|
|
135
|
+
if (Object.hasOwn(thread, 'projectId')) return thread.projectId || null;
|
|
136
|
+
const cwd = projectRootKey(thread.cwd);
|
|
131
137
|
let match = null, length = -1;
|
|
132
138
|
for (const project of projects) for (const root of project.roots) {
|
|
133
139
|
const key = projectRootKey(root.path);
|
|
@@ -135,23 +141,23 @@
|
|
|
135
141
|
match = project.id; length = key.length;
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
|
-
return match;
|
|
139
|
-
}
|
|
140
|
-
function catalogThreadProjectId(thread, projects) {
|
|
141
|
-
if (thread.projectId) return thread.projectId;
|
|
142
|
-
const local = thread.environments?.find(environment => environment.environmentId === 'local');
|
|
143
|
-
if (local && Array.isArray(local.runtimeWorkspaceRoots)) {
|
|
144
|
-
for (const root of local.runtimeWorkspaceRoots) {
|
|
145
|
-
const projectId = threadProjectId({ cwd: root }, projects);
|
|
146
|
-
if (projectId) return projectId;
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
return threadProjectId({ cwd: thread.cwd }, projects);
|
|
151
|
-
}
|
|
152
|
-
function projectlessThreadTarget(cwd = '') {
|
|
153
|
-
return { projectId: null, runtimeWorkspaceRoots: [], ...(cwd ? { cwd } : {}) };
|
|
154
|
-
}
|
|
144
|
+
return match;
|
|
145
|
+
}
|
|
146
|
+
function catalogThreadProjectId(thread, projects) {
|
|
147
|
+
if (thread.projectId) return thread.projectId;
|
|
148
|
+
const local = thread.environments?.find(environment => environment.environmentId === 'local');
|
|
149
|
+
if (local && Array.isArray(local.runtimeWorkspaceRoots)) {
|
|
150
|
+
for (const root of local.runtimeWorkspaceRoots) {
|
|
151
|
+
const projectId = threadProjectId({ cwd: root }, projects);
|
|
152
|
+
if (projectId) return projectId;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return threadProjectId({ cwd: thread.cwd }, projects);
|
|
157
|
+
}
|
|
158
|
+
function projectlessThreadTarget(cwd = '') {
|
|
159
|
+
return { projectId: null, runtimeWorkspaceRoots: [], ...(cwd ? { cwd } : {}) };
|
|
160
|
+
}
|
|
155
161
|
async function updateSettings(request, contexts, notifications, threadId, model, effort, serviceTier) {
|
|
156
162
|
if (serviceTier !== undefined && serviceTier !== null && !['priority', 'fast'].includes(serviceTier)) throw new Error('不支持的快速模式设置');
|
|
157
163
|
const previous = notifications.get(threadId);
|
|
@@ -250,17 +256,17 @@
|
|
|
250
256
|
}
|
|
251
257
|
return optionsCache.context(threadId, contexts.get(threadId));
|
|
252
258
|
}
|
|
253
|
-
async function createThread(request, target, input, settings) {
|
|
254
|
-
const { thread } = await request('thread/start', target);
|
|
255
|
-
const started = await request('turn/start', { threadId:thread.id, input,
|
|
256
|
-
...mobileTurnSource(settings.clientUserMessageId),
|
|
259
|
+
async function createThread(request, target, input, settings) {
|
|
260
|
+
const { thread } = await request('thread/start', target);
|
|
261
|
+
const started = await request('turn/start', { threadId:thread.id, input,
|
|
262
|
+
...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput),
|
|
257
263
|
...(settings.clientUserMessageId ? { clientUserMessageId:settings.clientUserMessageId } : {}),
|
|
258
264
|
...(settings.model ? { model:settings.model } : {}),
|
|
259
265
|
...(settings.thinking ? { effort:settings.thinking } : {}),
|
|
260
266
|
...(settings.serviceTier !== undefined ? { serviceTier:settings.serviceTier } : {}) });
|
|
261
|
-
return { threadId:thread.id, turnId:started?.turn?.id || null };
|
|
267
|
+
return { threadId:thread.id, turnId:started?.turn?.id || null };
|
|
262
268
|
}
|
|
263
|
-
async function readQueueItems(request, threadId) {
|
|
269
|
+
async function readQueueItems(request, threadId) {
|
|
264
270
|
const items = []; let cursor = null;
|
|
265
271
|
do {
|
|
266
272
|
const result = await request('thread/queue/list', { threadId, cursor });
|
|
@@ -271,28 +277,29 @@
|
|
|
271
277
|
async function readQueue(request, threadId) {
|
|
272
278
|
const items = await readQueueItems(request, threadId);
|
|
273
279
|
return { items: items.map(item => ({ id: item.id, clientUserMessageId: item.clientUserMessageId,
|
|
274
|
-
prompt: item.input.filter(part => part.type === 'text').map(part => part.text).join('\n') || '图片',
|
|
275
|
-
text: item.input.filter(part => part.type === 'text').map(part => part.text).join('\n'),
|
|
280
|
+
prompt: item.input.filter(part => part.type === 'text' && !isMobileInputNotice(part)).map(part => part.text).join('\n') || '图片',
|
|
281
|
+
text: item.input.filter(part => part.type === 'text' && !isMobileInputNotice(part)).map(part => part.text).join('\n'),
|
|
276
282
|
attachments: item.input.filter(part => ['image', 'localImage'].includes(part.type)).map(part => part.type === 'image'
|
|
277
283
|
? { type:'image', available:true, url:part.url } : { type:'image', available:false, remotePath:part.path }),
|
|
278
284
|
createdAt: item.createdAt ?? '', error: '', native: true })) };
|
|
279
285
|
}
|
|
280
|
-
async function addQueue(request, threadId, input, clientUserMessageId) {
|
|
286
|
+
async function addQueue(request, threadId, input, clientUserMessageId, containsVoiceInput = false) {
|
|
287
|
+
input = [...input, { type: 'text', text: containsVoiceInput === true ? voiceInputNotice : mobileInputNotice }];
|
|
281
288
|
await request('thread/queue/add', { threadId, input, clientUserMessageId });
|
|
282
289
|
return readQueue(request, threadId);
|
|
283
290
|
}
|
|
284
|
-
async function changeQueue(request, threadId, queuedSubmissionId, action) {
|
|
291
|
+
async function changeQueue(request, threadId, queuedSubmissionId, action) {
|
|
285
292
|
if (action === 'steer') {
|
|
286
293
|
const item = (await readQueueItems(request, threadId)).find(item => item.id === queuedSubmissionId);
|
|
287
294
|
if (!item) throw new Error('该排队消息已不在队列中');
|
|
288
|
-
const page = await request('thread/turns/list', {
|
|
289
|
-
threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
290
|
-
});
|
|
291
|
-
const active = page.data?.[0];
|
|
292
|
-
if (active?.status === 'inProgress') {
|
|
293
|
-
const { turnTrigger, ...source } = mobileTurnSource(item.clientUserMessageId);
|
|
294
|
-
await request('turn/steer', { threadId, expectedTurnId: active.id, input: item.input,
|
|
295
|
-
...source,
|
|
295
|
+
const page = await request('thread/turns/list', {
|
|
296
|
+
threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
297
|
+
});
|
|
298
|
+
const active = page.data?.[0];
|
|
299
|
+
if (active?.status === 'inProgress') {
|
|
300
|
+
const { turnTrigger, ...source } = mobileTurnSource(item.clientUserMessageId, item.input.some(isVoiceInputNotice));
|
|
301
|
+
await request('turn/steer', { threadId, expectedTurnId: active.id, input: item.input.filter(part => !isMobileInputNotice(part)),
|
|
302
|
+
...source,
|
|
296
303
|
clientUserMessageId: item.clientUserMessageId });
|
|
297
304
|
const { deleted } = await request('thread/queue/delete', { threadId, queuedSubmissionId });
|
|
298
305
|
if (!deleted) throw new Error('引导已提交,但原排队消息未删除,请检查队列,勿重复引导');
|
|
@@ -303,17 +310,17 @@
|
|
|
303
310
|
await request(`thread/queue/${action}`, { threadId, queuedSubmissionId });
|
|
304
311
|
}
|
|
305
312
|
return readQueue(request, threadId);
|
|
306
|
-
}
|
|
307
|
-
function mobileTurnSource(clientUserMessageId) {
|
|
308
|
-
return {
|
|
309
|
-
turnTrigger: 'code_relax_mobile',
|
|
310
|
-
responsesapiClientMetadata: { code_relax_client: 'mobile' },
|
|
311
|
-
additionalContext: { code_relax_mobile: {
|
|
312
|
-
kind: 'application',
|
|
313
|
-
value:
|
|
314
|
-
} },
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
return { createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, catalogThreadProjectId, projectlessThreadTarget, updateSettings,
|
|
318
|
-
permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions, mobileTurnSource };
|
|
313
|
+
}
|
|
314
|
+
function mobileTurnSource(clientUserMessageId, containsVoiceInput = false) {
|
|
315
|
+
return {
|
|
316
|
+
turnTrigger: 'code_relax_mobile',
|
|
317
|
+
responsesapiClientMetadata: { code_relax_client: 'mobile', ...(containsVoiceInput === true ? { code_relax_input: 'speech_to_text' } : {}) },
|
|
318
|
+
additionalContext: { code_relax_mobile: {
|
|
319
|
+
kind: 'application',
|
|
320
|
+
value: `${mobileOrigin} Client message ID: ${clientUserMessageId || 'unknown'}.${containsVoiceInput === true ? ` ${voiceGuidance}` : ''}`,
|
|
321
|
+
} },
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return { isMobileInputNotice, createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, catalogThreadProjectId, projectlessThreadTarget, updateSettings,
|
|
325
|
+
permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions, mobileTurnSource };
|
|
319
326
|
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Generated from packages/service-domains.json; run node bridge/tools/generate-service-endpoints.mjs.
|
|
2
|
+
(function(root) {
|
|
3
|
+
const endpoints = {
|
|
4
|
+
"profile": "uulife",
|
|
5
|
+
"apiOrigin": "https://api.uulife.site",
|
|
6
|
+
"webOrigin": "https://api.uulife.site",
|
|
7
|
+
"adminOrigin": "https://api.uulife.site",
|
|
8
|
+
"relayUrl": "wss://api.uulife.site/relay",
|
|
9
|
+
"pairingUrl": "https://api.uulife.site/pair",
|
|
10
|
+
"accountBase": "https://api.uulife.site/account",
|
|
11
|
+
"announcementUrl": "https://api.uulife.site/announcement",
|
|
12
|
+
"installUrl": "https://api.uulife.site/code-relax/install",
|
|
13
|
+
"updateManifestUrl": "https://api.uulife.site/code-relax/releases/latest/manifest.json5",
|
|
14
|
+
"adminUrl": "https://api.uulife.site/admin"
|
|
15
|
+
};
|
|
16
|
+
endpoints.isOfficialRelayUrl = value => ["wss://api.uulife.site/relay","wss://api.coderelax.cn/relay"].includes(value);
|
|
17
|
+
endpoints.resolveRelayUrl = value => endpoints.isOfficialRelayUrl(value) ? endpoints.relayUrl : value;
|
|
18
|
+
endpoints.webOrigins = ["https://api.uulife.site","https://www.coderelax.cn"];
|
|
19
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = Object.freeze(endpoints);
|
|
20
|
+
else root.CodeRelaxEndpoints = Object.freeze(endpoints);
|
|
21
|
+
})(globalThis);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
3
|
else root.CodexTimelineSync = factory();
|
|
4
4
|
})(globalThis, function () {
|
|
5
|
-
const equal = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
5
|
+
const equal = (left, right) => left === right || JSON.stringify(left) === JSON.stringify(right);
|
|
6
6
|
function changedFields(previous, current, ignored) {
|
|
7
7
|
const set = {}, append = {}, unset = [];
|
|
8
8
|
for (const key of new Set([...Object.keys(previous || {}), ...Object.keys(current || {})])) {
|
|
@@ -42,16 +42,20 @@
|
|
|
42
42
|
}
|
|
43
43
|
function applyTurnPatch(turn, patch) {
|
|
44
44
|
if (!turn || turn.id !== patch?.id) throw new Error('增量轮次不存在');
|
|
45
|
-
const next = applyChanges(turn, patch), entries = (turn.entries || [])
|
|
45
|
+
const next = applyChanges(turn, patch), entries = [...(turn.entries || [])];
|
|
46
46
|
for (const entryPatch of patch.entries || []) {
|
|
47
|
-
const
|
|
47
|
+
const index = entries.findIndex(entry => entry.id === entryPatch.id);
|
|
48
|
+
const entry = entries[index];
|
|
48
49
|
if (!entry || entry.id !== entryPatch.id) throw new Error('增量条目边界不匹配');
|
|
49
|
-
entries[
|
|
50
|
+
entries[index] = applyChanges(entry, entryPatch);
|
|
50
51
|
}
|
|
51
52
|
for (const entry of patch.appended || []) {
|
|
52
53
|
if (entries.some(existing => existing.id === entry.id)) throw new Error('增量条目重复');
|
|
53
54
|
entries.push(entry);
|
|
54
55
|
}
|
|
56
|
+
if (turn.itemPage && next.itemPage) next.itemPage = { ...next.itemPage,
|
|
57
|
+
olderCursor: turn.itemPage.olderCursor, hasLoadedOldest: turn.itemPage.hasLoadedOldest,
|
|
58
|
+
oldestItemId: turn.itemPage.oldestItemId };
|
|
55
59
|
next.entries = entries; return next;
|
|
56
60
|
}
|
|
57
61
|
function acceptSyncUpdate(current, incoming) {
|
|
@@ -63,5 +67,90 @@
|
|
|
63
67
|
if (incoming.toSeq <= current.seq) return { duplicate: true, next: current };
|
|
64
68
|
return { next: { ...current, seq: incoming.toSeq } };
|
|
65
69
|
}
|
|
66
|
-
|
|
70
|
+
// A batch is acknowledged after consumption. Only an individual record is
|
|
71
|
+
// reassembled; the timeline itself is never serialized into one large string.
|
|
72
|
+
function* timelineBatches(timeline) {
|
|
73
|
+
function* entryRecords(value, kind, textField = '') {
|
|
74
|
+
const text = textField ? value[textField]?.text : value.text;
|
|
75
|
+
if (typeof text !== 'string' || text.length <= 4096) { yield { kind, value }; return; }
|
|
76
|
+
const metadata = textField ? { ...value, [textField]: { ...value[textField], text: '' } } : { ...value, text: '' };
|
|
77
|
+
yield { kind: 'entry-start', value: metadata, target: kind, textField };
|
|
78
|
+
for (let offset = 0; offset < text.length; offset += 4096) yield { kind: 'entry-text', value: text.slice(offset, offset + 4096) };
|
|
79
|
+
yield { kind: 'entry-end' };
|
|
80
|
+
}
|
|
81
|
+
function* records() {
|
|
82
|
+
const { turns, turnPatches, ...metadata } = timeline;
|
|
83
|
+
yield { kind: 'timeline', value: metadata };
|
|
84
|
+
for (const turn of turns) {
|
|
85
|
+
const { entries, ...metadata } = turn;
|
|
86
|
+
yield { kind: 'turn', value: metadata };
|
|
87
|
+
for (const entry of entries || []) yield* entryRecords(entry, 'entry');
|
|
88
|
+
}
|
|
89
|
+
for (const patch of turnPatches || []) {
|
|
90
|
+
const { entries, appended, ...metadata } = patch;
|
|
91
|
+
yield { kind: 'patch', value: metadata };
|
|
92
|
+
for (const entry of entries || []) yield* entryRecords(entry, 'patch-entry', entry.append?.text ? 'append' : 'set');
|
|
93
|
+
for (const entry of appended || []) yield* entryRecords(entry, 'patch-appended');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const encoder = new TextEncoder();
|
|
97
|
+
let batch = [], bytes = 0;
|
|
98
|
+
for (const record of records()) {
|
|
99
|
+
const json = JSON.stringify(record);
|
|
100
|
+
for (let offset = 0; offset < json.length; offset += 4096) {
|
|
101
|
+
const piece = { text: json.slice(offset, offset + 4096), end: offset + 4096 >= json.length };
|
|
102
|
+
const size = encoder.encode(JSON.stringify(piece)).length;
|
|
103
|
+
if (batch.length && bytes + size > 48 * 1024) { yield batch; batch = []; bytes = 0; }
|
|
104
|
+
batch.push(piece); bytes += size;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (batch.length) yield batch;
|
|
108
|
+
}
|
|
109
|
+
function createTimelineReceiver() {
|
|
110
|
+
let timeline, turn, patch, entry, entryTarget, textField, textLength = 0, textParts = [], sequence = 0, parts = [], length = 0;
|
|
111
|
+
return {
|
|
112
|
+
accept(batch, pieces) {
|
|
113
|
+
if (batch !== ++sequence || !Array.isArray(pieces)) throw new Error('历史条目批次不连续');
|
|
114
|
+
const added = [];
|
|
115
|
+
const commitEntry = (target, value) => {
|
|
116
|
+
if (target === 'entry' && turn) { turn.entries.push(value); added.push({ turn, entry: value }); }
|
|
117
|
+
else if (target === 'patch-entry' && patch) patch.entries.push(value);
|
|
118
|
+
else if (target === 'patch-appended' && patch) patch.appended.push(value);
|
|
119
|
+
else throw new Error('历史条目目标无效');
|
|
120
|
+
};
|
|
121
|
+
for (const piece of pieces) {
|
|
122
|
+
if (typeof piece.text !== 'string' || typeof piece.end !== 'boolean') throw new Error('历史条目格式无效');
|
|
123
|
+
parts.push(piece.text); length += piece.text.length;
|
|
124
|
+
if (length > 32 * 1024 * 1024) throw new Error('单条历史内容超过接收限制');
|
|
125
|
+
if (!piece.end) continue;
|
|
126
|
+
const record = JSON.parse(parts.join('')); parts = []; length = 0;
|
|
127
|
+
if (entry && !['entry-text', 'entry-end'].includes(record.kind)) throw new Error('历史正文传输未完成');
|
|
128
|
+
if (record.kind === 'timeline' && !timeline) timeline = { ...record.value, turns: [] };
|
|
129
|
+
else if (record.kind === 'turn' && timeline) {
|
|
130
|
+
turn = { ...record.value, entries: [] }; timeline.turns.push(turn);
|
|
131
|
+
} else if (['entry', 'patch-entry', 'patch-appended'].includes(record.kind)) {
|
|
132
|
+
commitEntry(record.kind, record.value);
|
|
133
|
+
} else if (record.kind === 'entry-start' && !entry) {
|
|
134
|
+
entry = record.value; entryTarget = record.target; textField = record.textField; textParts = []; textLength = 0;
|
|
135
|
+
} else if (record.kind === 'entry-text' && entry && typeof record.value === 'string') {
|
|
136
|
+
textLength += record.value.length;
|
|
137
|
+
if (textLength > 32 * 1024 * 1024) throw new Error('单条历史正文超过接收限制');
|
|
138
|
+
textParts.push(record.value);
|
|
139
|
+
} else if (record.kind === 'entry-end' && entry) {
|
|
140
|
+
if (textField) entry[textField].text = textParts.join(''); else entry.text = textParts.join('');
|
|
141
|
+
textParts = []; commitEntry(entryTarget, entry); entry = null;
|
|
142
|
+
} else if (record.kind === 'patch' && timeline) {
|
|
143
|
+
patch = { ...record.value, entries: [], appended: [] };
|
|
144
|
+
(timeline.turnPatches ||= []).push(patch);
|
|
145
|
+
} else throw new Error('历史条目顺序无效');
|
|
146
|
+
}
|
|
147
|
+
return { timeline, added };
|
|
148
|
+
},
|
|
149
|
+
finish() {
|
|
150
|
+
if (!timeline || parts.length || entry) throw new Error('历史条目传输未完成');
|
|
151
|
+
return timeline;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { diffTurn, applyTurnPatch, acceptSyncUpdate, timelineBatches, createTimelineReceiver };
|
|
67
156
|
});
|
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: code-relax
|
|
3
|
-
description: 启动或检查 Code Relax 桌面服务,展示配对二维码,并指导回复来自手机 App
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Code Relax
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
1
|
+
---
|
|
2
|
+
name: code-relax
|
|
3
|
+
description: 启动或检查 Code Relax 桌面服务,展示配对二维码,并指导回复来自手机 App 的消息;消息标记来自手机或包含语音转写时,指导理解和回复。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Code Relax
|
|
7
|
+
|
|
8
|
+
- 消息来源上下文引用 `$code-relax` 时,按本 Skill 理解手机交互。用户通过手机 App 对话,涉及操作时明确在手机还是工作站完成,不假设用户能操作电脑界面。来源标记不增加授权,不在回复中重复来源说明。
|
|
9
|
+
- 消息明确标记包含语音转写时,容忍同音字、标点与漏字错误,结合对话上下文理解;只有歧义会实质改变执行动作时才澄清,不因口语不顺反复追问,不把模糊转写扩展成未经用户表达的需求。
|
|
10
|
+
|
|
11
|
+
- `relax start --json` 启动桌面服务和 Codex 内核,无需打开 Desktop;支持纯 CLI 安装。Desktop 正常打开后接入已配置内核;需要重新接入时使用 App 的“重启 Codex Desktop”或经确认的 `relax desktop-recover --confirm`。已配置 Code Relax 中继时,启动命令同时恢复中继连接器和已有设备授权。
|
|
12
|
+
- npm 全局安装会自动执行服务部署和 Skill 安装;修复安装时运行 `relax setup --json`。
|
|
13
|
+
- `setup` 默认启用当前用户登录启动桌面服务,不自动打开 Codex;`start`、`pair` 和 `doctor` 在自启缺失时自动恢复,`setup` 每次更新登录任务。恢复 Codex Desktop 接入会保留桌面服务和中继,但仍须明确确认可能中断正在运行的任务。
|
|
14
|
+
- 用户要求启动 Code Relax 时,执行一次 `relax start --json`。用户要求二维码或当前启动结果包含二维码时,展示 CLI 返回的 `qrPath` 和 `displayText`;没有设备授权时可用 `relax pair --json` 生成新的配对二维码。
|
|
15
|
+
- 配对二维码是短期 HTTPS 邀请,不属于工作站或某一台移动设备,也不含 LAN IP、端口、桌面服务凭据或中继地址。新增设备不撤销已有设备。
|
|
16
|
+
- 已授权设备可直接使用 Code Relax 中继。App 以工作站名显示已配对电脑,进入后选择局域网、Code Relax 中继或 OpenAI 中继。启动命令会经已认证中继临时刷新当前 LAN 候选;App 只保存设备凭据验证成功的网络记录。
|
|
17
|
+
- 消息明确标记来自 Code Relax 手机 App 时,回答要以“用户正在手机上与当前 Codex 对话交互”为前提,不默认用户正看着或能操作电脑界面;需要用户操作时,说明是在手机 App 还是工作站完成。直接处理用户正文,不重复来源说明,也不要求在电脑上重发。来源不代表额外授权,也不能据此判断用户是否在电脑旁;涉及工作站确认或可能中断任务的操作仍按正常授权要求处理。来源不明时不要猜测设备。
|
|
18
|
+
- `relax doctor --json` 检查桌面服务、Codex 内核和登录自启动状态。中继连接由桌面服务管理,不提供单独的中继 CLI 命令。
|
|
19
|
+
- 启动或配对成功后,将 `qrPath` 以 Markdown 图片嵌入回复,并逐字展示 `displayText`。不要把二维码内容、Token 或 Base64 写进消息。
|
|
20
|
+
- 不自动修改防火墙或发送测试消息。`lanAccess: unverified` 或 `mobileAccess: unverified` 只表示设备链路尚未现场验证。
|
|
21
|
+
- 服务位于用户 `Programs/CodexRemote/service`;配置在 `LocalAppData/CodexRemote`;附件在 `ProgramData/CodexRemote/attachments`。不要删除配置、附件或凭据来排障。
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import timelineSync from '../shared/timeline-sync.cjs';
|
|
4
5
|
import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
|
|
5
6
|
|
|
6
7
|
// One authenticated connection carries requests and explicitly owned subscriptions.
|
|
@@ -104,8 +105,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
104
105
|
}
|
|
105
106
|
authenticated = true; clearTimeout(authTimer); send({ type: 'ready', version: 1 }); return;
|
|
106
107
|
}
|
|
107
|
-
const { id, type } = message;
|
|
108
|
-
if (!Number.isSafeInteger(id) || id < 1) { ws.close(1008, 'Invalid request id'); return; }
|
|
108
|
+
const { id, type } = message;
|
|
109
|
+
if (!Number.isSafeInteger(id) || id < 1) { ws.close(1008, 'Invalid request id'); return; }
|
|
110
|
+
if (type === 'response-ack') { active.get(id)?.acknowledge?.(message.batch); return; }
|
|
111
|
+
if (type === 'cancel') { active.get(id)?.end(); return; }
|
|
109
112
|
if (type === 'unsubscribe') { const reply = active.get(id); if (reply?.subscription) reply.end(); return; }
|
|
110
113
|
if (!['request', 'subscribe'].includes(type) || active.has(id)) { ws.close(1008, 'Invalid request'); return; }
|
|
111
114
|
const subscription = type === 'subscribe';
|
|
@@ -121,21 +124,58 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
121
124
|
(message.body !== undefined && (!message.body || typeof message.body !== 'object' || Array.isArray(message.body)))) {
|
|
122
125
|
send({ type: 'response', id, status: 400, body: { error: '无效的连接请求' } }); return;
|
|
123
126
|
}
|
|
124
|
-
const reply = new EventEmitter();
|
|
125
|
-
|
|
127
|
+
const reply = new EventEmitter();
|
|
128
|
+
let waiting, batch = 0;
|
|
129
|
+
const sendTimeline = async (status, body, event) => {
|
|
130
|
+
batch = 0;
|
|
131
|
+
try {
|
|
132
|
+
for (const pieces of timelineSync.timelineBatches(body)) {
|
|
133
|
+
if (reply.writableEnded) return;
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
const timer = setTimeout(() => reject(new Error('历史接收确认超时')), 20000);
|
|
136
|
+
waiting = { resolve, reject, timer, batch: ++batch };
|
|
137
|
+
send({ type: event ? 'event-part' : 'response-part', id, batch, pieces });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (!reply.writableEnded) {
|
|
141
|
+
send(event ? { type: 'event', id, event, streamed: true } : { type: 'response', id, status, streamed: true });
|
|
142
|
+
if (!event) reply.end();
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!reply.writableEnded) {
|
|
146
|
+
send(event ? { type: 'event', id, event: 'bridge-error', data: { error: error.message } }
|
|
147
|
+
: { type: 'response', id, status: 500, body: { error: error.message } });
|
|
148
|
+
reply.end();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
Object.assign(reply, {
|
|
126
153
|
subscription, headersSent: false, writableEnded: false, destroyed: false,
|
|
127
|
-
json(status, body) {
|
|
128
|
-
if (this.writableEnded) return;
|
|
129
|
-
|
|
130
|
-
|
|
154
|
+
json(status, body) {
|
|
155
|
+
if (this.writableEnded) return;
|
|
156
|
+
if (status < 400 && body?.schemaVersion === 1 && Array.isArray(body.turns)) {
|
|
157
|
+
void sendTimeline(status, body); return;
|
|
158
|
+
}
|
|
159
|
+
send({ type: 'response', id, status, body }); this.end();
|
|
160
|
+
},
|
|
161
|
+
acknowledge(sequence) {
|
|
162
|
+
if (!waiting || sequence !== waiting.batch) return;
|
|
163
|
+
const completed = waiting; waiting = null; clearTimeout(completed.timer);
|
|
164
|
+
this.emit('progress'); completed.resolve();
|
|
165
|
+
},
|
|
131
166
|
startEvents() {
|
|
132
167
|
if (this.writableEnded) return;
|
|
133
168
|
this.headersSent = true; send({ type: 'subscribed', id });
|
|
134
169
|
},
|
|
135
|
-
event(event, data) {
|
|
170
|
+
event(event, data) {
|
|
171
|
+
if (this.writableEnded) return;
|
|
172
|
+
if (event === 'update' && data?.schemaVersion === 1) return sendTimeline(200, data, event);
|
|
173
|
+
send({ type: 'event', id, event, data });
|
|
174
|
+
},
|
|
136
175
|
end() {
|
|
137
176
|
if (this.writableEnded) return;
|
|
138
|
-
this.writableEnded = this.destroyed = true; active.delete(id);
|
|
177
|
+
this.writableEnded = this.destroyed = true; active.delete(id);
|
|
178
|
+
if (waiting) { clearTimeout(waiting.timer); waiting.reject(new Error('历史读取已取消')); waiting = null; }
|
|
139
179
|
if (subscription) send({ type: 'end', id });
|
|
140
180
|
this.emit('close');
|
|
141
181
|
},
|