@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.3
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.mjs +42 -0
- package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
- 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 +111 -2
- package/dist/src/platform/windows/launch-worker.ps1 +0 -13
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
(function (root) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
function parseQuestionReply(text) {
|
|
5
|
+
const match = /^\s*<send_user_message_question_reply>\s*([\s\S]*?)\s*<\/send_user_message_question_reply>\s*$/.exec(text);
|
|
6
|
+
if (!match) return null;
|
|
7
|
+
let replies;
|
|
8
|
+
try {
|
|
9
|
+
replies = JSON.parse(match[1]);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
if (!Array.isArray(replies) || !replies.length || !replies.every(reply =>
|
|
14
|
+
reply && typeof reply.question === 'string' && typeof reply.answer === 'string')) return null;
|
|
15
|
+
return replies;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripInternalMessageMetadata(text) {
|
|
19
|
+
if (typeof text !== 'string') return '';
|
|
20
|
+
return text.replace(/(?:^|\r?\n)[ \t]*<oai-mem-citation\b[\s\S]*?(?:<\/oai-mem-citation>[ \t]*(?=\r?\n|$)|$)/gi, '').trimEnd();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function visibleMessageText(kind, text) {
|
|
24
|
+
return kind === 'agent' ? stripInternalMessageMetadata(text) : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createMessageView(options) {
|
|
28
|
+
const {
|
|
29
|
+
document,
|
|
30
|
+
markdown,
|
|
31
|
+
enhanceCodeBlocks,
|
|
32
|
+
remoteImageElement,
|
|
33
|
+
openImageViewer,
|
|
34
|
+
linkActions,
|
|
35
|
+
isEmbedded,
|
|
36
|
+
scrollTimelineToBottom,
|
|
37
|
+
} = options;
|
|
38
|
+
|
|
39
|
+
function render(kind, text, role, imageAssets = {}, fileAssets = {}) {
|
|
40
|
+
const element = document.createElement('div');
|
|
41
|
+
element.className = `message ${kind}`;
|
|
42
|
+
const roleElement = document.createElement('span');
|
|
43
|
+
roleElement.className = 'role';
|
|
44
|
+
roleElement.textContent = role;
|
|
45
|
+
const content = document.createElement('div');
|
|
46
|
+
content.className = 'message-body';
|
|
47
|
+
const visibleText = visibleMessageText(kind, text);
|
|
48
|
+
const replies = kind === 'user' ? parseQuestionReply(visibleText) : null;
|
|
49
|
+
if (replies) {
|
|
50
|
+
for (const reply of replies) {
|
|
51
|
+
const item = document.createElement('div');
|
|
52
|
+
item.className = 'question-reply';
|
|
53
|
+
const question = document.createElement('div');
|
|
54
|
+
question.className = 'question-reply-question';
|
|
55
|
+
question.textContent = reply.question;
|
|
56
|
+
const answer = document.createElement('div');
|
|
57
|
+
answer.className = 'question-reply-answer';
|
|
58
|
+
answer.textContent = reply.answer;
|
|
59
|
+
item.append(question, answer);
|
|
60
|
+
content.append(item);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
content.innerHTML = visibleText
|
|
64
|
+
? markdown.render(visibleText, { imageAssets, fileAssets, allowRemoteFiles: kind === 'agent' })
|
|
65
|
+
: '';
|
|
66
|
+
enhanceCodeBlocks(content, visibleText);
|
|
67
|
+
}
|
|
68
|
+
for (const placeholder of content.querySelectorAll('[data-relay-image]')) {
|
|
69
|
+
placeholder.replaceWith(remoteImageElement(
|
|
70
|
+
imageAssets[placeholder.dataset.relayImage],
|
|
71
|
+
true,
|
|
72
|
+
placeholder.dataset.imageAlt,
|
|
73
|
+
));
|
|
74
|
+
}
|
|
75
|
+
const showImage = event => {
|
|
76
|
+
const target = event.target.closest('[data-full-image]');
|
|
77
|
+
if (!target) return;
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
const preview = target.currentSrc || target.src || target.dataset.fullImage;
|
|
80
|
+
openImageViewer(preview, target.alt || target.textContent || '图片', () => target.dataset.fullImage);
|
|
81
|
+
};
|
|
82
|
+
content.addEventListener('click', showImage);
|
|
83
|
+
content.addEventListener('click', event => linkActions.handleContentClick(event, isEmbedded()));
|
|
84
|
+
content.addEventListener('keydown', event => {
|
|
85
|
+
if (event.target.matches('img[data-full-image]') && ['Enter', ' '].includes(event.key)) showImage(event);
|
|
86
|
+
});
|
|
87
|
+
for (const image of content.querySelectorAll('img[data-full-image]')) {
|
|
88
|
+
image.addEventListener('load', () => scrollTimelineToBottom(), { once: true });
|
|
89
|
+
}
|
|
90
|
+
element.append(roleElement, content);
|
|
91
|
+
return element;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { render };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
root.CodexMessageView = Object.freeze({ createMessageView, parseQuestionReply, visibleMessageText });
|
|
98
|
+
})(globalThis);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
|
+
else root.CodeRelaxP2pDataFramer = factory();
|
|
4
|
+
})(globalThis, () => {
|
|
5
|
+
const CHUNK_CHARS = 12000;
|
|
6
|
+
const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const MAX_PARTS = 1024;
|
|
8
|
+
const byteLength = value => new TextEncoder().encode(value).length;
|
|
9
|
+
|
|
10
|
+
return class {
|
|
11
|
+
constructor() { this.inbound = undefined; }
|
|
12
|
+
create(payload, id) {
|
|
13
|
+
if (typeof payload !== 'string' || !/^[a-f0-9]{32}-[0-9]{1,10}$/.test(id)) throw new Error('Invalid P2P payload');
|
|
14
|
+
const bytes = byteLength(payload);
|
|
15
|
+
if (bytes > MAX_MESSAGE_BYTES) throw new Error('Code Relax 直连消息超过 8 MiB');
|
|
16
|
+
const total = Math.max(1, Math.ceil(payload.length / CHUNK_CHARS));
|
|
17
|
+
if (total > MAX_PARTS) throw new Error('Code Relax 直连消息分片过多');
|
|
18
|
+
return { id, payload, bytes, index: 0, total };
|
|
19
|
+
}
|
|
20
|
+
frame(item) {
|
|
21
|
+
if (!item || item.index >= item.total) return undefined;
|
|
22
|
+
const start = item.index * CHUNK_CHARS;
|
|
23
|
+
return JSON.stringify({ type: 'data', id: item.id, index: item.index, total: item.total,
|
|
24
|
+
payload: item.payload.slice(start, start + CHUNK_CHARS) });
|
|
25
|
+
}
|
|
26
|
+
commit(item) { item.index++; return item.index >= item.total; }
|
|
27
|
+
accept(message) {
|
|
28
|
+
if (!message || message.type !== 'data') return { handled: false };
|
|
29
|
+
if (!/^[a-f0-9]{32}-[0-9]{1,10}$/.test(message.id || '') || !Number.isSafeInteger(message.index) ||
|
|
30
|
+
!Number.isSafeInteger(message.total) || message.total < 1 || message.total > MAX_PARTS ||
|
|
31
|
+
message.index < 0 || message.index >= message.total || typeof message.payload !== 'string' ||
|
|
32
|
+
message.payload.length > CHUNK_CHARS) throw new Error('Invalid P2P data frame');
|
|
33
|
+
if (!this.inbound) {
|
|
34
|
+
if (message.index !== 0) throw new Error('Invalid P2P data order');
|
|
35
|
+
this.inbound = { id: message.id, total: message.total, next: 0, bytes: 0, parts: [] };
|
|
36
|
+
}
|
|
37
|
+
const current = this.inbound;
|
|
38
|
+
if (current.id !== message.id || current.total !== message.total || current.next !== message.index) {
|
|
39
|
+
throw new Error('Invalid P2P data order');
|
|
40
|
+
}
|
|
41
|
+
current.bytes += byteLength(message.payload);
|
|
42
|
+
if (current.bytes > MAX_MESSAGE_BYTES) throw new Error('P2P data message too large');
|
|
43
|
+
current.parts.push(message.payload); current.next++;
|
|
44
|
+
if (current.next !== current.total) return { handled: true, complete: false };
|
|
45
|
+
this.inbound = undefined;
|
|
46
|
+
return { handled: true, complete: true, payload: current.parts.join('') };
|
|
47
|
+
}
|
|
48
|
+
reset() { this.inbound = undefined; }
|
|
49
|
+
};
|
|
50
|
+
});
|
package/dist/web/p2p-probe.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
(function (root) {
|
|
2
|
-
|
|
2
|
+
const HIGH_WATER = 1024 * 1024;
|
|
3
|
+
const LOW_WATER = 512 * 1024;
|
|
4
|
+
const MAX_QUEUED = 16 * 1024 * 1024;
|
|
5
|
+
|
|
3
6
|
root.CodeRelaxP2pProbe = class {
|
|
4
|
-
constructor(send, report) {
|
|
5
|
-
this.send = send; this.report = report; this.
|
|
7
|
+
constructor(send, report, receiveData = () => {}) {
|
|
8
|
+
this.send = send; this.report = report; this.receiveData = receiveData;
|
|
9
|
+
this.closed = false; this.queue = Promise.resolve(); this.outbound = []; this.queuedBytes = 0; this.dataSerial = 0;
|
|
6
10
|
this.diagnostics = new root.CodeRelaxP2pDiagnostics();
|
|
11
|
+
this.framer = new root.CodeRelaxP2pDataFramer();
|
|
7
12
|
}
|
|
8
13
|
start() {
|
|
9
14
|
if (typeof root.RTCPeerConnection !== 'function' || !root.crypto?.getRandomValues) {
|
|
@@ -29,35 +34,68 @@
|
|
|
29
34
|
peer.ondatachannel = event => {
|
|
30
35
|
if (this.channel || event.channel.label !== 'code-relax-probe-v1') { event.channel.close(); this.close('failed'); return; }
|
|
31
36
|
const channel = this.channel = event.channel;
|
|
32
|
-
channel.
|
|
37
|
+
channel.bufferedAmountLowThreshold = LOW_WATER;
|
|
38
|
+
channel.onbufferedamountlow = () => this.pump();
|
|
39
|
+
channel.onopen = () => {
|
|
40
|
+
if (!this.closed) { this.diagnostics.open = true; channel.send(JSON.stringify({ type: 'ping', nonce: this.nonce })); }
|
|
41
|
+
};
|
|
33
42
|
channel.onerror = () => { this.diagnostics.error = 'channel'; this.close('failed'); };
|
|
34
43
|
channel.onclose = () => this.close('closed');
|
|
35
|
-
|
|
36
|
-
channel.onmessage = event => {
|
|
37
|
-
if (this.closed) return;
|
|
38
|
-
try {
|
|
39
|
-
if (typeof event.data !== 'string' || event.data.length > 4096 || ++received > 4) throw new Error();
|
|
40
|
-
const message = JSON.parse(event.data);
|
|
41
|
-
if (message.type === 'ping' && /^[\w-]{16,128}$/.test(message.nonce)) {
|
|
42
|
-
channel.send(JSON.stringify({ type: 'pong', nonce: message.nonce })); this.pingReceived = true; this.diagnostics.ping = true;
|
|
43
|
-
} else if (message.type === 'pong' && message.nonce === this.nonce) { this.pongReceived = true; this.diagnostics.pong = true; }
|
|
44
|
-
else throw new Error();
|
|
45
|
-
if (this.pingReceived && this.pongReceived) {
|
|
46
|
-
this.report('verified', this.diagnostics.snapshot('verified')); clearTimeout(this.timer);
|
|
47
|
-
// Allow the remote peer to receive the final pong before closing.
|
|
48
|
-
this.timer = setTimeout(() => this.close('complete'), 1000);
|
|
49
|
-
}
|
|
50
|
-
} catch { this.diagnostics.error = 'frame'; this.close('failed'); }
|
|
51
|
-
};
|
|
44
|
+
channel.onmessage = event => this.receiveChannelMessage(event.data);
|
|
52
45
|
};
|
|
53
46
|
}
|
|
47
|
+
receiveChannelMessage(value) {
|
|
48
|
+
if (this.closed) return;
|
|
49
|
+
try {
|
|
50
|
+
if (typeof value !== 'string' || value.length > 65536) throw new Error();
|
|
51
|
+
const message = JSON.parse(value);
|
|
52
|
+
const data = this.framer.accept(message);
|
|
53
|
+
if (data.handled) {
|
|
54
|
+
if (!this.transportActive) throw new Error();
|
|
55
|
+
if (data.complete) this.receiveData(data.payload);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (message.type === 'ping' && /^[\w-]{16,128}$/.test(message.nonce)) {
|
|
59
|
+
this.channel.send(JSON.stringify({ type: 'pong', nonce: message.nonce }));
|
|
60
|
+
this.pingReceived = true; this.diagnostics.ping = true;
|
|
61
|
+
} else if (message.type === 'pong' && message.nonce === this.nonce) {
|
|
62
|
+
this.pongReceived = true; this.diagnostics.pong = true;
|
|
63
|
+
} else throw new Error();
|
|
64
|
+
this.verify();
|
|
65
|
+
} catch { this.diagnostics.error = 'frame'; this.close('failed'); }
|
|
66
|
+
}
|
|
67
|
+
verify() {
|
|
68
|
+
if (this.verified || !this.pingReceived || !this.pongReceived) return;
|
|
69
|
+
this.verified = true; this.transportActive = this.remoteDirect;
|
|
70
|
+
clearTimeout(this.timer);
|
|
71
|
+
this.report(this.transportActive ? 'direct' : 'verified', this.diagnostics.snapshot('verified'));
|
|
72
|
+
this.pump();
|
|
73
|
+
}
|
|
74
|
+
sendData(payload) {
|
|
75
|
+
if (!this.transportActive || this.closed || this.channel?.readyState !== 'open') return false;
|
|
76
|
+
let item;
|
|
77
|
+
try { item = this.framer.create(payload, `${this.id}-${++this.dataSerial}`); }
|
|
78
|
+
catch { return false; }
|
|
79
|
+
if (this.queuedBytes + item.bytes > MAX_QUEUED) return false;
|
|
80
|
+
this.outbound.push(item); this.queuedBytes += item.bytes; this.pump(); return true;
|
|
81
|
+
}
|
|
82
|
+
pump() {
|
|
83
|
+
if (!this.transportActive || this.closed || this.channel?.readyState !== 'open') return;
|
|
84
|
+
try {
|
|
85
|
+
while (this.outbound.length && this.channel.bufferedAmount < HIGH_WATER) {
|
|
86
|
+
const item = this.outbound[0], frame = this.framer.frame(item);
|
|
87
|
+
this.channel.send(frame);
|
|
88
|
+
if (this.framer.commit(item)) { this.outbound.shift(); this.queuedBytes -= item.bytes; }
|
|
89
|
+
}
|
|
90
|
+
} catch { this.diagnostics.error = 'channel'; this.close('failed'); }
|
|
91
|
+
}
|
|
54
92
|
signal(value) { this.send({ type: 'p2p-probe', version: 1, probeId: this.id, ...value }); }
|
|
55
93
|
receive(message) {
|
|
56
94
|
if (this.closed || message.probeId !== this.id || message.version !== 1) return;
|
|
57
95
|
this.queue = this.queue.then(async () => {
|
|
58
96
|
if (this.closed) return;
|
|
59
97
|
if (message.kind === 'offer' && !this.offerReceived) {
|
|
60
|
-
this.offerReceived = true;
|
|
98
|
+
this.offerReceived = true; this.remoteDirect = message.direct === 1;
|
|
61
99
|
this.createPeer((message.iceServers || []).map(url => ({ urls: url })));
|
|
62
100
|
await this.peer.setRemoteDescription({ type: 'offer', sdp: message.sdp });
|
|
63
101
|
this.diagnostics.offer = true;
|
|
@@ -65,19 +103,21 @@
|
|
|
65
103
|
if (this.closed) return;
|
|
66
104
|
await this.peer.setLocalDescription(answer);
|
|
67
105
|
this.diagnostics.answer = true;
|
|
68
|
-
if (!this.closed) this.signal({ kind: 'answer', sdp: answer.sdp });
|
|
106
|
+
if (!this.closed) this.signal({ kind: 'answer', sdp: answer.sdp, direct: 1 });
|
|
69
107
|
} else if (message.kind === 'candidate') {
|
|
70
108
|
this.diagnostics.candidate('remote', message.candidate);
|
|
71
109
|
await this.peer.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid });
|
|
72
|
-
} else this.close('
|
|
110
|
+
} else if (message.kind === 'close') this.close('closed');
|
|
111
|
+
else this.close('failed');
|
|
73
112
|
}).catch(() => { this.diagnostics.error = message.kind === 'candidate' ? 'candidate' : 'description'; this.close('failed'); });
|
|
74
113
|
}
|
|
75
|
-
close(state = 'closed') {
|
|
114
|
+
close(state = 'closed', silent = false) {
|
|
76
115
|
if (this.closed) return;
|
|
77
|
-
this.closed = true; clearTimeout(this.timer);
|
|
116
|
+
this.closed = true; this.transportActive = false; clearTimeout(this.timer);
|
|
117
|
+
this.outbound = []; this.queuedBytes = 0; this.framer.reset();
|
|
78
118
|
this.channel?.close(); this.peer?.close();
|
|
79
119
|
if (this.id) this.signal({ kind: 'close' });
|
|
80
|
-
if (!
|
|
120
|
+
if (!silent) this.report(state, this.diagnostics.snapshot(state));
|
|
81
121
|
}
|
|
82
122
|
};
|
|
83
123
|
})(globalThis);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
(function (root) {
|
|
2
|
+
function preferredThreadId(options) {
|
|
3
|
+
const { pageResume, resumeConfig, saved, visited } = options;
|
|
4
|
+
return pageResume ? (resumeConfig?.resumeThreadId || saved?.selectedId || '') : (visited?.id || '');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function resolveConversation(options) {
|
|
8
|
+
const { pageResume, resumeConfig, saved, threads, visited, readCached, readLastCached, onCacheError } = options;
|
|
9
|
+
const requestedThreadId = preferredThreadId({ pageResume, resumeConfig, saved, visited });
|
|
10
|
+
const requestedThread = requestedThreadId
|
|
11
|
+
? saved?.selectedThread || threads.find(thread => thread.id === requestedThreadId)
|
|
12
|
+
|| (visited?.id === requestedThreadId ? visited : { id: requestedThreadId })
|
|
13
|
+
: null;
|
|
14
|
+
let cached = saved?.timeline || null;
|
|
15
|
+
if (requestedThreadId && cached?.thread?.id !== requestedThreadId) cached = null;
|
|
16
|
+
if (!cached && requestedThread) {
|
|
17
|
+
cached = await readCached(requestedThreadId, requestedThread).catch(error => {
|
|
18
|
+
onCacheError(error);
|
|
19
|
+
return null;
|
|
20
|
+
}) || { thread: requestedThread, turns: [] };
|
|
21
|
+
}
|
|
22
|
+
if (!cached && !pageResume) cached = await readLastCached();
|
|
23
|
+
if (!cached?.thread || (requestedThreadId && cached.thread.id !== requestedThreadId)) return null;
|
|
24
|
+
return cached;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
root.CodexPageResume = Object.freeze({ preferredThreadId, resolveConversation });
|
|
28
|
+
})(globalThis);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
(function (root) {
|
|
2
|
+
function createPendingMessageStore() {
|
|
3
|
+
const messagesByThread = new Map();
|
|
4
|
+
const clientId = item => item?.clientId || item?.clientUserMessageId;
|
|
5
|
+
const normalizeText = text => (text || '').replace(/\r\n?/g, '\n').trim();
|
|
6
|
+
const isImage = attachment => attachment && (attachment.type === 'image' || attachment.type === 'localImage');
|
|
7
|
+
|
|
8
|
+
function entries(timeline) {
|
|
9
|
+
return (timeline?.turns || []).flatMap(turn => (turn.entries || [])
|
|
10
|
+
.map(entry => ({ ...entry, turnId: turn.id, key: `${turn.id}/${entry.id}` })));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function baseline(timeline) {
|
|
14
|
+
return new Set(entries(timeline).map(entry => entry.key));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function get(threadId) {
|
|
18
|
+
return messagesByThread.get(threadId) || [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function set(threadId, messages) {
|
|
22
|
+
if (messages?.length) messagesByThread.set(threadId, messages);
|
|
23
|
+
else messagesByThread.delete(threadId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function has(threadId, message) {
|
|
27
|
+
return get(threadId).includes(message);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function retainImagePreviews(threadId, data) {
|
|
31
|
+
if (!data || data.cached) return;
|
|
32
|
+
const messages = get(threadId);
|
|
33
|
+
if (!messages.length) return;
|
|
34
|
+
const candidates = entries(data);
|
|
35
|
+
const consumed = new Set();
|
|
36
|
+
for (const message of messages) {
|
|
37
|
+
let match;
|
|
38
|
+
if (message.clientUserMessageId) {
|
|
39
|
+
match = candidates.find(candidate => candidate.role === 'user' && !consumed.has(candidate.key)
|
|
40
|
+
&& clientId(candidate) === message.clientUserMessageId);
|
|
41
|
+
}
|
|
42
|
+
match ||= candidates.find(candidate => candidate.role === 'user' && !consumed.has(candidate.key)
|
|
43
|
+
&& !message.baseline.has(candidate.key)
|
|
44
|
+
&& !(clientId(candidate) && message.baselineClientIds?.has(clientId(candidate)))
|
|
45
|
+
&& !(message.clientUserMessageId && clientId(candidate))
|
|
46
|
+
&& (!message.turnId || candidate.turnId === message.turnId || (message.settled && !message.status))
|
|
47
|
+
&& normalizeText(candidate.text) === normalizeText(message.text)
|
|
48
|
+
&& (candidate.attachments || []).length === message.attachments.length);
|
|
49
|
+
if (!match) continue;
|
|
50
|
+
consumed.add(match.key);
|
|
51
|
+
const previews = (message.attachments || []).filter(isImage);
|
|
52
|
+
const attachments = (match.attachments || []).filter(isImage);
|
|
53
|
+
attachments.forEach((attachment, index) => {
|
|
54
|
+
const preview = previews[index];
|
|
55
|
+
const url = preview?.url || preview?.thumbnailUrl;
|
|
56
|
+
if (!attachment.url && url) Object.assign(attachment, {
|
|
57
|
+
available: true,
|
|
58
|
+
url,
|
|
59
|
+
thumbnailUrl: preview.thumbnailUrl || url,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function reconcile(threadId, timeline) {
|
|
66
|
+
const messages = get(threadId);
|
|
67
|
+
const candidates = entries(timeline);
|
|
68
|
+
const consumed = new Set();
|
|
69
|
+
const matches = new Map();
|
|
70
|
+
if (!timeline?.cached) for (const message of messages) {
|
|
71
|
+
if (!message.clientUserMessageId) continue;
|
|
72
|
+
const match = candidates.find(entry => entry.role === 'user' && !consumed.has(entry.key)
|
|
73
|
+
&& clientId(entry) === message.clientUserMessageId);
|
|
74
|
+
if (match) {
|
|
75
|
+
matches.set(message, match);
|
|
76
|
+
consumed.add(match.key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let interruptedUnconfirmed = 0;
|
|
80
|
+
const remaining = messages.filter(message => {
|
|
81
|
+
const match = matches.get(message) || (!timeline?.cached && candidates.find(entry => entry.role === 'user'
|
|
82
|
+
&& !message.baseline.has(entry.key) && !consumed.has(entry.key)
|
|
83
|
+
&& !(clientId(entry) && message.baselineClientIds?.has(clientId(entry)))
|
|
84
|
+
&& !(message.clientUserMessageId && clientId(entry))
|
|
85
|
+
&& (!message.turnId || entry.turnId === message.turnId || (message.settled && !message.status))
|
|
86
|
+
&& normalizeText(entry.text) === normalizeText(message.text)
|
|
87
|
+
&& (entry.attachments || []).length === message.attachments.length));
|
|
88
|
+
if (match) consumed.add(match.key);
|
|
89
|
+
if (!match && !timeline?.cached && message.settled && !message.status && message.turnId
|
|
90
|
+
&& timeline?.turns?.some(turn => turn.id === message.turnId && turn.status === 'interrupted')) {
|
|
91
|
+
interruptedUnconfirmed++;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return !match;
|
|
95
|
+
});
|
|
96
|
+
for (const message of remaining) for (const entry of candidates) if (consumed.has(entry.key)) {
|
|
97
|
+
message.baseline.add(entry.key);
|
|
98
|
+
if (clientId(entry)) (message.baselineClientIds ||= new Set()).add(clientId(entry));
|
|
99
|
+
}
|
|
100
|
+
set(threadId, remaining);
|
|
101
|
+
return { remaining, interruptedUnconfirmed };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Object.freeze({ baseline, get, has, reconcile, retainImagePreviews, set });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
root.createPendingMessageStore = createPendingMessageStore;
|
|
108
|
+
})(globalThis);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
(function (root) {
|
|
2
|
+
function createQueueController(options) {
|
|
3
|
+
const {
|
|
4
|
+
document, el, api, state, pendingMessages,
|
|
5
|
+
isComposerActive, isConnectionBlocked, isThreadReadOnly,
|
|
6
|
+
renderPendingMessages, scrollTimelineToBottom, showToast,
|
|
7
|
+
} = options;
|
|
8
|
+
const steering = new Set();
|
|
9
|
+
|
|
10
|
+
function render(items) {
|
|
11
|
+
const panel = el('queuePanel');
|
|
12
|
+
panel.replaceChildren();
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
const row = document.createElement('div');
|
|
15
|
+
row.className = 'queued-message';
|
|
16
|
+
const text = document.createElement('span');
|
|
17
|
+
text.className = `queued-text${item.error ? ' queued-error' : ''}`;
|
|
18
|
+
text.textContent = item.error ? `${item.prompt} · ${item.error}` : item.prompt;
|
|
19
|
+
text.title = text.textContent;
|
|
20
|
+
const steerButton = document.createElement('button');
|
|
21
|
+
steerButton.className = 'queue-action';
|
|
22
|
+
steerButton.type = 'button';
|
|
23
|
+
steerButton.textContent = '↪ 引导';
|
|
24
|
+
steerButton.onclick = () => steer(item);
|
|
25
|
+
const removeButton = document.createElement('button');
|
|
26
|
+
removeButton.className = 'queue-action';
|
|
27
|
+
removeButton.type = 'button';
|
|
28
|
+
removeButton.textContent = '删除';
|
|
29
|
+
removeButton.onclick = () => remove(item.id);
|
|
30
|
+
const key = `${state.selectedId}/${item.id}`;
|
|
31
|
+
steerButton.disabled = removeButton.disabled = isConnectionBlocked() || isThreadReadOnly() || steering.has(key);
|
|
32
|
+
row.append(text, steerButton, removeButton);
|
|
33
|
+
panel.append(row);
|
|
34
|
+
}
|
|
35
|
+
panel.hidden = !items.length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function steer(item) {
|
|
39
|
+
const threadId = state.selectedId;
|
|
40
|
+
const key = `${threadId}/${item.id}`;
|
|
41
|
+
if (!threadId || isConnectionBlocked() || steering.has(key)) return;
|
|
42
|
+
steering.add(key);
|
|
43
|
+
const message = {
|
|
44
|
+
queueItemId: item.id,
|
|
45
|
+
clientUserMessageId: item.clientUserMessageId,
|
|
46
|
+
text: item.text ?? item.prompt,
|
|
47
|
+
attachments: item.attachments || [],
|
|
48
|
+
status: '',
|
|
49
|
+
turnId: isComposerActive() ? state.timeline?.turns?.at(-1)?.id : null,
|
|
50
|
+
settled: false,
|
|
51
|
+
baseline: pendingMessages.baseline(state.timeline),
|
|
52
|
+
};
|
|
53
|
+
pendingMessages.set(threadId, [...pendingMessages.get(threadId), message]);
|
|
54
|
+
renderPendingMessages();
|
|
55
|
+
scrollTimelineToBottom(true);
|
|
56
|
+
try {
|
|
57
|
+
const result = await api(`/api/threads/${encodeURIComponent(threadId)}/queue/${item.id}/steer`, { method: 'POST' });
|
|
58
|
+
if (state.selectedId === threadId) render(result.items || []);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
message.status = '发送未确认,收到正式消息后会自动合并';
|
|
61
|
+
showToast(`插队失败:${error.message}`);
|
|
62
|
+
} finally {
|
|
63
|
+
message.settled = true;
|
|
64
|
+
steering.delete(key);
|
|
65
|
+
if (state.selectedId === threadId) renderPendingMessages();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function remove(itemId) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await api(`/api/threads/${encodeURIComponent(state.selectedId)}/queue/${itemId}`, { method: 'DELETE' });
|
|
72
|
+
render(result.items || []);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
showToast(`删除失败:${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return Object.freeze({ render });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
root.createQueueController = createQueueController;
|
|
82
|
+
})(globalThis);
|
package/dist/web/resources.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
["server-requests.js","approval-ui.js","chat.css","highlight.min.js","highlight-powershell.min.js","capabilities.js","timeline-sync.js","harmony-platform.js","client-platform.js","lan-session.js","history-cache.js","p2p-diagnostics.js","p2p-probe.js","self-relay-session.js","chat-transport.js","chat.js"]
|
|
1
|
+
["server-requests.js","approval-ui.js","chat.css","community.css","highlight.min.js","highlight-powershell.min.js","capabilities.js","timeline-sync.js","timeline-reducer.js","harmony-platform.js","client-platform.js","lan-session.js","history-cache.js","p2p-diagnostics.js","p2p-data-channel.js","p2p-probe.js","self-relay-session.js","chat-transport.js","page-resume.js","conversation-controller.js","history-controller.js","usage-controller.js","task-list-view.js","draft-controller.js","thread-attention-controller.js","thread-title-controller.js","thread-list-controller.js","thread-list-sync.js","station-connection-controller.js","disclosure-state-controller.js","pending-message-store.js","queue-controller.js","thread-context-controller.js","composer-controller.js","message-send-controller.js","link-action-controller.js","message-view.js","activity-view.js","community-view.js","timeline-formatters.js","timeline-scroll-controller.js","timeline-renderer.js","chat.js"]
|
|
@@ -2,31 +2,40 @@
|
|
|
2
2
|
let socketSerial = 0;
|
|
3
3
|
const nativeViews = new Map();
|
|
4
4
|
root.computerConnectionEvent = (key, event, value) => nativeViews.get(key)?.emit(event, JSON.parse(value));
|
|
5
|
-
class NativeView extends EventTarget {
|
|
6
|
-
constructor() {
|
|
7
|
-
super(); this.key = `${Date.now()}-${++socketSerial}`; this.readyState = 0; nativeViews.set(this.key, this);
|
|
8
|
-
queueMicrotask(() => { if (this.readyState === 0) { this.readyState = 1; this.emit('open', {}); } });
|
|
9
|
-
}
|
|
5
|
+
class NativeView extends EventTarget {
|
|
6
|
+
constructor() {
|
|
7
|
+
super(); this.key = `${Date.now()}-${++socketSerial}`; this.readyState = 0; this.autoProbeStarted = false; nativeViews.set(this.key, this);
|
|
8
|
+
queueMicrotask(() => { if (this.readyState === 0) { this.readyState = 1; this.emit('open', {}); } });
|
|
9
|
+
}
|
|
10
10
|
emit(type, value) {
|
|
11
11
|
if (this.readyState === 3) return;
|
|
12
12
|
if (type === 'message' && value.type === 'p2p-probe') { this.probe?.receive(value); return; }
|
|
13
13
|
if (type === 'message' && value.type === 'p2p-restart') { this.startProbe(); return; }
|
|
14
|
-
if (type === '
|
|
15
|
-
|
|
14
|
+
if (type === 'message' && value.type === 'p2p-data-out') {
|
|
15
|
+
const sent = typeof value.payload === 'string' && this.probe?.sendData(value.payload) === true;
|
|
16
|
+
root.codexNative.computerTransport(JSON.stringify({ type: 'p2p-data-result', key: this.key,
|
|
17
|
+
payload: JSON.stringify({ token: value.token, sent }) }));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (type === 'close') { this.probe?.close(); this.readyState = 3; nativeViews.delete(this.key); }
|
|
21
|
+
this.dispatch(type, value);
|
|
22
|
+
if (type === 'message' && value.type === 'ready' && value.p2pProbe === 1 && !this.autoProbeStarted) {
|
|
23
|
+
this.autoProbeStarted = true; queueMicrotask(() => { if (this.readyState !== 3) this.startProbe(); });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
dispatch(type, value) {
|
|
16
27
|
const event = type === 'message' ? new MessageEvent(type, { data: JSON.stringify(value) }) : new Event(type);
|
|
17
28
|
if (type === 'close') Object.defineProperty(event, 'code', { value: value.code });
|
|
18
29
|
this.dispatchEvent(event); this[`on${type}`]?.(event);
|
|
19
30
|
}
|
|
20
|
-
// WebRTC is diagnostic-only. Never start it on connection readiness; the
|
|
21
|
-
// native diagnostics dialog explicitly emits p2p-restart for one test.
|
|
22
31
|
startProbe() {
|
|
23
|
-
this.probe?.close('replaced');
|
|
32
|
+
this.probe?.close('replaced', true);
|
|
24
33
|
this.probe = new root.CodeRelaxP2pProbe(
|
|
25
34
|
signal => root.codexNative.computerTransport(JSON.stringify({ type: 'p2p-probe', key: this.key, payload: JSON.stringify(signal) })),
|
|
26
35
|
(state, diagnostic) => {
|
|
27
36
|
if (diagnostic) root.codexNative.computerTransport(JSON.stringify({ type: 'p2p-diagnostic', key: this.key, payload: diagnostic }));
|
|
28
37
|
root.codexNative.computerTransport(JSON.stringify({ type: 'p2p-status', key: this.key, payload: state }));
|
|
29
|
-
});
|
|
38
|
+
}, payload => root.codexNative.computerTransport(JSON.stringify({ type: 'p2p-data-in', key: this.key, payload })));
|
|
30
39
|
try { this.probe.start(); }
|
|
31
40
|
catch { this.probe.diagnostics.error = 'initialization'; this.probe.close('failed'); }
|
|
32
41
|
}
|
|
@@ -37,8 +46,8 @@
|
|
|
37
46
|
}
|
|
38
47
|
close() {
|
|
39
48
|
this.probe?.close();
|
|
40
|
-
root.codexNative.computerTransport(JSON.stringify({ type: 'detach', key: this.key }));
|
|
41
|
-
this.emit('close', { code: 1000 });
|
|
49
|
+
root.codexNative.computerTransport(JSON.stringify({ type: 'detach', key: this.key }));
|
|
50
|
+
this.emit('close', { code: 1000 });
|
|
42
51
|
}
|
|
43
52
|
}
|
|
44
53
|
// Reuse LAN request, subscription and disconnect semantics; replace only the envelope.
|
|
@@ -57,13 +66,14 @@
|
|
|
57
66
|
return code ? { type: 'pair', room, code, clientId, ...(credential ? { credential } : {}) } : { type: 'resume', room, credential, clientId };
|
|
58
67
|
}
|
|
59
68
|
closeError(event) {
|
|
60
|
-
const reasons = { 4401: '设备授权不存在或已撤销,请重新扫码授权', 4408: '配对码已过期或使用,请生成新的首次授权码',
|
|
61
|
-
4406: 'Code Relax 中继协议需要升级,请更新工作站和手机', 4409: '连接已由同一设备的新连接接替', 4410: '工作站暂时离线,正在等待恢复,设备授权仍保留',
|
|
62
|
-
|
|
69
|
+
const reasons = { 4401: '设备授权不存在或已撤销,请重新扫码授权', 4408: '配对码已过期或使用,请生成新的首次授权码',
|
|
70
|
+
4406: 'Code Relax 中继协议需要升级,请更新工作站和手机', 4409: '连接已由同一设备的新连接接替', 4410: '工作站暂时离线,正在等待恢复,设备授权仍保留',
|
|
71
|
+
4428: '当前服务器压力较大,请稍后再试',
|
|
72
|
+
1011: '工作站 Bridge 暂时不可用,正在恢复连接' };
|
|
63
73
|
const error = new TypeError(reasons[event.code] || `Code Relax 中继连接已断开(${event.code}),正在恢复`);
|
|
64
74
|
error.code = event.code;
|
|
65
75
|
if (event.code === 4401) root.codexHistoryCache?.clear().catch(() => {});
|
|
66
|
-
if ([1008, 4401, 4406, 4408, 4409, 4429].includes(event.code)) this.terminalError = error;
|
|
76
|
+
if ([1008, 4401, 4406, 4408, 4409, 4428, 4429].includes(event.code)) this.terminalError = error;
|
|
67
77
|
return error;
|
|
68
78
|
}
|
|
69
79
|
encodeMessage(message) {
|
|
@@ -80,7 +90,7 @@
|
|
|
80
90
|
decodeMessage(message) {
|
|
81
91
|
if (message.type === 'paired') {
|
|
82
92
|
if (message.room !== this.config.room) throw new Error('配对工作站不匹配');
|
|
83
|
-
if (this.config.clientId && message.protocolVersion !==
|
|
93
|
+
if (this.config.clientId && message.protocolVersion !== 7) {
|
|
84
94
|
this.terminalError = Object.assign(new TypeError('Code Relax 中继协议需要升级'), { code: 4406 }); throw this.terminalError;
|
|
85
95
|
}
|
|
86
96
|
if (message.credential && this.config.credential && message.credential !== this.config.credential) throw new Error('设备凭据与已保存身份不一致');
|