orbit-codex-bridge 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/cli.mjs +4 -13
- package/lib/desktop.mjs +45 -7
- package/package.json +1 -1
- package/public/app.js +170 -32
- package/public/index.html +4 -3
- package/public/style.css +30 -0
- package/relay-agent.mjs +80 -38
- package/relay-server.mjs +27 -8
- package/server.mjs +262 -8
package/README.md
CHANGED
|
@@ -29,11 +29,20 @@ orbit login
|
|
|
29
29
|
|
|
30
30
|
1. 命令会打开电脑授权页面,登录或注册 Orbit 账号,确认添加这台电脑。
|
|
31
31
|
2. 手机打开 [orbit.cubesugar.tech](https://orbit.cubesugar.tech),登录同一账号。
|
|
32
|
-
3.
|
|
32
|
+
3. 登录后选择这台电脑;账号会自动完成安全配对,无需扫码或输入本机密钥。
|
|
33
33
|
4. 选择原有会话,开始使用。尚未加载的会话可先在 Codex 桌面打开,再点“重新连接”。
|
|
34
34
|
|
|
35
35
|
授权信息保存在电脑本地,下次启动会自动复用。连接器默认在当前用户的后台运行,可以关闭安装终端;断网后自动重连,工作进程异常退出后自动恢复。电脑需要保持开机、联网,Codex 桌面需要保持运行。电脑重启后运行 `orbit start` 恢复连接。
|
|
36
36
|
|
|
37
|
+
## 会话输入
|
|
38
|
+
|
|
39
|
+
- `普通`:按原会话的 Codex 模型、权限和设置继续。
|
|
40
|
+
- `计划`:使用 Codex 原生 Plan 协作模式开始新一轮;不会改动正在运行的轮次。
|
|
41
|
+
- `目标(本轮)`:将本轮消息作为明确目标继续推进。本版桌面 IPC 尚未开放持久 Goal 状态 API,因此不会伪装成 Codex 侧的长期目标追踪。
|
|
42
|
+
- `添加文件`:每次最多 5 个、每个最多 8 MiB。文件经账号设备中继传到本机,并暂存在项目内 `.orbit-attachments`;Codex 读取原路径。待发送文件 30 分钟清理,已发送文件保留最多 24 小时,任务完成后会提前删除。
|
|
43
|
+
- `电脑文件`:浏览当前会话项目并下载文件;限制在项目根目录内,排除 `.git`、`.codex`、密钥/环境文件和符号链接,单文件最大 8 MiB。
|
|
44
|
+
- 文件附件仅支持已登录账号、在线的自有电脑,以及已加载并已自动热接管的 Codex 桌面原会话。归档会话、子代理和独立续接不支持。
|
|
45
|
+
|
|
37
46
|
## 常用命令
|
|
38
47
|
|
|
39
48
|
| 命令 | 用途 |
|
|
@@ -90,6 +99,6 @@ npx.cmd --yes --package=orbit-codex-bridge@latest orbit rebind
|
|
|
90
99
|
|
|
91
100
|
在 Codex 桌面打开同一条会话,再点击网页中的“重新连接”。桌面热接管依赖 Codex 内部接口,桌面版本更新后可能需要更新 Orbit。
|
|
92
101
|
|
|
93
|
-
|
|
102
|
+
**账号登录与设备安全**
|
|
94
103
|
|
|
95
|
-
|
|
104
|
+
手机端只需使用 Orbit 账号登录。电脑连接凭证保存在本机,账号登录后由中继自动配对,不会要求输入或传输本机密钥。每台电脑使用独立连接凭证,可在工作台移除电脑,或运行 `orbit logout` 撤销授权。连接中断时,Orbit 不会自动重发已提交的消息和审批。
|
package/cli.mjs
CHANGED
|
@@ -6,7 +6,6 @@ import path from 'node:path';
|
|
|
6
6
|
import process from 'node:process';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import QRCode from 'qrcode';
|
|
10
9
|
import { createOrbitServer } from './server.mjs';
|
|
11
10
|
import { createRelayAgent } from './relay-agent.mjs';
|
|
12
11
|
import {connectorStatus,installConnectorRuntime,startConnector,stopConnector,superviseConnector} from './lib/connector-service.mjs';
|
|
@@ -147,13 +146,6 @@ export async function existingOrbitUrl(port) {
|
|
|
147
146
|
} catch { return null; }
|
|
148
147
|
}
|
|
149
148
|
|
|
150
|
-
async function printPairing(pairingKey, publicUrl) {
|
|
151
|
-
const pairingUrl = `${publicUrl}/#pair=${encodeURIComponent(pairingKey)}`;
|
|
152
|
-
console.log('本机配对二维码和密钥仅供自己的手机使用,请勿公开。');
|
|
153
|
-
console.log(await QRCode.toString(pairingUrl,{type:'terminal',errorCorrectionLevel:'L',small:true}));
|
|
154
|
-
console.log(`本机配对密钥:${pairingKey}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
149
|
async function runComputer(config, background = false) {
|
|
158
150
|
process.env.ORBIT_HOST = '127.0.0.1';
|
|
159
151
|
process.env.ORBIT_PORT = String(config.port || 4318);
|
|
@@ -168,13 +160,14 @@ async function runComputer(config, background = false) {
|
|
|
168
160
|
const info=await app.listen();
|
|
169
161
|
localUrl=info.url;
|
|
170
162
|
console.log(`Orbit 电脑端运行中:${localUrl}`);
|
|
171
|
-
if (!background) await printPairing(app.pairingKey,config.publicUrl);
|
|
172
163
|
}
|
|
173
164
|
agent = createRelayAgent({
|
|
174
165
|
relayUrl:config.relayUrl,
|
|
175
166
|
localUrl,
|
|
176
167
|
deviceId:config.deviceId,
|
|
177
168
|
agentToken:config.agentToken,
|
|
169
|
+
pairingKey:app?.pairingKey,
|
|
170
|
+
pairingKeyFile:path.join(configDirectory(),'pairing-key'),
|
|
178
171
|
onReady:() => console.log(`电脑“${config.deviceName}”已安全连接 Orbit。`),
|
|
179
172
|
onState:state => {
|
|
180
173
|
if (background && process.connected) process.send({type:'connector-state',state:{...state,workerReady:true}},() => {});
|
|
@@ -184,6 +177,7 @@ async function runComputer(config, background = false) {
|
|
|
184
177
|
agent.start();
|
|
185
178
|
if(!app)console.log(`复用本机 Orbit:${localUrl}`);
|
|
186
179
|
console.log(`手机工作台:${config.publicUrl}`);
|
|
180
|
+
console.log('手机登录同一 Orbit 账号,选择这台电脑即可,无需输入配对密钥。');
|
|
187
181
|
if (!background) console.log('前台诊断模式;保持此窗口运行,按 Ctrl+C 停止。');
|
|
188
182
|
await new Promise(resolve => {
|
|
189
183
|
const stop = () => {
|
|
@@ -211,10 +205,7 @@ async function runBackground(config) {
|
|
|
211
205
|
console.log('可以关闭此终端。断网自动重连,异常退出自动恢复;电脑休眠期间无法远程使用。');
|
|
212
206
|
console.log('停止:orbit stop;状态:orbit status;前台诊断:orbit login --foreground');
|
|
213
207
|
console.log(`诊断日志:${path.join(configDirectory(),'connector.log')}`);
|
|
214
|
-
|
|
215
|
-
const key = (await fs.readFile(path.join(configDirectory(),'pairing-key'),'utf8')).trim();
|
|
216
|
-
if (key) await printPairing(key,config.publicUrl);
|
|
217
|
-
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
208
|
+
console.log('手机登录同一 Orbit 账号,选择这台电脑即可,无需输入配对密钥。');
|
|
218
209
|
}
|
|
219
210
|
|
|
220
211
|
async function revokeComputer(config) {
|
package/lib/desktop.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
import { EventEmitter } from 'node:events';
|
|
4
6
|
import { messageFromItem } from './store.mjs';
|
|
5
7
|
|
|
@@ -261,6 +263,13 @@ export class DesktopRpc extends EventEmitter {
|
|
|
261
263
|
permissionMode: state?.currentPermissions?.sandboxPolicy?.type || state?.latestThreadSettings?.sandboxPolicy?.type || 'desktop-inherited',
|
|
262
264
|
model: state?.latestThreadSettings?.model || state?.latestModel || null,
|
|
263
265
|
effort: state?.latestThreadSettings?.effort ?? state?.latestReasoningEffort ?? null,
|
|
266
|
+
collaborationMode:state?.latestCollaborationMode?.mode||'default',
|
|
267
|
+
goal:state?.threadGoal?{
|
|
268
|
+
objective:state.threadGoal.objective,
|
|
269
|
+
status:state.threadGoal.status,
|
|
270
|
+
tokenBudget:state.threadGoal.tokenBudget??null,
|
|
271
|
+
tokensUsed:state.threadGoal.tokensUsed??0
|
|
272
|
+
}:null,
|
|
264
273
|
context: desktopContext(state),
|
|
265
274
|
messages: state ? desktopMessages(state) : [],
|
|
266
275
|
approvals: [...this.approvals.values()].filter(request => request.params.threadId === id).map(request => ({ ...request.params, requestId: String(request.id), method: request.method }))
|
|
@@ -297,8 +306,8 @@ export class DesktopRpc extends EventEmitter {
|
|
|
297
306
|
return thread;
|
|
298
307
|
}
|
|
299
308
|
|
|
300
|
-
async startTurn(id, text) { return this.writeMessage(id, text, false); }
|
|
301
|
-
async steer(id, text, expectedTurnId) { return this.writeMessage(id, text, true, expectedTurnId); }
|
|
309
|
+
async startTurn(id, text, options = {}) { return this.writeMessage(id, text, false, null, options); }
|
|
310
|
+
async steer(id, text, expectedTurnId, options = {}) { return this.writeMessage(id, text, true, expectedTurnId, options); }
|
|
302
311
|
|
|
303
312
|
async updateThreadSettings(id, model, effort, expectedModel, expectedEffort) {
|
|
304
313
|
if (this.starting.has(id) || this.updatingSettings.has(id)) throw new Error('会话正在处理另一项操作,请稍后重试。');
|
|
@@ -322,21 +331,50 @@ export class DesktopRpc extends EventEmitter {
|
|
|
322
331
|
} finally { this.updatingSettings.delete(id); }
|
|
323
332
|
}
|
|
324
333
|
|
|
325
|
-
async writeMessage(id, text, steering, expectedTurnId) {
|
|
334
|
+
async writeMessage(id, text, steering, expectedTurnId, options = {}) {
|
|
326
335
|
if (this.starting.has(id) || this.updatingSettings.has(id)) throw new Error('此会话正在处理其他操作,请勿并发提交。');
|
|
327
336
|
this.starting.add(id);
|
|
328
337
|
try {
|
|
329
338
|
const thread = await this.assertOwner(id);
|
|
330
339
|
if (steering ? !expectedTurnId || thread.turnId !== expectedTurnId : Boolean(thread.turnId)) throw new Error('任务状态已变化,请刷新后选择续聊或追加指令。');
|
|
340
|
+
const mode=options.mode||'default';
|
|
341
|
+
if(!['default','plan','goal'].includes(mode))throw new Error('Codex 轮次模式无效。');
|
|
342
|
+
const attachments=Array.isArray(options.attachments)?options.attachments:[];
|
|
343
|
+
if(attachments.length>5||attachments.some(file=>{
|
|
344
|
+
if(!file||typeof file.label!=='string'||typeof file.path!=='string'||!path.isAbsolute(file.path)||file.fsPath!==file.path||!thread.state.cwd)return true;
|
|
345
|
+
try{
|
|
346
|
+
const workspace=fs.realpathSync(thread.state.cwd);
|
|
347
|
+
const filename=path.resolve(file.path);
|
|
348
|
+
const relative=path.relative(workspace,filename);
|
|
349
|
+
const attachmentRoot=path.join(workspace,'.orbit-attachments');
|
|
350
|
+
const resolved=fs.realpathSync(filename);
|
|
351
|
+
const stat=fs.lstatSync(filename);
|
|
352
|
+
return !relative||relative==='..'||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative)
|
|
353
|
+
||path.dirname(filename)!==attachmentRoot||resolved!==filename||stat.isSymbolicLink()||!stat.isFile()||stat.size>8*1024*1024;
|
|
354
|
+
}catch{return true}
|
|
355
|
+
}))throw new Error('文件附件无效、位于工作区外或数量超限。');
|
|
356
|
+
const baseMode=thread.state.latestCollaborationMode||{mode:'default',settings:{}};
|
|
357
|
+
const selectedMode=mode==='plan'?'plan':baseMode.mode==='plan'?'default':baseMode.mode||'default';
|
|
358
|
+
const developerInstructions=baseMode.settings?.developer_instructions??null;
|
|
359
|
+
const collaborationMode={
|
|
360
|
+
mode:selectedMode,
|
|
361
|
+
settings:{
|
|
362
|
+
...baseMode.settings,
|
|
363
|
+
model:baseMode.settings?.model||thread.state.latestThreadSettings?.model||thread.state.latestModel||'',
|
|
364
|
+
reasoning_effort:baseMode.settings?.reasoning_effort??thread.state.latestThreadSettings?.effort??thread.state.latestReasoningEffort??null,
|
|
365
|
+
developer_instructions:developerInstructions
|
|
366
|
+
}
|
|
367
|
+
};
|
|
331
368
|
const messageId = crypto.randomUUID();
|
|
332
369
|
const input = [{ type: 'text', text, text_elements: [] }];
|
|
333
|
-
const
|
|
370
|
+
const fileAttachments=attachments.map(({label,path:filename})=>({label,path:filename,fsPath:filename}));
|
|
371
|
+
const restoreMessage = { id: messageId, text, cwd: thread.state.cwd, createdAt: Date.now(), context: { prompt: text, addedFiles: [], fileAttachments, uploadedFileAttachments:[], imageAttachments: [], ideContext: null, workspaceRoots: [thread.state.cwd] } };
|
|
334
372
|
let result;
|
|
335
|
-
if (steering) result = await this.request('thread-follower-steer-turn', { conversationId: id, input, restoreMessage, clientUserMessageId: messageId, attachments:
|
|
336
|
-
else result = await this.request('thread-follower-start-turn', { conversationId: id, turnStart: { request: { threadId: id, input, clientUserMessageId: messageId, turnTrigger: 'composer' }, context: { restoreMessage } } }, thread.ownerClientId);
|
|
373
|
+
if (steering) result = await this.request('thread-follower-steer-turn', { conversationId: id, input, restoreMessage, clientUserMessageId: messageId, attachments:fileAttachments }, thread.ownerClientId);
|
|
374
|
+
else result = await this.request('thread-follower-start-turn', { conversationId: id, turnStart: { request: { threadId: id, input, clientUserMessageId: messageId, turnTrigger: 'composer',collaborationMode }, context: { restoreMessage,attachments:fileAttachments,collaborationMode } } }, thread.ownerClientId);
|
|
337
375
|
const response = result.result || result;
|
|
338
376
|
const turnId = response.turnId || response.turn?.id || this.threads.get(id)?.turnId || null;
|
|
339
|
-
return { threadId:
|
|
377
|
+
return { threadId:id,turnId,completed:typeof response.completed==='boolean'?response.completed:Boolean(!steering&&!turnId),steering,sharedExecution:true,mode,attachments:attachments.map(file=>file.label) };
|
|
340
378
|
} finally { this.starting.delete(id); }
|
|
341
379
|
}
|
|
342
380
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -25,7 +25,7 @@ const paths = {
|
|
|
25
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
26
|
function decorate(parent = document) { parent.querySelectorAll('[data-icon]').forEach(element => { element.innerHTML = icon(element.dataset.icon); }); }
|
|
27
27
|
function markdown(text) { return DOMPurify.sanitize(marked.parse(String(text || ''), { breaks: true }), { ALLOWED_TAGS: ['p','br','strong','b','em','i','s','del','ul','ol','li','blockquote','pre','code','h1','h2','h3','h4','h5','h6','hr','a','table','thead','tbody','tr','th','td','sup','sub','kbd','details','summary'], ALLOWED_ATTR: ['href','title','start','colspan','rowspan'], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false }); }
|
|
28
|
-
const state = { threads: [], status: null, selected: null, selectionVersion: 0, historyStatus: 'idle', historyError: '', filter: 'all', project: null, search: '', view: 'sessions', socket: null, pending: new Map(), enabled: new Map(), baseMessages: [], olderMessages: [], cursor: null, liveMessages: new Map(), desktopMessages: [], desktopRuntime: null, selectedSettings: null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null,
|
|
28
|
+
const state = { threads: [], status: null, selected: null, selectionVersion: 0, historyStatus: 'idle', historyError: '', filter: 'all', project: null, search: '', view: 'sessions', socket: null, pending: new Map(), enabled: new Map(), baseMessages: [], olderMessages: [], cursor: null, liveMessages: new Map(), desktopMessages: [], desktopRuntime: null, selectedSettings: null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null, approvals: new Map(), pendingAttachments: [], reconnect: 0, loggedOut: false, busy: false, turnId: null };
|
|
29
29
|
let refreshTimer;
|
|
30
30
|
let toastTimer;
|
|
31
31
|
let reconnectTimer;
|
|
@@ -69,10 +69,9 @@ async function loadCloudConfig() {
|
|
|
69
69
|
if (!cloudRegistrationEnabled) setCloudAuthMode(false);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function
|
|
72
|
+
function discardCloudPairCode() {
|
|
73
73
|
const fragment=new URLSearchParams(location.hash.slice(1));
|
|
74
74
|
if(!fragment.has('pair'))return;
|
|
75
|
-
state.cloudPairCode=fragment.get('pair');
|
|
76
75
|
history.replaceState(null,'',location.pathname+location.search);
|
|
77
76
|
}
|
|
78
77
|
|
|
@@ -94,7 +93,7 @@ async function api(url, body, signal) {
|
|
|
94
93
|
const generation = workspaceGeneration;
|
|
95
94
|
const headers = {};
|
|
96
95
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
97
|
-
if (state.cloudMode && state.cloudDeviceId && !url.startsWith('/api/cloud/')) headers['X-Orbit-Device-Id'] = state.cloudDeviceId;
|
|
96
|
+
if (state.cloudMode && state.cloudDeviceId && (!url.startsWith('/api/cloud/') || url === '/api/cloud/connect')) headers['X-Orbit-Device-Id'] = state.cloudDeviceId;
|
|
98
97
|
const options = body === undefined ? { headers } : { method: 'POST', headers, body: JSON.stringify(body) };
|
|
99
98
|
if (signal) options.signal = signal;
|
|
100
99
|
let response;
|
|
@@ -113,7 +112,7 @@ async function api(url, body, signal) {
|
|
|
113
112
|
if (response.status === 401 && state.cloudMode && (result.code === 'cloud_login_required' || url.startsWith('/api/cloud/') && url !== '/api/cloud/login')) {
|
|
114
113
|
showCloudLogin(result.error);
|
|
115
114
|
error.handled = true;
|
|
116
|
-
} else if (response.status === 401 && url !== '/api/pair' && !url.startsWith('/api/cloud/')
|
|
115
|
+
} else if (response.status === 401 && !state.cloudMode && url !== '/api/pair' && !url.startsWith('/api/cloud/')) {
|
|
117
116
|
showPairing();
|
|
118
117
|
error.handled = true;
|
|
119
118
|
}
|
|
@@ -127,9 +126,7 @@ function showPairing(error = '') {
|
|
|
127
126
|
select('#workspace').hidden = true; select('#pair-screen').hidden = false;
|
|
128
127
|
select('#cloud-login-screen').hidden = true; select('#cloud-device-screen').hidden = true;
|
|
129
128
|
select('#pair-error').textContent = error;
|
|
130
|
-
select('#pair-screen .pair-form > p').textContent =
|
|
131
|
-
? `首次连接“${state.cloudDevices.find(device => device.id === state.cloudDeviceId)?.name || '这台电脑'}”,请扫描电脑终端中的配对二维码,或粘贴终端显示的本机配对密钥。`
|
|
132
|
-
: '输入电脑上的配对密钥。会话内容仅向已配对的设备开放。';
|
|
129
|
+
select('#pair-screen .pair-form > p').textContent = '输入电脑上的配对密钥。会话内容仅向已配对的设备开放。';
|
|
133
130
|
const fragment = new URLSearchParams(location.hash.slice(1));
|
|
134
131
|
if (fragment.has('pair')) { select('#pair-code').value = fragment.get('pair'); history.replaceState(null, '', location.pathname); }
|
|
135
132
|
}
|
|
@@ -155,9 +152,7 @@ function showCloudDevices(message = '') {
|
|
|
155
152
|
const quota = state.cloudUser?.maxDevices || 1;
|
|
156
153
|
select('#cloud-devices-title').textContent = state.cloudDevices.length ? '选择要连接的电脑' : '连接你的第一台电脑';
|
|
157
154
|
const greeting = `已登录 ${state.cloudUser?.displayName || state.cloudUser?.username}。`;
|
|
158
|
-
select('#cloud-device-message').textContent = state.
|
|
159
|
-
? `${greeting}选择发起连接的电脑,完成本机配对。${message ? ` ${message}` : ''}`
|
|
160
|
-
: message || greeting + (state.cloudDevices.length ? '选择一台电脑继续。' : '先添加你自己的电脑,连接后即可查看原有会话。');
|
|
155
|
+
select('#cloud-device-message').textContent = message || greeting + (state.cloudDevices.length ? '选择一台电脑继续,无需输入配对密钥。' : '先添加你自己的电脑,连接后即可查看原有会话。');
|
|
161
156
|
select('#cloud-device-quota').textContent = `套餐 ${state.cloudUser?.plan || 'free'} · 已添加 ${state.cloudDevices.length} / ${quota} 台电脑`;
|
|
162
157
|
select('#cloud-add-computer').disabled = state.cloudDevices.length >= quota;
|
|
163
158
|
select('#cloud-add-computer').title = state.cloudDevices.length >= quota ? '已达到设备上限,可移除旧电脑或联系管理员调整额度。' : '';
|
|
@@ -186,19 +181,6 @@ async function connectCloudDevice(deviceId) {
|
|
|
186
181
|
if (!device.online) { showCloudDevices(`电脑“${device.name}”尚未上线。请确认电脑已联网,在电脑终端运行 orbit login,后台启动后再点击连接。`); return; }
|
|
187
182
|
state.cloudDeviceId = deviceId;
|
|
188
183
|
localStorage.setItem('orbit_cloud_device_id', deviceId);
|
|
189
|
-
if (state.cloudPairCode) {
|
|
190
|
-
try {
|
|
191
|
-
await api('/api/pair', {code:state.cloudPairCode});
|
|
192
|
-
state.cloudPairCode = null;
|
|
193
|
-
} catch (error) {
|
|
194
|
-
if (error.status === 401 && !error.handled) {
|
|
195
|
-
state.cloudPairCode = null;
|
|
196
|
-
showPairing(error.message);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
throw error;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
184
|
await boot();
|
|
203
185
|
} catch (error) {
|
|
204
186
|
if (!error.handled && error.code !== 'STALE_WORKSPACE') showCloudDevices(error.message);
|
|
@@ -240,7 +222,7 @@ async function discoverCloudWorkspace() {
|
|
|
240
222
|
catch (error) { throw new Error('无法连接工作台服务,请检查网络。'); }
|
|
241
223
|
if (generation !== workspaceGeneration) return 'stale';
|
|
242
224
|
if (response.status === 404) return 'local';
|
|
243
|
-
|
|
225
|
+
discardCloudPairCode();
|
|
244
226
|
let configError = '';
|
|
245
227
|
try { await loadCloudConfig(); }
|
|
246
228
|
catch (error) { configError = error.message; }
|
|
@@ -255,8 +237,8 @@ async function discoverCloudWorkspace() {
|
|
|
255
237
|
state.cloudMode = true; state.loggedOut = false;
|
|
256
238
|
state.cloudUser = workspace.user; state.cloudDevices = workspace.devices || [];
|
|
257
239
|
const saved = localStorage.getItem('orbit_cloud_device_id');
|
|
258
|
-
if (
|
|
259
|
-
else if (state.cloudDevices.length === 1 && state.cloudDevices[0].status === 'active'
|
|
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') {
|
|
260
242
|
state.cloudDeviceId = state.cloudDevices[0].id;
|
|
261
243
|
localStorage.setItem('orbit_cloud_device_id',state.cloudDeviceId);
|
|
262
244
|
} else {
|
|
@@ -280,6 +262,13 @@ async function boot() {
|
|
|
280
262
|
try {
|
|
281
263
|
const cloudState = await discoverCloudWorkspace();
|
|
282
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
|
+
}
|
|
283
272
|
state.status = await api('/api/status');
|
|
284
273
|
if (generation !== workspaceGeneration) return;
|
|
285
274
|
state.loggedOut = false;
|
|
@@ -388,6 +377,7 @@ function connectSocket() {
|
|
|
388
377
|
setConnection(false);
|
|
389
378
|
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('连接中断,消息不会自动重发。请先检查会话记录。')); }
|
|
390
379
|
state.pending.clear();
|
|
380
|
+
state.pendingAttachments=[];
|
|
391
381
|
scheduleReconnect();
|
|
392
382
|
});
|
|
393
383
|
socket.addEventListener('error', () => {});
|
|
@@ -397,6 +387,7 @@ function scheduleReconnect() {
|
|
|
397
387
|
clearTimeout(reconnectTimer);
|
|
398
388
|
reconnectTimer = setTimeout(async () => {
|
|
399
389
|
try {
|
|
390
|
+
if (state.cloudMode) await api('/api/cloud/connect',{});
|
|
400
391
|
state.status = await api('/api/status');
|
|
401
392
|
state.enabled = new Map(Object.entries(state.status.execution.grants || {}));
|
|
402
393
|
connectSocket();
|
|
@@ -426,6 +417,8 @@ async function subscribeThread(id) {
|
|
|
426
417
|
state.turnId = result.turnId;
|
|
427
418
|
state.busy = Boolean(result.turnId);
|
|
428
419
|
state.approvals = new Map((result.approvals || []).map(approval => [approval.requestId, approval]));
|
|
420
|
+
state.pendingAttachments=result.attachments||[];
|
|
421
|
+
renderComposerAttachments();
|
|
429
422
|
if (desktop && result.control) {
|
|
430
423
|
controlRenewTimer = setTimeout(() => {
|
|
431
424
|
if (state.selected?.id === id && state.socket?.readyState === WebSocket.OPEN && state.desktopRuntime?.attached) {
|
|
@@ -502,6 +495,8 @@ async function openThread(id) {
|
|
|
502
495
|
if (select('#model-dialog').open) select('#model-dialog').close();
|
|
503
496
|
const selectionVersion = ++state.selectionVersion;
|
|
504
497
|
state.pendingModelChoice = null;
|
|
498
|
+
state.pendingAttachments=[];
|
|
499
|
+
select('#turn-mode').value='default';
|
|
505
500
|
state.historyStatus = 'loading'; state.historyError = '';
|
|
506
501
|
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;
|
|
507
502
|
select('#welcome').hidden = true; select('#chat').hidden = false; select('#workspace').classList.add('chat-open');
|
|
@@ -510,7 +505,7 @@ async function openThread(id) {
|
|
|
510
505
|
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>`;
|
|
511
506
|
select('#chat-details').hidden = true;
|
|
512
507
|
select('#messages').replaceChildren(); renderMessages();
|
|
513
|
-
renderList();
|
|
508
|
+
renderComposerAttachments();renderList();updateMode();renderApprovals();
|
|
514
509
|
subscribeThread(id).catch(error => { if (selectionVersion === state.selectionVersion) toast(error.message); });
|
|
515
510
|
try { await loadMessages(true); } catch (error) { if (selectionVersion === state.selectionVersion) toast(error.message); }
|
|
516
511
|
}
|
|
@@ -752,9 +747,107 @@ function updateMode() {
|
|
|
752
747
|
select('#send-message').title = state.busy && desktop ? '追加指令到当前桌面任务' : '沿用原会话发送';
|
|
753
748
|
select('#stop-turn').hidden = !state.busy || !state.turnId || !control;
|
|
754
749
|
select('#message-input').placeholder = state.busy && desktop ? '追加指令给电脑上正在运行的任务…' : control ? '沿着原来的上下文继续…' : desktop ? '连接原桌面会话后继续…' : '先确认接续方式,再沿着这条会话继续…';
|
|
755
|
-
select('#
|
|
750
|
+
select('#turn-mode').disabled=!control||!desktop||desktopConnecting||state.busy;
|
|
751
|
+
select('#attach-files').disabled=!control||!desktop||desktopConnecting||state.pendingAttachments.length>=5||state.settingsBusy;
|
|
752
|
+
select('#file-input').disabled=select('#attach-files').disabled;
|
|
753
|
+
select('#turn-mode').title=state.busy?'当前桌面任务中无法切换模式。':!desktop?'仅 Codex 桌面原会话支持模式选择。':'模式仅作用于新一轮消息。';
|
|
754
|
+
const mode=select('#turn-mode').value;
|
|
755
|
+
select('#composer-note').textContent=mode==='plan'
|
|
756
|
+
? '计划模式先分析并列出步骤,等待你确认后再执行。只影响下一轮。'
|
|
757
|
+
: mode==='goal'
|
|
758
|
+
? '目标模式将本轮消息作为明确目标;Codex 桌面 IPC 暂不提供持久目标状态设置。'
|
|
759
|
+
: control ? desktop ? '消息进入原 Codex 桌面会话;任务不会迁移或重启。文件暂存 30 分钟。断线不会自动重发。' : '原会话 ID 不变;断线不会自动重发消息。'
|
|
760
|
+
: '只读查看不会唤醒或打断电脑上的执行。';
|
|
756
761
|
renderModelStatus();
|
|
757
762
|
}
|
|
763
|
+
function renderComposerAttachments() {
|
|
764
|
+
select('#composer-attachments').innerHTML=state.pendingAttachments.map(file=>
|
|
765
|
+
`<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>`
|
|
766
|
+
).join('');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function uploadFiles(fileList) {
|
|
770
|
+
if(!state.selected||!state.enabled.has(state.selected.id)||!state.desktopRuntime?.attached)return toast('请先连接并接管原 Codex 会话。');
|
|
771
|
+
const files=[...fileList];
|
|
772
|
+
if(state.pendingAttachments.length+files.length>5)return toast('单条消息最多添加 5 个文件。');
|
|
773
|
+
for(const file of files)if(file.size===0||file.size>8*1024*1024)return toast(`“${file.name}”为空或超过 8 MiB 限制。`);
|
|
774
|
+
const threadId=state.selected.id,selectionVersion=state.selectionVersion;
|
|
775
|
+
const button=select('#attach-files');
|
|
776
|
+
button.disabled=true;
|
|
777
|
+
try{
|
|
778
|
+
for(const file of files){
|
|
779
|
+
const headers={'Content-Type':file.type||'application/octet-stream'};
|
|
780
|
+
if(state.cloudMode&&state.cloudDeviceId)headers['X-Orbit-Device-Id']=state.cloudDeviceId;
|
|
781
|
+
const response=await fetch(`/api/threads/${encodeURIComponent(threadId)}/attachments?name=${encodeURIComponent(file.name)}`,{
|
|
782
|
+
method:'POST',headers,body:file,credentials:'same-origin',signal:AbortSignal.timeout(30000)
|
|
783
|
+
});
|
|
784
|
+
const result=await response.json().catch(()=>({}));
|
|
785
|
+
if(!response.ok)throw new Error(result.error||'文件上传失败。');
|
|
786
|
+
if(threadId!==state.selected?.id||selectionVersion!==state.selectionVersion){
|
|
787
|
+
await api(`/api/threads/${encodeURIComponent(threadId)}/attachments/remove`,{attachmentId:result.id});
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
state.pendingAttachments.push(result);
|
|
791
|
+
renderComposerAttachments();updateMode();
|
|
792
|
+
}
|
|
793
|
+
toast(files.length===1?'文件已附加到待发送消息。':`已附加 ${files.length} 个文件。`);
|
|
794
|
+
}catch(error){toast(error.name==='TimeoutError'?'文件上传超时,请确认网络后重试。':error.message)}
|
|
795
|
+
finally{button.disabled=false;select('#file-input').value='';updateMode()}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function browseProjectFiles(relativePath='') {
|
|
799
|
+
if(!state.selected)return;
|
|
800
|
+
const threadId=state.selected.id,selectionVersion=state.selectionVersion;
|
|
801
|
+
const list=select('#file-browser-list');
|
|
802
|
+
select('#file-browser-error').textContent='';
|
|
803
|
+
list.innerHTML='<p class="file-browser-empty">正在读取项目文件…</p>';
|
|
804
|
+
try{
|
|
805
|
+
const query=relativePath?`?path=${encodeURIComponent(relativePath)}`:'';
|
|
806
|
+
const result=await api(`/api/threads/${encodeURIComponent(threadId)}/files${query}`);
|
|
807
|
+
if(threadId!==state.selected?.id||selectionVersion!==state.selectionVersion)return;
|
|
808
|
+
select('#file-browser-path').textContent=result.path||'项目根目录';
|
|
809
|
+
const rows=[];
|
|
810
|
+
if(result.path){
|
|
811
|
+
const parent=result.path.split(/[\\/]/).slice(0,-1).join('/');
|
|
812
|
+
rows.push(`<button class="file-browser-entry file-browser-parent" type="button" data-file-directory="${escapeHtml(parent)}"><span>↰</span><strong>上一级</strong></button>`);
|
|
813
|
+
}
|
|
814
|
+
for(const entry of result.entries||[]){
|
|
815
|
+
rows.push(entry.type==='directory'
|
|
816
|
+
? `<button class="file-browser-entry" type="button" data-file-directory="${escapeHtml(entry.path)}"><span>▸</span><strong>${escapeHtml(entry.name)}</strong></button>`
|
|
817
|
+
: `<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>`);
|
|
818
|
+
}
|
|
819
|
+
if(result.truncated)rows.push('<p class="file-browser-empty">列表已达到 1000 项上限,请打开子文件夹细看。</p>');
|
|
820
|
+
list.innerHTML=rows.join('')||'<p class="file-browser-empty">此文件夹为空。</p>';
|
|
821
|
+
}catch(error){
|
|
822
|
+
list.replaceChildren();
|
|
823
|
+
select('#file-browser-error').textContent=error.message;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function downloadProjectFile(relativePath){
|
|
828
|
+
if(!state.selected)return;
|
|
829
|
+
const threadId=state.selected.id;
|
|
830
|
+
const headers={};
|
|
831
|
+
if(state.cloudMode&&state.cloudDeviceId)headers['X-Orbit-Device-Id']=state.cloudDeviceId;
|
|
832
|
+
try{
|
|
833
|
+
const response=await fetch(`/api/threads/${encodeURIComponent(threadId)}/file?path=${encodeURIComponent(relativePath)}`,{
|
|
834
|
+
headers,credentials:'same-origin',cache:'no-store',signal:AbortSignal.timeout(35000)
|
|
835
|
+
});
|
|
836
|
+
if(!response.ok){
|
|
837
|
+
const result=await response.json().catch(()=>({}));
|
|
838
|
+
if(response.status===401&&state.cloudMode){showCloudLogin(result.error);return}
|
|
839
|
+
throw new Error(result.error||'无法下载此项目文件。');
|
|
840
|
+
}
|
|
841
|
+
const disposition=response.headers.get('content-disposition')||'';
|
|
842
|
+
const encoded=disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
843
|
+
const filename=encoded?decodeURIComponent(encoded):relativePath.split(/[\\/]/).at(-1)||'project-file';
|
|
844
|
+
const url=URL.createObjectURL(await response.blob());
|
|
845
|
+
const anchor=document.createElement('a');
|
|
846
|
+
anchor.href=url;anchor.download=filename;anchor.click();
|
|
847
|
+
setTimeout(()=>URL.revokeObjectURL(url),30000);
|
|
848
|
+
}catch(error){toast(error.name==='TimeoutError'?'项目文件下载超时。':error.message)}
|
|
849
|
+
}
|
|
850
|
+
|
|
758
851
|
function renderApprovals() {
|
|
759
852
|
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('');
|
|
760
853
|
}
|
|
@@ -772,12 +865,18 @@ function renderDevices() {
|
|
|
772
865
|
cloudRow.querySelector('span:last-child').classList.toggle('pending', !state.cloudMode);
|
|
773
866
|
select('#copy-url').onclick = async () => { try { await navigator.clipboard.writeText(url); toast('连接地址已复制。'); } catch { toast('浏览器不允许复制,请手动复制地址。'); } };
|
|
774
867
|
select('#show-qr').onclick = async () => {
|
|
868
|
+
if (state.cloudMode) return;
|
|
775
869
|
try {
|
|
776
870
|
const data = await api('/api/pair/qr', { url });
|
|
777
871
|
select('#qr-container').innerHTML = `<img src="${escapeHtml(data.image)}" alt="敏感的设备配对二维码"><p>${escapeHtml(data.warning)}</p>`;
|
|
778
872
|
select('#qr-container').hidden = false;
|
|
779
873
|
} catch (error) { toast(error.message); }
|
|
780
874
|
};
|
|
875
|
+
if (state.cloudMode) {
|
|
876
|
+
const card = select('#show-qr').closest('section');
|
|
877
|
+
card.querySelector('p').textContent = '手机打开工作台,登录同一 Orbit 账号并选择这台电脑,即可继续原有会话,无需扫码或输入密钥。';
|
|
878
|
+
select('#show-qr').hidden = true;
|
|
879
|
+
}
|
|
781
880
|
}
|
|
782
881
|
|
|
783
882
|
function closeNav() { select('#workspace').classList.remove('nav-open'); select('#scrim').hidden = true; }
|
|
@@ -1091,18 +1190,57 @@ select('#model-form').addEventListener('submit', async event => {
|
|
|
1091
1190
|
finally { button.disabled = false; state.settingsBusy = false; updateMode(); }
|
|
1092
1191
|
});
|
|
1093
1192
|
select('#message-input').addEventListener('input', updateMode);
|
|
1193
|
+
select('#turn-mode').addEventListener('change',updateMode);
|
|
1194
|
+
select('#browse-files').onclick=()=>{
|
|
1195
|
+
if(!state.selected)return;
|
|
1196
|
+
browseProjectFiles();
|
|
1197
|
+
select('#file-browser-dialog').showModal();
|
|
1198
|
+
};
|
|
1199
|
+
select('#close-file-browser').onclick=()=>select('#file-browser-dialog').close();
|
|
1200
|
+
select('#file-browser-list').addEventListener('click',event=>{
|
|
1201
|
+
const folder=event.target.closest('[data-file-directory]');
|
|
1202
|
+
if(folder){browseProjectFiles(folder.dataset.fileDirectory);return}
|
|
1203
|
+
const file=event.target.closest('[data-file-download]');
|
|
1204
|
+
if(file)downloadProjectFile(file.dataset.fileDownload);
|
|
1205
|
+
});
|
|
1206
|
+
select('#attach-files').onclick=()=>select('#file-input').click();
|
|
1207
|
+
select('#file-input').addEventListener('change',event=>uploadFiles(event.target.files));
|
|
1208
|
+
select('#composer-attachments').addEventListener('click',async event=>{
|
|
1209
|
+
const button=event.target.closest('[data-remove-attachment]');
|
|
1210
|
+
if(!button||!state.selected)return;
|
|
1211
|
+
const id=button.dataset.removeAttachment;
|
|
1212
|
+
const threadId=state.selected.id;
|
|
1213
|
+
try{
|
|
1214
|
+
await api(`/api/threads/${encodeURIComponent(threadId)}/attachments/remove`,{attachmentId:id});
|
|
1215
|
+
state.pendingAttachments=state.pendingAttachments.filter(file=>file.id!==id);
|
|
1216
|
+
renderComposerAttachments();updateMode();
|
|
1217
|
+
}catch(error){toast(error.message)}
|
|
1218
|
+
});
|
|
1094
1219
|
select('#message-input').addEventListener('keydown', event => {
|
|
1095
1220
|
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !matchMedia('(max-width:760px)').matches) { event.preventDefault(); if (!select('#send-message').disabled) select('#composer').requestSubmit(); }
|
|
1096
1221
|
});
|
|
1097
1222
|
select('#composer').addEventListener('submit', async event => {
|
|
1098
1223
|
event.preventDefault(); if (select('#send-message').disabled) return;
|
|
1099
|
-
const id = state.selected.id; const
|
|
1224
|
+
const id = state.selected.id; const inputText = select('#message-input').value.trim(); const wasBusy = state.busy;
|
|
1100
1225
|
const turnId = state.turnId;
|
|
1101
1226
|
const steering = wasBusy && state.status.execution.mode === 'desktop';
|
|
1227
|
+
const mode=select('#turn-mode').value;
|
|
1228
|
+
const attachmentIds=state.pendingAttachments.map(file=>file.id);
|
|
1229
|
+
const text=inputText|| (attachmentIds.length?`请查看并处理以下附件:${state.pendingAttachments.map(file=>file.label).join('、')}`:'');
|
|
1102
1230
|
state.busy = true; updateMode();
|
|
1103
1231
|
try {
|
|
1104
|
-
const result = await rpc(steering ? 'turn.steer' : 'turn.start', {
|
|
1105
|
-
|
|
1232
|
+
const result = await rpc(steering ? 'turn.steer' : 'turn.start', {
|
|
1233
|
+
threadId:id,text,attachmentIds,mode,...(steering?{turnId}:{})
|
|
1234
|
+
});
|
|
1235
|
+
if (state.selected?.id === id) {
|
|
1236
|
+
state.turnId = result.completed ? null : result.turnId || (steering ? turnId : null);
|
|
1237
|
+
state.busy = !result.completed;
|
|
1238
|
+
select('#message-input').value = '';
|
|
1239
|
+
state.pendingAttachments=[];
|
|
1240
|
+
renderComposerAttachments();
|
|
1241
|
+
if(!steering)select('#turn-mode').value='default';
|
|
1242
|
+
scheduleRefresh();
|
|
1243
|
+
}
|
|
1106
1244
|
} catch (error) { if (state.selected?.id === id) state.busy = wasBusy; toast(error.message); }
|
|
1107
1245
|
updateMode();
|
|
1108
1246
|
});
|
package/public/index.html
CHANGED
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
</section>
|
|
31
31
|
<section id="cloud-device-screen" class="devices-view cloud-device-screen" hidden>
|
|
32
32
|
<header class="cloud-device-header"><div><span class="eyebrow">YOUR CONNECTED COMPUTERS</span><h1 id="cloud-devices-title">选择要连接的电脑</h1><p id="cloud-device-message">只显示绑定到你账号的设备。</p><p id="cloud-device-quota"></p></div><div class="cloud-device-actions"><button id="cloud-refresh-devices" class="button small" type="button">刷新状态</button><button id="cloud-add-computer" class="primary" type="button">添加电脑</button><button id="cloud-logout-picker" class="button small" type="button">退出登录</button></div></header>
|
|
33
|
-
<section id="cloud-onboarding" class="cloud-onboarding" hidden><div><span class="mini-badge">FIRST CONNECTION</span><h2>一条命令,连接你的电脑</h2><p>电脑需要运行 Codex 桌面应用。Windows 用户复制下面命令到 PowerShell 运行。</p><ol><li>命令会检查 Node.js 24+,缺少时通过 winget 安装。</li><li>浏览器打开后登录此 Orbit 账号,确认添加这台电脑。</li><li
|
|
33
|
+
<section id="cloud-onboarding" class="cloud-onboarding" hidden><div><span class="mini-badge">FIRST CONNECTION</span><h2>一条命令,连接你的电脑</h2><p>电脑需要运行 Codex 桌面应用。Windows 用户复制下面命令到 PowerShell 运行。</p><ol><li>命令会检查 Node.js 24+,缺少时通过 winget 安装。</li><li>浏览器打开后登录此 Orbit 账号,确认添加这台电脑。</li><li>手机登录同一账号,选择已上线的电脑即可使用,无需扫码或输入配对密钥。</li></ol><div class="cloud-command-box"><pre class="cloud-install-command"><code id="cloud-install-command"></code></pre><button id="cloud-copy-install" class="button small" type="button">复制命令</button></div><p id="cloud-install-status" class="cloud-install-status" role="status"></p><p class="cloud-release-note">如果电脑没有 winget,请先从 nodejs.org 安装 Node.js 24+。winget 可能会请求系统安装授权。</p><p class="cloud-release-note">macOS / Linux:先安装 Node.js 24+,再运行 <code>npx --yes --package=orbit-codex-bridge@latest orbit login</code>。</p></div></section>
|
|
34
34
|
<div id="cloud-devices-list" class="device-grid cloud-device-list"></div></section>
|
|
35
35
|
<section id="pair-screen" class="pair-screen" hidden>
|
|
36
36
|
<div class="pair-art"><a class="brand light" href="/"><img src="/icon.svg" alt=""><span>orbit<span class="brand-dot">.</span></span></a><div class="orbital-art"><div class="orbit-ring ring-one"></div><div class="orbit-ring ring-two"></div><div class="orbit-core">o</div><span class="orbit-point"></span></div><div class="pair-copy"><span class="eyebrow">YOUR WORK. IN YOUR ORBIT.</span><h1>会话留在电脑,<br>灵感随你出发。</h1><p>不复制上下文,不从头再来。<br>从熟悉的项目,找到原来的对话。</p></div><span class="art-caption">LOCAL FIRST / BUILT FOR CONTINUITY</span></div>
|
|
37
|
-
<div class="pair-form-wrap"><div class="pair-form"><span class="mini-badge">PRIVATE WORKSPACE</span><h2>连接你的工作台</h2><p>输入电脑上的配对密钥。会话内容仅向已配对的设备开放。</p><form id="pair-form"><label for="pair-code">设备配对密钥</label><input id="pair-code" type="password" placeholder="粘贴本机配对密钥" autocomplete="off" required><button class="primary full" type="submit">安全连接 <span>↗</span></button><p id="pair-error" class="error" role="alert"></p></form><div class="pair-help"><span data-icon="shield"></span><div><strong
|
|
37
|
+
<div class="pair-form-wrap"><div class="pair-form"><span class="mini-badge">PRIVATE WORKSPACE</span><h2>连接你的工作台</h2><p>输入电脑上的配对密钥。会话内容仅向已配对的设备开放。</p><form id="pair-form"><label for="pair-code">设备配对密钥</label><input id="pair-code" type="password" placeholder="粘贴本机配对密钥" autocomplete="off" required><button class="primary full" type="submit">安全连接 <span>↗</span></button><p id="pair-error" class="error" role="alert"></p></form><div class="pair-help"><span data-icon="shield"></span><div><strong>原会话始终留在电脑</strong><p>选择原会话后,消息和操作会发送到这台电脑上正在运行的 Codex。</p></div></div><details><summary>在哪里找到密钥?</summary><p>在 Orbit 目录运行 <code>Get-Content .runtime\pairing-key</code>。仅用于可信设备;它不是模型 API Key。</p></details></div><footer>ORBIT <span>原生会话 · 电脑执行</span></footer></div>
|
|
38
38
|
</section>
|
|
39
39
|
<div id="workspace" class="workspace" hidden>
|
|
40
40
|
<button id="scrim" class="scrim" aria-label="收起导航" hidden></button>
|
|
@@ -47,11 +47,12 @@
|
|
|
47
47
|
<header class="topbar"><div class="breadcrumbs"><button id="menu-button" class="icon-button mobile-only" aria-label="展开导航"><span data-icon="menu"></span></button><span data-icon="orbit"></span><span>工作空间</span><span class="slash">/</span><strong id="breadcrumb-current">所有会话</strong></div><div class="top-actions"><span class="live-pill" id="connection-pill"><i class="tiny-dot"></i> 正在连接</span><button id="connect-device" class="button small"><span data-icon="devices"></span><span class="desktop-label">连接设备</span></button><button id="cloud-device-switcher" class="button small" type="button" hidden></button><button id="logout" class="avatar-button" title="断开当前设备" aria-label="断开当前设备">O</button></div></header>
|
|
48
48
|
<section id="sessions-view" class="sessions-view">
|
|
49
49
|
<section class="thread-pane"><div class="pane-heading"><span class="eyebrow">YOUR CONVERSATIONS</span><div><h1 id="list-title">所有会话</h1><span id="thread-count" class="count-badge">—</span><button id="refresh" class="icon-button" aria-label="刷新会话"><span data-icon="refresh"></span></button></div><p>原来的项目,原来的上下文。</p></div><div class="search-box"><span data-icon="search"></span><input id="search" type="search" placeholder="搜索会话或项目" aria-label="搜索会话或项目"><kbd>⌘ K</kbd></div><div class="filters" role="group" aria-label="会话筛选"><button class="active" data-filter="all" aria-pressed="true">全部</button><button data-filter="locked" aria-pressed="false">已占用</button><button data-filter="pinned" aria-pressed="false">已置顶</button><button data-filter="archived" aria-pressed="false">已归档</button></div><div id="thread-list" class="thread-list" aria-label="原生会话列表"></div><div class="list-footer"><span data-icon="database"></span><span id="list-footer-text">读取 Codex 原生记录</span><i class="tiny-dot"></i></div></section>
|
|
50
|
-
<section id="conversation" class="conversation"><div id="welcome" class="welcome"></div><div id="chat" class="chat" hidden><header class="chat-header"><button id="back" class="icon-button mobile-only" aria-label="返回会话列表"><span data-icon="back"></span></button><div class="chat-title"><h2 id="chat-title"></h2><p id="chat-subtitle"></p></div><button id="details-toggle" class="icon-button" aria-label="查看会话详情"><span data-icon="info"></span></button><button id="enable-control" class="button small">接续会话</button></header><div id="chat-details" class="chat-details" hidden></div><div id="mode-banner" class="mode-banner"></div><div id="messages" class="messages" aria-live="polite"></div><div id="approvals" class="approvals"></div><div class="composer-wrap"><div class="session-settings-bar"><button id="model-settings-button" class="model-settings-button" type="button" title="选择模型和推理级别" aria-label="选择模型和推理级别" disabled><span data-icon="settings"></span><span id="model-settings-label">当前模型</span></button><div id="context-meter" class="context-meter" title="暂无准确的上下文用量"><span>上下文</span><strong id="context-label">暂无用量</strong><span class="context-track" role="progressbar" aria-label="上下文已用比例" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" hidden><span id="context-fill"></span></span></div></div><form id="composer" class="composer"><textarea id="message-input" placeholder="
|
|
50
|
+
<section id="conversation" class="conversation"><div id="welcome" class="welcome"></div><div id="chat" class="chat" hidden><header class="chat-header"><button id="back" class="icon-button mobile-only" aria-label="返回会话列表"><span data-icon="back"></span></button><div class="chat-title"><h2 id="chat-title"></h2><p id="chat-subtitle"></p></div><button id="details-toggle" class="icon-button" aria-label="查看会话详情"><span data-icon="info"></span></button><button id="enable-control" class="button small">接续会话</button></header><div id="chat-details" class="chat-details" hidden></div><div id="mode-banner" class="mode-banner"></div><div id="messages" class="messages" aria-live="polite"></div><div id="approvals" class="approvals"></div><div class="composer-wrap"><div class="session-settings-bar"><button id="model-settings-button" class="model-settings-button" type="button" title="选择模型和推理级别" aria-label="选择模型和推理级别" disabled><span data-icon="settings"></span><span id="model-settings-label">当前模型</span></button><button id="browse-files" class="button small" type="button" aria-label="浏览电脑项目文件">电脑文件</button><div id="context-meter" class="context-meter" title="暂无准确的上下文用量"><span>上下文</span><strong id="context-label">暂无用量</strong><span class="context-track" role="progressbar" aria-label="上下文已用比例" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" hidden><span id="context-fill"></span></span></div></div><form id="composer" class="composer"><textarea id="message-input" placeholder="沿着这条会话继续…" rows="2" aria-label="发送消息"></textarea><div id="composer-attachments" class="composer-attachments" aria-live="polite"></div><div class="composer-toolbar"><label class="turn-mode-label" for="turn-mode">模式</label><select id="turn-mode" class="turn-mode-select" aria-label="本轮对话模式"><option value="default">普通</option><option value="plan">计划</option><option value="goal">目标(本轮)</option></select><input id="file-input" type="file" multiple hidden><button id="attach-files" class="button small" type="button" aria-label="添加附件">添加文件</button><span id="composer-mode"><span data-icon="shield"></span>只读镜像</span><button id="stop-turn" class="button small" type="button" hidden>停止任务</button><button id="send-message" class="send-button" aria-label="发送消息" disabled><span data-icon="arrow"></span></button></div></form><p class="composer-note" id="composer-note">查看不会唤醒会话,也不会打断电脑上的执行。</p></div></div></section>
|
|
51
51
|
</section><section id="devices-view" class="devices-view" hidden></section>
|
|
52
52
|
</main>
|
|
53
53
|
</div>
|
|
54
54
|
<dialog id="control-dialog"><form id="control-form"><span class="mini-badge">CONTINUE, NOT FORK</span><h2 id="control-title">授权热接管</h2><p id="control-description">保持原会话与桌面执行实例,不创建新会话或独立执行器。</p><div class="notice"><span data-icon="shield"></span><span id="control-notice">指令会交给原 Codex 桌面会话执行,并沿用其权限与审批策略。</span></div><label class="check-row" id="allow-write-row"><input id="allow-write" type="checkbox"><span>允许修改此会话的工作区文件<small>默认只读。扩大权限仍需要审批;不会启用无限制执行。</small></span></label><label class="check-row"><input id="control-confirm" type="checkbox" required><span id="control-confirm-copy">我确认授权此设备控制这条原生会话。</span></label><div class="dialog-actions"><button type="button" id="cancel-control" class="button">先只读查看</button><button class="primary" id="confirm-control-button" type="submit">授权热接管 <span>↗</span></button></div><p id="control-error" class="error" role="alert"></p></form></dialog>
|
|
55
55
|
<dialog id="model-dialog"><form id="model-form"><h2>会话参数</h2><p id="model-description">更改将在这条会话的下一轮生效。</p><label for="model-select">模型</label><select id="model-select" required></select><label for="effort-select">推理级别</label><select id="effort-select"></select><p id="model-note" class="model-note"></p><p id="model-error" class="error" role="alert"></p><div class="dialog-actions"><button id="cancel-model" type="button" class="button">取消</button><button id="authorize-model" type="button" class="primary" hidden>先授权热接管</button><button id="apply-model" type="submit" class="primary">应用到下一轮</button></div></form></dialog>
|
|
56
|
+
<dialog id="file-browser-dialog" class="file-browser-dialog"><section class="file-browser-panel"><header><div><h2>电脑项目文件</h2><p id="file-browser-path">项目根目录</p></div><button id="close-file-browser" class="button small" type="button">关闭</button></header><p class="file-browser-notice">仅浏览当前会话工作目录;系统目录、凭证文件及符号链接不会开放。单个文件最大 8 MiB。</p><div id="file-browser-list" class="file-browser-list" aria-live="polite"></div><p id="file-browser-error" class="error" role="alert"></p></section></dialog>
|
|
56
57
|
<div id="toast" class="toast" role="status" hidden></div>
|
|
57
58
|
</body></html>
|