@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.2
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 +5 -5
- package/dist/bin/self-relay-server.mjs +5 -2
- package/dist/shared/app-server-events.cjs +45 -19
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/src/app-server-tasks.mjs +28 -25
- package/dist/src/lan-socket-server.mjs +8 -2
- package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
- package/dist/src/platform/windows/background-process.mjs +7 -2
- package/dist/src/platform/windows/launch-worker.ps1 +13 -6
- package/dist/src/platform/windows/start-hidden-console.ps1 +11 -6
- package/dist/src/self-relay/admin-state.mjs +61 -0
- package/dist/src/self-relay/client.mjs +2 -0
- package/dist/src/self-relay/connector.mjs +23 -8
- package/dist/src/self-relay/demo.mjs +2 -2
- package/dist/src/self-relay/lifecycle.mjs +1 -1
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +137 -11
- package/dist/src/server.mjs +337 -255
- package/dist/src/shared-app-server.mjs +180 -24
- package/dist/src/shared-catalog.mjs +4 -14
- package/dist/src/thread-catalog.mjs +12 -7
- package/dist/web/activity-view.js +283 -0
- package/dist/web/capabilities.js +2 -2
- package/dist/web/chat-transport.js +15 -9
- package/dist/web/chat.css +139 -93
- package/dist/web/chat.js +1451 -2770
- package/dist/web/community-view.js +20 -0
- package/dist/web/community.css +77 -0
- package/dist/web/community.html +37 -0
- package/dist/web/composer-controller.js +101 -0
- package/dist/web/conversation-controller.js +99 -0
- package/dist/web/disclosure-state-controller.js +95 -0
- package/dist/web/draft-controller.js +103 -0
- package/dist/web/harmony-platform.js +3 -2
- package/dist/web/history-cache.js +112 -18
- package/dist/web/history-controller.js +167 -0
- package/dist/web/index.html +141 -38
- package/dist/web/link-action-controller.js +212 -0
- package/dist/web/message-send-controller.js +177 -0
- package/dist/web/message-view.js +98 -0
- package/dist/web/p2p-data-channel.js +50 -0
- package/dist/web/p2p-probe.js +67 -27
- package/dist/web/page-resume.js +28 -0
- package/dist/web/pending-message-store.js +108 -0
- package/dist/web/queue-controller.js +82 -0
- package/dist/web/resources.json +1 -1
- package/dist/web/self-relay-session.js +28 -18
- package/dist/web/station-connection-controller.js +75 -0
- package/dist/web/task-list-view.js +296 -0
- package/dist/web/thread-attention-controller.js +124 -0
- package/dist/web/thread-context-controller.js +30 -0
- package/dist/web/thread-list-controller.js +61 -0
- package/dist/web/thread-list-sync.js +86 -0
- package/dist/web/thread-title-controller.js +57 -0
- package/dist/web/timeline-formatters.js +249 -0
- package/dist/web/timeline-reducer.js +81 -0
- package/dist/web/timeline-renderer.js +161 -0
- package/dist/web/timeline-scroll-controller.js +50 -0
- package/dist/web/usage-controller.js +258 -0
- package/dist/web/vendor/lucide.LICENSE.txt +17 -0
- package/package.json +1 -1
- package/tools/postinstall.mjs +66 -2
|
@@ -6,6 +6,9 @@ import serverRequests from '../shared/server-requests.cjs';
|
|
|
6
6
|
|
|
7
7
|
const syncTokenTtlMs = 30_000;
|
|
8
8
|
const syncBufferMaxBytes = 8 * 1024 * 1024;
|
|
9
|
+
const historyPageMaxTurns = 8;
|
|
10
|
+
const historyPageMaxBytes = 500 * 1024;
|
|
11
|
+
const turnItemPageSize = 50;
|
|
9
12
|
const writerConflictMessages = ['already has an active writer', 'already has a live local writer'];
|
|
10
13
|
|
|
11
14
|
export class ThreadWriterConflictError extends Error {
|
|
@@ -38,9 +41,10 @@ export class SharedAppServer extends EventEmitter {
|
|
|
38
41
|
this.threads = new Map();
|
|
39
42
|
this.contexts = new Map();
|
|
40
43
|
this.settingsNotifications = new Map();
|
|
41
|
-
this.permissionOptions = new events.PermissionOptionsCache();
|
|
42
|
-
this.subscriptions = new Map();
|
|
43
|
-
this.
|
|
44
|
+
this.permissionOptions = new events.PermissionOptionsCache();
|
|
45
|
+
this.subscriptions = new Map();
|
|
46
|
+
this.operationGroups = new Map();
|
|
47
|
+
this.usageCache = new events.UsageCache(value => this.emit('usage', value));
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
async connect() {
|
|
@@ -78,9 +82,10 @@ export class SharedAppServer extends EventEmitter {
|
|
|
78
82
|
clearTimeout(pending.timer);
|
|
79
83
|
pending.reject(new Error('完整控制连接断开;发送结果可能未确认,请检查对话后再操作。'));
|
|
80
84
|
}
|
|
81
|
-
this.pending.clear();
|
|
82
|
-
this.serverRequests.reset(randomUUID());
|
|
83
|
-
this.threads.clear();
|
|
85
|
+
this.pending.clear();
|
|
86
|
+
this.serverRequests.reset(randomUUID());
|
|
87
|
+
this.threads.clear();
|
|
88
|
+
this.operationGroups.clear();
|
|
84
89
|
for (const slot of this.subscriptions.values()) clearTimeout(slot.expiry);
|
|
85
90
|
this.subscriptions.clear();
|
|
86
91
|
this.contexts.clear();
|
|
@@ -159,14 +164,20 @@ export class SharedAppServer extends EventEmitter {
|
|
|
159
164
|
if (method === 'thread/tokenUsage/updated') {
|
|
160
165
|
this.contexts.set(id, { ...this.contexts.get(id), tokenUsage: params.tokenUsage });
|
|
161
166
|
return;
|
|
162
|
-
}
|
|
163
|
-
const record = this.threads.get(id);
|
|
164
|
-
if (!record) return;
|
|
165
|
-
const
|
|
167
|
+
}
|
|
168
|
+
const record = this.threads.get(id);
|
|
169
|
+
if (!record) return;
|
|
170
|
+
const current = params.turn?.id ? record.thread.turns?.find(turn => turn.id === params.turn.id) : null;
|
|
171
|
+
const groupedHistory = current?.items?.some(item => item?.type === 'lazyOperationGroup');
|
|
172
|
+
const appliedParams = groupedHistory && method === 'turn/completed' && Array.isArray(params.turn?.items)
|
|
173
|
+
? { ...params, turn: { ...params.turn,
|
|
174
|
+
items: params.turn.items.filter(item => ['userMessage', 'agentMessage'].includes(item?.type)) } }
|
|
175
|
+
: params;
|
|
176
|
+
const turn = events.applyNotification(record.thread, method, appliedParams);
|
|
166
177
|
if (turn) {
|
|
167
178
|
const sequence = ++record.sequence;
|
|
168
179
|
record.changes.set(turn.id, { sequence, turn });
|
|
169
|
-
record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params }));
|
|
180
|
+
record.changeBytes += Buffer.byteLength(JSON.stringify({ method, params: appliedParams }));
|
|
170
181
|
if (record.changeBytes > syncBufferMaxBytes) {
|
|
171
182
|
record.recoverAfter = sequence;
|
|
172
183
|
record.changes.clear(); record.changeBytes = 0;
|
|
@@ -177,7 +188,7 @@ export class SharedAppServer extends EventEmitter {
|
|
|
177
188
|
if (method === 'turn/completed') record.thread.turns = record.thread.turns.slice(-10);
|
|
178
189
|
}
|
|
179
190
|
|
|
180
|
-
async timeline(threadId) {
|
|
191
|
+
async timeline(threadId, pageOptions = {}) {
|
|
181
192
|
await this.connect();
|
|
182
193
|
const slot = this.slot(threadId);
|
|
183
194
|
const task = slot.tail.then(async () => {
|
|
@@ -191,26 +202,34 @@ export class SharedAppServer extends EventEmitter {
|
|
|
191
202
|
let result;
|
|
192
203
|
try {
|
|
193
204
|
result = await this.request('thread/resume', { threadId, excludeTurns: true,
|
|
194
|
-
initialTurnsPage: { limit:
|
|
205
|
+
initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'summary' } });
|
|
195
206
|
} catch (error) {
|
|
196
207
|
if (!isThreadWriterConflict(error)) throw error;
|
|
197
|
-
const read = await
|
|
198
|
-
|
|
208
|
+
const [read, page] = await Promise.all([
|
|
209
|
+
this.request('thread/read', { threadId, includeTurns: false }),
|
|
210
|
+
this.request('thread/turns/list', {
|
|
211
|
+
threadId, limit: historyPageMaxTurns, sortDirection: 'desc', itemsView: 'summary',
|
|
212
|
+
}),
|
|
213
|
+
]);
|
|
214
|
+
const compact = await Promise.all([...(page.data || [])].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
215
|
+
const turns = this.fitTurns({ thread: read.thread }, compact, pageOptions);
|
|
199
216
|
const thread = { ...read.thread, turns, readOnly: true,
|
|
200
217
|
readOnlyReason: '该对话正在另一个 Codex 实例中使用。' };
|
|
201
|
-
record = { thread, nextCursor: null, sequence: 0, recoverAfter: 0,
|
|
218
|
+
record = { thread, nextCursor: page.nextCursor ?? null, sequence: 0, recoverAfter: 0,
|
|
202
219
|
changes: new Map(), changeBytes: 0, tokens: new Map(), readOnly: true };
|
|
203
220
|
this.threads.set(threadId, record);
|
|
204
221
|
result = null;
|
|
205
222
|
}
|
|
206
223
|
if (result) {
|
|
207
224
|
if (!Array.isArray(result.initialTurnsPage?.data)) throw new Error('完整控制未返回分页历史。');
|
|
208
|
-
const
|
|
225
|
+
const compact = await Promise.all([...result.initialTurnsPage.data].reverse().map(turn => this.compactTurn(threadId, turn)));
|
|
226
|
+
const thread = { ...result.thread, turns: compact };
|
|
209
227
|
record = { thread, nextCursor: result.initialTurnsPage.nextCursor ?? null, sequence: 0,
|
|
210
228
|
recoverAfter: 0, changes: new Map(), changeBytes: 0, tokens: new Map() };
|
|
211
229
|
this.threads.set(threadId, record);
|
|
212
230
|
this.contexts.set(threadId, { ...this.contexts.get(threadId), model: result.model, thinking: result.reasoningEffort, serviceTier: result.serviceTier ?? null,
|
|
213
231
|
cwd: result.cwd, ...events.permissionState(result) });
|
|
232
|
+
await this.extendHistoryRecord(threadId, record, pageOptions);
|
|
214
233
|
}
|
|
215
234
|
}
|
|
216
235
|
const id = randomUUID(), expires = Date.now() + syncTokenTtlMs;
|
|
@@ -250,7 +269,8 @@ export class SharedAppServer extends EventEmitter {
|
|
|
250
269
|
if (record?.tokens.size) { this.scheduleCleanup(threadId, slot); return; }
|
|
251
270
|
slot.tail = slot.tail.then(async () => {
|
|
252
271
|
if (slot.users || this.threads.get(threadId)?.tokens.size) return;
|
|
253
|
-
this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
|
|
272
|
+
this.threads.delete(threadId); this.contexts.delete(threadId); this.permissionOptions.delete(threadId);
|
|
273
|
+
this.operationGroups.delete(threadId);
|
|
254
274
|
this.subscriptions.delete(threadId); clearTimeout(slot.expiry);
|
|
255
275
|
if (this.ready && !record?.readOnly) await this.request('thread/unsubscribe', { threadId });
|
|
256
276
|
this.serverRequests.forget(threadId);
|
|
@@ -291,12 +311,135 @@ export class SharedAppServer extends EventEmitter {
|
|
|
291
311
|
snapshot, catchup, release };
|
|
292
312
|
}
|
|
293
313
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
314
|
+
pageSettings(options = {}) {
|
|
315
|
+
return {
|
|
316
|
+
maxTurns: Math.max(1, Math.min(historyPageMaxTurns, Number(options.maxTurns) || historyPageMaxTurns)),
|
|
317
|
+
maxBytes: Math.max(1, Number(options.maxBytes) || historyPageMaxBytes),
|
|
318
|
+
measure: typeof options.measure === 'function' ? options.measure : value => Buffer.byteLength(JSON.stringify(value)),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async compactTurn(threadId, turn) {
|
|
323
|
+
const compact = events.compactTurn(turn);
|
|
324
|
+
if (!turn?.id) return compact;
|
|
325
|
+
const batches = [];
|
|
326
|
+
let cursor;
|
|
327
|
+
do {
|
|
328
|
+
const page = await this.request('thread/items/list', {
|
|
329
|
+
threadId, turnId: turn.id, ...(cursor ? { cursor } : {}),
|
|
330
|
+
limit: 100, sortDirection: 'desc',
|
|
331
|
+
});
|
|
332
|
+
if (!Array.isArray(page.data)) throw new Error('完整控制未返回单轮条目索引。');
|
|
333
|
+
batches.push(page.data.map(entry => events.compactItem(entry.item)));
|
|
334
|
+
cursor = page.nextCursor ?? null;
|
|
335
|
+
} while (cursor);
|
|
336
|
+
const items = batches.reverse().flatMap(batch => batch.reverse());
|
|
337
|
+
const groups = new Map(), skeleton = [];
|
|
338
|
+
let operationItems = [];
|
|
339
|
+
const flushOperations = () => {
|
|
340
|
+
if (!operationItems.length) return;
|
|
341
|
+
const visibleItems = operationItems.filter(item => item?.type !== 'reasoning');
|
|
342
|
+
operationItems = [];
|
|
343
|
+
if (!visibleItems.length) return;
|
|
344
|
+
const groupId = `g${groups.size + 1}`;
|
|
345
|
+
const outline = operationOutline(visibleItems, false);
|
|
346
|
+
groups.set(groupId, visibleItems);
|
|
347
|
+
skeleton.push({ type: 'lazyOperationGroup', id: `${turn.id}:${groupId}`,
|
|
348
|
+
turnId: turn.id, groupId, title: outline.title, count: visibleItems.length });
|
|
349
|
+
};
|
|
350
|
+
for (const item of items) {
|
|
351
|
+
if (['userMessage', 'agentMessage', 'imageGeneration'].includes(item?.type)) {
|
|
352
|
+
flushOperations();
|
|
353
|
+
skeleton.push(item);
|
|
354
|
+
} else operationItems.push(item);
|
|
355
|
+
}
|
|
356
|
+
flushOperations();
|
|
357
|
+
let threadGroups = this.operationGroups.get(threadId);
|
|
358
|
+
if (!threadGroups) { threadGroups = new Map(); this.operationGroups.set(threadId, threadGroups); }
|
|
359
|
+
threadGroups.set(turn.id, groups);
|
|
360
|
+
return { ...compact, items: skeleton };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async operationGroupItems(threadId, turnId, groupId, offset) {
|
|
364
|
+
await this.connect();
|
|
365
|
+
const items = this.operationGroups.get(threadId)?.get(turnId)?.get(groupId);
|
|
366
|
+
if (!items) throw new Error('操作组已失效,请重新打开对话。');
|
|
367
|
+
const start = Math.max(0, Number(offset) || 0);
|
|
368
|
+
const pageItems = items.slice(start, start + turnItemPageSize);
|
|
369
|
+
const nextOffset = start + pageItems.length < items.length ? start + pageItems.length : null;
|
|
370
|
+
return { turnId, groupId, items: pageItems, nextOffset, total: items.length };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async turnContext(threadId, turnId) {
|
|
374
|
+
await this.connect();
|
|
375
|
+
if (!threadId || !turnId) throw new Error('缺少图片所属的对话或轮次。');
|
|
376
|
+
return this.compactTurn(threadId, { id: turnId });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fitTurns(base, turns, options = {}) {
|
|
380
|
+
const settings = this.pageSettings(options), accepted = [];
|
|
381
|
+
let firstOversized = false;
|
|
382
|
+
for (let index = turns.length - 1; index >= 0 && accepted.length < settings.maxTurns; index--) {
|
|
383
|
+
const candidate = [turns[index], ...accepted];
|
|
384
|
+
const candidateBytes = settings.measure({ ...base, thread: { ...base.thread, turns: candidate } });
|
|
385
|
+
if (accepted.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
386
|
+
accepted.unshift(turns[index]);
|
|
387
|
+
if (accepted.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
388
|
+
if (firstOversized || (accepted.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
389
|
+
}
|
|
390
|
+
return accepted;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async extendHistoryRecord(threadId, record, options = {}) {
|
|
394
|
+
const settings = this.pageSettings(options);
|
|
395
|
+
if (!record.nextCursor || !record.thread.turns.length) return;
|
|
396
|
+
const firstOversized = settings.measure({ thread: record.thread, nextCursor: record.nextCursor }) > settings.maxBytes;
|
|
397
|
+
if (firstOversized) return;
|
|
398
|
+
while (record.nextCursor && record.thread.turns.length < settings.maxTurns) {
|
|
399
|
+
const cursor = record.nextCursor;
|
|
400
|
+
const page = await this.request('thread/turns/list', {
|
|
401
|
+
threadId, cursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
402
|
+
});
|
|
403
|
+
if (!Array.isArray(page.data) || !page.data.length) {
|
|
404
|
+
record.nextCursor = page.nextCursor ?? null;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
const candidate = [await this.compactTurn(threadId, page.data[0]), ...record.thread.turns];
|
|
408
|
+
const value = { thread: { ...record.thread, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
409
|
+
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
410
|
+
record.thread.turns = candidate;
|
|
411
|
+
record.nextCursor = page.nextCursor ?? null;
|
|
412
|
+
if (record.thread.turns.length >= 2 && settings.measure(value) > settings.maxBytes) break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async history(threadId, cursor, options = {}) {
|
|
417
|
+
await this.connect();
|
|
418
|
+
if (!cursor) throw new Error('缺少历史分页游标。');
|
|
419
|
+
const settings = this.pageSettings(options), turns = [];
|
|
420
|
+
let firstOversized = false;
|
|
421
|
+
let nextCursor = cursor;
|
|
422
|
+
while (nextCursor && turns.length < settings.maxTurns) {
|
|
423
|
+
const pageCursor = nextCursor;
|
|
424
|
+
const page = await this.request('thread/turns/list', {
|
|
425
|
+
threadId, cursor: pageCursor, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
426
|
+
});
|
|
427
|
+
if (!Array.isArray(page.data) || !page.data.length) {
|
|
428
|
+
nextCursor = page.nextCursor ?? null;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
const compact = await this.compactTurn(threadId, page.data[0]);
|
|
432
|
+
const candidate = [compact, ...turns];
|
|
433
|
+
const value = { thread: { id: threadId, turns: candidate }, nextCursor: page.nextCursor ?? null };
|
|
434
|
+
const candidateBytes = settings.measure(value);
|
|
435
|
+
if (turns.length >= 2 && candidateBytes > settings.maxBytes) break;
|
|
436
|
+
turns.unshift(compact);
|
|
437
|
+
nextCursor = page.nextCursor ?? null;
|
|
438
|
+
if (turns.length === 1) firstOversized = candidateBytes > settings.maxBytes;
|
|
439
|
+
if (firstOversized || (turns.length >= 2 && candidateBytes > settings.maxBytes)) break;
|
|
440
|
+
}
|
|
441
|
+
return { thread: { id: threadId, turns }, nextCursor };
|
|
442
|
+
}
|
|
300
443
|
|
|
301
444
|
async interrupt(threadId, turnId) {
|
|
302
445
|
await this.connect();
|
|
@@ -354,3 +497,16 @@ export class SharedAppServer extends EventEmitter {
|
|
|
354
497
|
|
|
355
498
|
close() { this.socket?.close(); }
|
|
356
499
|
}
|
|
500
|
+
|
|
501
|
+
function operationOutline(items, hasMore) {
|
|
502
|
+
const types = new Set(items.map(item => item?.type));
|
|
503
|
+
const parts = [];
|
|
504
|
+
if (types.has('fileChange')) parts.push('编辑了文件');
|
|
505
|
+
if (types.has('commandExecution')) parts.push('运行了命令');
|
|
506
|
+
if (types.has('mcpToolCall') || types.has('dynamicToolCall')) parts.push('调用了工具');
|
|
507
|
+
if (types.has('webSearch')) parts.push('搜索了网页');
|
|
508
|
+
if (types.has('imageView')) parts.push('查看了图像');
|
|
509
|
+
if (types.has('collabAgentToolCall') || types.has('subAgentActivity')) parts.push('处理了子任务');
|
|
510
|
+
const available = hasMore || items.some(item => item && !['userMessage', 'agentMessage'].includes(item.type));
|
|
511
|
+
return { available, title: parts.length ? `${parts.join(' · ')}${hasMore ? '等' : ''}` : '执行过程' };
|
|
512
|
+
}
|
|
@@ -14,18 +14,14 @@ export function sharedThreadProjectId(thread, projects) {
|
|
|
14
14
|
return events.threadProjectId({ cwd: thread.cwd }, projects);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
//
|
|
17
|
+
// Mobile catalog order comes entirely from app-server authority, independent of Desktop UI state.
|
|
18
18
|
export class SharedCatalog {
|
|
19
|
-
constructor(server
|
|
19
|
+
constructor(server) { this.server = server; }
|
|
20
20
|
|
|
21
21
|
async projects() {
|
|
22
22
|
const native = await this.server.projects();
|
|
23
23
|
const projects = native.map(project => ({ projectId: project.id, name: project.name,
|
|
24
24
|
label: project.name, path: project.roots[0]?.path || '', cwd: project.roots[0]?.path || '', hostId: 'local' }));
|
|
25
|
-
const ordered = this.desktop.snapshot?.projects || [];
|
|
26
|
-
const key = value => value.replace(/^\\\\\?\\/, '').replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase();
|
|
27
|
-
const ranks = new Map(ordered.filter(p => p.hostId === 'local').map((p, i) => [key(p.path || ''), i]));
|
|
28
|
-
projects.sort((a, b) => (ranks.get(key(a.path)) ?? Infinity) - (ranks.get(key(b.path)) ?? Infinity));
|
|
29
25
|
return { projects, native };
|
|
30
26
|
}
|
|
31
27
|
|
|
@@ -40,16 +36,10 @@ export class SharedCatalog {
|
|
|
40
36
|
const summaries = threads.map(thread => ({ id: thread.id, kind: 'codex', hostId: 'local',
|
|
41
37
|
title: thread.name || thread.preview || '', cwd: thread.cwd,
|
|
42
38
|
projectId: sharedThreadProjectId(thread, native), updatedAt: thread.updatedAt,
|
|
39
|
+
recencyAt: thread.recencyAt ?? thread.updatedAt,
|
|
43
40
|
status: typeof thread.status === 'string' ? thread.status : thread.status?.type || 'unknown',
|
|
44
41
|
activeFlags: threadActiveFlags(thread.status) }));
|
|
45
|
-
|
|
46
|
-
const byId = new Map(summaries.map(thread => [thread.id, thread]));
|
|
47
|
-
const pinnedThreads = (listing?.pinnedThreads || []).filter(t => t.hostId === 'local' && byId.has(t.id)).map(t => byId.get(t.id));
|
|
48
|
-
const pinned = new Set(pinnedThreads.map(t => t.id));
|
|
49
|
-
const ranks = new Map((listing?.threads || []).filter(t => t.hostId === 'local').map((t, i) => [t.id, i]));
|
|
50
|
-
const rest = summaries.filter(t => !pinned.has(t.id));
|
|
51
|
-
rest.sort((a, b) => (ranks.get(a.id) ?? Infinity) - (ranks.get(b.id) ?? Infinity));
|
|
52
|
-
return { projects, pinnedThreads, threads: rest };
|
|
42
|
+
return { projects, pinnedThreads: [], threads: summaries };
|
|
53
43
|
}
|
|
54
44
|
}
|
|
55
45
|
|
|
@@ -21,20 +21,25 @@ export function supplementThreadCatalog(data, projects, codexRoot, limit) {
|
|
|
21
21
|
const project = localProjects.find(project => cwd === normalizedPath(project.path) || cwd.startsWith(normalizedPath(project.path) + '/'));
|
|
22
22
|
return { ...thread, projectId: project?.projectId || null };
|
|
23
23
|
};
|
|
24
|
+
const normalizeRecency = thread => {
|
|
25
|
+
const assigned = assignProject(thread);
|
|
26
|
+
const row = assigned.hostId === 'local' ? indexed.get(assigned.id) : null;
|
|
27
|
+
return row ? { ...assigned, updatedAt: row.updated_ms / 1000, recencyAt: row.sort_ms / 1000 }
|
|
28
|
+
: { ...assigned, recencyAt: assigned.recencyAt ?? assigned.updatedAt };
|
|
29
|
+
};
|
|
24
30
|
const visible = thread => thread.kind === 'codex'
|
|
25
31
|
&& (thread.hostId !== 'local' || !indexed.get(thread.id)?.archived);
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const threads = new Map((data.threads || []).filter(visible).map(thread => [thread.id, assignProject(thread)]));
|
|
32
|
+
const listed = [...(data.pinnedThreads || []), ...(data.threads || [])];
|
|
33
|
+
const threads = new Map(listed.filter(visible).map(thread => [thread.id, normalizeRecency(thread)]));
|
|
29
34
|
for (const row of rows) {
|
|
30
|
-
if (row.archived ||
|
|
31
|
-
threads.set(row.id,
|
|
35
|
+
if (row.archived || threads.has(row.id)) continue;
|
|
36
|
+
threads.set(row.id, normalizeRecency({ id: row.id, kind: 'codex', hostId: 'local',
|
|
32
37
|
title: row.name || row.title, cwd: row.cwd, projectId: row.project_id,
|
|
33
|
-
updatedAt: row.updated_ms / 1000, status: 'unknown' }));
|
|
38
|
+
updatedAt: row.updated_ms / 1000, recencyAt: row.sort_ms / 1000, status: 'unknown' }));
|
|
34
39
|
}
|
|
35
40
|
const recency = thread => thread.hostId === 'local' && indexed.has(thread.id)
|
|
36
41
|
? indexed.get(thread.id).sort_ms : Number(thread.updatedAt || 0) * 1000;
|
|
37
|
-
return { ...data, pinnedThreads, threads: [...threads.values()]
|
|
42
|
+
return { ...data, pinnedThreads: [], threads: [...threads.values()]
|
|
38
43
|
.sort((a, b) => recency(b) - recency(a)).slice(0, limit) };
|
|
39
44
|
} finally {
|
|
40
45
|
db.close();
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
(function (root) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
function createActivityView(options) {
|
|
5
|
+
const {
|
|
6
|
+
document, disclosures, entryDisclosureKey, activityIcon, disclosureChevronElement,
|
|
7
|
+
codeBlockIcon, copyText, showToast, compactActivityElement,
|
|
8
|
+
activityPreview, commandStatusText, diffLineCounts, formatDuration, statusText, toolStatusText,
|
|
9
|
+
} = options;
|
|
10
|
+
|
|
11
|
+
function copyButton(value, label) {
|
|
12
|
+
const button = document.createElement('button');
|
|
13
|
+
button.type = 'button';
|
|
14
|
+
button.className = 'activity-copy';
|
|
15
|
+
button.title = label;
|
|
16
|
+
button.setAttribute('aria-label', label);
|
|
17
|
+
button.append(codeBlockIcon('copy'));
|
|
18
|
+
button.onclick = async event => {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
event.stopPropagation();
|
|
21
|
+
if (button.dataset.copyState === 'copied') return;
|
|
22
|
+
try {
|
|
23
|
+
await copyText(value);
|
|
24
|
+
button.dataset.copyState = 'copied';
|
|
25
|
+
button.title = '已复制';
|
|
26
|
+
button.setAttribute('aria-label', '已复制');
|
|
27
|
+
button.replaceChildren(codeBlockIcon('check'));
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
if (!button.isConnected) return;
|
|
30
|
+
delete button.dataset.copyState;
|
|
31
|
+
button.title = label;
|
|
32
|
+
button.setAttribute('aria-label', label);
|
|
33
|
+
button.replaceChildren(codeBlockIcon('copy'));
|
|
34
|
+
}, 1500);
|
|
35
|
+
} catch {
|
|
36
|
+
showToast('复制失败,可长按选择文字。', 3000);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return button;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function command(entry) {
|
|
43
|
+
const entryStatus = statusText(entry.status);
|
|
44
|
+
const commandText = Array.isArray(entry.command) ? entry.command.join(' ') : String(entry.command || '');
|
|
45
|
+
const output = String(entry.output || '');
|
|
46
|
+
const preview = activityPreview(commandText);
|
|
47
|
+
const details = document.createElement('details');
|
|
48
|
+
details.className = 'activity command-activity';
|
|
49
|
+
const summary = document.createElement('summary');
|
|
50
|
+
const label = document.createElement('span');
|
|
51
|
+
label.className = 'activity-title';
|
|
52
|
+
label.textContent = `${commandStatusText(entry.status)}${preview ? ` ${preview}` : ''}`;
|
|
53
|
+
summary.append(activityIcon('command'), label, disclosureChevronElement());
|
|
54
|
+
const shell = document.createElement('div');
|
|
55
|
+
shell.className = 'command-shell';
|
|
56
|
+
if (commandText) {
|
|
57
|
+
const row = document.createElement('div');
|
|
58
|
+
row.className = 'command-shell-command';
|
|
59
|
+
const prompt = document.createElement('span');
|
|
60
|
+
prompt.className = 'command-shell-prompt';
|
|
61
|
+
prompt.textContent = '$';
|
|
62
|
+
const code = document.createElement('code');
|
|
63
|
+
code.textContent = commandText;
|
|
64
|
+
row.append(prompt, code, copyButton(commandText, '复制命令'));
|
|
65
|
+
shell.append(row);
|
|
66
|
+
}
|
|
67
|
+
const outputArea = document.createElement('div');
|
|
68
|
+
outputArea.className = 'command-shell-output';
|
|
69
|
+
const outputText = document.createElement('pre');
|
|
70
|
+
outputText.textContent = output || (entryStatus === 'inProgress' ? '' : '无输出');
|
|
71
|
+
if (!output) outputText.classList.add('empty');
|
|
72
|
+
outputArea.append(outputText);
|
|
73
|
+
if (output) outputArea.append(copyButton(output, '复制输出'));
|
|
74
|
+
shell.append(outputArea);
|
|
75
|
+
const footer = document.createElement('div');
|
|
76
|
+
footer.className = 'command-shell-footer';
|
|
77
|
+
const cwd = document.createElement('span');
|
|
78
|
+
cwd.className = 'command-shell-cwd';
|
|
79
|
+
cwd.textContent = entry.cwd ? `目录:${entry.cwd}` : '';
|
|
80
|
+
cwd.title = entry.cwd || '';
|
|
81
|
+
const status = document.createElement('span');
|
|
82
|
+
status.className = 'command-shell-status';
|
|
83
|
+
const duration = Number(entry.durationMs);
|
|
84
|
+
const durationText = Number.isFinite(duration) && duration >= 0 ? ` · ${formatDuration(duration)}` : '';
|
|
85
|
+
if (entryStatus === 'inProgress') status.textContent = `运行中${durationText}`;
|
|
86
|
+
else if (entryStatus === 'interrupted') status.textContent = `已停止${durationText}`;
|
|
87
|
+
else if (entry.exitCode === 0) status.textContent = `成功${durationText}`;
|
|
88
|
+
else if (entry.exitCode != null) status.textContent = `退出码 ${entry.exitCode}${durationText}`;
|
|
89
|
+
else status.textContent = `${entryStatus === 'failed' ? '失败' : '已完成'}${durationText}`;
|
|
90
|
+
footer.append(cwd, status);
|
|
91
|
+
shell.append(footer);
|
|
92
|
+
details.append(summary, shell);
|
|
93
|
+
return disclosures.bind(details, entryDisclosureKey(entry));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const fieldLabels = {
|
|
97
|
+
cmd: '命令', command: '命令', cwd: '目录', workdir: '目录', path: '路径', file: '文件',
|
|
98
|
+
query: '查询', url: '地址', prompt: '内容', message: '消息', target: '目标', pattern: '匹配',
|
|
99
|
+
server: '服务', tool: '工具', status: '状态', language: '语言', timeout: '超时',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function fieldLabel(value) {
|
|
103
|
+
const key = String(value || '字段');
|
|
104
|
+
return fieldLabels[key] || key.replaceAll('_', ' ');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parsedStructuredText(value) {
|
|
108
|
+
const text = String(value || '').trim();
|
|
109
|
+
if (!text || !['{', '['].includes(text[0])) return null;
|
|
110
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function appendReadableValue(container, value, depth = 0) {
|
|
114
|
+
if (typeof value === 'string') {
|
|
115
|
+
const structured = parsedStructuredText(value);
|
|
116
|
+
if (structured != null) { appendReadableValue(container, structured, depth); return; }
|
|
117
|
+
const node = document.createElement(value.includes('\n') ? 'pre' : 'span');
|
|
118
|
+
node.className = value.includes('\n') ? 'tool-detail-text' : 'tool-detail-value';
|
|
119
|
+
node.textContent = value || '空';
|
|
120
|
+
container.append(node);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (value == null || typeof value !== 'object') {
|
|
124
|
+
const node = document.createElement('span');
|
|
125
|
+
node.className = 'tool-detail-value';
|
|
126
|
+
node.textContent = value == null ? '无' : String(value);
|
|
127
|
+
container.append(node);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (depth >= 3) {
|
|
131
|
+
const node = document.createElement('span');
|
|
132
|
+
node.className = 'tool-detail-value muted';
|
|
133
|
+
node.textContent = Array.isArray(value) ? `${value.length} 项` : `${Object.keys(value).length} 个字段`;
|
|
134
|
+
container.append(node);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(value)) {
|
|
138
|
+
if (!value.length) {
|
|
139
|
+
const node = document.createElement('span');
|
|
140
|
+
node.className = 'tool-detail-value muted';
|
|
141
|
+
node.textContent = '无';
|
|
142
|
+
container.append(node);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const list = document.createElement('ul');
|
|
146
|
+
list.className = 'tool-detail-list';
|
|
147
|
+
for (const item of value.slice(0, 20)) {
|
|
148
|
+
const row = document.createElement('li');
|
|
149
|
+
appendReadableValue(row, item, depth + 1);
|
|
150
|
+
list.append(row);
|
|
151
|
+
}
|
|
152
|
+
if (value.length > 20) {
|
|
153
|
+
const more = document.createElement('li');
|
|
154
|
+
more.className = 'muted';
|
|
155
|
+
more.textContent = `另有 ${value.length - 20} 项`;
|
|
156
|
+
list.append(more);
|
|
157
|
+
}
|
|
158
|
+
container.append(list);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const entries = Object.entries(value);
|
|
162
|
+
if (!entries.length) {
|
|
163
|
+
const node = document.createElement('span');
|
|
164
|
+
node.className = 'tool-detail-value muted';
|
|
165
|
+
node.textContent = '无';
|
|
166
|
+
container.append(node);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const fields = document.createElement('dl');
|
|
170
|
+
fields.className = 'tool-detail-fields';
|
|
171
|
+
for (const [key, fieldValue] of entries.slice(0, 24)) {
|
|
172
|
+
const term = document.createElement('dt');
|
|
173
|
+
term.textContent = fieldLabel(key);
|
|
174
|
+
const description = document.createElement('dd');
|
|
175
|
+
appendReadableValue(description, fieldValue, depth + 1);
|
|
176
|
+
fields.append(term, description);
|
|
177
|
+
}
|
|
178
|
+
if (entries.length > 24) {
|
|
179
|
+
const description = document.createElement('dd');
|
|
180
|
+
description.className = 'muted';
|
|
181
|
+
description.textContent = `另有 ${entries.length - 24} 个字段`;
|
|
182
|
+
fields.append(description);
|
|
183
|
+
}
|
|
184
|
+
container.append(fields);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function detailSection(title, value, className = '') {
|
|
188
|
+
const section = document.createElement('section');
|
|
189
|
+
section.className = `tool-detail-section ${className}`.trim();
|
|
190
|
+
const heading = document.createElement('div');
|
|
191
|
+
heading.className = 'tool-detail-heading';
|
|
192
|
+
heading.textContent = title;
|
|
193
|
+
section.append(heading);
|
|
194
|
+
appendReadableValue(section, value);
|
|
195
|
+
return section;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function tool(entry) {
|
|
199
|
+
const source = [entry.server, entry.tool].filter(Boolean).join('/');
|
|
200
|
+
const title = `${toolStatusText(entry.status)}${source ? ` ${source}` : '工具'}`;
|
|
201
|
+
const argumentsValue = entry.arguments && typeof entry.arguments === 'object' ? entry.arguments : {};
|
|
202
|
+
const resources = Array.isArray(entry.resources) ? entry.resources : [];
|
|
203
|
+
const hasDetails = Object.keys(argumentsValue).length > 0 || entry.resultText || entry.errorText || resources.length || entry.mediaCount;
|
|
204
|
+
if (!hasDetails) return compactActivityElement(title, 'tool');
|
|
205
|
+
const details = document.createElement('details');
|
|
206
|
+
details.className = 'activity tool-activity';
|
|
207
|
+
const summary = document.createElement('summary');
|
|
208
|
+
const label = document.createElement('span');
|
|
209
|
+
label.className = 'activity-title';
|
|
210
|
+
label.textContent = title;
|
|
211
|
+
summary.append(activityIcon('tool'), label, disclosureChevronElement());
|
|
212
|
+
const body = document.createElement('div');
|
|
213
|
+
body.className = 'tool-detail';
|
|
214
|
+
if (Object.keys(argumentsValue).length) body.append(detailSection('参数', argumentsValue));
|
|
215
|
+
if (entry.resultText) body.append(detailSection('结果', entry.resultText));
|
|
216
|
+
if (entry.errorText) body.append(detailSection('错误', entry.errorText, 'error'));
|
|
217
|
+
if (resources.length) body.append(detailSection('资源', resources));
|
|
218
|
+
if (entry.mediaCount) body.append(detailSection('媒体', `返回了 ${entry.mediaCount} 个媒体结果`));
|
|
219
|
+
const duration = Number(entry.durationMs);
|
|
220
|
+
if (Number.isFinite(duration) && duration >= 0) {
|
|
221
|
+
const footer = document.createElement('div');
|
|
222
|
+
footer.className = 'tool-detail-footer';
|
|
223
|
+
footer.textContent = formatDuration(duration);
|
|
224
|
+
body.append(footer);
|
|
225
|
+
}
|
|
226
|
+
details.append(summary, body);
|
|
227
|
+
return disclosures.bind(details, entryDisclosureKey(entry));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function activity(title, text, status, iconKind = null, disclosureKey = '') {
|
|
231
|
+
const details = document.createElement('details');
|
|
232
|
+
details.className = 'activity';
|
|
233
|
+
const summary = document.createElement('summary');
|
|
234
|
+
if (iconKind) summary.append(activityIcon(iconKind));
|
|
235
|
+
const label = document.createElement('span');
|
|
236
|
+
label.className = 'activity-title';
|
|
237
|
+
if (typeof title === 'string') label.textContent = title;
|
|
238
|
+
else label.append(title);
|
|
239
|
+
summary.append(label, disclosureChevronElement());
|
|
240
|
+
const content = document.createElement('pre');
|
|
241
|
+
content.textContent = text || '无详细信息';
|
|
242
|
+
details.append(summary, content);
|
|
243
|
+
return disclosures.bind(details, disclosureKey);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function fileChange(entry) {
|
|
247
|
+
const entryStatus = statusText(entry.status);
|
|
248
|
+
const changes = Array.isArray(entry.changes) ? entry.changes : [];
|
|
249
|
+
if (!changes.length) {
|
|
250
|
+
const title = entryStatus === 'completed' || entryStatus === 'failed' ? '已编辑文件' : '正在编辑文件';
|
|
251
|
+
return activity(title, '无详细信息', entry.status, 'edit');
|
|
252
|
+
}
|
|
253
|
+
const fragment = document.createDocumentFragment();
|
|
254
|
+
for (const [index, change] of changes.entries()) {
|
|
255
|
+
const title = document.createDocumentFragment();
|
|
256
|
+
const label = document.createElement('span');
|
|
257
|
+
const action = entryStatus === 'completed' || entryStatus === 'failed' ? '已编辑' : '正在编辑';
|
|
258
|
+
const filename = String(change.path || '文件').split(/[\\/]/).filter(Boolean).at(-1) || '文件';
|
|
259
|
+
label.textContent = `${action} ${filename}`;
|
|
260
|
+
title.append(label);
|
|
261
|
+
const counts = diffLineCounts(typeof change.diff === 'string' ? change.diff : change.diff?.text);
|
|
262
|
+
if (counts.added || counts.removed) {
|
|
263
|
+
const added = document.createElement('span');
|
|
264
|
+
added.className = 'diff-add';
|
|
265
|
+
added.textContent = `+${counts.added}`;
|
|
266
|
+
const removed = document.createElement('span');
|
|
267
|
+
removed.className = 'diff-remove';
|
|
268
|
+
removed.textContent = `-${counts.removed}`;
|
|
269
|
+
title.append(added, removed);
|
|
270
|
+
}
|
|
271
|
+
const diff = typeof change.diff === 'string' ? change.diff : change.diff?.text || '';
|
|
272
|
+
const detail = [change.path ? `路径:${change.path}` : '', diff].filter(Boolean).join('\n\n');
|
|
273
|
+
fragment.append(activity(title, detail || '已记录文件修改', entry.status, 'edit',
|
|
274
|
+
entryDisclosureKey(entry, `file:${index}:${change.path || ''}`)));
|
|
275
|
+
}
|
|
276
|
+
return fragment;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { activity, command, fileChange, tool };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
root.createActivityView = createActivityView;
|
|
283
|
+
})(globalThis);
|
package/dist/web/capabilities.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// Execution semantics belong to a connection/task, never to a client OS.
|
|
6
6
|
const profiles = {
|
|
7
7
|
'lan-legacy': { input: 'markdown-path', history: 'recent', settings: 'next-message', updates: 'snapshot-poll',
|
|
8
|
-
stop: 'none', queueOwner: 'bridge'
|
|
8
|
+
stop: 'none', queueOwner: 'bridge' },
|
|
9
9
|
'lan-shared': { input: 'inline-image', history: 'paged', settings: 'immediate', updates: 'events',
|
|
10
|
-
stop: 'primary', queueOwner: 'codex', archive: true, approvals: true,
|
|
10
|
+
stop: 'primary', queueOwner: 'codex', archive: true, approvals: true, syncBeforeWrite: true, sharedRecovery: true },
|
|
11
11
|
relay: { input: 'inline-image', history: 'paged', settings: 'immediate', updates: 'events',
|
|
12
12
|
stop: 'primary', queueOwner: 'codex', archive: true, approvals: true, syncBeforeWrite: true, historyCache: true, remoteImages: true, remoteFiles: true, managedConnection: true },
|
|
13
13
|
'app-server': { input: 'inline-image', history: 'recent', settings: 'next-message', updates: 'events',
|