orbit-codex-bridge 0.0.0-stage → 0.1.0
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 +189 -2
- package/cli.mjs +244 -0
- package/lib/desktop.mjs +404 -0
- package/lib/device-auth.mjs +69 -0
- package/lib/rpc.mjs +203 -0
- package/lib/store.mjs +162 -0
- package/orbit +4 -0
- package/package.json +33 -4
- package/public/admin.css +67 -0
- package/public/admin.html +23 -0
- package/public/admin.js +186 -0
- package/public/app.js +1035 -0
- package/public/connect.html +56 -0
- package/public/connect.js +126 -0
- package/public/icon.svg +1 -0
- package/public/index.html +38 -0
- package/public/layout.css +73 -0
- package/public/manifest.webmanifest +1 -0
- package/public/style.css +40 -0
- package/relay-agent.mjs +181 -0
- package/relay-server.mjs +684 -0
- package/relay-store.mjs +368 -0
- package/server.mjs +485 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
const select = selector => document.querySelector(selector);
|
|
2
|
+
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[character]));
|
|
3
|
+
const paths = {
|
|
4
|
+
inbox:'<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M3 13h5l2 3h4l2-3h5"/>',
|
|
5
|
+
archive:'<rect x="3" y="3" width="18" height="5" rx="1"/><path d="M5 8v12h14V8M10 12h4"/>',
|
|
6
|
+
devices:'<rect x="2" y="4" width="14" height="12" rx="2"/><path d="M9 16v4H5m4 0h4"/><rect x="17" y="10" width="5" height="11" rx="1"/>',
|
|
7
|
+
shield:'<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6z"/><path d="m8 12 3 3 5-6"/>',
|
|
8
|
+
monitor:'<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M12 17v4M8 21h8"/>',
|
|
9
|
+
chevron:'<path d="m9 5 7 7-7 7"/>',folder:'<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
10
|
+
orbit:'<circle cx="12" cy="12" r="5"/><ellipse cx="12" cy="12" rx="11" ry="4" transform="rotate(-35 12 12)"/>',
|
|
11
|
+
search:'<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>',
|
|
12
|
+
refresh:'<path d="M20 7a9 9 0 1 0 1 8M20 3v5h-5"/>',
|
|
13
|
+
database:'<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 4 16 4 16 0V5M4 12c0 4 16 4 16 0"/>',
|
|
14
|
+
arrow:'<path d="M12 20V4m-6 6 6-6 6 6"/>',back:'<path d="m14 5-7 7 7 7"/>',
|
|
15
|
+
info:'<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7h.01"/>',
|
|
16
|
+
menu:'<path d="M4 6h16M4 12h16M4 18h16"/>',
|
|
17
|
+
copy:'<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/>',
|
|
18
|
+
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/>',
|
|
19
|
+
settings:'<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2"/><circle cx="15" cy="17" r="2"/>',
|
|
20
|
+
terminal:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3m6 0h4"/>',
|
|
21
|
+
file:'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>',
|
|
22
|
+
searchWeb:'<circle cx="11" cy="11" r="7"/><path d="m16 16 5 5M4 11h14M11 4a11 11 0 0 1 0 14M11 4a11 11 0 0 0 0 14"/>',
|
|
23
|
+
image:'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>'
|
|
24
|
+
};
|
|
25
|
+
const icon = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.inbox}</svg>`;
|
|
26
|
+
function decorate(parent = document) { parent.querySelectorAll('[data-icon]').forEach(element => { element.innerHTML = icon(element.dataset.icon); }); }
|
|
27
|
+
function markdown(text) { return DOMPurify.sanitize(marked.parse(String(text || ''), { breaks: true }), { ALLOWED_TAGS: ['p','br','strong','b','em','i','s','del','ul','ol','li','blockquote','pre','code','h1','h2','h3','h4','h5','h6','hr','a','table','thead','tbody','tr','th','td','sup','sub','kbd','details','summary'], ALLOWED_ATTR: ['href','title','start','colspan','rowspan'], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false }); }
|
|
28
|
+
const state = { threads: [], status: null, selected: null, selectionVersion: 0, historyStatus: 'idle', historyError: '', filter: 'all', project: null, search: '', view: 'sessions', socket: null, pending: new Map(), enabled: new Map(), baseMessages: [], olderMessages: [], cursor: null, liveMessages: new Map(), desktopMessages: [], desktopRuntime: null, selectedSettings: null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null, cloudPairCode: null, approvals: new Map(), reconnect: 0, loggedOut: false, busy: false, turnId: null };
|
|
29
|
+
let refreshTimer;
|
|
30
|
+
let toastTimer;
|
|
31
|
+
let reconnectTimer;
|
|
32
|
+
let desktopConnectionAttempt = 0;
|
|
33
|
+
let desktopConnecting = false;
|
|
34
|
+
let workspaceGeneration = 0;
|
|
35
|
+
let cloudRegistrationEnabled = false;
|
|
36
|
+
let cloudAuthVersion = 0;
|
|
37
|
+
const computerInstallCommands = {
|
|
38
|
+
windows:'$minimum = 24; $node = Get-Command node -ErrorAction SilentlyContinue; if (-not $node -or [int](node -p "process.versions.node.split(\'.\')[0]" 2>$null) -lt $minimum) { if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { throw "需要 Node.js 24+;未检测到 winget,请先从 https://nodejs.org 安装 Node.js 24+。" }; winget install --id OpenJS.NodeJS --exact --source winget --silent --accept-source-agreements --accept-package-agreements; if ($LASTEXITCODE -ne 0) { throw "Node.js 安装失败。" }; $env:Path = "$env:ProgramFiles\\nodejs;$env:Path" }; if ([int](node -p "process.versions.node.split(\'.\')[0]") -lt $minimum) { throw "Node.js 24+ 安装未完成,请重新打开 PowerShell 后重试。" }; npx --yes --package=orbit-codex-bridge orbit login',
|
|
39
|
+
unix:'npx --yes --package=orbit-codex-bridge orbit login'
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function setCloudAuthMode(register = false) {
|
|
43
|
+
cloudAuthVersion++;
|
|
44
|
+
const registering = register && cloudRegistrationEnabled;
|
|
45
|
+
select('#cloud-login-form').hidden = registering;
|
|
46
|
+
select('#cloud-register-form').hidden = !registering;
|
|
47
|
+
select('#cloud-auth-title').textContent = registering ? '创建你的 Orbit 账号' : '登录 Orbit';
|
|
48
|
+
select('#cloud-auth-description').textContent = registering ? '从一台电脑开始,让原来的会话随你出发。' : '登录后,继续连接你的电脑和原有会话。';
|
|
49
|
+
select('#cloud-show-login').classList.toggle('active', !registering);
|
|
50
|
+
select('#cloud-show-register').classList.toggle('active', registering);
|
|
51
|
+
select('#cloud-show-login').setAttribute('aria-pressed', String(!registering));
|
|
52
|
+
select('#cloud-show-register').setAttribute('aria-pressed', String(registering));
|
|
53
|
+
select('#cloud-login-error').textContent = '';
|
|
54
|
+
select('#cloud-register-error').textContent = '';
|
|
55
|
+
select('#cloud-password').value = '';
|
|
56
|
+
select('#register-password').value = '';
|
|
57
|
+
select('#register-password-confirm').value = '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadCloudConfig() {
|
|
61
|
+
const response = await fetch('/api/cloud/config', { cache:'no-store' });
|
|
62
|
+
if (!response.ok) throw new Error('无法读取账号设置,请刷新页面重试。');
|
|
63
|
+
const config = await response.json();
|
|
64
|
+
cloudRegistrationEnabled = config.registrationEnabled === true;
|
|
65
|
+
select('#cloud-auth-tabs').hidden = !cloudRegistrationEnabled;
|
|
66
|
+
select('#cloud-registration-notice').hidden = cloudRegistrationEnabled;
|
|
67
|
+
if (!cloudRegistrationEnabled) setCloudAuthMode(false);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function captureCloudPairCode() {
|
|
71
|
+
const fragment=new URLSearchParams(location.hash.slice(1));
|
|
72
|
+
if(!fragment.has('pair'))return;
|
|
73
|
+
state.cloudPairCode=fragment.get('pair');
|
|
74
|
+
history.replaceState(null,'',location.pathname+location.search);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toast(text) { select('#toast').textContent = text; select('#toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { select('#toast').hidden = true; }, 5500); }
|
|
78
|
+
function mergeDesktopMessages(messages = []) {
|
|
79
|
+
const merged = new Map(state.desktopMessages.map(message => [message.id, message]));
|
|
80
|
+
for (const message of messages) if (message?.id) merged.set(message.id, message);
|
|
81
|
+
state.desktopMessages = [...merged.values()].slice(-160);
|
|
82
|
+
}
|
|
83
|
+
function ago(timestamp) {
|
|
84
|
+
const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60000));
|
|
85
|
+
if (minutes < 1) return '刚刚';
|
|
86
|
+
if (minutes < 60) return `${minutes} 分钟前`;
|
|
87
|
+
if (minutes < 1440) return `${Math.floor(minutes / 60)} 小时前`;
|
|
88
|
+
if (minutes < 10080) return `${Math.floor(minutes / 1440)} 天前`;
|
|
89
|
+
return new Date(timestamp).toLocaleDateString('zh-CN', { month:'2-digit', day:'2-digit' });
|
|
90
|
+
}
|
|
91
|
+
async function api(url, body, signal) {
|
|
92
|
+
const generation = workspaceGeneration;
|
|
93
|
+
const headers = {};
|
|
94
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
95
|
+
if (state.cloudMode && state.cloudDeviceId && !url.startsWith('/api/cloud/')) headers['X-Orbit-Device-Id'] = state.cloudDeviceId;
|
|
96
|
+
const options = body === undefined ? { headers } : { method: 'POST', headers, body: JSON.stringify(body) };
|
|
97
|
+
if (signal) options.signal = signal;
|
|
98
|
+
const response = await fetch(url, options);
|
|
99
|
+
const result = await response.json();
|
|
100
|
+
if (generation !== workspaceGeneration) throw Object.assign(new Error('工作台已切换。'),{code:'STALE_WORKSPACE'});
|
|
101
|
+
if (response.status === 401 && state.cloudMode && result.code === 'cloud_login_required') showCloudLogin(result.error);
|
|
102
|
+
else if (response.status === 401 && state.cloudMode && !state.cloudPairCode && !url.startsWith('/api/cloud/')) showPairing();
|
|
103
|
+
else if (response.status === 401 && !state.cloudMode && url !== '/api/pair' && !url.startsWith('/api/cloud/')) showPairing();
|
|
104
|
+
if (!response.ok) throw new Error(result.error || '请求失败。');
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
function showPairing() {
|
|
108
|
+
state.loggedOut = true; clearTimeout(reconnectTimer);
|
|
109
|
+
state.socket?.close();
|
|
110
|
+
select('#workspace').hidden = true; select('#pair-screen').hidden = false;
|
|
111
|
+
select('#cloud-login-screen').hidden = true; select('#cloud-device-screen').hidden = true;
|
|
112
|
+
const fragment = new URLSearchParams(location.hash.slice(1));
|
|
113
|
+
if (fragment.has('pair')) { select('#pair-code').value = fragment.get('pair'); history.replaceState(null, '', location.pathname); }
|
|
114
|
+
}
|
|
115
|
+
function showCloudLogin(error = '') {
|
|
116
|
+
workspaceGeneration++;
|
|
117
|
+
resetCloudWorkspace();
|
|
118
|
+
state.cloudMode = true;
|
|
119
|
+
state.loggedOut = true;
|
|
120
|
+
state.socket?.close();
|
|
121
|
+
select('#workspace').hidden = true; select('#pair-screen').hidden = true; select('#cloud-device-screen').hidden = true;
|
|
122
|
+
select('#cloud-login-screen').hidden = false;
|
|
123
|
+
setCloudAuthMode(false);
|
|
124
|
+
select('#cloud-login-error').textContent = error;
|
|
125
|
+
}
|
|
126
|
+
function showCloudDevices(message = '') {
|
|
127
|
+
workspaceGeneration++;
|
|
128
|
+
resetCloudWorkspace();
|
|
129
|
+
state.loggedOut = false;
|
|
130
|
+
state.cloudDeviceId = null; localStorage.removeItem('orbit_cloud_device_id');
|
|
131
|
+
select('#workspace').hidden = true; select('#pair-screen').hidden = true; select('#cloud-login-screen').hidden = true;
|
|
132
|
+
select('#cloud-device-screen').hidden = false;
|
|
133
|
+
const quota = state.cloudUser?.maxDevices || 1;
|
|
134
|
+
select('#cloud-devices-title').textContent = state.cloudDevices.length ? '选择要连接的电脑' : '连接你的第一台电脑';
|
|
135
|
+
const greeting = `已登录 ${state.cloudUser?.displayName || state.cloudUser?.username}。`;
|
|
136
|
+
select('#cloud-device-message').textContent = state.cloudPairCode
|
|
137
|
+
? `${greeting}选择发起连接的电脑,完成本机配对。${message ? ` ${message}` : ''}`
|
|
138
|
+
: message || greeting + (state.cloudDevices.length ? '选择一台电脑继续。' : '先添加你自己的电脑,连接后即可查看原有会话。');
|
|
139
|
+
select('#cloud-device-quota').textContent = `套餐 ${state.cloudUser?.plan || 'free'} · 已添加 ${state.cloudDevices.length} / ${quota} 台电脑`;
|
|
140
|
+
select('#cloud-add-computer').disabled = state.cloudDevices.length >= quota;
|
|
141
|
+
select('#cloud-add-computer').title = state.cloudDevices.length >= quota ? '已达到设备上限,可移除旧电脑或联系管理员调整额度。' : '';
|
|
142
|
+
select('#cloud-onboarding').hidden = state.cloudDevices.length > 0;
|
|
143
|
+
select('#cloud-devices-list').innerHTML = state.cloudDevices.map(device =>
|
|
144
|
+
`<article class="cloud-computer-card"><button class="device-card cloud-device-option" data-cloud-device="${escapeHtml(device.id)}" ${device.status !== 'active' || !device.online ? 'disabled' : ''}><div class="device-big-icon">${icon('monitor')}</div><h2>${escapeHtml(device.name)}<span class="capability-state ${device.online ? '' : 'pending'}">${device.online ? '在线 · 点击连接' : '等待电脑上线'}</span></h2><p>${device.lastSeen ? `最近在线 · ${ago(device.lastSeen)}` : '请在这台电脑保存连接配置,启动 Orbit 和 Agent。'}</p></button><footer><span>${device.online ? '仅你的账号可连接' : '启动电脑端后刷新状态'}</span><button type="button" class="button small" data-cloud-revoke="${escapeHtml(device.id)}">移除电脑</button></footer></article>`
|
|
145
|
+
).join('');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function refreshCloudDevices() {
|
|
149
|
+
const workspace = await api('/api/cloud/me');
|
|
150
|
+
state.cloudUser = workspace.user;
|
|
151
|
+
state.cloudDevices = workspace.devices || [];
|
|
152
|
+
showCloudDevices();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function resetCloudWorkspace() {
|
|
156
|
+
state.loggedOut = true; clearTimeout(reconnectTimer); clearTimeout(refreshTimer); refreshTimer = null;
|
|
157
|
+
state.selectionVersion++;
|
|
158
|
+
state.socket?.close();
|
|
159
|
+
state.socket = null;
|
|
160
|
+
for (const pending of state.pending.values()) {clearTimeout(pending.timer);pending.reject(new Error('工作台已断开。'))}
|
|
161
|
+
state.pending.clear();
|
|
162
|
+
state.selected = null; state.desktopRuntime = null; state.selectedSettings = null; state.contextUsage = null;
|
|
163
|
+
state.threads = []; state.models = []; state.project = null;
|
|
164
|
+
state.enabled.clear(); state.approvals.clear(); state.desktopMessages = []; state.baseMessages = [];
|
|
165
|
+
state.olderMessages = []; state.liveMessages.clear(); state.cursor = null; state.turnId = null; state.busy = false;
|
|
166
|
+
select('#chat').hidden = true; select('#welcome').hidden = false;
|
|
167
|
+
select('#messages').replaceChildren(); select('#projects').replaceChildren(); select('#thread-list').replaceChildren();
|
|
168
|
+
select('#message-input').value = '';
|
|
169
|
+
select('#workspace').classList.remove('chat-open');
|
|
170
|
+
select('#sessions-view').hidden = false; select('#devices-view').hidden = true; state.view = 'sessions';
|
|
171
|
+
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
|
|
172
|
+
}
|
|
173
|
+
async function discoverCloudWorkspace() {
|
|
174
|
+
const generation = workspaceGeneration;
|
|
175
|
+
let response;
|
|
176
|
+
try { response = await fetch('/api/cloud/me', { cache:'no-store' }); }
|
|
177
|
+
catch (error) { throw new Error('无法连接工作台服务,请检查网络。'); }
|
|
178
|
+
if (generation !== workspaceGeneration) return 'stale';
|
|
179
|
+
if (response.status === 404) return 'local';
|
|
180
|
+
captureCloudPairCode();
|
|
181
|
+
let configError = '';
|
|
182
|
+
try { await loadCloudConfig(); }
|
|
183
|
+
catch (error) { configError = error.message; }
|
|
184
|
+
if (generation !== workspaceGeneration) return 'stale';
|
|
185
|
+
if (response.status === 401) {
|
|
186
|
+
showCloudLogin();
|
|
187
|
+
select('#cloud-login-error').textContent = configError;
|
|
188
|
+
return 'login';
|
|
189
|
+
}
|
|
190
|
+
if (!response.ok) { showCloudLogin('云端服务暂时不可用,请稍后重试。'); return 'login'; }
|
|
191
|
+
const workspace = await response.json();
|
|
192
|
+
state.cloudMode = true; state.loggedOut = false;
|
|
193
|
+
state.cloudUser = workspace.user; state.cloudDevices = workspace.devices || [];
|
|
194
|
+
const saved = localStorage.getItem('orbit_cloud_device_id');
|
|
195
|
+
if (saved && state.cloudDevices.some(device => device.id === saved && device.status === 'active')) state.cloudDeviceId = saved;
|
|
196
|
+
else if (state.cloudDevices.length === 1 && state.cloudDevices[0].status === 'active' && !state.cloudPairCode) {
|
|
197
|
+
state.cloudDeviceId = state.cloudDevices[0].id;
|
|
198
|
+
localStorage.setItem('orbit_cloud_device_id',state.cloudDeviceId);
|
|
199
|
+
} else {
|
|
200
|
+
state.cloudDeviceId = null;
|
|
201
|
+
showCloudDevices();
|
|
202
|
+
return 'devices';
|
|
203
|
+
}
|
|
204
|
+
select('#cloud-device-switcher').hidden = false;
|
|
205
|
+
const selected = state.cloudDevices.find(device => device.id === state.cloudDeviceId);
|
|
206
|
+
select('#cloud-device-switcher').textContent = selected?.name || '选择电脑';
|
|
207
|
+
return 'ready';
|
|
208
|
+
}
|
|
209
|
+
async function refreshThreads() {
|
|
210
|
+
const data = await api('/api/threads'); state.threads = data.threads;
|
|
211
|
+
renderProjects(); renderList();
|
|
212
|
+
if (!state.selected) renderWelcome();
|
|
213
|
+
else { const updated = state.threads.find(thread => thread.id === state.selected.id); if (updated) state.selected = updated; updateMode(); }
|
|
214
|
+
}
|
|
215
|
+
async function boot() {
|
|
216
|
+
const generation = ++workspaceGeneration;
|
|
217
|
+
try {
|
|
218
|
+
const cloudState = await discoverCloudWorkspace();
|
|
219
|
+
if (cloudState !== 'local' && cloudState !== 'ready') return;
|
|
220
|
+
state.status = await api('/api/status');
|
|
221
|
+
if (generation !== workspaceGeneration) return;
|
|
222
|
+
state.loggedOut = false;
|
|
223
|
+
select('#pair-code').value = '';
|
|
224
|
+
select('#pair-screen').hidden = true; select('#workspace').hidden = false;
|
|
225
|
+
select('#cloud-login-screen').hidden = true; select('#cloud-device-screen').hidden = true;
|
|
226
|
+
select('#device-name').textContent = state.status.device;
|
|
227
|
+
state.enabled = new Map(Object.entries(state.status.execution.grants || {}));
|
|
228
|
+
await refreshThreads(); connectSocket();
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error.code === 'STALE_WORKSPACE') return;
|
|
231
|
+
if (state.cloudMode) showCloudDevices(error.message);
|
|
232
|
+
else if (!state.loggedOut) { showPairing(); select('#pair-error').textContent = error.message; }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function connectSocket() {
|
|
236
|
+
state.socket?.close();
|
|
237
|
+
const suffix = state.cloudMode && state.cloudDeviceId ? `?deviceId=${encodeURIComponent(state.cloudDeviceId)}` : '';
|
|
238
|
+
const socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/socket${suffix}`);
|
|
239
|
+
state.socket = socket;
|
|
240
|
+
socket.addEventListener('open', () => {
|
|
241
|
+
if (state.socket !== socket) return;
|
|
242
|
+
state.reconnect = 0; setConnection(true);
|
|
243
|
+
if (state.selected) subscribeThread(state.selected.id).catch(error => toast(error.message));
|
|
244
|
+
refreshThreads().catch(() => {});
|
|
245
|
+
});
|
|
246
|
+
socket.addEventListener('message', async event => {
|
|
247
|
+
if (state.socket !== socket) return;
|
|
248
|
+
const message = JSON.parse(event.data);
|
|
249
|
+
if (message.id && state.pending.has(message.id)) {
|
|
250
|
+
const pending = state.pending.get(message.id); clearTimeout(pending.timer); state.pending.delete(message.id);
|
|
251
|
+
if (message.error) pending.reject(Object.assign(new Error(message.error), { code:message.code })); else pending.resolve(message.result);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (message.method === 'store/changed') scheduleRefresh();
|
|
255
|
+
const params = message.params || {};
|
|
256
|
+
if (params.threadId && params.threadId !== state.selected?.id) return;
|
|
257
|
+
if (message.method === 'item/agentMessage/delta') {
|
|
258
|
+
const current = state.liveMessages.get(params.itemId) || { id: params.itemId, role:'assistant', text:'', timestamp:Date.now() };
|
|
259
|
+
current.text += params.delta || ''; state.liveMessages.set(params.itemId, current); renderMessages();
|
|
260
|
+
}
|
|
261
|
+
if (message.method === 'turn/started') { state.busy = true; state.turnId = params.turn.id; updateMode(); }
|
|
262
|
+
if (message.method === 'turn/completed') { state.busy = false; state.turnId = null; updateMode(); scheduleRefresh(); }
|
|
263
|
+
if (message.method === 'orbit/desktop' && state.status?.execution.mode === 'desktop') {
|
|
264
|
+
state.desktopRuntime = params;
|
|
265
|
+
if (!params.attached) { state.enabled.delete(params.threadId); state.selectedSettings = null; }
|
|
266
|
+
if (params.attached) mergeDesktopMessages(params.messages || []);
|
|
267
|
+
state.turnId = params.attached ? params.turnId : null;
|
|
268
|
+
state.busy = Boolean(state.turnId);
|
|
269
|
+
state.approvals = new Map((params.attached ? params.approvals || [] : []).map(approval => [approval.requestId, approval]));
|
|
270
|
+
renderMessages(); renderApprovals(); updateMode();
|
|
271
|
+
}
|
|
272
|
+
if (message.method === 'thread/tokenUsage/updated' && state.status?.execution.mode !== 'desktop') {
|
|
273
|
+
const usage = params.tokenUsage || params.tokenUsageInfo;
|
|
274
|
+
if (Number.isSafeInteger(usage?.last?.totalTokens) && usage.last.totalTokens >= 0) {
|
|
275
|
+
state.contextUsage = {
|
|
276
|
+
usedTokens: usage.last.totalTokens,
|
|
277
|
+
windowTokens: Number.isSafeInteger(usage.modelContextWindow) && usage.modelContextWindow > 0 ? usage.modelContextWindow : null,
|
|
278
|
+
cachedTokens: Number.isSafeInteger(usage.last.cachedInputTokens) ? usage.last.cachedInputTokens : null
|
|
279
|
+
};
|
|
280
|
+
renderContext();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (message.method === 'orbit/control-revoked') {
|
|
284
|
+
state.enabled.delete(params.threadId);
|
|
285
|
+
if (params.threadId === state.selected?.id) {
|
|
286
|
+
state.selectedSettings = null;
|
|
287
|
+
state.pendingModelChoice = null;
|
|
288
|
+
state.busy = false; state.turnId = null;
|
|
289
|
+
state.reopenModelAfterGrant = false;
|
|
290
|
+
state.desktopRuntime = {threadId:params.threadId,attached:false,reason:params.reason,reasonCode:params.reasonCode};
|
|
291
|
+
if (select('#model-dialog').open) select('#model-dialog').close();
|
|
292
|
+
if (select('#control-dialog').open) select('#control-dialog').close();
|
|
293
|
+
state.approvals.clear(); renderApprovals(); updateMode();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (message.method === 'orbit/settings-applied' && params.threadId === state.selected?.id) {
|
|
297
|
+
state.selectedSettings = { model:params.model,effort:params.effort,pending:false };
|
|
298
|
+
if (state.desktopRuntime?.attached) state.desktopRuntime = { ...state.desktopRuntime,model:params.model,effort:params.effort };
|
|
299
|
+
renderModelStatus();
|
|
300
|
+
toast('下一轮模型参数已应用。');
|
|
301
|
+
}
|
|
302
|
+
if (message.method === 'orbit/settings-error' && params.threadId === state.selected?.id) {
|
|
303
|
+
state.selectedSettings = null;
|
|
304
|
+
renderModelStatus();
|
|
305
|
+
toast(params.error || '下一轮模型参数未应用,请重新选择。');
|
|
306
|
+
}
|
|
307
|
+
if (message.method === 'orbit/engine') {
|
|
308
|
+
if (state.status?.execution) state.status.execution.connected = Boolean(params.connected);
|
|
309
|
+
if (!params.connected) {
|
|
310
|
+
state.enabled.clear(); state.desktopRuntime = null; state.selectedSettings = null; state.pendingModelChoice = null;
|
|
311
|
+
if (select('#model-dialog').open) select('#model-dialog').close();
|
|
312
|
+
if (select('#control-dialog').open) select('#control-dialog').close();
|
|
313
|
+
state.reopenModelAfterGrant = false;
|
|
314
|
+
state.approvals.clear(); state.busy = false; state.turnId = null;
|
|
315
|
+
renderApprovals(); renderMessages();
|
|
316
|
+
}
|
|
317
|
+
updateMode();
|
|
318
|
+
}
|
|
319
|
+
if (message.method === 'orbit/approval') { state.approvals.set(params.requestId, params); renderApprovals(); }
|
|
320
|
+
if (message.method === 'serverRequest/resolved') { state.approvals.delete(String(params.requestId)); renderApprovals(); }
|
|
321
|
+
if (message.method === 'error') toast(params.error?.message || params.message || '执行发生错误,请查看会话状态。');
|
|
322
|
+
});
|
|
323
|
+
socket.addEventListener('close', () => {
|
|
324
|
+
if (state.socket !== socket) return;
|
|
325
|
+
setConnection(false);
|
|
326
|
+
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('连接中断,消息不会自动重发。请先检查会话记录。')); }
|
|
327
|
+
state.pending.clear();
|
|
328
|
+
scheduleReconnect();
|
|
329
|
+
});
|
|
330
|
+
socket.addEventListener('error', () => {});
|
|
331
|
+
}
|
|
332
|
+
function scheduleReconnect() {
|
|
333
|
+
if (state.loggedOut) return;
|
|
334
|
+
clearTimeout(reconnectTimer);
|
|
335
|
+
reconnectTimer = setTimeout(async () => {
|
|
336
|
+
try {
|
|
337
|
+
state.status = await api('/api/status');
|
|
338
|
+
state.enabled = new Map(Object.entries(state.status.execution.grants || {}));
|
|
339
|
+
connectSocket();
|
|
340
|
+
} catch { scheduleReconnect(); }
|
|
341
|
+
}, Math.min(15000, 1000 * 2 ** Math.min(4, state.reconnect++)));
|
|
342
|
+
}
|
|
343
|
+
async function subscribeThread(id) {
|
|
344
|
+
const selectionVersion = state.selectionVersion;
|
|
345
|
+
const attempt = ++desktopConnectionAttempt;
|
|
346
|
+
desktopConnecting = true; updateMode();
|
|
347
|
+
try {
|
|
348
|
+
const result = await rpc('thread.subscribe', { threadId: id });
|
|
349
|
+
if (id !== state.selected?.id || selectionVersion !== state.selectionVersion || attempt !== desktopConnectionAttempt) return;
|
|
350
|
+
if (result.control) state.enabled.set(id, result.control); else state.enabled.delete(id);
|
|
351
|
+
state.desktopRuntime = result.runtime || null;
|
|
352
|
+
state.selectedSettings = result.settings || null;
|
|
353
|
+
if (result.runtime?.attached) mergeDesktopMessages(result.runtime.messages || []);
|
|
354
|
+
state.turnId = result.turnId;
|
|
355
|
+
state.busy = Boolean(result.turnId);
|
|
356
|
+
state.approvals = new Map((result.approvals || []).map(approval => [approval.requestId, approval]));
|
|
357
|
+
renderMessages(); renderApprovals();
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (id === state.selected?.id && selectionVersion === state.selectionVersion && attempt === desktopConnectionAttempt) {
|
|
360
|
+
state.enabled.delete(id); state.selectedSettings = null; state.turnId = null; state.busy = false;
|
|
361
|
+
state.desktopRuntime = {threadId:id,attached:false,reason:error.message,reasonCode:error.code};
|
|
362
|
+
state.approvals.clear(); renderApprovals();
|
|
363
|
+
}
|
|
364
|
+
throw error;
|
|
365
|
+
} finally {
|
|
366
|
+
if (attempt === desktopConnectionAttempt) { desktopConnecting = false; updateMode(); }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function setConnection(connected) {
|
|
370
|
+
select('#connection-pill').innerHTML = `<i class="tiny-dot"></i>${connected ? '原生记录已连接' : '连接已断开'}`;
|
|
371
|
+
select('#connection-pill').classList.toggle('offline', !connected); updateMode();
|
|
372
|
+
}
|
|
373
|
+
function rpc(method, params) {
|
|
374
|
+
if (state.socket?.readyState !== WebSocket.OPEN) return Promise.reject(new Error('实时连接尚未就绪。'));
|
|
375
|
+
const id = crypto.randomUUID ? crypto.randomUUID() : [...crypto.getRandomValues(new Uint8Array(16))].map(value => value.toString(16).padStart(2, '0')).join('');
|
|
376
|
+
return new Promise((resolve, reject) => {
|
|
377
|
+
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error('操作超时,不会自动重发。请检查会话状态。')); }, 45000);
|
|
378
|
+
state.pending.set(id, { resolve, reject, timer });
|
|
379
|
+
state.socket.send(JSON.stringify({ id, method, params }));
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function scheduleRefresh() {
|
|
383
|
+
if (refreshTimer) return;
|
|
384
|
+
refreshTimer = setTimeout(async () => {
|
|
385
|
+
refreshTimer = null;
|
|
386
|
+
try { await refreshThreads(); if (state.selected) await loadMessages(false); } catch {}
|
|
387
|
+
}, 800);
|
|
388
|
+
}
|
|
389
|
+
function renderProjects() {
|
|
390
|
+
const projects = new Map();
|
|
391
|
+
for (const thread of state.threads) {
|
|
392
|
+
const project = projects.get(thread.projectId) || { name: thread.projectName, count: 0 }; project.count++; projects.set(thread.projectId, project);
|
|
393
|
+
}
|
|
394
|
+
select('#projects').innerHTML = [...projects].map(([id, project]) => `<button class="project-item ${state.project === id ? 'active' : ''}" data-project="${escapeHtml(id)}" title="${escapeHtml(project.name)}"><span class="project-dot"></span><span>${escapeHtml(project.name)}</span><small>${project.count}</small></button>`).join('');
|
|
395
|
+
select('#nav-count').textContent = state.threads.length;
|
|
396
|
+
}
|
|
397
|
+
function filteredThreads() {
|
|
398
|
+
return state.threads.filter(thread => (!state.project || thread.projectId === state.project)
|
|
399
|
+
&& (state.filter !== 'archived' || thread.archived)
|
|
400
|
+
&& (state.filter !== 'locked' || thread.locked)
|
|
401
|
+
&& (state.filter !== 'pinned' || thread.pinned)
|
|
402
|
+
&& `${thread.title} ${thread.projectName} ${thread.preview}`.toLowerCase().includes(state.search.toLowerCase()))
|
|
403
|
+
.sort((left, right) => Number(right.pinned) - Number(left.pinned) || right.updatedAt - left.updatedAt);
|
|
404
|
+
}
|
|
405
|
+
function renderList() {
|
|
406
|
+
const threads = filteredThreads();
|
|
407
|
+
select('#thread-count').textContent = threads.length;
|
|
408
|
+
select('#list-title').textContent = state.project ? (state.threads.find(thread => thread.projectId === state.project)?.projectName || '项目会话') : state.filter === 'archived' ? '已归档' : state.filter === 'pinned' ? '已置顶' : '所有会话';
|
|
409
|
+
select('#list-footer-text').textContent = `${state.threads.length} 条原生记录 · 自动同步`;
|
|
410
|
+
select('#thread-list').innerHTML = threads.map(thread => `<button class="thread-item ${state.selected?.id === thread.id ? 'selected' : ''}" data-thread="${escapeHtml(thread.id)}"><div class="thread-meta"><span class="project-initial">${escapeHtml(thread.projectName.slice(0,1).toUpperCase())}</span><span class="project-label">${escapeHtml(thread.projectName)}</span><time>${ago(thread.updatedAt)}</time></div><h3>${escapeHtml(thread.title)}</h3><p>${escapeHtml(thread.preview || '点击查看原生会话记录')}</p><div class="thread-badges">${thread.pinned ? '<span class="tag pinned">已置顶</span>' : ''}${thread.locked ? '<span class="tag busy"><i class="tiny-dot"></i>原客户端占用</span>' : '<span class="tag">可查看</span>'}${thread.archived ? '<span class="tag archived">已归档</span>' : ''}<span class="tag">${escapeHtml(thread.provider || 'Codex')}</span></div></button>`).join('') || '<div class="empty">没有找到匹配的会话。<br>试试其他关键词或筛选条件。</div>';
|
|
411
|
+
document.querySelectorAll('[data-filter]').forEach(button => {
|
|
412
|
+
const active = button.dataset.filter === state.filter;
|
|
413
|
+
button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active));
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
function renderWelcome() {
|
|
417
|
+
const projects = new Set(state.threads.map(thread => thread.projectId));
|
|
418
|
+
const archived = state.threads.filter(thread => thread.archived).length;
|
|
419
|
+
select('#welcome').innerHTML = `<div class="welcome-heading"><div><h2>你的工作,都在这里。</h2><p>连接本机 Codex,从上次停下的地方找到灵感。</p></div><span class="date-label">${new Date().toLocaleDateString('zh-CN', { timeZone:'Asia/Shanghai', month:'long', day:'numeric', weekday:'long' })}</span></div><div class="hero"><div class="hero-grid"></div><div class="hero-orbit"><i></i></div><span class="eyebrow">KEEP THE CONTEXT. FIND YOUR FLOW.</span><h2>工作在电脑上,<br>灵感不必。</h2><p>一个入口,查看真实的项目与原生会话。<br>不再复制粘贴,也不必重新解释。</p><span class="hero-status"><i class="tiny-dot"></i> 本机直读 · 默认只读保护</span></div><div class="stats-grid"><div class="stat-card"><span>${icon('inbox')}原生会话</span><strong>${state.threads.length}</strong><small>来自本机,而非演示数据</small></div><div class="stat-card"><span>${icon('folder')}项目空间</span><strong>${projects.size}</strong><small>按原生归属 / 工作目录分组</small></div><div class="stat-card"><span>${icon('archive')}归档记录</span><strong>${archived}</strong><small>旧上下文,也值得保留</small></div></div><div class="section-heading"><h3>最近的上下文</h3><span>从熟悉的对话开始 ↗</span></div>${state.threads.filter(thread => !thread.archived).slice(0,3).map(thread => `<button class="recent-card" data-thread="${escapeHtml(thread.id)}"><span class="recent-icon">${icon('inbox')}</span><div><strong>${escapeHtml(thread.title)}</strong><p>${escapeHtml(thread.projectName)} · ${ago(thread.updatedAt)}</p></div><span>↗</span></button>`).join('')}<div class="honesty-note">${icon('info')}<span>原生会话列表与历史记录直接读取。授权后可通过桌面 IPC 接入已加载的 Codex 会话;尚未加载的会话需先在 Codex 桌面打开,不会新建或迁移执行器。</span></div>`;
|
|
420
|
+
}
|
|
421
|
+
async function openThread(id) {
|
|
422
|
+
const thread = state.threads.find(entry => entry.id === id); if (!thread) return;
|
|
423
|
+
if (select('#model-dialog').open) select('#model-dialog').close();
|
|
424
|
+
const selectionVersion = ++state.selectionVersion;
|
|
425
|
+
state.pendingModelChoice = null;
|
|
426
|
+
state.historyStatus = 'loading'; state.historyError = '';
|
|
427
|
+
state.selected = thread; state.baseMessages = []; state.olderMessages = []; state.liveMessages.clear(); state.desktopMessages = []; state.desktopRuntime = null; state.selectedSettings = null; state.contextUsage = null; state.approvals.clear(); state.cursor = null; state.busy = false; state.turnId = null;
|
|
428
|
+
select('#welcome').hidden = true; select('#chat').hidden = false; select('#workspace').classList.add('chat-open');
|
|
429
|
+
select('#chat-title').textContent = thread.title;
|
|
430
|
+
select('#chat-subtitle').textContent = `${thread.projectName} / ${thread.provider || 'Codex'}${thread.model ? ` · ${thread.model}` : ''}`;
|
|
431
|
+
select('#chat-details').innerHTML = `<dl><dt>原生会话 ID</dt><dd>${escapeHtml(id)}</dd><dt>工作目录</dt><dd>${escapeHtml(thread.cwd)}</dd><dt>记录来源 / 分支</dt><dd>${escapeHtml(thread.source)} / ${escapeHtml(thread.branch || '—')}</dd></dl>`;
|
|
432
|
+
select('#chat-details').hidden = true;
|
|
433
|
+
select('#messages').replaceChildren(); renderMessages();
|
|
434
|
+
renderList(); updateMode(); renderApprovals();
|
|
435
|
+
subscribeThread(id).catch(error => { if (selectionVersion === state.selectionVersion) toast(error.message); });
|
|
436
|
+
try { await loadMessages(true); } catch (error) { if (selectionVersion === state.selectionVersion) toast(error.message); }
|
|
437
|
+
}
|
|
438
|
+
async function loadMessages(initial, older = false) {
|
|
439
|
+
const id = state.selected?.id; if (!id) return;
|
|
440
|
+
const selectionVersion = state.selectionVersion;
|
|
441
|
+
if (older && state.paging === id) return;
|
|
442
|
+
if (older) state.paging = id;
|
|
443
|
+
if (initial || state.historyStatus === 'error') { state.historyStatus = 'loading'; state.historyError = ''; renderMessages(); }
|
|
444
|
+
const cursor = state.cursor;
|
|
445
|
+
let data;
|
|
446
|
+
try { data = await api(`/api/threads/${encodeURIComponent(id)}/messages${older ? `?before=${encodeURIComponent(cursor)}` : ''}`, undefined, AbortSignal.timeout(15000)); }
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (id !== state.selected?.id || selectionVersion !== state.selectionVersion) return;
|
|
449
|
+
state.historyStatus = 'error';
|
|
450
|
+
state.historyError = error.name === 'TimeoutError' ? '读取原生历史超时,请点击刷新重试。' : error.message;
|
|
451
|
+
renderMessages();
|
|
452
|
+
throw new Error(state.historyError);
|
|
453
|
+
}
|
|
454
|
+
finally { if (state.paging === id) state.paging = null; }
|
|
455
|
+
if (id !== state.selected?.id || selectionVersion !== state.selectionVersion) return;
|
|
456
|
+
state.historyStatus = 'loaded'; state.historyError = '';
|
|
457
|
+
if (older) state.olderMessages = [...data.messages, ...state.olderMessages];
|
|
458
|
+
else state.baseMessages = initial ? data.messages : [...new Map([...state.baseMessages, ...data.messages].map(message => [message.id, message])).values()];
|
|
459
|
+
if (initial || older) state.cursor = data.nextCursor;
|
|
460
|
+
const persistedIds = new Set(data.messages.map(message => message.id));
|
|
461
|
+
state.desktopMessages = state.desktopMessages.filter(message => !persistedIds.has(message.id));
|
|
462
|
+
for (const message of data.messages) state.liveMessages.delete(message.id);
|
|
463
|
+
renderMessages(initial, older);
|
|
464
|
+
if (data.truncated && initial) toast('此旧格式会话较大,目前展示末尾 4 MB 范围内的记录。');
|
|
465
|
+
}
|
|
466
|
+
function renderMessages(scroll = false, older = false) {
|
|
467
|
+
const container = select('#messages');
|
|
468
|
+
const previousHeight = container.scrollHeight;
|
|
469
|
+
const previousTop = container.scrollTop;
|
|
470
|
+
const nearBottom = previousHeight - previousTop - container.clientHeight < 120;
|
|
471
|
+
const messages = [...new Map([...state.olderMessages, ...state.baseMessages, ...state.desktopMessages, ...state.liveMessages.values()].map(message => [message.id, message])).values()];
|
|
472
|
+
let loadMore = select('#load-more');
|
|
473
|
+
if (state.cursor !== null && !loadMore) {
|
|
474
|
+
loadMore = document.createElement('button'); loadMore.id = 'load-more'; loadMore.className = 'load-more'; loadMore.textContent = '加载更早的记录 ↑';
|
|
475
|
+
} else if (state.cursor === null && loadMore) { loadMore.remove(); loadMore = null; }
|
|
476
|
+
if (loadMore && container.firstChild !== loadMore) container.prepend(loadMore);
|
|
477
|
+
|
|
478
|
+
const existing = new Map([...container.querySelectorAll(':scope > [data-message-id]')].map(node => [node.dataset.messageId, node]));
|
|
479
|
+
let empty = container.querySelector(':scope > .empty');
|
|
480
|
+
container.querySelectorAll(':scope > .empty').forEach(node => { if (messages.length || node !== empty) node.remove(); });
|
|
481
|
+
let reference = loadMore?.nextSibling || container.firstChild;
|
|
482
|
+
for (const message of messages) {
|
|
483
|
+
const id = String(message.id);
|
|
484
|
+
const activity = message.role === 'activity';
|
|
485
|
+
let node = existing.get(id);
|
|
486
|
+
if (node && node.matches('details') !== activity) { if (node === reference) reference = node.nextSibling; node.remove(); node = null; }
|
|
487
|
+
if (!node) {
|
|
488
|
+
if (activity) {
|
|
489
|
+
node = document.createElement('details'); node.className = 'activity-message';
|
|
490
|
+
node.innerHTML = '<summary><span class="activity-symbol"></span><span class="activity-label"></span><span class="activity-summary"></span><span class="activity-meta"></span></summary><div class="activity-content"></div>';
|
|
491
|
+
} else {
|
|
492
|
+
node = document.createElement('article'); node.innerHTML = '<div class="message-head"><span class="message-avatar"></span><strong></strong><time></time><button type="button" class="icon-button message-copy" data-copy-message aria-label="复制消息" title="复制消息"></button></div><div class="message-body"></div><div class="message-attachments"></div>';
|
|
493
|
+
node.querySelector('[data-copy-message]').innerHTML = icon('copy');
|
|
494
|
+
}
|
|
495
|
+
node.dataset.messageId = id;
|
|
496
|
+
}
|
|
497
|
+
existing.delete(id);
|
|
498
|
+
const text = String(message.text || '');
|
|
499
|
+
const renderKey = activity ? JSON.stringify([message.kind, message.text, message.status, message.exitCode, message.durationMs, message.cwd, message.output, message.arguments, message.changes]) : JSON.stringify([text, message.attachments]);
|
|
500
|
+
if (node.dataset.messageRenderKey !== renderKey) {
|
|
501
|
+
if (activity) {
|
|
502
|
+
renderActivityMessage(node, message);
|
|
503
|
+
} else {
|
|
504
|
+
node.querySelector('.message-body').innerHTML = markdown(text);
|
|
505
|
+
const attachmentContainer = node.querySelector('.message-attachments');
|
|
506
|
+
attachmentContainer.innerHTML = (message.attachments || []).map(attachment => `<span class="attachment-label">${icon(attachment.type?.includes('image') ? 'image' : 'file')}<span>${escapeHtml(attachment.name || '附件')}</span></span>`).join('');
|
|
507
|
+
node.querySelectorAll('.message-body pre').forEach(pre => {
|
|
508
|
+
const button = document.createElement('button');
|
|
509
|
+
button.type = 'button'; button.className = 'copy-code'; button.dataset.copyCode = 'true';
|
|
510
|
+
button.setAttribute('aria-label', '复制代码'); button.title = '复制代码'; button.innerHTML = icon('copy');
|
|
511
|
+
pre.prepend(button);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
node.dataset.messageRenderKey = renderKey;
|
|
515
|
+
node.querySelectorAll('a').forEach(link => { link.target = '_blank'; link.rel = 'noopener noreferrer'; });
|
|
516
|
+
}
|
|
517
|
+
if (!activity) {
|
|
518
|
+
node.className = `message ${message.role === 'user' ? 'user' : 'assistant'}`;
|
|
519
|
+
node.querySelector('.message-avatar').textContent = message.role === 'user' ? '你' : '✳';
|
|
520
|
+
node.querySelector('.message-head strong').textContent = message.role === 'user' ? '你' : 'Codex';
|
|
521
|
+
const time = message.timestamp ? new Date(message.timestamp).toLocaleString('zh-CN', { hour:'2-digit', minute:'2-digit', month:'2-digit', day:'2-digit' }) : '';
|
|
522
|
+
if (node.querySelector('time').textContent !== time) node.querySelector('time').textContent = time;
|
|
523
|
+
node.dataset.messageText = text;
|
|
524
|
+
}
|
|
525
|
+
if (node !== reference) container.insertBefore(node, reference);
|
|
526
|
+
reference = node.nextSibling;
|
|
527
|
+
}
|
|
528
|
+
for (const node of existing.values()) node.remove();
|
|
529
|
+
if (!messages.length) {
|
|
530
|
+
if (!empty) { empty = document.createElement('div'); empty.className = 'empty'; empty.setAttribute('role', 'status'); container.append(empty); }
|
|
531
|
+
const text = state.historyStatus === 'loading' ? '正在读取原生上下文…' : state.historyStatus === 'error' ? state.historyError : '这条会话暂无可展示的消息。';
|
|
532
|
+
if (empty.textContent !== text) empty.textContent = text;
|
|
533
|
+
}
|
|
534
|
+
if (older) container.scrollTop = previousTop + container.scrollHeight - previousHeight;
|
|
535
|
+
else if (scroll || nearBottom) container.scrollTop = container.scrollHeight;
|
|
536
|
+
else container.scrollTop = previousTop;
|
|
537
|
+
}
|
|
538
|
+
function formatDuration(durationMs) {
|
|
539
|
+
if (!Number.isFinite(durationMs)) return '';
|
|
540
|
+
return durationMs >= 1000 ? `${(durationMs / 1000).toFixed(durationMs >= 10000 ? 0 : 1)} 秒` : `${Math.round(durationMs)} 毫秒`;
|
|
541
|
+
}
|
|
542
|
+
function displayValue(value, maxLength = 60000) {
|
|
543
|
+
if (value === null || value === undefined) return '';
|
|
544
|
+
let text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
545
|
+
return String(text || '').slice(0, maxLength);
|
|
546
|
+
}
|
|
547
|
+
function activityStatusLabel(status) {
|
|
548
|
+
return ({ completed:'已完成', inProgress:'执行中', running:'执行中', failed:'失败', declined:'已拒绝', cancelled:'已取消' })[status] || '';
|
|
549
|
+
}
|
|
550
|
+
function diffMarkup(diff) {
|
|
551
|
+
return String(diff || '').split('\n').map(line => {
|
|
552
|
+
const kind = line.startsWith('+') && !line.startsWith('+++') ? 'diff-added' : line.startsWith('-') && !line.startsWith('---') ? 'diff-removed' : '';
|
|
553
|
+
return `<span class="${kind}">${escapeHtml(line) || ' '}</span>`;
|
|
554
|
+
}).join('\n');
|
|
555
|
+
}
|
|
556
|
+
function renderActivityMessage(node, message) {
|
|
557
|
+
const kind = message.kind || 'activity';
|
|
558
|
+
const labels = { command:'命令执行', fileChange:'文件变更', tool:'工具调用', webSearch:'网页搜索', imageView:'查看图像' };
|
|
559
|
+
const symbols = { command:'terminal', fileChange:'file', tool:'devices', webSearch:'searchWeb', imageView:'image' };
|
|
560
|
+
node.dataset.kind = kind;
|
|
561
|
+
node.querySelector('.activity-symbol').innerHTML = icon(symbols[kind] || 'info');
|
|
562
|
+
node.querySelector('.activity-label').textContent = labels[kind] || '执行记录';
|
|
563
|
+
node.querySelector('.activity-summary').textContent = String(message.text || '').slice(0, 180);
|
|
564
|
+
const meta = [activityStatusLabel(message.status), message.exitCode !== null && message.exitCode !== undefined ? `退出码 ${message.exitCode}` : '', formatDuration(message.durationMs)].filter(Boolean);
|
|
565
|
+
node.querySelector('.activity-meta').textContent = meta.join(' · ');
|
|
566
|
+
const details = [];
|
|
567
|
+
if (kind === 'command') {
|
|
568
|
+
details.push(`<pre class="activity-command"><code>${escapeHtml(message.text || '')}</code></pre>`);
|
|
569
|
+
if (message.cwd) details.push(`<div class="activity-path">${escapeHtml(message.cwd)}</div>`);
|
|
570
|
+
if (message.output) details.push(`<div class="activity-section-label">输出</div><pre class="activity-output"><code>${escapeHtml(message.output)}</code></pre>`);
|
|
571
|
+
} else if (kind === 'fileChange') {
|
|
572
|
+
for (const change of message.changes || []) {
|
|
573
|
+
details.push(`<div class="activity-file"><div class="activity-file-head"><span>${escapeHtml(change.path)}</span><small>${escapeHtml(change.kind || '')}</small></div>${change.diff ? `<pre class="activity-diff"><code>${diffMarkup(change.diff)}</code></pre>` : ''}</div>`);
|
|
574
|
+
}
|
|
575
|
+
} else if (kind === 'tool') {
|
|
576
|
+
if (message.arguments) details.push(`<div class="activity-section-label">参数</div><pre class="activity-output"><code>${escapeHtml(displayValue(message.arguments, 30000))}</code></pre>`);
|
|
577
|
+
if (message.output) details.push(`<div class="activity-section-label">${message.status === 'failed' ? '错误' : '结果'}</div><pre class="activity-output"><code>${escapeHtml(message.output)}</code></pre>`);
|
|
578
|
+
} else if (message.text) {
|
|
579
|
+
details.push(`<pre class="activity-output"><code>${escapeHtml(message.text)}</code></pre>`);
|
|
580
|
+
}
|
|
581
|
+
node.querySelector('.activity-content').innerHTML = details.join('') || '<p class="activity-empty">没有可展示的详细信息。</p>';
|
|
582
|
+
}
|
|
583
|
+
async function copyText(text, success) {
|
|
584
|
+
try {
|
|
585
|
+
if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable');
|
|
586
|
+
await navigator.clipboard.writeText(text);
|
|
587
|
+
toast(success);
|
|
588
|
+
} catch { toast('浏览器不允许访问剪贴板。'); }
|
|
589
|
+
}
|
|
590
|
+
function selectedModelSettings() {
|
|
591
|
+
if (state.status?.execution.mode === 'desktop' && state.selectedSettings?.pending) {
|
|
592
|
+
return { model:state.selectedSettings.model,effort:state.selectedSettings.effort };
|
|
593
|
+
}
|
|
594
|
+
return state.status?.execution.mode === 'desktop'
|
|
595
|
+
? { model: state.desktopRuntime?.model || state.selected?.model || null, effort: state.desktopRuntime?.effort ?? null }
|
|
596
|
+
: { model: state.selectedSettings?.model || state.selected?.model || null, effort: state.selectedSettings?.effort ?? null };
|
|
597
|
+
}
|
|
598
|
+
function formatTokens(value) {
|
|
599
|
+
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}m`;
|
|
600
|
+
if (value >= 1000) return `${Math.round(value / 1000)}k`;
|
|
601
|
+
return String(value);
|
|
602
|
+
}
|
|
603
|
+
function renderContext() {
|
|
604
|
+
const context = state.status?.execution.mode === 'desktop' ? state.desktopRuntime?.context : state.contextUsage;
|
|
605
|
+
const meter = select('#context-meter');
|
|
606
|
+
const label = select('#context-label');
|
|
607
|
+
const track = meter.querySelector('.context-track');
|
|
608
|
+
if (!context || !Number.isSafeInteger(context.usedTokens)) {
|
|
609
|
+
label.textContent = '暂无用量'; meter.title = '当前执行器没有提供准确的上下文用量';
|
|
610
|
+
track.hidden = true; meter.classList.remove('warning', 'critical'); return;
|
|
611
|
+
}
|
|
612
|
+
const windowTokens = context.windowTokens;
|
|
613
|
+
const percentage = windowTokens ? Math.round(context.usedTokens / windowTokens * 100) : null;
|
|
614
|
+
label.textContent = `${formatTokens(context.usedTokens)}${windowTokens ? ` / ${formatTokens(windowTokens)} · ${percentage}%` : ' 已用'}`;
|
|
615
|
+
meter.title = `已用 ${context.usedTokens.toLocaleString('zh-CN')} token${windowTokens ? ` / 上下文窗口 ${windowTokens.toLocaleString('zh-CN')} token` : ''}${context.cachedTokens != null ? `;缓存输入 ${context.cachedTokens.toLocaleString('zh-CN')} token` : ''}`;
|
|
616
|
+
track.hidden = !windowTokens;
|
|
617
|
+
if (windowTokens) {
|
|
618
|
+
track.setAttribute('aria-valuenow', String(Math.min(100, Math.max(0, percentage))));
|
|
619
|
+
select('#context-fill').style.width = `${Math.min(100, Math.max(0, percentage))}%`;
|
|
620
|
+
meter.classList.toggle('warning', percentage >= 70);
|
|
621
|
+
meter.classList.toggle('critical', percentage >= 90);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function renderModelStatus() {
|
|
625
|
+
const { model, effort } = selectedModelSettings();
|
|
626
|
+
const option = state.models.find(entry => entry.model === model);
|
|
627
|
+
select('#model-settings-label').textContent = model ? `${option?.displayName || model}${effort ? ` · ${effort}` : ''}${state.selectedSettings?.pending ? ' · 排队中' : ''}` : '模型信息待载入';
|
|
628
|
+
const desktop = state.status?.execution.mode === 'desktop';
|
|
629
|
+
select('#model-settings-button').disabled = state.socket?.readyState !== WebSocket.OPEN
|
|
630
|
+
|| state.settingsBusy || (desktop && (desktopConnecting || !state.desktopRuntime?.attached || !state.desktopRuntime.model));
|
|
631
|
+
select('#model-settings-button').title = desktop && !state.desktopRuntime?.attached
|
|
632
|
+
? '先在 Codex 桌面打开这条原会话,并重新连接'
|
|
633
|
+
: '选择模型和推理级别';
|
|
634
|
+
renderContext();
|
|
635
|
+
}
|
|
636
|
+
function renderEffortChoices(preferredEffort = null) {
|
|
637
|
+
const model = state.models.find(entry => entry.model === select('#model-select').value);
|
|
638
|
+
const available = model?.supportedReasoningEfforts || [];
|
|
639
|
+
select('#effort-select').innerHTML = '<option value="">默认</option>' + available.map(effort => `<option value="${escapeHtml(effort)}">${escapeHtml(effort)}</option>`).join('');
|
|
640
|
+
select('#effort-select').value = preferredEffort && available.includes(preferredEffort) ? preferredEffort : model?.defaultReasoningEffort && available.includes(model.defaultReasoningEffort) ? model.defaultReasoningEffort : '';
|
|
641
|
+
}
|
|
642
|
+
function updateMode() {
|
|
643
|
+
if (!state.selected) return;
|
|
644
|
+
const thread = state.selected;
|
|
645
|
+
if (state.enabled.get(thread.id)?.expires <= Date.now()) state.enabled.delete(thread.id);
|
|
646
|
+
const control = state.enabled.get(thread.id);
|
|
647
|
+
const connected = state.socket?.readyState === WebSocket.OPEN;
|
|
648
|
+
const desktop = state.status?.execution.mode === 'desktop';
|
|
649
|
+
const desktopConnected = desktop && state.status.execution.connected;
|
|
650
|
+
const attached = desktopConnected && state.desktopRuntime?.attached === true;
|
|
651
|
+
const blocked = thread.archived || (desktop ? !attached || desktopConnecting : thread.locked && !control && state.status?.execution.mode !== 'endpoint');
|
|
652
|
+
select('#enable-control').disabled = Boolean(blocked || control);
|
|
653
|
+
select('#enable-control').textContent = control ? desktop ? '热接管已授权' : '接续已授权' : thread.archived ? '归档只读' : desktopConnecting && desktop ? '连接中…' : desktop && !desktopConnected ? '桌面未连接' : desktop && !attached ? '原会话未连接' : blocked ? '原客户端占用' : desktop ? '授权热接管' : '接续会话';
|
|
654
|
+
select('#mode-banner').classList.toggle('controlled', Boolean(control));
|
|
655
|
+
const banner = desktop
|
|
656
|
+
? desktopConnecting ? '正在连接这条原会话;历史仍可查看。'
|
|
657
|
+
: !desktopConnected ? 'Codex 桌面连接已断开。请保持桌面应用运行,再点击“重新连接”。'
|
|
658
|
+
: !attached ? state.desktopRuntime?.reason || '这条原会话尚未连接。请先在 Codex 桌面打开它,再点击“重新连接”。'
|
|
659
|
+
: control ? '热接管已授权 · 与电脑共用同一 Codex 执行实例;消息、运行状态和审批实时同步。'
|
|
660
|
+
: '这条原会话已连接 · 授权后可发送、停止任务和处理审批。'
|
|
661
|
+
: control ? `原 ID 接续 · ${control.writable ? '工作区可写 / 操作需审批' : '只读执行沙箱'} · ${control.sharedExecution ? '目标服务已加载此会话' : '独立执行实例'}`
|
|
662
|
+
: thread.locked ? '原客户端持有此会话:独立接续不会抢占桌面执行。' : '只读镜像:查看原生历史不会启动执行实例。';
|
|
663
|
+
const modeBanner = select('#mode-banner');
|
|
664
|
+
if (!modeBanner.querySelector('.mode-copy')) modeBanner.innerHTML = `${icon('shield')}<span class="mode-copy"></span><button type="button" class="button small" id="reconnect-desktop">重新连接</button>`;
|
|
665
|
+
modeBanner.querySelector('.mode-copy').textContent = banner;
|
|
666
|
+
const retryButton = select('#reconnect-desktop');
|
|
667
|
+
retryButton.hidden = !desktop || attached && !desktopConnecting;
|
|
668
|
+
retryButton.disabled = !connected || desktopConnecting;
|
|
669
|
+
retryButton.textContent = desktopConnecting ? '连接中…' : '重新连接';
|
|
670
|
+
select('#composer-mode').innerHTML = `${icon('shield')}${control ? desktop ? '热接管 · 桌面权限' : control.writable ? '工作区可写 · 按需审批' : '接续执行 · 只读沙箱' : '原生记录 · 只读镜像'}`;
|
|
671
|
+
const waitingForTurn = state.busy && (!desktop || !state.turnId);
|
|
672
|
+
select('#send-message').disabled = !control || !connected || desktop && (!attached || desktopConnecting) || waitingForTurn || state.settingsBusy || !select('#message-input').value.trim();
|
|
673
|
+
select('#send-message').title = state.busy && desktop ? '追加指令到当前桌面任务' : '沿用原会话发送';
|
|
674
|
+
select('#stop-turn').hidden = !state.busy || !state.turnId || !control;
|
|
675
|
+
select('#message-input').placeholder = state.busy && desktop ? '追加指令给电脑上正在运行的任务…' : control ? '沿着原来的上下文继续…' : '先确认接续方式,再沿着这条会话继续…';
|
|
676
|
+
select('#composer-note').textContent = control ? desktop ? '消息进入原 Codex 桌面会话;任务不会迁移或重启。断线不会自动重发。' : '原会话 ID 不变;断线不会自动重发消息。' : '只读查看不会唤醒或打断电脑上的执行。';
|
|
677
|
+
renderModelStatus();
|
|
678
|
+
}
|
|
679
|
+
function renderApprovals() {
|
|
680
|
+
select('#approvals').innerHTML = [...state.approvals.values()].map(approval => `<section class="approval-card" data-approval="${escapeHtml(approval.requestId)}"><strong>${approval.method === 'item/tool/requestUserInput' ? 'Codex 需要你的回答' : '等待你的审批'}</strong><p>${escapeHtml(approval.reason || '请核对操作范围后决定。')}</p>${approval.command ? `<pre>${escapeHtml(approval.command)}</pre>` : ''}${approval.method !== 'item/tool/requestUserInput' ? `<details><summary>查看完整审批范围</summary><pre>${escapeHtml(JSON.stringify(approval, null, 2))}</pre></details>` : ''}${approval.method === 'item/tool/requestUserInput' ? (approval.questions || []).map(question => `<label>${escapeHtml(question.question)}${question.options ? `<small> ${question.options.map(option => escapeHtml(option.label)).join(' / ')}</small>` : ''}<input data-question="${escapeHtml(question.id)}" placeholder="输入你的回答" maxlength="4000"></label>`).join('') + '<button class="button small" data-decision="answers">提交回答</button>' : `<button class="button small" data-decision="decline">拒绝</button>${approval.method !== 'item/permissions/requestApproval' ? '<button class="button small" data-decision="accept">仅允许本次</button>' : '<p>扩大环境权限请在本机处理,首版仅支持拒绝。</p>'}`}</section>`).join('');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function renderDevices() {
|
|
684
|
+
const status = state.status;
|
|
685
|
+
const url = status.connectionUrls.find(address => !address.includes('127.0.0.1')) || status.connectionUrls[0];
|
|
686
|
+
const desktop = status.execution.mode === 'desktop';
|
|
687
|
+
const executionStatus = desktop ? status.execution.connected ? 'Codex 桌面 IPC 已连接' : 'Codex 桌面 IPC 未连接' : status.execution.mode === 'endpoint' ? 'App Server 端点' : '独立执行器';
|
|
688
|
+
const hotStatus = desktop && status.desktopHotAttachVerified ? '已验证实时会话' : desktop && status.desktopHotAttachReady ? '可授权热接管' : '未连接';
|
|
689
|
+
select('#devices-view').innerHTML = `<div class="devices-heading"><span class="eyebrow">CONNECTED TO YOUR WORLD</span><h1>设备与连接</h1><p>电脑负责执行。你的设备,只需要保持连接。</p></div><div class="device-grid"><section class="device-card"><div class="device-big-icon">${icon('monitor')}</div><h2>${escapeHtml(status.device)}<span class="capability-state">本机桥接在线</span></h2><p>直接读取本机原生会话与历史记录,不复制到第三方数据库。</p><dl><dt>原生会话</dt><dd>${state.threads.length} 条 / ${new Set(state.threads.map(thread => thread.projectId)).size} 个空间</dd><dt>数据目录</dt><dd>${escapeHtml(status.home)}</dd><dt>消息通道</dt><dd>${location.protocol === 'https:' ? 'WSS · TLS 加密' : 'WS · 本机 / 可信局域网演示'}</dd><dt>执行连接</dt><dd>${executionStatus}</dd><dt>热接管</dt><dd>${hotStatus}</dd></dl></section><section class="device-card"><div class="device-big-icon">${icon('devices')}</div><h2>把工作台带在身边</h2><p>${status.lanEnabled || status.secureTransport ? '在可信设备上打开地址并配对,即可查看同一份原生记录。二维码包含控制凭据,切勿公开。' : '当前仅监听本机。手机同 Wi-Fi 体验,请停止本次服务,再运行 start.ps1 -Lan。'}</p><div class="connection-url"><code>${escapeHtml(url)}</code><button class="icon-button" id="copy-url" aria-label="复制连接地址">${icon('copy')}</button></div><button id="show-qr" class="button small">显示配对二维码</button><div id="qr-container" class="qr-wrap" hidden></div><p>${status.secureTransport ? '已配置 HTTPS 入口;请确认反向代理正确转发 WebSocket。' : '注意:HTTP / WS 不加密,不要将此端口直接暴露公网。公网必须配置 HTTPS / WSS 与独立鉴权。'}</p></section><section class="device-card wide"><h2>能力状态,透明可见。</h2><div class="capability-row"><span>原生项目 / 全量本机会话 / 归档记录</span><span class="capability-state">真实数据已接入</span></div><div class="capability-row"><span>历史分页 / WebSocket 实时更新</span><span class="capability-state">可体验</span></div><div class="capability-row"><span>接入 Codex 桌面已加载的原会话</span><span class="capability-state ${hotStatus === '未连接' ? 'pending' : ''}">${hotStatus}</span></div><div class="capability-row"><span>追加指令 / 停止任务 / 审批同步</span><span class="capability-state ${status.execution.connected && desktop ? '' : 'pending'}">${desktop && status.execution.connected ? '同一桌面执行实例' : '桌面未连接'}</span></div><div class="capability-row"><span>公网中继 / 多用户 / 计费 / 推送</span><span class="capability-state pending">未实现</span></div><p>Orbit 是独立第三方原型,非 OpenAI 官方产品。桌面 IPC 属内部接口,升级后需重新做兼容测试;商业发布前还需独立安全审计。</p></section></div>`;
|
|
690
|
+
const cloudRow = select('#devices-view .capability-row:last-of-type');
|
|
691
|
+
cloudRow.querySelector('span:first-child').textContent = '多用户云中继 / 设备管理后台';
|
|
692
|
+
cloudRow.querySelector('span:last-child').textContent = state.cloudMode ? '已连接云端账号' : '可独立部署';
|
|
693
|
+
cloudRow.querySelector('span:last-child').classList.toggle('pending', !state.cloudMode);
|
|
694
|
+
select('#copy-url').onclick = async () => { try { await navigator.clipboard.writeText(url); toast('连接地址已复制。'); } catch { toast('浏览器不允许复制,请手动复制地址。'); } };
|
|
695
|
+
select('#show-qr').onclick = async () => {
|
|
696
|
+
try {
|
|
697
|
+
const data = await api('/api/pair/qr', { url });
|
|
698
|
+
select('#qr-container').innerHTML = `<img src="${escapeHtml(data.image)}" alt="敏感的设备配对二维码"><p>${escapeHtml(data.warning)}</p>`;
|
|
699
|
+
select('#qr-container').hidden = false;
|
|
700
|
+
} catch (error) { toast(error.message); }
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function closeNav() { select('#workspace').classList.remove('nav-open'); select('#scrim').hidden = true; }
|
|
705
|
+
async function setView(view) {
|
|
706
|
+
state.view = view; closeNav();
|
|
707
|
+
state.pendingModelChoice = null;
|
|
708
|
+
if (select('#model-dialog').open) select('#model-dialog').close();
|
|
709
|
+
const devices = view === 'devices';
|
|
710
|
+
select('#sessions-view').hidden = devices; select('#devices-view').hidden = !devices;
|
|
711
|
+
document.querySelectorAll('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === view));
|
|
712
|
+
select('#breadcrumb-current').textContent = devices ? '设备与连接' : view === 'archived' ? '已归档' : '所有会话';
|
|
713
|
+
if (devices) { try { state.status = await api('/api/status'); renderDevices(); } catch (error) { toast(error.message); } return; }
|
|
714
|
+
state.project = null; state.filter = view === 'archived' ? 'archived' : 'all';
|
|
715
|
+
state.selected = null; select('#workspace').classList.remove('chat-open');
|
|
716
|
+
select('#welcome').hidden = false; select('#chat').hidden = true;
|
|
717
|
+
renderProjects(); renderList(); renderWelcome();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
decorate();
|
|
721
|
+
select('#pair-form').addEventListener('submit', async event => {
|
|
722
|
+
event.preventDefault();
|
|
723
|
+
const button = event.submitter; button.disabled = true; select('#pair-error').textContent = '';
|
|
724
|
+
try { await api('/api/pair', { code: select('#pair-code').value }); select('#pair-code').value = ''; await boot(); }
|
|
725
|
+
catch (error) { select('#pair-error').textContent = error.message; }
|
|
726
|
+
finally { button.disabled = false; }
|
|
727
|
+
});
|
|
728
|
+
select('#cloud-login-form').addEventListener('submit',async event=>{
|
|
729
|
+
event.preventDefault();
|
|
730
|
+
const button=event.submitter;button.disabled=true;select('#cloud-login-error').textContent='';
|
|
731
|
+
try{
|
|
732
|
+
await api('/api/cloud/login',{username:select('#cloud-username').value,password:select('#cloud-password').value});
|
|
733
|
+
select('#cloud-password').value='';
|
|
734
|
+
await boot();
|
|
735
|
+
}catch(error){select('#cloud-login-error').textContent=error.message}
|
|
736
|
+
finally{button.disabled=false}
|
|
737
|
+
});
|
|
738
|
+
select('#cloud-show-login').onclick = () => setCloudAuthMode(false);
|
|
739
|
+
select('#cloud-show-register').onclick = () => setCloudAuthMode(true);
|
|
740
|
+
select('#cloud-register-form').addEventListener('submit', async event => {
|
|
741
|
+
event.preventDefault();
|
|
742
|
+
const version = cloudAuthVersion;
|
|
743
|
+
const button = event.submitter;
|
|
744
|
+
select('#cloud-register-error').textContent = '';
|
|
745
|
+
if (select('#register-password').value !== select('#register-password-confirm').value) {
|
|
746
|
+
select('#cloud-register-error').textContent = '两次输入的密码不一致。';
|
|
747
|
+
select('#register-password-confirm').focus();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
button.disabled = true;
|
|
751
|
+
try {
|
|
752
|
+
await api('/api/cloud/register', {
|
|
753
|
+
username:select('#register-username').value,
|
|
754
|
+
displayName:select('#register-display-name').value,
|
|
755
|
+
password:select('#register-password').value
|
|
756
|
+
});
|
|
757
|
+
select('#cloud-register-form').reset();
|
|
758
|
+
await boot();
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (version === cloudAuthVersion) select('#cloud-register-error').textContent = error.message;
|
|
761
|
+
} finally { button.disabled = false; }
|
|
762
|
+
});
|
|
763
|
+
select('#cloud-add-computer').onclick = () => {
|
|
764
|
+
select('#cloud-onboarding').hidden = !select('#cloud-onboarding').hidden;
|
|
765
|
+
if (!select('#cloud-onboarding').hidden) updateComputerInstallCommand();
|
|
766
|
+
};
|
|
767
|
+
select('#cloud-refresh-devices').onclick = async event => {
|
|
768
|
+
const button = event.currentTarget;
|
|
769
|
+
button.disabled = true;
|
|
770
|
+
try { await refreshCloudDevices(); }
|
|
771
|
+
catch (error) { toast(error.message); }
|
|
772
|
+
finally { button.disabled = false; }
|
|
773
|
+
};
|
|
774
|
+
async function copyInstallCommand(commandSelector,statusSelector){
|
|
775
|
+
const command=select(commandSelector).textContent.trim();
|
|
776
|
+
const status=select(statusSelector);
|
|
777
|
+
try {
|
|
778
|
+
await navigator.clipboard.writeText(command);
|
|
779
|
+
status.textContent = '命令已复制,粘贴到电脑 PowerShell 或终端运行。';
|
|
780
|
+
} catch {
|
|
781
|
+
const temporary = document.createElement('textarea');
|
|
782
|
+
temporary.value = command;
|
|
783
|
+
temporary.setAttribute('readonly', '');
|
|
784
|
+
temporary.style.position = 'fixed';
|
|
785
|
+
temporary.style.opacity = '0';
|
|
786
|
+
document.body.append(temporary);
|
|
787
|
+
temporary.select();
|
|
788
|
+
const copied = document.execCommand('copy');
|
|
789
|
+
temporary.remove();
|
|
790
|
+
status.textContent = copied ? '命令已复制,粘贴到电脑 PowerShell 或终端运行。' : '无法自动复制,请长按命令并手动复制。';
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
select('#cloud-copy-install').onclick=()=>copyInstallCommand('#cloud-install-command','#cloud-install-status');
|
|
794
|
+
function updateComputerInstallCommand(){
|
|
795
|
+
select('#cloud-install-command').textContent=computerInstallCommands.windows;
|
|
796
|
+
select('#cloud-install-status').textContent='';
|
|
797
|
+
}
|
|
798
|
+
updateComputerInstallCommand();
|
|
799
|
+
document.addEventListener('click', async event => {
|
|
800
|
+
const revoke = event.target.closest('[data-cloud-revoke]');
|
|
801
|
+
if (revoke) {
|
|
802
|
+
const device = state.cloudDevices.find(item => item.id === revoke.dataset.cloudRevoke);
|
|
803
|
+
if (!device || !window.confirm(`移除“${device.name}”?其连接密钥将永久失效,重新添加需要生成新配置。`)) return;
|
|
804
|
+
revoke.disabled = true;
|
|
805
|
+
try {
|
|
806
|
+
await api(`/api/cloud/devices/${encodeURIComponent(device.id)}/revoke`, {});
|
|
807
|
+
await refreshCloudDevices();
|
|
808
|
+
toast('电脑已移除,其连接密钥已失效。');
|
|
809
|
+
} catch (error) { toast(error.message); revoke.disabled = false; }
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const cloudDevice=event.target.closest('[data-cloud-device]');
|
|
813
|
+
if(cloudDevice){
|
|
814
|
+
state.cloudDeviceId=cloudDevice.dataset.cloudDevice;
|
|
815
|
+
localStorage.setItem('orbit_cloud_device_id',state.cloudDeviceId);
|
|
816
|
+
if(state.cloudPairCode){
|
|
817
|
+
cloudDevice.disabled=true;
|
|
818
|
+
try{
|
|
819
|
+
await api('/api/pair',{code:state.cloudPairCode});
|
|
820
|
+
state.cloudPairCode=null;
|
|
821
|
+
showCloudDevices();
|
|
822
|
+
await boot();
|
|
823
|
+
}catch(error){
|
|
824
|
+
showCloudDevices(error.message);
|
|
825
|
+
}
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
await boot();return;
|
|
829
|
+
}
|
|
830
|
+
if(event.target.closest('#cloud-logout-picker')){
|
|
831
|
+
try{await api('/api/cloud/logout',{});}catch{}
|
|
832
|
+
localStorage.removeItem('orbit_cloud_device_id');state.cloudDeviceId=null;state.cloudUser=null;state.cloudDevices=[];
|
|
833
|
+
showCloudLogin();return;
|
|
834
|
+
}
|
|
835
|
+
if(event.target.closest('#cloud-device-switcher')){showCloudDevices();return;}
|
|
836
|
+
if (event.target.closest('#reconnect-desktop')) {
|
|
837
|
+
const id = state.selected?.id;
|
|
838
|
+
if (!id || select('#reconnect-desktop').disabled) return;
|
|
839
|
+
try {
|
|
840
|
+
await subscribeThread(id);
|
|
841
|
+
if (id === state.selected?.id) toast(state.desktopRuntime?.attached ? '这条原会话已连接,请授权后操作。' : state.desktopRuntime?.reason || '原会话尚未连接。');
|
|
842
|
+
} catch (error) { toast(error.message); }
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const copyCodeButton = event.target.closest('[data-copy-code]');
|
|
846
|
+
if (copyCodeButton) {
|
|
847
|
+
await copyText(copyCodeButton.closest('pre')?.querySelector('code')?.textContent || '', '代码已复制。');
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const copyMessageButton = event.target.closest('[data-copy-message]');
|
|
851
|
+
if (copyMessageButton) {
|
|
852
|
+
await copyText(copyMessageButton.closest('[data-message-id]')?.dataset.messageText || '', '消息已复制。');
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const thread = event.target.closest('[data-thread]');
|
|
856
|
+
if (thread) { await openThread(thread.dataset.thread); return; }
|
|
857
|
+
const project = event.target.closest('[data-project]');
|
|
858
|
+
if (project) {
|
|
859
|
+
state.project = state.project === project.dataset.project ? null : project.dataset.project; state.filter = 'all';
|
|
860
|
+
state.view = 'sessions'; select('#sessions-view').hidden = false; select('#devices-view').hidden = true;
|
|
861
|
+
select('#workspace').classList.remove('chat-open'); closeNav(); renderProjects(); renderList(); return;
|
|
862
|
+
}
|
|
863
|
+
const filter = event.target.closest('[data-filter]');
|
|
864
|
+
if (filter) { state.filter = filter.dataset.filter; renderList(); return; }
|
|
865
|
+
const nav = event.target.closest('[data-view]');
|
|
866
|
+
if (nav) { await setView(nav.dataset.view); return; }
|
|
867
|
+
if (event.target.closest('#load-more')) { try { await loadMessages(false, true); } catch (error) { toast(error.message); } }
|
|
868
|
+
const decision = event.target.closest('[data-decision]');
|
|
869
|
+
if (decision) {
|
|
870
|
+
const card = decision.closest('[data-approval]');
|
|
871
|
+
const answers = Object.fromEntries([...card.querySelectorAll('[data-question]')].map(input => [input.dataset.question, input.value]));
|
|
872
|
+
try { await rpc('approval.reply', { requestId:card.dataset.approval, decision:decision.dataset.decision, answers }); state.approvals.delete(card.dataset.approval); renderApprovals(); }
|
|
873
|
+
catch (error) { toast(error.message); }
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
select('#search').addEventListener('input', event => { state.search = event.target.value; renderList(); });
|
|
877
|
+
document.addEventListener('keydown', event => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); select('#search').focus(); } });
|
|
878
|
+
select('#refresh').onclick = async () => {
|
|
879
|
+
try {
|
|
880
|
+
await refreshThreads();
|
|
881
|
+
if (state.selected) {
|
|
882
|
+
await loadMessages(false);
|
|
883
|
+
if (state.status?.execution.mode === 'desktop') await subscribeThread(state.selected.id);
|
|
884
|
+
}
|
|
885
|
+
toast('原生记录已刷新。');
|
|
886
|
+
} catch (error) { toast(error.message); }
|
|
887
|
+
};
|
|
888
|
+
select('#menu-button').onclick = () => { select('#workspace').classList.add('nav-open'); select('#scrim').hidden = false; };
|
|
889
|
+
select('#scrim').onclick = closeNav;
|
|
890
|
+
select('#back').onclick = () => select('#workspace').classList.remove('chat-open');
|
|
891
|
+
select('#connect-device').onclick = () => state.cloudMode ? showCloudDevices() : setView('devices');
|
|
892
|
+
select('#details-toggle').onclick = () => { select('#chat-details').hidden = !select('#chat-details').hidden; };
|
|
893
|
+
select('#logout').onclick = async () => {
|
|
894
|
+
try {
|
|
895
|
+
if(state.cloudMode){
|
|
896
|
+
await api('/api/cloud/logout',{});
|
|
897
|
+
localStorage.removeItem('orbit_cloud_device_id');state.cloudDeviceId=null;state.cloudUser=null;state.cloudDevices=[];
|
|
898
|
+
showCloudLogin();
|
|
899
|
+
}else{
|
|
900
|
+
await api('/api/logout',{});state.enabled.clear();showPairing();
|
|
901
|
+
}
|
|
902
|
+
} catch(error){toast(error.message)}
|
|
903
|
+
};
|
|
904
|
+
select('#enable-control').onclick = () => {
|
|
905
|
+
const desktop = state.status.execution.mode === 'desktop';
|
|
906
|
+
select('#control-form').reset(); select('#control-error').textContent = '';
|
|
907
|
+
select('#control-title').textContent = desktop ? '授权热接管' : '沿用原会话';
|
|
908
|
+
select('#control-description').textContent = desktop ? '不创建新会话、不迁移执行器。Orbit 将订阅并控制 Codex 桌面当前加载的原会话。' : '保持原会话 ID,使用独立执行通道继续。原客户端占用时不会强行抢锁。';
|
|
909
|
+
select('#control-notice').textContent = desktop ? '消息、追加指令、停止和审批将交给原 Codex 桌面实例;权限与运行策略沿用电脑端设置。' : '如果原客户端仍占用这条会话,Orbit 会拒绝发送,不会强制抢锁。';
|
|
910
|
+
select('#allow-write-row').hidden = desktop;
|
|
911
|
+
select('#control-confirm-copy').textContent = desktop ? '我确认授权这台设备向原 Codex 会话发送指令、停止任务并处理审批。' : '我已停止在原客户端使用这条会话,并理解首版限制。';
|
|
912
|
+
select('#confirm-control-button').textContent = desktop ? '授权热接管 ↗' : '确认接续 ↗';
|
|
913
|
+
select('#control-dialog').showModal();
|
|
914
|
+
};
|
|
915
|
+
select('#cancel-control').onclick = () => {
|
|
916
|
+
state.reopenModelAfterGrant = false; state.pendingModelChoice = null;
|
|
917
|
+
select('#control-dialog').close();
|
|
918
|
+
};
|
|
919
|
+
select('#control-form').addEventListener('submit', async event => {
|
|
920
|
+
event.preventDefault(); const button = event.submitter; button.disabled = true;
|
|
921
|
+
const id = state.selected.id;
|
|
922
|
+
try {
|
|
923
|
+
const result = await rpc('thread.enable', { threadId:id, confirm:select('#control-confirm').checked, writable:select('#allow-write').checked });
|
|
924
|
+
state.enabled.set(id, result); select('#control-dialog').close(); updateMode(); toast('已确认接续方式。尚未发送消息。');
|
|
925
|
+
if (state.reopenModelAfterGrant) {
|
|
926
|
+
setTimeout(() => {
|
|
927
|
+
state.reopenModelAfterGrant = false;
|
|
928
|
+
if (state.selected?.id === id) select('#model-settings-button').click();
|
|
929
|
+
}, 0);
|
|
930
|
+
}
|
|
931
|
+
} catch (error) { select('#control-error').textContent = error.message; }
|
|
932
|
+
finally { button.disabled = false; }
|
|
933
|
+
});
|
|
934
|
+
async function openModelSettings() {
|
|
935
|
+
if (select('#model-settings-button').disabled || !state.selected) return;
|
|
936
|
+
const id = state.selected.id;
|
|
937
|
+
const selectionVersion = state.selectionVersion;
|
|
938
|
+
try {
|
|
939
|
+
const { models } = await api('/api/models');
|
|
940
|
+
if (selectionVersion !== state.selectionVersion || id !== state.selected?.id) return;
|
|
941
|
+
state.models = models;
|
|
942
|
+
const current = selectedModelSettings();
|
|
943
|
+
const pending = state.pendingModelChoice?.threadId === id ? state.pendingModelChoice : null;
|
|
944
|
+
const model = pending?.model || current.model;
|
|
945
|
+
const effort = pending ? pending.effort : current.effort;
|
|
946
|
+
const dialog = select('#model-dialog');
|
|
947
|
+
dialog.dataset.threadId = id;
|
|
948
|
+
dialog.dataset.expectedModel = current.model || '';
|
|
949
|
+
dialog.dataset.expectedEffort = JSON.stringify(current.effort);
|
|
950
|
+
select('#model-select').innerHTML = models.map(entry => `<option value="${escapeHtml(entry.model)}">${escapeHtml(entry.displayName)}</option>`).join('');
|
|
951
|
+
if (model && !models.some(entry => entry.model === model)) {
|
|
952
|
+
select('#model-select').insertAdjacentHTML('afterbegin', `<option value="" disabled>当前模型 ${escapeHtml(model)} 不在目录中</option>`);
|
|
953
|
+
select('#model-select').value = '';
|
|
954
|
+
} else select('#model-select').value = model || '';
|
|
955
|
+
renderEffortChoices(effort);
|
|
956
|
+
const authorized = Boolean(state.enabled.get(id));
|
|
957
|
+
const turnRunning = Boolean(state.desktopRuntime?.turnId);
|
|
958
|
+
select('#model-description').textContent = authorized
|
|
959
|
+
? turnRunning ? '当前任务继续运行;新参数会排队到它完成后的下一轮。' : '更改将在这条会话的下一轮生效。'
|
|
960
|
+
: turnRunning ? '可以先查看和选择;授权后会排队到当前任务完成。' : '可以先查看可用选项;授权热接管后才能应用更改。';
|
|
961
|
+
select('#model-note').textContent = state.status.execution.mode === 'desktop'
|
|
962
|
+
? turnRunning ? '执行中的任务不会切换;新参数会在它结束后应用,并用于下一轮。' : '只修改原桌面会话的下一轮;运行中的任务不会切换。'
|
|
963
|
+
: '只对本设备接下来的消息生效;不会修改 Codex 全局设置。';
|
|
964
|
+
select('#authorize-model').hidden = authorized;
|
|
965
|
+
select('#apply-model').disabled = !authorized || !select('#model-select').value;
|
|
966
|
+
select('#apply-model').textContent = turnRunning ? '排到下一轮' : '应用到下一轮';
|
|
967
|
+
select('#model-error').textContent = '';
|
|
968
|
+
dialog.showModal();
|
|
969
|
+
renderModelStatus();
|
|
970
|
+
} catch (error) { toast(error.message); }
|
|
971
|
+
}
|
|
972
|
+
select('#model-settings-button').onclick = openModelSettings;
|
|
973
|
+
select('#model-select').onchange = () => {
|
|
974
|
+
renderEffortChoices();
|
|
975
|
+
select('#apply-model').disabled = !state.enabled.get(select('#model-dialog').dataset.threadId) || !select('#model-select').value;
|
|
976
|
+
};
|
|
977
|
+
select('#cancel-model').onclick = () => { state.pendingModelChoice = null; select('#model-dialog').close(); };
|
|
978
|
+
select('#authorize-model').onclick = () => {
|
|
979
|
+
if (!state.selected || state.selected.id !== select('#model-dialog').dataset.threadId) return;
|
|
980
|
+
state.pendingModelChoice = { threadId:state.selected.id,model:select('#model-select').value,effort:select('#effort-select').value || null };
|
|
981
|
+
state.reopenModelAfterGrant = true;
|
|
982
|
+
select('#model-dialog').close();
|
|
983
|
+
select('#enable-control').click();
|
|
984
|
+
};
|
|
985
|
+
select('#model-form').addEventListener('submit', async event => {
|
|
986
|
+
event.preventDefault();
|
|
987
|
+
const button = event.submitter;
|
|
988
|
+
const dialog = select('#model-dialog');
|
|
989
|
+
const id = dialog.dataset.threadId;
|
|
990
|
+
const model = select('#model-select').value;
|
|
991
|
+
const effort = select('#effort-select').value || null;
|
|
992
|
+
if (!id || id !== state.selected?.id) {
|
|
993
|
+
select('#model-error').textContent = '会话状态已变化,请重新打开设置。';
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (!state.enabled.has(id)) {
|
|
997
|
+
select('#model-error').textContent = '先授权热接管,才能更改原 Codex 会话设置。';
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (model === dialog.dataset.expectedModel && effort === JSON.parse(dialog.dataset.expectedEffort)) { dialog.close(); return; }
|
|
1001
|
+
button.disabled = true; state.settingsBusy = true; select('#model-error').textContent = ''; updateMode();
|
|
1002
|
+
try {
|
|
1003
|
+
const result = await rpc('thread.settings', { threadId:id, model, effort, expectedModel:dialog.dataset.expectedModel, expectedEffort:JSON.parse(dialog.dataset.expectedEffort) });
|
|
1004
|
+
if (id === state.selected?.id) {
|
|
1005
|
+
if (result.queued) state.selectedSettings = { model:result.model,effort:result.effort,pending:true };
|
|
1006
|
+
else {
|
|
1007
|
+
if (state.status.execution.mode === 'desktop' && state.desktopRuntime?.attached) state.desktopRuntime = { ...state.desktopRuntime, model: result.model, effort: result.effort };
|
|
1008
|
+
else state.selectedSettings = { model: result.model, effort: result.effort,pending:false };
|
|
1009
|
+
}
|
|
1010
|
+
state.pendingModelChoice = null;
|
|
1011
|
+
dialog.close();
|
|
1012
|
+
toast(result.queued ? '已排队;当前任务完成后应用到下一轮。' : '下一轮模型参数已更新。');
|
|
1013
|
+
renderModelStatus();
|
|
1014
|
+
}
|
|
1015
|
+
} catch (error) { select('#model-error').textContent = error.message; }
|
|
1016
|
+
finally { button.disabled = false; state.settingsBusy = false; updateMode(); }
|
|
1017
|
+
});
|
|
1018
|
+
select('#message-input').addEventListener('input', updateMode);
|
|
1019
|
+
select('#message-input').addEventListener('keydown', event => {
|
|
1020
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !matchMedia('(max-width:760px)').matches) { event.preventDefault(); if (!select('#send-message').disabled) select('#composer').requestSubmit(); }
|
|
1021
|
+
});
|
|
1022
|
+
select('#composer').addEventListener('submit', async event => {
|
|
1023
|
+
event.preventDefault(); if (select('#send-message').disabled) return;
|
|
1024
|
+
const id = state.selected.id; const text = select('#message-input').value; const wasBusy = state.busy;
|
|
1025
|
+
const turnId = state.turnId;
|
|
1026
|
+
const steering = wasBusy && state.status.execution.mode === 'desktop';
|
|
1027
|
+
state.busy = true; updateMode();
|
|
1028
|
+
try {
|
|
1029
|
+
const result = await rpc(steering ? 'turn.steer' : 'turn.start', { threadId:id, text, ...(steering ? { turnId } : {}) });
|
|
1030
|
+
if (state.selected?.id === id) { state.turnId = result.completed ? null : result.turnId || (steering ? turnId : null); state.busy = !result.completed; select('#message-input').value = ''; scheduleRefresh(); }
|
|
1031
|
+
} catch (error) { if (state.selected?.id === id) state.busy = wasBusy; toast(error.message); }
|
|
1032
|
+
updateMode();
|
|
1033
|
+
});
|
|
1034
|
+
select('#stop-turn').onclick = async () => { try { await rpc('turn.interrupt', { threadId:state.selected.id, turnId:state.turnId }); } catch (error) { toast(error.message); } };
|
|
1035
|
+
boot();
|