orbit-codex-bridge 0.1.6 → 0.1.7

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/public/app.js DELETED
@@ -1,1382 +0,0 @@
1
- const select = selector => document.querySelector(selector);
2
- const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[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, goal:null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null, approvals: new Map(), pendingAttachments: [], reconnect: 0, loggedOut: false, busy: false, turnId: null };
29
- let refreshTimer;
30
- let toastTimer;
31
- let reconnectTimer;
32
- let controlRenewTimer;
33
- let desktopConnectionAttempt = 0;
34
- let desktopConnecting = false;
35
- let workspaceGeneration = 0;
36
- let cloudRegistrationEnabled = false;
37
- let cloudAuthVersion = 0;
38
- let cloudConnecting = false;
39
- const computerInstallCommands = {
40
- windows:'$minimum = 24; $node = Get-Command node.exe -ErrorAction SilentlyContinue; $nodeMajor = if ($node) { [int](& $node.Source -p "parseInt(process.versions.node)") } else { 0 }; if ($nodeMajor -lt $minimum) { if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) { throw "Node.js 24+ 未安装且找不到 winget;请先从 https://nodejs.org 安装 Node.js 24+。" }; winget.exe 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"; $node = Get-Command node.exe -ErrorAction SilentlyContinue; $nodeMajor = if ($node) { [int](& $node.Source -p "parseInt(process.versions.node)") } else { 0 } }; if (-not $node -or $nodeMajor -lt $minimum) { throw "Node.js 24+ 安装未完成,请关闭并重新打开 PowerShell 后重试。" }; npx.cmd --yes --package=orbit-codex-bridge@latest orbit login',
41
- unix:'npx --yes --package=orbit-codex-bridge@latest orbit login'
42
- };
43
-
44
- function setCloudAuthMode(register = false) {
45
- cloudAuthVersion++;
46
- const registering = register && cloudRegistrationEnabled;
47
- select('#cloud-login-form').hidden = registering;
48
- select('#cloud-register-form').hidden = !registering;
49
- select('#cloud-auth-title').textContent = registering ? '创建你的 Orbit 账号' : '登录 Orbit';
50
- select('#cloud-auth-description').textContent = registering ? '从一台电脑开始,让原来的会话随你出发。' : '登录后,继续连接你的电脑和原有会话。';
51
- select('#cloud-show-login').classList.toggle('active', !registering);
52
- select('#cloud-show-register').classList.toggle('active', registering);
53
- select('#cloud-show-login').setAttribute('aria-pressed', String(!registering));
54
- select('#cloud-show-register').setAttribute('aria-pressed', String(registering));
55
- select('#cloud-login-error').textContent = '';
56
- select('#cloud-register-error').textContent = '';
57
- select('#cloud-password').value = '';
58
- select('#register-password').value = '';
59
- select('#register-password-confirm').value = '';
60
- }
61
-
62
- async function loadCloudConfig() {
63
- const response = await fetch('/api/cloud/config', { cache:'no-store', signal:AbortSignal.timeout(15000) });
64
- if (!response.ok) throw new Error('无法读取账号设置,请刷新页面重试。');
65
- const config = await response.json();
66
- cloudRegistrationEnabled = config.registrationEnabled === true;
67
- select('#cloud-auth-tabs').hidden = !cloudRegistrationEnabled;
68
- select('#cloud-registration-notice').hidden = cloudRegistrationEnabled;
69
- if (!cloudRegistrationEnabled) setCloudAuthMode(false);
70
- }
71
-
72
- function discardCloudPairCode() {
73
- const fragment=new URLSearchParams(location.hash.slice(1));
74
- if(!fragment.has('pair'))return;
75
- history.replaceState(null,'',location.pathname+location.search);
76
- }
77
-
78
- function toast(text) { select('#toast').textContent = text; select('#toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { select('#toast').hidden = true; }, 5500); }
79
- function mergeDesktopMessages(messages = []) {
80
- const merged = new Map(state.desktopMessages.map(message => [message.id, message]));
81
- for (const message of messages) if (message?.id) merged.set(message.id, message);
82
- state.desktopMessages = [...merged.values()].slice(-160);
83
- }
84
- function ago(timestamp) {
85
- const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60000));
86
- if (minutes < 1) return '刚刚';
87
- if (minutes < 60) return `${minutes} 分钟前`;
88
- if (minutes < 1440) return `${Math.floor(minutes / 60)} 小时前`;
89
- if (minutes < 10080) return `${Math.floor(minutes / 1440)} 天前`;
90
- return new Date(timestamp).toLocaleDateString('zh-CN', { month:'2-digit', day:'2-digit' });
91
- }
92
- async function api(url, body, signal) {
93
- const generation = workspaceGeneration;
94
- const headers = {};
95
- if (body !== undefined) headers['Content-Type'] = 'application/json';
96
- if (state.cloudMode && state.cloudDeviceId && (!url.startsWith('/api/cloud/') || url === '/api/cloud/connect')) headers['X-Orbit-Device-Id'] = state.cloudDeviceId;
97
- const options = body === undefined ? { headers } : { method: 'POST', headers, body: JSON.stringify(body) };
98
- if (signal) options.signal = signal;
99
- let response;
100
- let result;
101
- try {
102
- response = await fetch(url, {...options, signal:signal || AbortSignal.timeout(35000)});
103
- result = await response.json();
104
- } catch (error) {
105
- if (generation !== workspaceGeneration) throw Object.assign(new Error('工作台已切换。'), {code:'STALE_WORKSPACE'});
106
- if (signal?.aborted) throw error;
107
- throw new Error(error.name === 'TimeoutError' ? '连接超时,请检查电脑端和网络后重试。' : '无法连接 Orbit 服务,请检查网络后重试。');
108
- }
109
- if (generation !== workspaceGeneration) throw Object.assign(new Error('工作台已切换。'),{code:'STALE_WORKSPACE'});
110
- if (!response.ok) {
111
- const error = Object.assign(new Error(result.error || '请求失败。'), {status:response.status, code:result.code});
112
- if (response.status === 401 && state.cloudMode && (result.code === 'cloud_login_required' || url.startsWith('/api/cloud/') && url !== '/api/cloud/login')) {
113
- showCloudLogin(result.error);
114
- error.handled = true;
115
- } else if (response.status === 401 && !state.cloudMode && url !== '/api/pair' && !url.startsWith('/api/cloud/')) {
116
- showPairing();
117
- error.handled = true;
118
- }
119
- throw error;
120
- }
121
- return result;
122
- }
123
- function showPairing(error = '') {
124
- state.loggedOut = true; clearTimeout(reconnectTimer);
125
- state.socket?.close();
126
- select('#workspace').hidden = true; select('#pair-screen').hidden = false;
127
- select('#cloud-login-screen').hidden = true; select('#cloud-device-screen').hidden = true;
128
- select('#pair-error').textContent = error;
129
- select('#pair-screen .pair-form > p').textContent = '输入电脑上的配对密钥。会话内容仅向已配对的设备开放。';
130
- const fragment = new URLSearchParams(location.hash.slice(1));
131
- if (fragment.has('pair')) { select('#pair-code').value = fragment.get('pair'); history.replaceState(null, '', location.pathname); }
132
- }
133
- function showCloudLogin(error = '') {
134
- workspaceGeneration++;
135
- resetCloudWorkspace();
136
- state.cloudMode = true;
137
- state.loggedOut = true;
138
- state.socket?.close();
139
- select('#workspace').hidden = true; select('#pair-screen').hidden = true; select('#cloud-device-screen').hidden = true;
140
- select('#cloud-login-screen').hidden = false;
141
- setCloudAuthMode(false);
142
- select('#cloud-login-error').textContent = error;
143
- }
144
- function showCloudDevices(message = '') {
145
- workspaceGeneration++;
146
- resetCloudWorkspace();
147
- state.loggedOut = false;
148
- state.cloudDeviceId = null; localStorage.removeItem('orbit_cloud_device_id');
149
- select('#workspace').hidden = true; select('#pair-screen').hidden = true; select('#cloud-login-screen').hidden = true;
150
- select('#cloud-device-screen').hidden = false;
151
- select('#cloud-device-message').setAttribute('role', 'status');
152
- const quota = state.cloudUser?.maxDevices || 1;
153
- select('#cloud-devices-title').textContent = state.cloudDevices.length ? '选择要连接的电脑' : '连接你的第一台电脑';
154
- const greeting = `已登录 ${state.cloudUser?.displayName || state.cloudUser?.username}。`;
155
- select('#cloud-device-message').textContent = message || greeting + (state.cloudDevices.length ? '选择一台电脑继续,无需输入配对密钥。' : '先添加你自己的电脑,连接后即可查看原有会话。');
156
- select('#cloud-device-quota').textContent = `套餐 ${state.cloudUser?.plan || 'free'} · 已添加 ${state.cloudDevices.length} / ${quota} 台电脑`;
157
- select('#cloud-add-computer').disabled = state.cloudDevices.length >= quota;
158
- select('#cloud-add-computer').title = state.cloudDevices.length >= quota ? '已达到设备上限,可移除旧电脑或联系管理员调整额度。' : '';
159
- select('#cloud-onboarding').hidden = state.cloudDevices.length > 0;
160
- select('#cloud-devices-list').innerHTML = state.cloudDevices.map(device =>
161
- `<article class="cloud-computer-card"><button type="button" class="device-card cloud-device-option" data-cloud-device="${escapeHtml(device.id)}" ${device.status !== 'active' ? '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 login,启动后台连接器。'}</p></button><footer><span>${device.online ? '仅你的账号可连接' : '点击可重新检查上线状态'}</span><button type="button" class="button small" data-cloud-revoke="${escapeHtml(device.id)}">移除电脑</button></footer></article>`
162
- ).join('');
163
- }
164
-
165
- async function connectCloudDevice(deviceId) {
166
- if (cloudConnecting) return;
167
- cloudConnecting = true;
168
- const button = select(`[data-cloud-device="${CSS.escape(deviceId)}"]`);
169
- if (button) {
170
- button.disabled = true;
171
- button.setAttribute('aria-busy', 'true');
172
- button.querySelector('.capability-state').textContent = '正在连接…';
173
- }
174
- select('#cloud-device-message').textContent = '正在检查电脑连接,请稍候…';
175
- try {
176
- const profile = await api('/api/cloud/me');
177
- state.cloudUser = profile.user;
178
- state.cloudDevices = profile.devices || [];
179
- const device = state.cloudDevices.find(item => item.id === deviceId && item.status === 'active');
180
- if (!device) { showCloudDevices('这台电脑已被移除或不属于当前账号,请重新选择。'); return; }
181
- if (!device.online) { showCloudDevices(`电脑“${device.name}”尚未上线。请确认电脑已联网,在电脑终端运行 orbit login,后台启动后再点击连接。`); return; }
182
- state.cloudDeviceId = deviceId;
183
- localStorage.setItem('orbit_cloud_device_id', deviceId);
184
- await boot();
185
- } catch (error) {
186
- if (!error.handled && error.code !== 'STALE_WORKSPACE') showCloudDevices(error.message);
187
- } finally {
188
- cloudConnecting = false;
189
- if (button?.isConnected) { button.disabled = false; button.removeAttribute('aria-busy'); }
190
- }
191
- }
192
-
193
- async function refreshCloudDevices() {
194
- const workspace = await api('/api/cloud/me');
195
- state.cloudUser = workspace.user;
196
- state.cloudDevices = workspace.devices || [];
197
- showCloudDevices();
198
- }
199
-
200
- function resetCloudWorkspace() {
201
- state.loggedOut = true; clearTimeout(reconnectTimer); clearTimeout(refreshTimer); clearTimeout(controlRenewTimer); refreshTimer = null;
202
- state.selectionVersion++;
203
- state.socket?.close();
204
- state.socket = null;
205
- for (const pending of state.pending.values()) {clearTimeout(pending.timer);pending.reject(new Error('工作台已断开。'))}
206
- state.pending.clear();
207
- state.selected = null; state.desktopRuntime = null; state.selectedSettings = null; state.contextUsage = null;
208
- state.threads = []; state.models = []; state.project = null;
209
- state.enabled.clear(); state.approvals.clear(); state.desktopMessages = []; state.baseMessages = [];
210
- state.olderMessages = []; state.liveMessages.clear(); state.cursor = null; state.turnId = null; state.busy = false;
211
- select('#chat').hidden = true; select('#welcome').hidden = false;
212
- select('#messages').replaceChildren(); select('#projects').replaceChildren(); select('#thread-list').replaceChildren();
213
- select('#message-input').value = '';
214
- select('#workspace').classList.remove('chat-open');
215
- select('#sessions-view').hidden = false; select('#devices-view').hidden = true; state.view = 'sessions';
216
- for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
217
- }
218
- async function discoverCloudWorkspace() {
219
- const generation = workspaceGeneration;
220
- let response;
221
- try { response = await fetch('/api/cloud/me', { cache:'no-store', signal:AbortSignal.timeout(15000) }); }
222
- catch (error) { throw new Error('无法连接工作台服务,请检查网络。'); }
223
- if (generation !== workspaceGeneration) return 'stale';
224
- if (response.status === 404) return 'local';
225
- discardCloudPairCode();
226
- let configError = '';
227
- try { await loadCloudConfig(); }
228
- catch (error) { configError = error.message; }
229
- if (generation !== workspaceGeneration) return 'stale';
230
- if (response.status === 401) {
231
- showCloudLogin();
232
- select('#cloud-login-error').textContent = configError;
233
- return 'login';
234
- }
235
- if (!response.ok) { showCloudLogin('云端服务暂时不可用,请稍后重试。'); return 'login'; }
236
- const workspace = await response.json();
237
- state.cloudMode = true; state.loggedOut = false;
238
- state.cloudUser = workspace.user; state.cloudDevices = workspace.devices || [];
239
- const saved = localStorage.getItem('orbit_cloud_device_id');
240
- if (saved && state.cloudDevices.some(device => device.id === saved && device.status === 'active')) state.cloudDeviceId = saved;
241
- else if (state.cloudDevices.length === 1 && state.cloudDevices[0].status === 'active') {
242
- state.cloudDeviceId = state.cloudDevices[0].id;
243
- localStorage.setItem('orbit_cloud_device_id',state.cloudDeviceId);
244
- } else {
245
- state.cloudDeviceId = null;
246
- showCloudDevices();
247
- return 'devices';
248
- }
249
- select('#cloud-device-switcher').hidden = false;
250
- const selected = state.cloudDevices.find(device => device.id === state.cloudDeviceId);
251
- select('#cloud-device-switcher').textContent = selected?.name || '选择电脑';
252
- return 'ready';
253
- }
254
- async function refreshThreads() {
255
- const data = await api('/api/threads'); state.threads = data.threads;
256
- renderProjects(); renderList();
257
- if (!state.selected) renderWelcome();
258
- else { const updated = state.threads.find(thread => thread.id === state.selected.id); if (updated) state.selected = updated; updateMode(); }
259
- }
260
- async function boot() {
261
- const generation = ++workspaceGeneration;
262
- try {
263
- const cloudState = await discoverCloudWorkspace();
264
- if (cloudState !== 'local' && cloudState !== 'ready') return;
265
- if (cloudState === 'ready') {
266
- try { await api('/api/cloud/connect',{}); }
267
- catch (error) {
268
- if (error.status === 404) throw new Error('云端尚未支持账号直接连接,请先更新云端 Orbit 服务到 0.1.2 或更高版本。');
269
- throw error;
270
- }
271
- }
272
- state.status = await api('/api/status');
273
- if (generation !== workspaceGeneration) return;
274
- state.loggedOut = false;
275
- select('#pair-code').value = '';
276
- select('#pair-screen').hidden = true; select('#workspace').hidden = false;
277
- select('#cloud-login-screen').hidden = true; select('#cloud-device-screen').hidden = true;
278
- select('#device-name').textContent = state.status.device;
279
- state.enabled = new Map(Object.entries(state.status.execution.grants || {}));
280
- await refreshThreads(); connectSocket();
281
- } catch (error) {
282
- if (error.code === 'STALE_WORKSPACE' || error.handled) return;
283
- if (state.cloudMode) showCloudDevices(error.message);
284
- else if (!state.loggedOut) { showPairing(); select('#pair-error').textContent = error.message; }
285
- }
286
- }
287
- function connectSocket() {
288
- state.socket?.close();
289
- const suffix = state.cloudMode && state.cloudDeviceId ? `?deviceId=${encodeURIComponent(state.cloudDeviceId)}` : '';
290
- const socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/socket${suffix}`);
291
- state.socket = socket;
292
- socket.addEventListener('open', () => {
293
- if (state.socket !== socket) return;
294
- state.reconnect = 0; setConnection(true);
295
- if (state.selected) subscribeThread(state.selected.id).catch(error => toast(error.message));
296
- refreshThreads().catch(() => {});
297
- });
298
- socket.addEventListener('message', async event => {
299
- if (state.socket !== socket) return;
300
- const message = JSON.parse(event.data);
301
- if (message.id && state.pending.has(message.id)) {
302
- const pending = state.pending.get(message.id); clearTimeout(pending.timer); state.pending.delete(message.id);
303
- if (message.error) pending.reject(Object.assign(new Error(message.error), { code:message.code })); else pending.resolve(message.result);
304
- return;
305
- }
306
- if (message.method === 'store/changed') scheduleRefresh();
307
- const params = message.params || {};
308
- if (params.threadId && params.threadId !== state.selected?.id) return;
309
- if (message.method === 'item/agentMessage/delta') {
310
- const current = state.liveMessages.get(params.itemId) || { id: params.itemId, role:'assistant', text:'', timestamp:Date.now() };
311
- current.text += params.delta || ''; state.liveMessages.set(params.itemId, current); renderMessages();
312
- }
313
- if (message.method === 'turn/started') { state.busy = true; state.turnId = params.turn.id; updateMode(); }
314
- if (message.method === 'turn/completed') { state.busy = false; state.turnId = null; updateMode(); scheduleRefresh(); }
315
- if (message.method === 'orbit/desktop' && state.status?.execution.mode === 'desktop') {
316
- state.desktopRuntime = params;
317
- if(params.threadId===state.selected?.id)state.goal=params.attached?params.goal||null:null;
318
- if (!params.attached) { state.enabled.delete(params.threadId); state.selectedSettings = null; }
319
- if (params.attached) mergeDesktopMessages(params.messages || []);
320
- state.turnId = params.attached ? params.turnId : null;
321
- state.busy = Boolean(state.turnId);
322
- state.approvals = new Map((params.attached ? params.approvals || [] : []).map(approval => [approval.requestId, approval]));
323
- renderMessages();renderApprovals();renderList();updateMode();
324
- }
325
- if (message.method === 'thread/tokenUsage/updated' && state.status?.execution.mode !== 'desktop') {
326
- const usage = params.tokenUsage || params.tokenUsageInfo;
327
- if (Number.isSafeInteger(usage?.last?.totalTokens) && usage.last.totalTokens >= 0) {
328
- state.contextUsage = {
329
- usedTokens: usage.last.totalTokens,
330
- windowTokens: Number.isSafeInteger(usage.modelContextWindow) && usage.modelContextWindow > 0 ? usage.modelContextWindow : null,
331
- cachedTokens: Number.isSafeInteger(usage.last.cachedInputTokens) ? usage.last.cachedInputTokens : null
332
- };
333
- renderContext();
334
- }
335
- }
336
- if (message.method === 'orbit/control-revoked') {
337
- state.enabled.delete(params.threadId);
338
- if (params.threadId === state.selected?.id) {
339
- state.selectedSettings = null;
340
- state.pendingModelChoice = null;
341
- state.busy = false; state.turnId = null;
342
- state.reopenModelAfterGrant = false;
343
- state.desktopRuntime = {threadId:params.threadId,attached:false,reason:params.reason,reasonCode:params.reasonCode};
344
- state.goal=null;renderGoalStatus();
345
- if (select('#model-dialog').open) select('#model-dialog').close();
346
- if (select('#control-dialog').open) select('#control-dialog').close();
347
- state.approvals.clear(); renderApprovals(); updateMode();
348
- }
349
- }
350
- if (message.method === 'orbit/settings-applied' && params.threadId === state.selected?.id) {
351
- state.selectedSettings = { model:params.model,effort:params.effort,pending:false };
352
- if (state.desktopRuntime?.attached) state.desktopRuntime = { ...state.desktopRuntime,model:params.model,effort:params.effort };
353
- renderModelStatus();
354
- toast('下一轮模型参数已应用。');
355
- }
356
- if (message.method === 'orbit/settings-error' && params.threadId === state.selected?.id) {
357
- state.selectedSettings = null;
358
- renderModelStatus();
359
- toast(params.error || '下一轮模型参数未应用,请重新选择。');
360
- }
361
- if (message.method === 'orbit/engine') {
362
- if (state.status?.execution) {
363
- state.status.execution.connected = Boolean(params.connected);
364
- if(params.desktopConnected!==undefined)state.status.execution.desktopConnected=Boolean(params.desktopConnected);
365
- else if(params.mode==='desktop')state.status.execution.desktopConnected=Boolean(params.connected);
366
- if(params.resumedConnected!==undefined)state.status.execution.resumedConnected=Boolean(params.resumedConnected);
367
- }
368
- if (!params.connected) {
369
- state.enabled.clear(); state.desktopRuntime = null; state.selectedSettings = null; state.pendingModelChoice = null;
370
- state.goal=null;renderGoalStatus();
371
- if (select('#model-dialog').open) select('#model-dialog').close();
372
- if (select('#control-dialog').open) select('#control-dialog').close();
373
- state.reopenModelAfterGrant = false;
374
- state.approvals.clear(); state.busy = false; state.turnId = null;
375
- renderApprovals(); renderMessages();
376
- }
377
- updateMode();
378
- }
379
- if(message.method==='thread/goal/updated'&&params.threadId===state.selected?.id){
380
- state.goal=params.goal||null;
381
- if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
382
- renderGoalStatus();
383
- }
384
- if(message.method==='thread/goal/cleared'&&params.threadId===state.selected?.id){
385
- state.goal=null;
386
- if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:null};
387
- renderGoalStatus();
388
- }
389
- if (message.method === 'orbit/approval') { state.approvals.set(params.requestId, params); renderApprovals(); }
390
- if (message.method === 'serverRequest/resolved') { state.approvals.delete(String(params.requestId)); renderApprovals(); }
391
- if (message.method === 'error') toast(params.error?.message || params.message || '执行发生错误,请查看会话状态。');
392
- });
393
- socket.addEventListener('close', () => {
394
- if (state.socket !== socket) return;
395
- setConnection(false);
396
- for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('连接中断,消息不会自动重发。请先检查会话记录。')); }
397
- state.pending.clear();
398
- state.pendingAttachments=[];
399
- scheduleReconnect();
400
- });
401
- socket.addEventListener('error', () => {});
402
- }
403
- function scheduleReconnect() {
404
- if (state.loggedOut) return;
405
- clearTimeout(reconnectTimer);
406
- reconnectTimer = setTimeout(async () => {
407
- try {
408
- if (state.cloudMode) await api('/api/cloud/connect',{});
409
- state.status = await api('/api/status');
410
- state.enabled = new Map(Object.entries(state.status.execution.grants || {}));
411
- connectSocket();
412
- } catch { scheduleReconnect(); }
413
- }, Math.min(15000, 1000 * 2 ** Math.min(4, state.reconnect++)));
414
- }
415
- async function subscribeThread(id) {
416
- clearTimeout(controlRenewTimer);
417
- const selectionVersion = state.selectionVersion;
418
- const attempt = ++desktopConnectionAttempt;
419
- desktopConnecting = true; updateMode();
420
- try {
421
- const desktop = state.status?.execution.mode === 'desktop';
422
- const eligible = desktop && state.selected?.id === id && !state.selected.archived && !String(state.selected.source).startsWith('{');
423
- let result = await rpc('thread.subscribe', { threadId:id, autoControl:eligible });
424
- if (id !== state.selected?.id || selectionVersion !== state.selectionVersion || attempt !== desktopConnectionAttempt) return;
425
- if (eligible && result.runtime?.attached && !result.control) {
426
- await rpc('thread.enable', {threadId:id,confirm:true});
427
- if (id !== state.selected?.id || selectionVersion !== state.selectionVersion || attempt !== desktopConnectionAttempt) return;
428
- result = await rpc('thread.subscribe', {threadId:id});
429
- }
430
- if (id !== state.selected?.id || selectionVersion !== state.selectionVersion || attempt !== desktopConnectionAttempt) return;
431
- if (result.control) state.enabled.set(id, result.control); else state.enabled.delete(id);
432
- state.desktopRuntime = result.runtime || null;
433
- state.selectedSettings = result.settings || null;
434
- if (result.runtime?.attached) mergeDesktopMessages(result.runtime.messages || []);
435
- state.turnId = result.turnId;
436
- state.busy = Boolean(result.turnId);
437
- state.approvals = new Map((result.approvals || []).map(approval => [approval.requestId, approval]));
438
- state.pendingAttachments=result.attachments||[];
439
- state.goal=result.runtime?.goal||null;
440
- renderComposerAttachments();
441
- if (desktop && result.control) {
442
- controlRenewTimer = setTimeout(() => {
443
- if (state.selected?.id === id && state.socket?.readyState === WebSocket.OPEN && state.desktopRuntime?.attached) {
444
- subscribeThread(id).catch(error => toast(error.message));
445
- }
446
- },Math.max(1000,result.control.expires - Date.now() - 120000));
447
- }
448
- renderMessages();renderApprovals();renderList();
449
- } catch (error) {
450
- if (id === state.selected?.id && selectionVersion === state.selectionVersion && attempt === desktopConnectionAttempt) {
451
- state.enabled.delete(id); state.selectedSettings = null; state.turnId = null; state.busy = false;
452
- state.desktopRuntime = {threadId:id,attached:false,reason:error.message,reasonCode:error.code};
453
- state.approvals.clear();renderApprovals();renderList();
454
- }
455
- throw error;
456
- } finally {
457
- if (attempt === desktopConnectionAttempt) { desktopConnecting = false; updateMode(); }
458
- }
459
- }
460
- function setConnection(connected) {
461
- select('#connection-pill').innerHTML = `<i class="tiny-dot"></i>${connected ? '原生记录已连接' : '连接已断开'}`;
462
- select('#connection-pill').classList.toggle('offline', !connected); updateMode();
463
- }
464
- function rpc(method, params) {
465
- if (state.socket?.readyState !== WebSocket.OPEN) return Promise.reject(new Error('实时连接尚未就绪。'));
466
- const id = crypto.randomUUID ? crypto.randomUUID() : [...crypto.getRandomValues(new Uint8Array(16))].map(value => value.toString(16).padStart(2, '0')).join('');
467
- return new Promise((resolve, reject) => {
468
- const timer = setTimeout(() => { state.pending.delete(id); reject(new Error('操作超时,不会自动重发。请检查会话状态。')); }, 45000);
469
- state.pending.set(id, { resolve, reject, timer });
470
- state.socket.send(JSON.stringify({ id, method, params }));
471
- });
472
- }
473
- function scheduleRefresh() {
474
- if (refreshTimer) return;
475
- refreshTimer = setTimeout(async () => {
476
- refreshTimer = null;
477
- try { await refreshThreads(); if (state.selected) await loadMessages(false); } catch {}
478
- }, 800);
479
- }
480
- function renderProjects() {
481
- const projects = new Map();
482
- for (const thread of state.threads) {
483
- const project = projects.get(thread.projectId) || { name: thread.projectName, count: 0 }; project.count++; projects.set(thread.projectId, project);
484
- }
485
- 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('');
486
- select('#nav-count').textContent = state.threads.length;
487
- }
488
- function filteredThreads() {
489
- return state.threads.filter(thread => (!state.project || thread.projectId === state.project)
490
- && (state.filter !== 'archived' || thread.archived)
491
- && (state.filter !== 'pinned' || thread.pinned)
492
- && `${thread.title} ${thread.projectName} ${thread.preview}`.toLowerCase().includes(state.search.toLowerCase()))
493
- .sort((left, right) => Number(right.pinned) - Number(left.pinned) || right.updatedAt - left.updatedAt);
494
- }
495
- function threadAvailabilityBadge(thread) {
496
- if(thread.archived)return '<span class="tag archived">已归档</span>';
497
- if(String(thread.source).startsWith('{'))return '<span class="tag">子代理</span>';
498
- return '<span class="tag">原会话</span>';
499
- }
500
- function renderList() {
501
- const threads = filteredThreads();
502
- select('#thread-count').textContent = threads.length;
503
- select('#list-title').textContent = state.project ? (state.threads.find(thread => thread.projectId === state.project)?.projectName || '项目会话') : state.filter === 'archived' ? '已归档' : state.filter === 'pinned' ? '已置顶' : '所有会话';
504
- select('#list-footer-text').textContent = `${state.threads.length} 条原生记录 · 自动同步`;
505
- 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>' : ''}${threadAvailabilityBadge(thread)}${thread.archived ? '<span class="tag archived">已归档</span>' : ''}<span class="tag">${escapeHtml(thread.provider || 'Codex')}</span></div></button>`).join('') || '<div class="empty">没有找到匹配的会话。<br>试试其他关键词或筛选条件。</div>';
506
- document.querySelectorAll('[data-filter]').forEach(button => {
507
- const active = button.dataset.filter === state.filter;
508
- button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active));
509
- });
510
- }
511
- function renderWelcome() {
512
- const projects = new Set(state.threads.map(thread => thread.projectId));
513
- const archived = state.threads.filter(thread => thread.archived).length;
514
- 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>`;
515
- select('#welcome .hero-status').innerHTML=`<i class="tiny-dot"></i> 已连接电脑 · 打开原会话即可继续`;
516
- select('#welcome .honesty-note span').textContent='点击任意普通原会话,即可从当前上下文继续;不会新建会话。计划和目标使用 Codex 原生功能。';
517
- }
518
- async function openThread(id) {
519
- const thread = state.threads.find(entry => entry.id === id); if (!thread) return;
520
- if (select('#model-dialog').open) select('#model-dialog').close();
521
- const selectionVersion = ++state.selectionVersion;
522
- state.pendingModelChoice = null;
523
- state.pendingAttachments=[];
524
- select('#turn-mode').value='default';
525
- state.historyStatus = 'loading'; state.historyError = '';
526
- 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;
527
- state.goal=null;
528
- select('#welcome').hidden = true; select('#chat').hidden = false; select('#workspace').classList.add('chat-open');
529
- select('#chat-title').textContent = thread.title;
530
- select('#chat-subtitle').textContent = `${thread.projectName} / ${thread.provider || 'Codex'}${thread.model ? ` · ${thread.model}` : ''}`;
531
- 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>`;
532
- select('#chat-details').hidden = true;
533
- select('#messages').replaceChildren(); renderMessages();
534
- renderComposerAttachments();renderList();updateMode();renderApprovals();
535
- subscribeThread(id).catch(error => { if (selectionVersion === state.selectionVersion) toast(error.message); });
536
- try { await loadMessages(true); } catch (error) { if (selectionVersion === state.selectionVersion) toast(error.message); }
537
- }
538
- async function loadMessages(initial, older = false) {
539
- const id = state.selected?.id; if (!id) return;
540
- const selectionVersion = state.selectionVersion;
541
- if (older && state.paging === id) return;
542
- if (older) state.paging = id;
543
- if (initial || state.historyStatus === 'error') { state.historyStatus = 'loading'; state.historyError = ''; renderMessages(); }
544
- const cursor = state.cursor;
545
- let data;
546
- try { data = await api(`/api/threads/${encodeURIComponent(id)}/messages${older ? `?before=${encodeURIComponent(cursor)}` : ''}`, undefined, AbortSignal.timeout(15000)); }
547
- catch (error) {
548
- if (id !== state.selected?.id || selectionVersion !== state.selectionVersion) return;
549
- state.historyStatus = 'error';
550
- state.historyError = error.name === 'TimeoutError' ? '读取原生历史超时,请点击刷新重试。' : error.message;
551
- renderMessages();
552
- throw new Error(state.historyError);
553
- }
554
- finally { if (state.paging === id) state.paging = null; }
555
- if (id !== state.selected?.id || selectionVersion !== state.selectionVersion) return;
556
- state.historyStatus = 'loaded'; state.historyError = '';
557
- if (older) state.olderMessages = [...data.messages, ...state.olderMessages];
558
- else state.baseMessages = initial ? data.messages : [...new Map([...state.baseMessages, ...data.messages].map(message => [message.id, message])).values()];
559
- if (initial || older) state.cursor = data.nextCursor;
560
- const persistedIds = new Set(data.messages.map(message => message.id));
561
- state.desktopMessages = state.desktopMessages.filter(message => !persistedIds.has(message.id));
562
- for (const message of data.messages) state.liveMessages.delete(message.id);
563
- renderMessages(initial, older);
564
- if (data.truncated && initial) toast('此旧格式会话较大,目前展示末尾 4 MB 范围内的记录。');
565
- }
566
- function renderMessages(scroll = false, older = false) {
567
- const container = select('#messages');
568
- const previousHeight = container.scrollHeight;
569
- const previousTop = container.scrollTop;
570
- const nearBottom = previousHeight - previousTop - container.clientHeight < 120;
571
- const messages = [...new Map([...state.olderMessages, ...state.baseMessages, ...state.desktopMessages, ...state.liveMessages.values()].map(message => [message.id, message])).values()];
572
- let loadMore = select('#load-more');
573
- if (state.cursor !== null && !loadMore) {
574
- loadMore = document.createElement('button'); loadMore.id = 'load-more'; loadMore.className = 'load-more'; loadMore.textContent = '加载更早的记录 ↑';
575
- } else if (state.cursor === null && loadMore) { loadMore.remove(); loadMore = null; }
576
- if (loadMore && container.firstChild !== loadMore) container.prepend(loadMore);
577
-
578
- const existing = new Map([...container.querySelectorAll(':scope > [data-message-id]')].map(node => [node.dataset.messageId, node]));
579
- let empty = container.querySelector(':scope > .empty');
580
- container.querySelectorAll(':scope > .empty').forEach(node => { if (messages.length || node !== empty) node.remove(); });
581
- let reference = loadMore?.nextSibling || container.firstChild;
582
- for (const message of messages) {
583
- const id = String(message.id);
584
- const activity = message.role === 'activity';
585
- let node = existing.get(id);
586
- if (node && node.matches('details') !== activity) { if (node === reference) reference = node.nextSibling; node.remove(); node = null; }
587
- if (!node) {
588
- if (activity) {
589
- node = document.createElement('details'); node.className = 'activity-message';
590
- 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>';
591
- } else {
592
- 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>';
593
- node.querySelector('[data-copy-message]').innerHTML = icon('copy');
594
- }
595
- node.dataset.messageId = id;
596
- }
597
- existing.delete(id);
598
- const text = String(message.text || '');
599
- 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]);
600
- if (node.dataset.messageRenderKey !== renderKey) {
601
- if (activity) {
602
- renderActivityMessage(node, message);
603
- } else {
604
- node.querySelector('.message-body').innerHTML = markdown(text);
605
- const attachmentContainer = node.querySelector('.message-attachments');
606
- attachmentContainer.innerHTML = (message.attachments || []).map(attachment => `<span class="attachment-label">${icon(attachment.type?.includes('image') ? 'image' : 'file')}<span>${escapeHtml(attachment.name || '附件')}</span></span>`).join('');
607
- node.querySelectorAll('.message-body pre').forEach(pre => {
608
- const button = document.createElement('button');
609
- button.type = 'button'; button.className = 'copy-code'; button.dataset.copyCode = 'true';
610
- button.setAttribute('aria-label', '复制代码'); button.title = '复制代码'; button.innerHTML = icon('copy');
611
- pre.prepend(button);
612
- });
613
- }
614
- node.dataset.messageRenderKey = renderKey;
615
- node.querySelectorAll('a').forEach(link => { link.target = '_blank'; link.rel = 'noopener noreferrer'; });
616
- }
617
- if (!activity) {
618
- node.className = `message ${message.role === 'user' ? 'user' : 'assistant'}`;
619
- node.querySelector('.message-avatar').textContent = message.role === 'user' ? '你' : '✳';
620
- node.querySelector('.message-head strong').textContent = message.role === 'user' ? '你' : 'Codex';
621
- const time = message.timestamp ? new Date(message.timestamp).toLocaleString('zh-CN', { hour:'2-digit', minute:'2-digit', month:'2-digit', day:'2-digit' }) : '';
622
- if (node.querySelector('time').textContent !== time) node.querySelector('time').textContent = time;
623
- node.dataset.messageText = text;
624
- }
625
- if (node !== reference) container.insertBefore(node, reference);
626
- reference = node.nextSibling;
627
- }
628
- for (const node of existing.values()) node.remove();
629
- if (!messages.length) {
630
- if (!empty) { empty = document.createElement('div'); empty.className = 'empty'; empty.setAttribute('role', 'status'); container.append(empty); }
631
- const text = state.historyStatus === 'loading' ? '正在读取原生上下文…' : state.historyStatus === 'error' ? state.historyError : '这条会话暂无可展示的消息。';
632
- if (empty.textContent !== text) empty.textContent = text;
633
- }
634
- if (older) container.scrollTop = previousTop + container.scrollHeight - previousHeight;
635
- else if (scroll || nearBottom) container.scrollTop = container.scrollHeight;
636
- else container.scrollTop = previousTop;
637
- }
638
- function formatDuration(durationMs) {
639
- if (!Number.isFinite(durationMs)) return '';
640
- return durationMs >= 1000 ? `${(durationMs / 1000).toFixed(durationMs >= 10000 ? 0 : 1)} 秒` : `${Math.round(durationMs)} 毫秒`;
641
- }
642
- function displayValue(value, maxLength = 60000) {
643
- if (value === null || value === undefined) return '';
644
- let text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
645
- return String(text || '').slice(0, maxLength);
646
- }
647
- function activityStatusLabel(status) {
648
- return ({ completed:'已完成', inProgress:'执行中', running:'执行中', failed:'失败', declined:'已拒绝', cancelled:'已取消' })[status] || '';
649
- }
650
- function diffMarkup(diff) {
651
- return String(diff || '').split('\n').map(line => {
652
- const kind = line.startsWith('+') && !line.startsWith('+++') ? 'diff-added' : line.startsWith('-') && !line.startsWith('---') ? 'diff-removed' : '';
653
- return `<span class="${kind}">${escapeHtml(line) || '&nbsp;'}</span>`;
654
- }).join('\n');
655
- }
656
- function renderActivityMessage(node, message) {
657
- const kind = message.kind || 'activity';
658
- const labels = { command:'命令执行', fileChange:'文件变更', tool:'工具调用', webSearch:'网页搜索', imageView:'查看图像' };
659
- const symbols = { command:'terminal', fileChange:'file', tool:'devices', webSearch:'searchWeb', imageView:'image' };
660
- node.dataset.kind = kind;
661
- node.querySelector('.activity-symbol').innerHTML = icon(symbols[kind] || 'info');
662
- node.querySelector('.activity-label').textContent = labels[kind] || '执行记录';
663
- node.querySelector('.activity-summary').textContent = String(message.text || '').slice(0, 180);
664
- const meta = [activityStatusLabel(message.status), message.exitCode !== null && message.exitCode !== undefined ? `退出码 ${message.exitCode}` : '', formatDuration(message.durationMs)].filter(Boolean);
665
- node.querySelector('.activity-meta').textContent = meta.join(' · ');
666
- const details = [];
667
- if (kind === 'command') {
668
- details.push(`<pre class="activity-command"><code>${escapeHtml(message.text || '')}</code></pre>`);
669
- if (message.cwd) details.push(`<div class="activity-path">${escapeHtml(message.cwd)}</div>`);
670
- if (message.output) details.push(`<div class="activity-section-label">输出</div><pre class="activity-output"><code>${escapeHtml(message.output)}</code></pre>`);
671
- } else if (kind === 'fileChange') {
672
- for (const change of message.changes || []) {
673
- 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>`);
674
- }
675
- } else if (kind === 'tool') {
676
- if (message.arguments) details.push(`<div class="activity-section-label">参数</div><pre class="activity-output"><code>${escapeHtml(displayValue(message.arguments, 30000))}</code></pre>`);
677
- if (message.output) details.push(`<div class="activity-section-label">${message.status === 'failed' ? '错误' : '结果'}</div><pre class="activity-output"><code>${escapeHtml(message.output)}</code></pre>`);
678
- } else if (message.text) {
679
- details.push(`<pre class="activity-output"><code>${escapeHtml(message.text)}</code></pre>`);
680
- }
681
- node.querySelector('.activity-content').innerHTML = details.join('') || '<p class="activity-empty">没有可展示的详细信息。</p>';
682
- }
683
- async function copyText(text, success) {
684
- try {
685
- if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable');
686
- await navigator.clipboard.writeText(text);
687
- toast(success);
688
- } catch { toast('浏览器不允许访问剪贴板。'); }
689
- }
690
- function selectedModelSettings() {
691
- if (state.status?.execution.mode === 'desktop' && state.selectedSettings?.pending) {
692
- return { model:state.selectedSettings.model,effort:state.selectedSettings.effort };
693
- }
694
- return state.status?.execution.mode === 'desktop'
695
- ? { model: state.desktopRuntime?.model || state.selected?.model || null, effort: state.desktopRuntime?.effort ?? null }
696
- : { model: state.selectedSettings?.model || state.selected?.model || null, effort: state.selectedSettings?.effort ?? null };
697
- }
698
- function formatTokens(value) {
699
- if (value >= 1000000) return `${(value / 1000000).toFixed(1)}m`;
700
- if (value >= 1000) return `${Math.round(value / 1000)}k`;
701
- return String(value);
702
- }
703
- function renderContext() {
704
- const context = state.status?.execution.mode === 'desktop' ? state.desktopRuntime?.context : state.contextUsage;
705
- const meter = select('#context-meter');
706
- const label = select('#context-label');
707
- const track = meter.querySelector('.context-track');
708
- if (!context || !Number.isSafeInteger(context.usedTokens)) {
709
- label.textContent = '暂无用量'; meter.title = '当前执行器没有提供准确的上下文用量';
710
- track.hidden = true; meter.classList.remove('warning', 'critical'); return;
711
- }
712
- const windowTokens = context.windowTokens;
713
- const percentage = windowTokens ? Math.round(context.usedTokens / windowTokens * 100) : null;
714
- label.textContent = `${formatTokens(context.usedTokens)}${windowTokens ? ` / ${formatTokens(windowTokens)} · ${percentage}%` : ' 已用'}`;
715
- meter.title = `已用 ${context.usedTokens.toLocaleString('zh-CN')} token${windowTokens ? ` / 上下文窗口 ${windowTokens.toLocaleString('zh-CN')} token` : ''}${context.cachedTokens != null ? `;缓存输入 ${context.cachedTokens.toLocaleString('zh-CN')} token` : ''}`;
716
- track.hidden = !windowTokens;
717
- if (windowTokens) {
718
- track.setAttribute('aria-valuenow', String(Math.min(100, Math.max(0, percentage))));
719
- select('#context-fill').style.width = `${Math.min(100, Math.max(0, percentage))}%`;
720
- meter.classList.toggle('warning', percentage >= 70);
721
- meter.classList.toggle('critical', percentage >= 90);
722
- }
723
- }
724
- function renderModelStatus() {
725
- const { model, effort } = selectedModelSettings();
726
- const option = state.models.find(entry => entry.model === model);
727
- select('#model-settings-label').textContent = model ? `${option?.displayName || model}${effort ? ` · ${effort}` : ''}${state.selectedSettings?.pending ? ' · 排队中' : ''}` : '模型信息待载入';
728
- const desktop = state.status?.execution.mode === 'desktop';
729
- select('#model-settings-button').disabled = state.socket?.readyState !== WebSocket.OPEN
730
- || state.settingsBusy || (desktop && (desktopConnecting || !state.desktopRuntime?.attached || !state.desktopRuntime.model));
731
- select('#model-settings-button').title = desktop && !state.desktopRuntime?.attached
732
- ? '先在 Codex 桌面打开这条原会话,并重新连接'
733
- : '选择模型和推理级别';
734
- renderContext();
735
- }
736
- function renderEffortChoices(preferredEffort = null) {
737
- const model = state.models.find(entry => entry.model === select('#model-select').value);
738
- const available = model?.supportedReasoningEfforts || [];
739
- select('#effort-select').innerHTML = '<option value="">默认</option>' + available.map(effort => `<option value="${escapeHtml(effort)}">${escapeHtml(effort)}</option>`).join('');
740
- select('#effort-select').value = preferredEffort && available.includes(preferredEffort) ? preferredEffort : model?.defaultReasoningEffort && available.includes(model.defaultReasoningEffort) ? model.defaultReasoningEffort : '';
741
- }
742
- function updateMode() {
743
- if (!state.selected) return;
744
- const thread = state.selected;
745
- if (state.enabled.get(thread.id)?.expires <= Date.now()) state.enabled.delete(thread.id);
746
- const control = state.enabled.get(thread.id);
747
- const connected = state.socket?.readyState === WebSocket.OPEN;
748
- const desktop = state.status?.execution.mode === 'desktop';
749
- const desktopConnected = desktop && state.status.execution.connected;
750
- const attached = desktopConnected && state.desktopRuntime?.attached === true;
751
- const sameIdResume = attached && state.desktopRuntime?.mode === 'same-id-resume';
752
- const appServerExecution=['same-id-resume','endpoint','cold-resume'].includes(state.desktopRuntime?.mode)
753
- || ['endpoint','cold-resume'].includes(state.status?.execution.mode);
754
- const nativePlanAvailable=Boolean(control&&(desktop?attached:appServerExecution));
755
- const blocked = thread.archived || String(thread.source).startsWith('{') || desktopConnecting;
756
- select('#enable-control').disabled = Boolean(blocked || control);
757
- select('#enable-control').textContent = control ? '已连接' : desktopConnecting && desktop ? '连接中…' : thread.archived ? '已归档' : String(thread.source).startsWith('{') ? '请打开父会话' : '继续会话';
758
- select('#mode-banner').classList.toggle('controlled', Boolean(control));
759
- const banner = desktop
760
- ? desktopConnecting ? '正在连接原会话…'
761
- : !attached ? state.desktopRuntime?.reason || '正在连接原会话…'
762
- : control ? sameIdResume ? '已连接;在原会话中使用 Codex 原生功能。' : '已连接;操作在电脑上的 Codex 中执行。'
763
- : '正在连接原会话…'
764
- : control ? '已连接;继续在原会话中工作。'
765
- : '连接后直接在这条原会话中继续。';
766
- const modeBanner = select('#mode-banner');
767
- if (!modeBanner.querySelector('.mode-copy')) modeBanner.innerHTML = `${icon('shield')}<span class="mode-copy"></span><button type="button" class="button small" id="reconnect-desktop">重新连接</button>`;
768
- modeBanner.querySelector('.mode-copy').textContent = banner;
769
- const retryButton = select('#reconnect-desktop');
770
- retryButton.hidden = !desktop || attached && !desktopConnecting;
771
- retryButton.disabled = !connected || desktopConnecting;
772
- retryButton.textContent = desktopConnecting ? '连接中…' : '重新连接';
773
- select('#composer-mode').innerHTML = `${icon('shield')}${control ? '原会话' : '连接中'}`;
774
- const waitingForTurn = state.busy && (!desktop || !state.turnId || sameIdResume);
775
- select('#send-message').disabled = !control || !connected || desktop && (!attached || desktopConnecting) || waitingForTurn || state.settingsBusy || !select('#message-input').value.trim();
776
- select('#send-message').title = state.busy && desktop && !sameIdResume ? '追加指令到当前桌面任务' : '沿用原会话发送';
777
- select('#stop-turn').hidden = !state.busy || !state.turnId || !control;
778
- select('#message-input').placeholder = state.busy && desktop && !sameIdResume ? '追加指令给电脑上正在运行的任务…' : state.busy && sameIdResume ? '原会话正在执行,请等待本轮完成…' : control ? '沿着原来的上下文继续…' : desktop ? '连接后在原会话中继续…' : '继续这条原会话…';
779
- select('#turn-mode').disabled=!nativePlanAvailable||desktopConnecting||state.busy;
780
- select('#attach-files').disabled=!control||!desktop||sameIdResume||desktopConnecting||state.pendingAttachments.length>=5||state.settingsBusy;
781
- select('#file-input').disabled=select('#attach-files').disabled;
782
- select('#turn-mode').title=state.busy?'当前任务中无法切换模式。':!nativePlanAvailable?'当前执行通道不支持原生协作模式。':'通过 Codex 原生协作模式控制下一轮。';
783
- const mode=select('#turn-mode').value;
784
- select('#composer-note').textContent=sameIdResume
785
- ? state.busy ? '原会话正在执行;请等待当前轮次结束。'
786
- : mode==='plan'?'Codex 原生计划模式;只作用于本轮。'
787
- :'消息和目标都直接作用于这条原会话。'
788
- : mode==='plan'
789
- ? '使用 Codex 原生计划模式;计划完成后按原生流程继续。'
790
- : control ? '消息继续发送到这条原会话;不会创建新会话。'
791
- : '连接原会话后即可继续。';
792
- renderModelStatus();
793
- renderGoalStatus();
794
- }
795
- const goalStatusNames={active:'进行中',paused:'已暂停',blocked:'受阻',usageLimited:'用量受限',budgetLimited:'预算已用尽',complete:'已完成'};
796
- function nativeGoalWritable() {
797
- const runtime=state.desktopRuntime;
798
- return Boolean(state.selected&&state.enabled.has(state.selected.id)
799
- &&(runtime?.mode==='same-id-resume'||state.status?.execution.mode!=='desktop'));
800
- }
801
- function renderGoalStatus() {
802
- const button=select('#goal-button');
803
- if(!button)return;
804
- const goal=state.goal||state.desktopRuntime?.goal||null;
805
- button.textContent=goal?`目标 · ${goalStatusNames[goal.status]||goal.status}`:'设置目标';
806
- button.classList.toggle('active',Boolean(goal));
807
- button.disabled=!state.selected||state.selected.archived||String(state.selected.source).startsWith('{');
808
- button.title='管理这条原会话中的 Codex 原生持续目标。';
809
- }
810
- function openGoalDialog() {
811
- if(!state.selected)return;
812
- const goal=state.goal||state.desktopRuntime?.goal||null;
813
- const writable=nativeGoalWritable();
814
- select('#goal-objective').value=goal?.objective||'';
815
- select('#goal-token-budget').value=goal?.tokenBudget??'';
816
- select('#goal-current-status').textContent=goal
817
- ? `${goalStatusNames[goal.status]||goal.status} · 已用 ${goal.tokensUsed||0} tokens${goal.tokenBudget?` / ${goal.tokenBudget}`:''}`
818
- : '尚未设置持续目标。';
819
- const desktopOwned=state.status?.execution.mode==='desktop'&&state.desktopRuntime?.mode!=='same-id-resume';
820
- select('#goal-description').textContent=desktopOwned
821
- ? '目标状态与 Codex 桌面同步。目标编辑暂不可用,请在本机 Codex 中管理。'
822
- : writable
823
- ? '使用 Codex 原生 Goal;目标会跨轮次保留,不会转换成提示词。'
824
- : '连接原会话后,可在这里创建和管理 Codex 原生持续目标。';
825
- select('#goal-objective').disabled=!writable;
826
- select('#goal-token-budget').disabled=!writable;
827
- select('#save-goal').disabled=!writable;
828
- select('#pause-goal').disabled=!writable||!goal;
829
- select('#clear-goal').disabled=!writable||!goal;
830
- select('#pause-goal').textContent=goal?.status==='paused'?'继续目标':'暂停目标';
831
- select('#save-goal').textContent=goal?'更新目标':'开始追求目标';
832
- select('#goal-error').textContent='';
833
- select('#goal-dialog').showModal();
834
- }
835
- async function updateGoalStatus(status) {
836
- if(!state.selected||!nativeGoalWritable())return;
837
- const id=state.selected.id,selectionVersion=state.selectionVersion;
838
- try{
839
- const result=await rpc('thread.goal.status',{threadId:id,status});
840
- if(selectionVersion!==state.selectionVersion||id!==state.selected?.id)return;
841
- state.goal=result.goal||null;
842
- if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
843
- select('#goal-current-status').textContent=state.goal?`${goalStatusNames[state.goal.status]||state.goal.status} · 已用 ${state.goal.tokensUsed||0} tokens${state.goal.tokenBudget?` / ${state.goal.tokenBudget}`:''}`:'尚未设置持续目标。';
844
- renderGoalStatus();toast(status==='paused'?'已暂停原生目标。':'已继续原生目标。');
845
- }catch(error){select('#goal-error').textContent=error.message;}
846
- }
847
- async function clearGoal() {
848
- if(!state.selected||!nativeGoalWritable())return;
849
- try{
850
- await rpc('thread.goal.clear',{threadId:state.selected.id});
851
- state.goal=null;
852
- if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:null};
853
- select('#goal-dialog').close();renderGoalStatus();toast('已清除原生目标。');
854
- }catch(error){select('#goal-error').textContent=error.message;}
855
- }
856
- function renderComposerAttachments() {
857
- select('#composer-attachments').innerHTML=state.pendingAttachments.map(file=>
858
- `<span class="composer-attachment" data-attachment="${escapeHtml(file.id)}">${icon(file.mimeType?.startsWith('image/')?'image':'file')}<span>${escapeHtml(file.label)} · ${escapeHtml((file.size/1024).toFixed(file.size<1024*1024?0:1))} KB</span><button type="button" data-remove-attachment="${escapeHtml(file.id)}" aria-label="移除 ${escapeHtml(file.label)}">×</button></span>`
859
- ).join('');
860
- }
861
-
862
- async function uploadFiles(fileList) {
863
- if(!state.selected||!state.enabled.has(state.selected.id)||!state.desktopRuntime?.attached)return toast('请先连接并接管原 Codex 会话。');
864
- const files=[...fileList];
865
- if(state.pendingAttachments.length+files.length>5)return toast('单条消息最多添加 5 个文件。');
866
- for(const file of files)if(file.size===0||file.size>8*1024*1024)return toast(`“${file.name}”为空或超过 8 MiB 限制。`);
867
- const threadId=state.selected.id,selectionVersion=state.selectionVersion;
868
- const button=select('#attach-files');
869
- button.disabled=true;
870
- try{
871
- for(const file of files){
872
- const headers={'Content-Type':file.type||'application/octet-stream'};
873
- if(state.cloudMode&&state.cloudDeviceId)headers['X-Orbit-Device-Id']=state.cloudDeviceId;
874
- const response=await fetch(`/api/threads/${encodeURIComponent(threadId)}/attachments?name=${encodeURIComponent(file.name)}`,{
875
- method:'POST',headers,body:file,credentials:'same-origin',signal:AbortSignal.timeout(30000)
876
- });
877
- const result=await response.json().catch(()=>({}));
878
- if(!response.ok)throw new Error(result.error||'文件上传失败。');
879
- if(threadId!==state.selected?.id||selectionVersion!==state.selectionVersion){
880
- await api(`/api/threads/${encodeURIComponent(threadId)}/attachments/remove`,{attachmentId:result.id});
881
- return;
882
- }
883
- state.pendingAttachments.push(result);
884
- renderComposerAttachments();updateMode();
885
- }
886
- toast(files.length===1?'文件已附加到待发送消息。':`已附加 ${files.length} 个文件。`);
887
- }catch(error){toast(error.name==='TimeoutError'?'文件上传超时,请确认网络后重试。':error.message)}
888
- finally{button.disabled=false;select('#file-input').value='';updateMode()}
889
- }
890
-
891
- async function browseProjectFiles(relativePath='') {
892
- if(!state.selected)return;
893
- const threadId=state.selected.id,selectionVersion=state.selectionVersion;
894
- const list=select('#file-browser-list');
895
- select('#file-browser-error').textContent='';
896
- list.innerHTML='<p class="file-browser-empty">正在读取项目文件…</p>';
897
- try{
898
- const query=relativePath?`?path=${encodeURIComponent(relativePath)}`:'';
899
- const result=await api(`/api/threads/${encodeURIComponent(threadId)}/files${query}`);
900
- if(threadId!==state.selected?.id||selectionVersion!==state.selectionVersion)return;
901
- select('#file-browser-path').textContent=result.path||'项目根目录';
902
- const rows=[];
903
- if(result.path){
904
- const parent=result.path.split(/[\\/]/).slice(0,-1).join('/');
905
- rows.push(`<button class="file-browser-entry file-browser-parent" type="button" data-file-directory="${escapeHtml(parent)}"><span>↰</span><strong>上一级</strong></button>`);
906
- }
907
- for(const entry of result.entries||[]){
908
- rows.push(entry.type==='directory'
909
- ? `<button class="file-browser-entry" type="button" data-file-directory="${escapeHtml(entry.path)}"><span>▸</span><strong>${escapeHtml(entry.name)}</strong></button>`
910
- : `<button class="file-browser-entry" type="button" data-file-download="${escapeHtml(entry.path)}"><span>${icon('file')}</span><strong>${escapeHtml(entry.name)}</strong><small>${escapeHtml((entry.size/1024).toFixed(entry.size<1024*1024?0:1))} KB</small></button>`);
911
- }
912
- if(result.truncated)rows.push('<p class="file-browser-empty">列表已达到 1000 项上限,请打开子文件夹细看。</p>');
913
- list.innerHTML=rows.join('')||'<p class="file-browser-empty">此文件夹为空。</p>';
914
- }catch(error){
915
- list.replaceChildren();
916
- select('#file-browser-error').textContent=error.message;
917
- }
918
- }
919
-
920
- async function downloadProjectFile(relativePath){
921
- if(!state.selected)return;
922
- const threadId=state.selected.id;
923
- const headers={};
924
- if(state.cloudMode&&state.cloudDeviceId)headers['X-Orbit-Device-Id']=state.cloudDeviceId;
925
- try{
926
- const response=await fetch(`/api/threads/${encodeURIComponent(threadId)}/file?path=${encodeURIComponent(relativePath)}`,{
927
- headers,credentials:'same-origin',cache:'no-store',signal:AbortSignal.timeout(35000)
928
- });
929
- if(!response.ok){
930
- const result=await response.json().catch(()=>({}));
931
- if(response.status===401&&state.cloudMode){showCloudLogin(result.error);return}
932
- throw new Error(result.error||'无法下载此项目文件。');
933
- }
934
- const disposition=response.headers.get('content-disposition')||'';
935
- const encoded=disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
936
- const filename=encoded?decodeURIComponent(encoded):relativePath.split(/[\\/]/).at(-1)||'project-file';
937
- const url=URL.createObjectURL(await response.blob());
938
- const anchor=document.createElement('a');
939
- anchor.href=url;anchor.download=filename;anchor.click();
940
- setTimeout(()=>URL.revokeObjectURL(url),30000);
941
- }catch(error){toast(error.name==='TimeoutError'?'项目文件下载超时。':error.message)}
942
- }
943
-
944
- function renderApprovals() {
945
- 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('');
946
- }
947
-
948
- function renderDevices() {
949
- const status = state.status;
950
- const url = status.connectionUrls.find(address => !address.includes('127.0.0.1')) || status.connectionUrls[0];
951
- const desktop = status.execution.mode === 'desktop';
952
- const desktopConnected = status.execution.desktopConnected ?? status.execution.connected;
953
- const resumedConnected = Boolean(status.execution.resumedConnected);
954
- const executionStatus = desktop
955
- ? desktopConnected ? 'Codex 桌面 IPC 已连接'
956
- : resumedConnected ? 'Codex App Server 原 ID 接续'
957
- : '等待本机 Codex 执行器'
958
- : status.execution.mode === 'endpoint' ? 'App Server 端点' : '独立执行器';
959
- const hotStatus = desktop && desktopConnected
960
- ? status.desktopHotAttachVerified ? '已发现实时会话' : '支持自动热接管'
961
- : desktop && resumedConnected ? '原 ID 接续中' : '打开会话时自动连接';
962
- 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>`;
963
- const cloudRow = select('#devices-view .capability-row:last-of-type');
964
- cloudRow.querySelector('span:first-child').textContent = '多用户云中继 / 设备管理后台';
965
- cloudRow.querySelector('span:last-child').textContent = state.cloudMode ? '已连接云端账号' : '可独立部署';
966
- cloudRow.querySelector('span:last-child').classList.toggle('pending', !state.cloudMode);
967
- select('#copy-url').onclick = async () => { try { await navigator.clipboard.writeText(url); toast('连接地址已复制。'); } catch { toast('浏览器不允许复制,请手动复制地址。'); } };
968
- select('#show-qr').onclick = async () => {
969
- if (state.cloudMode) return;
970
- try {
971
- const data = await api('/api/pair/qr', { url });
972
- select('#qr-container').innerHTML = `<img src="${escapeHtml(data.image)}" alt="敏感的设备配对二维码"><p>${escapeHtml(data.warning)}</p>`;
973
- select('#qr-container').hidden = false;
974
- } catch (error) { toast(error.message); }
975
- };
976
- if (state.cloudMode) {
977
- const card = select('#show-qr').closest('section');
978
- card.querySelector('p').textContent = '手机打开工作台,登录同一 Orbit 账号并选择这台电脑,即可继续原有会话,无需扫码或输入密钥。';
979
- select('#show-qr').hidden = true;
980
- }
981
- }
982
-
983
- function closeNav() { select('#workspace').classList.remove('nav-open'); select('#scrim').hidden = true; }
984
- async function setView(view) {
985
- state.view = view; closeNav();
986
- state.pendingModelChoice = null;
987
- if (select('#model-dialog').open) select('#model-dialog').close();
988
- const devices = view === 'devices';
989
- select('#sessions-view').hidden = devices; select('#devices-view').hidden = !devices;
990
- document.querySelectorAll('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === view));
991
- select('#breadcrumb-current').textContent = devices ? '设备与连接' : view === 'archived' ? '已归档' : '所有会话';
992
- if (devices) { try { state.status = await api('/api/status'); renderDevices(); } catch (error) { toast(error.message); } return; }
993
- state.project = null; state.filter = view === 'archived' ? 'archived' : 'all';
994
- state.selected = null; select('#workspace').classList.remove('chat-open');
995
- select('#welcome').hidden = false; select('#chat').hidden = true;
996
- renderProjects(); renderList(); renderWelcome();
997
- }
998
-
999
- decorate();
1000
- select('#pair-form').addEventListener('submit', async event => {
1001
- event.preventDefault();
1002
- const button = event.submitter; button.disabled = true; select('#pair-error').textContent = '';
1003
- try { await api('/api/pair', { code: select('#pair-code').value }); select('#pair-code').value = ''; await boot(); }
1004
- catch (error) { select('#pair-error').textContent = error.message; }
1005
- finally { button.disabled = false; }
1006
- });
1007
- select('#cloud-login-form').addEventListener('submit',async event=>{
1008
- event.preventDefault();
1009
- const button=event.submitter;button.disabled=true;select('#cloud-login-error').textContent='';
1010
- try{
1011
- await api('/api/cloud/login',{username:select('#cloud-username').value,password:select('#cloud-password').value});
1012
- select('#cloud-password').value='';
1013
- await boot();
1014
- }catch(error){select('#cloud-login-error').textContent=error.message}
1015
- finally{button.disabled=false}
1016
- });
1017
- select('#cloud-show-login').onclick = () => setCloudAuthMode(false);
1018
- select('#cloud-show-register').onclick = () => setCloudAuthMode(true);
1019
- select('#cloud-register-form').addEventListener('submit', async event => {
1020
- event.preventDefault();
1021
- const version = cloudAuthVersion;
1022
- const button = event.submitter;
1023
- select('#cloud-register-error').textContent = '';
1024
- if (select('#register-password').value !== select('#register-password-confirm').value) {
1025
- select('#cloud-register-error').textContent = '两次输入的密码不一致。';
1026
- select('#register-password-confirm').focus();
1027
- return;
1028
- }
1029
- button.disabled = true;
1030
- try {
1031
- await api('/api/cloud/register', {
1032
- username:select('#register-username').value,
1033
- displayName:select('#register-display-name').value,
1034
- password:select('#register-password').value
1035
- });
1036
- select('#cloud-register-form').reset();
1037
- await boot();
1038
- } catch (error) {
1039
- if (version === cloudAuthVersion) select('#cloud-register-error').textContent = error.message;
1040
- } finally { button.disabled = false; }
1041
- });
1042
- select('#cloud-add-computer').onclick = () => {
1043
- select('#cloud-onboarding').hidden = !select('#cloud-onboarding').hidden;
1044
- if (!select('#cloud-onboarding').hidden) updateComputerInstallCommand();
1045
- };
1046
- select('#cloud-refresh-devices').onclick = async event => {
1047
- const button = event.currentTarget;
1048
- button.disabled = true;
1049
- try { await refreshCloudDevices(); }
1050
- catch (error) { toast(error.message); }
1051
- finally { button.disabled = false; }
1052
- };
1053
- async function copyInstallCommand(commandSelector,statusSelector){
1054
- const command=select(commandSelector).textContent.trim();
1055
- const status=select(statusSelector);
1056
- try {
1057
- await navigator.clipboard.writeText(command);
1058
- status.textContent = '命令已复制,粘贴到电脑 PowerShell 或终端运行。';
1059
- } catch {
1060
- const temporary = document.createElement('textarea');
1061
- temporary.value = command;
1062
- temporary.setAttribute('readonly', '');
1063
- temporary.style.position = 'fixed';
1064
- temporary.style.opacity = '0';
1065
- document.body.append(temporary);
1066
- temporary.select();
1067
- const copied = document.execCommand('copy');
1068
- temporary.remove();
1069
- status.textContent = copied ? '命令已复制,粘贴到电脑 PowerShell 或终端运行。' : '无法自动复制,请长按命令并手动复制。';
1070
- }
1071
- }
1072
- select('#cloud-copy-install').onclick=()=>copyInstallCommand('#cloud-install-command','#cloud-install-status');
1073
- function updateComputerInstallCommand(){
1074
- select('#cloud-install-command').textContent=computerInstallCommands.windows;
1075
- select('#cloud-install-status').textContent='';
1076
- }
1077
- updateComputerInstallCommand();
1078
- document.addEventListener('click', async event => {
1079
- const revoke = event.target.closest('[data-cloud-revoke]');
1080
- if (revoke) {
1081
- const device = state.cloudDevices.find(item => item.id === revoke.dataset.cloudRevoke);
1082
- if (!device || !window.confirm(`移除“${device.name}”?其连接密钥将永久失效,重新添加需要生成新配置。`)) return;
1083
- revoke.disabled = true;
1084
- try {
1085
- await api(`/api/cloud/devices/${encodeURIComponent(device.id)}/revoke`, {});
1086
- await refreshCloudDevices();
1087
- toast('电脑已移除。原电脑运行 orbit rebind 可重新绑定。');
1088
- } catch (error) { toast(error.message); revoke.disabled = false; }
1089
- return;
1090
- }
1091
- const cloudDevice=event.target.closest('[data-cloud-device]');
1092
- if(cloudDevice){
1093
- await connectCloudDevice(cloudDevice.dataset.cloudDevice);return;
1094
- }
1095
- if(event.target.closest('#cloud-logout-picker')){
1096
- try{await api('/api/cloud/logout',{});}catch{}
1097
- localStorage.removeItem('orbit_cloud_device_id');state.cloudDeviceId=null;state.cloudUser=null;state.cloudDevices=[];
1098
- showCloudLogin();return;
1099
- }
1100
- if(event.target.closest('#cloud-device-switcher')){showCloudDevices();return;}
1101
- if (event.target.closest('#reconnect-desktop')) {
1102
- const id = state.selected?.id;
1103
- if (!id || select('#reconnect-desktop').disabled) return;
1104
- try {
1105
- await subscribeThread(id);
1106
- if (id === state.selected?.id) toast(state.enabled.has(id) ? '已连接并接管这条原会话。' : state.desktopRuntime?.reason || '原会话尚未连接。');
1107
- } catch (error) { toast(error.message); }
1108
- return;
1109
- }
1110
- const copyCodeButton = event.target.closest('[data-copy-code]');
1111
- if (copyCodeButton) {
1112
- await copyText(copyCodeButton.closest('pre')?.querySelector('code')?.textContent || '', '代码已复制。');
1113
- return;
1114
- }
1115
- const copyMessageButton = event.target.closest('[data-copy-message]');
1116
- if (copyMessageButton) {
1117
- await copyText(copyMessageButton.closest('[data-message-id]')?.dataset.messageText || '', '消息已复制。');
1118
- return;
1119
- }
1120
- const thread = event.target.closest('[data-thread]');
1121
- if (thread) { await openThread(thread.dataset.thread); return; }
1122
- const project = event.target.closest('[data-project]');
1123
- if (project) {
1124
- state.project = state.project === project.dataset.project ? null : project.dataset.project; state.filter = 'all';
1125
- state.view = 'sessions'; select('#sessions-view').hidden = false; select('#devices-view').hidden = true;
1126
- select('#workspace').classList.remove('chat-open'); closeNav(); renderProjects(); renderList(); return;
1127
- }
1128
- const filter = event.target.closest('[data-filter]');
1129
- if (filter) { state.filter = filter.dataset.filter; renderList(); return; }
1130
- const nav = event.target.closest('[data-view]');
1131
- if (nav) { await setView(nav.dataset.view); return; }
1132
- if (event.target.closest('#load-more')) { try { await loadMessages(false, true); } catch (error) { toast(error.message); } }
1133
- const decision = event.target.closest('[data-decision]');
1134
- if (decision) {
1135
- const card = decision.closest('[data-approval]');
1136
- const answers = Object.fromEntries([...card.querySelectorAll('[data-question]')].map(input => [input.dataset.question, input.value]));
1137
- try { await rpc('approval.reply', { requestId:card.dataset.approval, decision:decision.dataset.decision, answers }); state.approvals.delete(card.dataset.approval); renderApprovals(); }
1138
- catch (error) { toast(error.message); }
1139
- }
1140
- });
1141
- select('#search').addEventListener('input', event => { state.search = event.target.value; renderList(); });
1142
- document.addEventListener('keydown', event => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); select('#search').focus(); } });
1143
- select('#refresh').onclick = async () => {
1144
- try {
1145
- await refreshThreads();
1146
- if (state.selected) {
1147
- await loadMessages(false);
1148
- if (state.status?.execution.mode === 'desktop') await subscribeThread(state.selected.id);
1149
- }
1150
- toast('原生记录已刷新。');
1151
- } catch (error) { toast(error.message); }
1152
- };
1153
- select('#menu-button').onclick = () => { select('#workspace').classList.add('nav-open'); select('#scrim').hidden = false; };
1154
- select('#scrim').onclick = closeNav;
1155
- select('#back').onclick = () => select('#workspace').classList.remove('chat-open');
1156
- select('#connect-device').onclick = () => state.cloudMode ? showCloudDevices() : setView('devices');
1157
- select('#details-toggle').onclick = () => { select('#chat-details').hidden = !select('#chat-details').hidden; };
1158
- select('#logout').onclick = async () => {
1159
- try {
1160
- if(state.cloudMode){
1161
- await api('/api/cloud/logout',{});
1162
- localStorage.removeItem('orbit_cloud_device_id');state.cloudDeviceId=null;state.cloudUser=null;state.cloudDevices=[];
1163
- showCloudLogin();
1164
- }else{
1165
- await api('/api/logout',{});state.enabled.clear();showPairing();
1166
- }
1167
- } catch(error){toast(error.message)}
1168
- };
1169
- select('#enable-control').onclick = async () => {
1170
- const desktop = state.status.execution.mode === 'desktop';
1171
- if (desktop) {
1172
- try {
1173
- await subscribeThread(state.selected.id);
1174
- if (state.reopenModelAfterGrant && state.enabled.has(state.selected?.id)) {
1175
- state.reopenModelAfterGrant = false;
1176
- await openModelSettings();
1177
- }
1178
- } catch (error) { toast(error.message); }
1179
- return;
1180
- }
1181
- select('#control-form').reset(); select('#control-error').textContent = '';
1182
- select('#control-title').textContent = desktop ? '授权热接管' : '沿用原会话';
1183
- select('#control-description').textContent = desktop ? '不创建新会话、不迁移执行器。Orbit 将订阅并控制 Codex 桌面当前加载的原会话。' : '保持原会话 ID,使用独立执行通道继续。原客户端占用时不会强行抢锁。';
1184
- select('#control-notice').textContent = desktop ? '消息、追加指令、停止和审批将交给原 Codex 桌面实例;权限与运行策略沿用电脑端设置。' : '如果原客户端仍占用这条会话,Orbit 会拒绝发送,不会强制抢锁。';
1185
- select('#allow-write-row').hidden = desktop;
1186
- select('#control-confirm-copy').textContent = desktop ? '我确认授权这台设备向原 Codex 会话发送指令、停止任务并处理审批。' : '我已停止在原客户端使用这条会话,并理解首版限制。';
1187
- select('#confirm-control-button').textContent = desktop ? '授权热接管 ↗' : '确认接续 ↗';
1188
- select('#control-dialog').showModal();
1189
- };
1190
- select('#cancel-control').onclick = () => {
1191
- state.reopenModelAfterGrant = false; state.pendingModelChoice = null;
1192
- select('#control-dialog').close();
1193
- };
1194
- select('#control-form').addEventListener('submit', async event => {
1195
- event.preventDefault(); const button = event.submitter; button.disabled = true;
1196
- const id = state.selected.id;
1197
- try {
1198
- const result = await rpc('thread.enable', { threadId:id, confirm:select('#control-confirm').checked, writable:select('#allow-write').checked });
1199
- state.enabled.set(id, result); select('#control-dialog').close(); updateMode(); toast('已确认接续方式。尚未发送消息。');
1200
- if (state.reopenModelAfterGrant) {
1201
- setTimeout(() => {
1202
- state.reopenModelAfterGrant = false;
1203
- if (state.selected?.id === id) select('#model-settings-button').click();
1204
- }, 0);
1205
- }
1206
- } catch (error) { select('#control-error').textContent = error.message; }
1207
- finally { button.disabled = false; }
1208
- });
1209
- async function openModelSettings() {
1210
- if (select('#model-settings-button').disabled || !state.selected) return;
1211
- const id = state.selected.id;
1212
- const selectionVersion = state.selectionVersion;
1213
- try {
1214
- const { models } = await api('/api/models');
1215
- if (selectionVersion !== state.selectionVersion || id !== state.selected?.id) return;
1216
- state.models = models;
1217
- const current = selectedModelSettings();
1218
- const pending = state.pendingModelChoice?.threadId === id ? state.pendingModelChoice : null;
1219
- const model = pending?.model || current.model;
1220
- const effort = pending ? pending.effort : current.effort;
1221
- const dialog = select('#model-dialog');
1222
- dialog.dataset.threadId = id;
1223
- dialog.dataset.expectedModel = current.model || '';
1224
- dialog.dataset.expectedEffort = JSON.stringify(current.effort);
1225
- select('#model-select').innerHTML = models.map(entry => `<option value="${escapeHtml(entry.model)}">${escapeHtml(entry.displayName)}</option>`).join('');
1226
- if (model && !models.some(entry => entry.model === model)) {
1227
- select('#model-select').insertAdjacentHTML('afterbegin', `<option value="" disabled>当前模型 ${escapeHtml(model)} 不在目录中</option>`);
1228
- select('#model-select').value = '';
1229
- } else select('#model-select').value = model || '';
1230
- renderEffortChoices(effort);
1231
- const authorized = Boolean(state.enabled.get(id));
1232
- const turnRunning = Boolean(state.desktopRuntime?.turnId);
1233
- select('#model-description').textContent = authorized
1234
- ? turnRunning ? '当前任务继续运行;新参数会排队到它完成后的下一轮。' : '更改将在这条会话的下一轮生效。'
1235
- : turnRunning ? '可以先查看和选择;授权后会排队到当前任务完成。' : '可以先查看可用选项;授权热接管后才能应用更改。';
1236
- select('#model-note').textContent = state.status.execution.mode === 'desktop'
1237
- ? turnRunning ? '执行中的任务不会切换;新参数会在它结束后应用,并用于下一轮。' : '只修改原桌面会话的下一轮;运行中的任务不会切换。'
1238
- : '只对本设备接下来的消息生效;不会修改 Codex 全局设置。';
1239
- select('#authorize-model').hidden = authorized;
1240
- select('#apply-model').disabled = !authorized || !select('#model-select').value;
1241
- select('#apply-model').textContent = turnRunning ? '排到下一轮' : '应用到下一轮';
1242
- select('#model-error').textContent = '';
1243
- dialog.showModal();
1244
- renderModelStatus();
1245
- } catch (error) { toast(error.message); }
1246
- }
1247
- select('#model-settings-button').onclick = openModelSettings;
1248
- select('#model-select').onchange = () => {
1249
- renderEffortChoices();
1250
- select('#apply-model').disabled = !state.enabled.get(select('#model-dialog').dataset.threadId) || !select('#model-select').value;
1251
- };
1252
- select('#cancel-model').onclick = () => { state.pendingModelChoice = null; select('#model-dialog').close(); };
1253
- select('#authorize-model').onclick = () => {
1254
- if (!state.selected || state.selected.id !== select('#model-dialog').dataset.threadId) return;
1255
- state.pendingModelChoice = { threadId:state.selected.id,model:select('#model-select').value,effort:select('#effort-select').value || null };
1256
- state.reopenModelAfterGrant = true;
1257
- select('#model-dialog').close();
1258
- select('#enable-control').click();
1259
- };
1260
- select('#model-form').addEventListener('submit', async event => {
1261
- event.preventDefault();
1262
- const button = event.submitter;
1263
- const dialog = select('#model-dialog');
1264
- const id = dialog.dataset.threadId;
1265
- const model = select('#model-select').value;
1266
- const effort = select('#effort-select').value || null;
1267
- if (!id || id !== state.selected?.id) {
1268
- select('#model-error').textContent = '会话状态已变化,请重新打开设置。';
1269
- return;
1270
- }
1271
- if (!state.enabled.has(id)) {
1272
- select('#model-error').textContent = '先授权热接管,才能更改原 Codex 会话设置。';
1273
- return;
1274
- }
1275
- if (model === dialog.dataset.expectedModel && effort === JSON.parse(dialog.dataset.expectedEffort)) { dialog.close(); return; }
1276
- button.disabled = true; state.settingsBusy = true; select('#model-error').textContent = ''; updateMode();
1277
- try {
1278
- const result = await rpc('thread.settings', { threadId:id, model, effort, expectedModel:dialog.dataset.expectedModel, expectedEffort:JSON.parse(dialog.dataset.expectedEffort) });
1279
- if (id === state.selected?.id) {
1280
- if (result.queued) state.selectedSettings = { model:result.model,effort:result.effort,pending:true };
1281
- else {
1282
- if (state.status.execution.mode === 'desktop' && state.desktopRuntime?.attached) state.desktopRuntime = { ...state.desktopRuntime, model: result.model, effort: result.effort };
1283
- else state.selectedSettings = { model: result.model, effort: result.effort,pending:false };
1284
- }
1285
- state.pendingModelChoice = null;
1286
- dialog.close();
1287
- toast(result.queued ? '已排队;当前任务完成后应用到下一轮。' : '下一轮模型参数已更新。');
1288
- renderModelStatus();
1289
- }
1290
- } catch (error) { select('#model-error').textContent = error.message; }
1291
- finally { button.disabled = false; state.settingsBusy = false; updateMode(); }
1292
- });
1293
- select('#message-input').addEventListener('input', updateMode);
1294
- const goalButton=document.createElement('button');
1295
- select('#turn-mode option[value="goal"]')?.remove();
1296
- goalButton.id='goal-button';
1297
- goalButton.type='button';
1298
- goalButton.className='button small goal-button';
1299
- goalButton.textContent='设置目标';
1300
- goalButton.setAttribute('aria-label','设置 Codex 原生持久目标');
1301
- select('#turn-mode').insertAdjacentElement('afterend',goalButton);
1302
- goalButton.onclick=openGoalDialog;
1303
- select('#turn-mode').addEventListener('change',updateMode);
1304
- select('#goal-form').addEventListener('submit',async event=>{
1305
- event.preventDefault();
1306
- if(!state.selected||!nativeGoalWritable())return;
1307
- const button=select('#save-goal');
1308
- const id=state.selected.id,selectionVersion=state.selectionVersion;
1309
- const budgetText=select('#goal-token-budget').value.trim();
1310
- const tokenBudget=budgetText?Number(budgetText):null;
1311
- button.disabled=true;select('#goal-error').textContent='';
1312
- try{
1313
- const result=await rpc('thread.goal.set',{
1314
- threadId:id,objective:select('#goal-objective').value.trim(),tokenBudget
1315
- });
1316
- if(selectionVersion!==state.selectionVersion||id!==state.selected?.id)return;
1317
- state.goal=result.goal||null;
1318
- if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
1319
- select('#goal-dialog').close();renderGoalStatus();toast('已设置 Codex 原生持久目标。');
1320
- }catch(error){select('#goal-error').textContent=error.message;}
1321
- finally{button.disabled=false;}
1322
- });
1323
- select('#pause-goal').onclick=()=>updateGoalStatus(state.goal?.status==='paused'?'active':'paused');
1324
- select('#clear-goal').onclick=clearGoal;
1325
- select('#cancel-goal').onclick=()=>select('#goal-dialog').close();
1326
- select('[data-filter="locked"]')?.remove();
1327
- renderGoalStatus();
1328
- select('#browse-files').onclick=()=>{
1329
- if(!state.selected)return;
1330
- browseProjectFiles();
1331
- select('#file-browser-dialog').showModal();
1332
- };
1333
- select('#close-file-browser').onclick=()=>select('#file-browser-dialog').close();
1334
- select('#file-browser-list').addEventListener('click',event=>{
1335
- const folder=event.target.closest('[data-file-directory]');
1336
- if(folder){browseProjectFiles(folder.dataset.fileDirectory);return}
1337
- const file=event.target.closest('[data-file-download]');
1338
- if(file)downloadProjectFile(file.dataset.fileDownload);
1339
- });
1340
- select('#attach-files').onclick=()=>select('#file-input').click();
1341
- select('#file-input').addEventListener('change',event=>uploadFiles(event.target.files));
1342
- select('#composer-attachments').addEventListener('click',async event=>{
1343
- const button=event.target.closest('[data-remove-attachment]');
1344
- if(!button||!state.selected)return;
1345
- const id=button.dataset.removeAttachment;
1346
- const threadId=state.selected.id;
1347
- try{
1348
- await api(`/api/threads/${encodeURIComponent(threadId)}/attachments/remove`,{attachmentId:id});
1349
- state.pendingAttachments=state.pendingAttachments.filter(file=>file.id!==id);
1350
- renderComposerAttachments();updateMode();
1351
- }catch(error){toast(error.message)}
1352
- });
1353
- select('#message-input').addEventListener('keydown', event => {
1354
- if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !matchMedia('(max-width:760px)').matches) { event.preventDefault(); if (!select('#send-message').disabled) select('#composer').requestSubmit(); }
1355
- });
1356
- select('#composer').addEventListener('submit', async event => {
1357
- event.preventDefault(); if (select('#send-message').disabled) return;
1358
- const id = state.selected.id; const inputText = select('#message-input').value.trim(); const wasBusy = state.busy;
1359
- const turnId = state.turnId;
1360
- const steering = wasBusy && state.status.execution.mode === 'desktop' && state.desktopRuntime?.mode !== 'same-id-resume';
1361
- const mode=select('#turn-mode').value;
1362
- const attachmentIds=state.pendingAttachments.map(file=>file.id);
1363
- const text=inputText|| (attachmentIds.length?`请查看并处理以下附件:${state.pendingAttachments.map(file=>file.label).join('、')}`:'');
1364
- state.busy = true; updateMode();
1365
- try {
1366
- const result = await rpc(steering ? 'turn.steer' : 'turn.start', {
1367
- threadId:id,text,attachmentIds,mode,...(steering?{turnId}:{})
1368
- });
1369
- if (state.selected?.id === id) {
1370
- state.turnId = result.completed ? null : result.turnId || (steering ? turnId : null);
1371
- state.busy = !result.completed;
1372
- select('#message-input').value = '';
1373
- state.pendingAttachments=[];
1374
- renderComposerAttachments();
1375
- if(!steering)select('#turn-mode').value='default';
1376
- scheduleRefresh();
1377
- }
1378
- } catch (error) { if (state.selected?.id === id) state.busy = wasBusy; toast(error.message); }
1379
- updateMode();
1380
- });
1381
- select('#stop-turn').onclick = async () => { try { await rpc('turn.interrupt', { threadId:state.selected.id, turnId:state.turnId }); } catch (error) { toast(error.message); } };
1382
- boot();