orbit-codex-bridge 0.1.2 → 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 +9 -0
- package/lib/desktop.mjs +45 -7
- package/package.json +1 -1
- package/public/app.js +148 -6
- package/public/index.html +2 -1
- package/public/style.css +30 -0
- package/relay-agent.mjs +4 -2
- package/relay-server.mjs +15 -4
- package/server.mjs +262 -8
package/README.md
CHANGED
|
@@ -34,6 +34,15 @@ orbit login
|
|
|
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
|
| 命令 | 用途 |
|
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, approvals: new Map(), reconnect: 0, loggedOut: false, busy: false, turnId: 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;
|
|
@@ -377,6 +377,7 @@ function connectSocket() {
|
|
|
377
377
|
setConnection(false);
|
|
378
378
|
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('连接中断,消息不会自动重发。请先检查会话记录。')); }
|
|
379
379
|
state.pending.clear();
|
|
380
|
+
state.pendingAttachments=[];
|
|
380
381
|
scheduleReconnect();
|
|
381
382
|
});
|
|
382
383
|
socket.addEventListener('error', () => {});
|
|
@@ -416,6 +417,8 @@ async function subscribeThread(id) {
|
|
|
416
417
|
state.turnId = result.turnId;
|
|
417
418
|
state.busy = Boolean(result.turnId);
|
|
418
419
|
state.approvals = new Map((result.approvals || []).map(approval => [approval.requestId, approval]));
|
|
420
|
+
state.pendingAttachments=result.attachments||[];
|
|
421
|
+
renderComposerAttachments();
|
|
419
422
|
if (desktop && result.control) {
|
|
420
423
|
controlRenewTimer = setTimeout(() => {
|
|
421
424
|
if (state.selected?.id === id && state.socket?.readyState === WebSocket.OPEN && state.desktopRuntime?.attached) {
|
|
@@ -492,6 +495,8 @@ async function openThread(id) {
|
|
|
492
495
|
if (select('#model-dialog').open) select('#model-dialog').close();
|
|
493
496
|
const selectionVersion = ++state.selectionVersion;
|
|
494
497
|
state.pendingModelChoice = null;
|
|
498
|
+
state.pendingAttachments=[];
|
|
499
|
+
select('#turn-mode').value='default';
|
|
495
500
|
state.historyStatus = 'loading'; state.historyError = '';
|
|
496
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;
|
|
497
502
|
select('#welcome').hidden = true; select('#chat').hidden = false; select('#workspace').classList.add('chat-open');
|
|
@@ -500,7 +505,7 @@ async function openThread(id) {
|
|
|
500
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>`;
|
|
501
506
|
select('#chat-details').hidden = true;
|
|
502
507
|
select('#messages').replaceChildren(); renderMessages();
|
|
503
|
-
renderList();
|
|
508
|
+
renderComposerAttachments();renderList();updateMode();renderApprovals();
|
|
504
509
|
subscribeThread(id).catch(error => { if (selectionVersion === state.selectionVersion) toast(error.message); });
|
|
505
510
|
try { await loadMessages(true); } catch (error) { if (selectionVersion === state.selectionVersion) toast(error.message); }
|
|
506
511
|
}
|
|
@@ -742,9 +747,107 @@ function updateMode() {
|
|
|
742
747
|
select('#send-message').title = state.busy && desktop ? '追加指令到当前桌面任务' : '沿用原会话发送';
|
|
743
748
|
select('#stop-turn').hidden = !state.busy || !state.turnId || !control;
|
|
744
749
|
select('#message-input').placeholder = state.busy && desktop ? '追加指令给电脑上正在运行的任务…' : control ? '沿着原来的上下文继续…' : desktop ? '连接原桌面会话后继续…' : '先确认接续方式,再沿着这条会话继续…';
|
|
745
|
-
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
|
+
: '只读查看不会唤醒或打断电脑上的执行。';
|
|
746
761
|
renderModelStatus();
|
|
747
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
|
+
|
|
748
851
|
function renderApprovals() {
|
|
749
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('');
|
|
750
853
|
}
|
|
@@ -1087,18 +1190,57 @@ select('#model-form').addEventListener('submit', async event => {
|
|
|
1087
1190
|
finally { button.disabled = false; state.settingsBusy = false; updateMode(); }
|
|
1088
1191
|
});
|
|
1089
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
|
+
});
|
|
1090
1219
|
select('#message-input').addEventListener('keydown', event => {
|
|
1091
1220
|
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !matchMedia('(max-width:760px)').matches) { event.preventDefault(); if (!select('#send-message').disabled) select('#composer').requestSubmit(); }
|
|
1092
1221
|
});
|
|
1093
1222
|
select('#composer').addEventListener('submit', async event => {
|
|
1094
1223
|
event.preventDefault(); if (select('#send-message').disabled) return;
|
|
1095
|
-
const id = state.selected.id; const
|
|
1224
|
+
const id = state.selected.id; const inputText = select('#message-input').value.trim(); const wasBusy = state.busy;
|
|
1096
1225
|
const turnId = state.turnId;
|
|
1097
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('、')}`:'');
|
|
1098
1230
|
state.busy = true; updateMode();
|
|
1099
1231
|
try {
|
|
1100
|
-
const result = await rpc(steering ? 'turn.steer' : 'turn.start', {
|
|
1101
|
-
|
|
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
|
+
}
|
|
1102
1244
|
} catch (error) { if (state.selected?.id === id) state.busy = wasBusy; toast(error.message); }
|
|
1103
1245
|
updateMode();
|
|
1104
1246
|
});
|
package/public/index.html
CHANGED
|
@@ -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>
|
package/public/style.css
CHANGED
|
@@ -37,4 +37,34 @@
|
|
|
37
37
|
.activity-diff .diff-added{color:#a8d89a;background:#47614845}
|
|
38
38
|
.activity-diff .diff-removed{color:#edb0a7;background:#70474445}
|
|
39
39
|
.activity-empty{margin:8px 0;color:#9ba38e;font-size:10px}
|
|
40
|
+
.turn-mode-label{font-size:10px;color:#8b9785}
|
|
41
|
+
.turn-mode-select{max-width:95px;border:1px solid #e2e7dc;background:#f7f8f4;border-radius:6px;padding:5px 7px;color:#506045;font-size:10px}
|
|
42
|
+
.turn-mode-select:disabled{opacity:.55}
|
|
43
|
+
.composer-toolbar .turn-mode-label,.composer-toolbar .turn-mode-select,.composer-toolbar #attach-files{flex:none}
|
|
44
|
+
.composer-toolbar #composer-mode{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
45
|
+
.composer-attachments{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
|
46
|
+
.composer-attachment{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:5px 7px;border:1px solid #dfe6d5;border-radius:6px;background:#f7f9f2;color:#68795a;font-size:9px}
|
|
47
|
+
.composer-attachment>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
48
|
+
.composer-attachment svg{width:13px;height:13px}
|
|
49
|
+
.composer-attachment button{width:20px;height:20px;border-radius:4px;color:#8b9785;font-size:16px;line-height:1}
|
|
50
|
+
.composer-attachment button:hover{background:#e8eddf}
|
|
51
|
+
.composer-attachment button:disabled{opacity:.5}
|
|
52
|
+
.file-browser-dialog{width:min(560px,calc(100vw - 28px));max-width:560px;max-height:min(78dvh,760px);padding:0;border:1px solid #dfe5d7;border-radius:13px;background:#fbfcf8;color:#354536;box-shadow:0 20px 80px #17251a40}
|
|
53
|
+
.file-browser-dialog::backdrop{background:#1b2c22a8;backdrop-filter:blur(3px)}
|
|
54
|
+
.file-browser-panel{display:flex;flex-direction:column;max-height:min(78dvh,760px);padding:20px}
|
|
55
|
+
.file-browser-panel>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding-bottom:12px}
|
|
56
|
+
.file-browser-panel h2{margin:0;font-size:17px;font-weight:550}
|
|
57
|
+
.file-browser-panel header p{margin:5px 0 0;max-width:360px;overflow-wrap:anywhere;color:#7d8a72;font-size:10px}
|
|
58
|
+
.file-browser-notice{font-size:10px;line-height:1.7;color:#899380}
|
|
59
|
+
.file-browser-list{overflow:auto;min-height:100px;max-height:52dvh;border:1px solid var(--line);border-radius:8px;background:#fff}
|
|
60
|
+
.file-browser-entry{width:100%;min-height:43px;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid #eef0e9;text-align:left}
|
|
61
|
+
.file-browser-entry:last-child{border-bottom:0}
|
|
62
|
+
.file-browser-entry:hover{background:#f3f6ed}
|
|
63
|
+
.file-browser-entry>span{color:#80916d;flex:none}
|
|
64
|
+
.file-browser-entry svg{width:15px;height:15px}
|
|
65
|
+
.file-browser-entry strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-weight:450}
|
|
66
|
+
.file-browser-entry small{margin-left:auto;flex:none;color:#929d88;font-size:9px}
|
|
67
|
+
.file-browser-parent{background:#f8faf4;color:#647653}
|
|
68
|
+
.file-browser-empty{padding:16px;color:#909b84;font-size:10px}
|
|
69
|
+
@media(max-width:760px){.composer-toolbar{flex-wrap:wrap}.composer-toolbar #composer-mode{flex:1 1 65px}.turn-mode-select{max-width:92px}.composer-attachment{max-width:100%}.file-browser-panel{padding:16px}.session-settings-bar{gap:5px}.session-settings-bar #browse-files{padding:6px 8px;font-size:9px}}
|
|
40
70
|
@media(max-width:760px){.message-head .message-copy,.copy-code{opacity:1}.activity-message{margin-left:0}.activity-content{padding-left:14px}.activity-meta{max-width:40%;white-space:normal}.message-attachments{margin-left:0}.message-body pre{padding-top:38px}}
|
package/relay-agent.mjs
CHANGED
|
@@ -69,7 +69,9 @@ export function createRelayAgent(options = {}) {
|
|
|
69
69
|
requests.delete(request);
|
|
70
70
|
resolve({statusCode:response.statusCode || 502,headers:{
|
|
71
71
|
'content-type':response.headers['content-type'] || 'application/json',
|
|
72
|
-
...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {})
|
|
72
|
+
...(response.headers['set-cookie'] ? {'set-cookie':response.headers['set-cookie']} : {}),
|
|
73
|
+
...Object.fromEntries(['content-disposition','content-length','accept-ranges','content-range','etag']
|
|
74
|
+
.filter(name=>typeof response.headers[name]==='string').map(name=>[name,response.headers[name]]))
|
|
73
75
|
},body:Buffer.concat(chunks)});
|
|
74
76
|
});
|
|
75
77
|
});
|
|
@@ -97,7 +99,7 @@ export function createRelayAgent(options = {}) {
|
|
|
97
99
|
const body = Buffer.from(message.body || '', 'base64');
|
|
98
100
|
if (body.length > maxBody) return;
|
|
99
101
|
const headers = {};
|
|
100
|
-
for (const name of ['accept','content-type','cookie','user-agent']) {
|
|
102
|
+
for (const name of ['accept','content-type','cookie','user-agent','range','if-range','if-none-match']) {
|
|
101
103
|
const value = message.headers?.[name];
|
|
102
104
|
if (typeof value === 'string') headers[name] = value;
|
|
103
105
|
}
|
package/relay-server.mjs
CHANGED
|
@@ -71,13 +71,24 @@ function responseHeaders(headers, deviceId, secureCookie) {
|
|
|
71
71
|
const token = paired.split(';')[0].slice('orbit_session='.length);
|
|
72
72
|
safe['Set-Cookie'] = `${deviceCookieName(deviceId)}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? 43200 : 0}${secureCookie ? '; Secure' : ''}`;
|
|
73
73
|
}
|
|
74
|
+
const contentType=headers?.['content-type'];
|
|
75
|
+
if(typeof contentType==='string'&&/^(?:application\/(?:json|pdf|zip|octet-stream)|text\/[a-z0-9.+-]+(?:;\s*charset=utf-8)?|image\/(?:png|jpeg|gif|webp|svg\+xml))$/i.test(contentType))safe['Content-Type']=contentType;
|
|
76
|
+
const disposition=headers?.['content-disposition'];
|
|
77
|
+
if(typeof disposition==='string'&&disposition.length<=1000
|
|
78
|
+
&&/^attachment;\s*filename="[^"\r\n]{1,180}";\s*filename\*=UTF-8''[a-zA-Z0-9%._~-]{1,600}$/.test(disposition))safe['Content-Disposition']=disposition;
|
|
79
|
+
const length=headers?.['content-length'];
|
|
80
|
+
if(/^\d{1,8}$/.test(String(length||'')))safe['Content-Length']=length;
|
|
81
|
+
if(/^(?:bytes \d+-\d+\/\d+|bytes \*\/\d+)$/.test(headers?.['content-range']||''))safe['Content-Range']=headers['content-range'];
|
|
82
|
+
if(headers?.['accept-ranges']==='bytes')safe['Accept-Ranges']='bytes';
|
|
83
|
+
if(typeof headers?.etag==='string'&&/^"orbit-\d+-\d+"$/.test(headers.etag))safe.ETag=headers.etag;
|
|
74
84
|
return safe;
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
function isLocalApi(pathname, method) {
|
|
78
88
|
if (method === 'GET') return ['/api/status','/api/threads','/api/models'].includes(pathname)
|
|
79
|
-
|| /^\/api\/threads\/[a-zA-Z0-9_-]{8,120}\/messages$/.test(pathname);
|
|
80
|
-
return method === 'POST' && ['/api/pair','/api/logout','/api/pair/qr'].includes(pathname)
|
|
89
|
+
|| /^\/api\/threads\/[a-zA-Z0-9_-]{8,120}\/(?:messages|files|file)$/.test(pathname);
|
|
90
|
+
return method === 'POST' && (['/api/pair','/api/logout','/api/pair/qr'].includes(pathname)
|
|
91
|
+
|| /^\/api\/threads\/[a-zA-Z0-9_-]{8,120}\/attachments(?:\/remove)?$/.test(pathname));
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
function json(response, status, body, headers = {}) {
|
|
@@ -261,14 +272,14 @@ export function createRelayServer(options = {}) {
|
|
|
261
272
|
const agent=agents.get(device.id);
|
|
262
273
|
if(!agent||agent.socket.readyState!==WebSocket.OPEN)return json(response,503,{error:`电脑“${device.name}”当前不在线。`});
|
|
263
274
|
if(accountConnect&&!agent.accountConnect)return json(response,409,{
|
|
264
|
-
error:'请在电脑上更新 Orbit 连接器到 0.1.
|
|
275
|
+
error:'请在电脑上更新 Orbit 连接器到 0.1.3 或更高版本后重试。',code:'connector_upgrade_required'
|
|
265
276
|
});
|
|
266
277
|
const body=await readBody(request);
|
|
267
278
|
if (!store.hasSession(authenticated.tokenHash,authenticated.user.id)) return json(response,401,{error:'请先登录云端工作台。',code:'cloud_login_required'});
|
|
268
279
|
chooseDevice(request, store.getUser(authenticated.user.id));
|
|
269
280
|
const requestId=crypto.randomUUID();
|
|
270
281
|
const headers={};
|
|
271
|
-
for(const name of ['accept','content-type','host','origin','user-agent'])if(request.headers[name])headers[name]=request.headers[name];
|
|
282
|
+
for(const name of ['accept','content-type','host','origin','user-agent','range','if-range','if-none-match'])if(request.headers[name])headers[name]=request.headers[name];
|
|
272
283
|
headers.cookie = localCookie(request,device.id);
|
|
273
284
|
if(pendingHttp.size>=500)return json(response,429,{error:'请求过多,请稍后重试。'});
|
|
274
285
|
const promise=new Promise((resolve,reject)=>{
|
package/server.mjs
CHANGED
|
@@ -13,12 +13,139 @@ import { DesktopRpc } from './lib/desktop.mjs';
|
|
|
13
13
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const packageVersion = JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).version;
|
|
15
15
|
const sessionLifetime = 12 * 60 * 60 * 1000;
|
|
16
|
+
const attachmentLifetime = 30 * 60 * 1000;
|
|
17
|
+
const attachmentRetention = 24 * 60 * 60 * 1000;
|
|
18
|
+
const maxAttachmentBytes = 8 * 1024 * 1024;
|
|
19
|
+
const maxPendingAttachments = 5;
|
|
16
20
|
const safeError = error => String(error.message || error).replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]').replace(/(?:sk-|sk_)[\w-]{12,}/g, '[REDACTED]').slice(0, 700);
|
|
17
21
|
|
|
18
22
|
export function lanAddresses() {
|
|
19
23
|
return Object.values(os.networkInterfaces()).flat().filter(address => address && address.family === 'IPv4' && !address.internal).map(address => address.address);
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
function cleanupStaleAttachments(store) {
|
|
27
|
+
const now=Date.now();
|
|
28
|
+
for(const cwd of new Set(store.list().map(thread=>thread.cwd).filter(Boolean))){
|
|
29
|
+
let projectRoot;
|
|
30
|
+
try{projectRoot=fs.realpathSync(cwd)}catch{continue}
|
|
31
|
+
const directory=path.join(projectRoot,'.orbit-attachments');
|
|
32
|
+
let entries;
|
|
33
|
+
try{
|
|
34
|
+
const stat=fs.lstatSync(directory);
|
|
35
|
+
if(stat.isSymbolicLink()||!stat.isDirectory())continue;
|
|
36
|
+
entries=fs.readdirSync(directory);
|
|
37
|
+
}catch{continue}
|
|
38
|
+
for(const name of entries){
|
|
39
|
+
if(!/^[a-f0-9]{32}(?:\.[a-zA-Z0-9]{1,12})?$/.test(name))continue;
|
|
40
|
+
const filename=path.join(directory,name);
|
|
41
|
+
try{
|
|
42
|
+
const stat=fs.lstatSync(filename);
|
|
43
|
+
if(!stat.isSymbolicLink()&&stat.isFile()&&now-stat.mtimeMs>attachmentRetention)fs.unlinkSync(filename);
|
|
44
|
+
}catch{}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const excludedProjectEntries=new Set([
|
|
50
|
+
'.git','.codex','.orbit-attachments','node_modules','.venv','venv','dist','build',
|
|
51
|
+
'.ssh','.aws','.azure','.config','.npm','.docker','secrets','credentials'
|
|
52
|
+
]);
|
|
53
|
+
const excludedProjectFiles=/^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.git-credentials|id_(?:rsa|ed25519)(?:\.pub)?|.*(?:secret|credential)(?:\..*)?|.*\.(?:pem|p12|pfx|key|keystore))$/i;
|
|
54
|
+
|
|
55
|
+
function checkedProjectEntry(row,relativePath=''){
|
|
56
|
+
if(!row?.cwd||typeof relativePath!=='string'||relativePath.length>1024||relativePath.includes('\0'))throw new Error('项目文件路径无效。');
|
|
57
|
+
const root=fs.realpathSync(row.cwd);
|
|
58
|
+
if(!fs.statSync(root).isDirectory())throw new Error('会话工作目录不可用。');
|
|
59
|
+
const parts=relativePath?relativePath.split(/[\\/]/):[];
|
|
60
|
+
if(parts.some(part=>!part||part==='.'||part==='..'))throw new Error('不允许访问项目目录之外的路径。');
|
|
61
|
+
let current=root;
|
|
62
|
+
for(const part of parts){
|
|
63
|
+
if(excludedProjectEntries.has(part.toLowerCase())||excludedProjectFiles.test(part))throw new Error('此系统目录或凭证文件不开放传输。');
|
|
64
|
+
current=path.join(current,part);
|
|
65
|
+
const stat=fs.lstatSync(current);
|
|
66
|
+
if(stat.isSymbolicLink())throw new Error('不支持通过符号链接传输文件。');
|
|
67
|
+
}
|
|
68
|
+
const resolved=path.resolve(current);
|
|
69
|
+
const relative=path.relative(root,resolved);
|
|
70
|
+
if(relative==='..'||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative))throw new Error('不允许访问项目目录之外的路径。');
|
|
71
|
+
return {root,path:resolved,stat:fs.statSync(resolved),relative:parts.join(path.sep)};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function projectFileList(row,relativePath=''){
|
|
75
|
+
const directory=checkedProjectEntry(row,relativePath);
|
|
76
|
+
if(!directory.stat.isDirectory())throw new Error('所选路径不是文件夹。');
|
|
77
|
+
const entries=[];
|
|
78
|
+
const visit=(current,relative,depth)=>{
|
|
79
|
+
if(depth>8||entries.length>=1000)return;
|
|
80
|
+
for(const name of fs.readdirSync(current)){
|
|
81
|
+
if(excludedProjectEntries.has(name.toLowerCase())||excludedProjectFiles.test(name))continue;
|
|
82
|
+
const target=path.join(current,name);
|
|
83
|
+
let stat;
|
|
84
|
+
try{stat=fs.lstatSync(target)}catch{continue}
|
|
85
|
+
if(stat.isSymbolicLink())continue;
|
|
86
|
+
const child=relative?`${relative}/${name}`:name;
|
|
87
|
+
if(stat.isDirectory())entries.push({name,path:child,type:'directory',size:null,updatedAt:stat.mtimeMs});
|
|
88
|
+
else if(stat.isFile())entries.push({name,path:child,type:'file',size:stat.size,updatedAt:stat.mtimeMs});
|
|
89
|
+
if(stat.isDirectory())visit(target,child,depth+1);
|
|
90
|
+
if(entries.length>=1000)break;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
visit(directory.path,directory.relative,0);
|
|
94
|
+
entries.sort((left,right)=>left.type===right.type?left.name.localeCompare(right.name):left.type==='directory'?-1:1);
|
|
95
|
+
return {path:directory.relative,entries:entries.slice(0,1000),truncated:entries.length>=1000};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const projectMimeTypes=new Map([
|
|
99
|
+
['.txt','text/plain; charset=utf-8'],['.md','text/markdown; charset=utf-8'],['.json','application/json'],
|
|
100
|
+
['.csv','text/csv; charset=utf-8'],['.html','text/html; charset=utf-8'],['.css','text/css; charset=utf-8'],
|
|
101
|
+
['.js','text/javascript; charset=utf-8'],['.ts','text/plain; charset=utf-8'],['.xml','application/xml'],
|
|
102
|
+
['.pdf','application/pdf'],['.png','image/png'],['.jpg','image/jpeg'],['.jpeg','image/jpeg'],['.gif','image/gif'],
|
|
103
|
+
['.webp','image/webp'],['.svg','image/svg+xml'],['.zip','application/zip']
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
function sendFileRange(request,response,row,relativePath){
|
|
107
|
+
const file=checkedProjectEntry(row,relativePath);
|
|
108
|
+
if(!file.stat.isFile())throw new Error('所选路径不是文件。');
|
|
109
|
+
if(file.stat.size>8*1024*1024)throw Object.assign(new Error('单个文件下载不得超过 8 MiB。'),{status:413});
|
|
110
|
+
const filename=path.basename(file.path);
|
|
111
|
+
const fallback=filename.replace(/[^\x20-\x7e]|["\\;]/g,'_');
|
|
112
|
+
const encoded=encodeURIComponent(filename).replace(/[!'()*]/g,character=>`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
113
|
+
const etag=`"orbit-${file.stat.size}-${Math.trunc(file.stat.mtimeMs)}"`;
|
|
114
|
+
const headers={
|
|
115
|
+
'Content-Type':projectMimeTypes.get(path.extname(filename).toLowerCase())||'application/octet-stream',
|
|
116
|
+
'Accept-Ranges':'bytes',
|
|
117
|
+
'ETag':etag,
|
|
118
|
+
'Content-Disposition':`attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
|
|
119
|
+
'X-Content-Type-Options':'nosniff'
|
|
120
|
+
};
|
|
121
|
+
if(request.headers['if-none-match']===etag){response.writeHead(304,headers);return response.end()}
|
|
122
|
+
const ifRange=request.headers['if-range'];
|
|
123
|
+
const range=ifRange&&ifRange!==etag?null:request.headers.range;
|
|
124
|
+
if(!range){
|
|
125
|
+
response.writeHead(200,{...headers,'Content-Length':file.stat.size});
|
|
126
|
+
if(request.method==='HEAD')return response.end();
|
|
127
|
+
return fs.createReadStream(file.path).pipe(response);
|
|
128
|
+
}
|
|
129
|
+
const match=String(range).match(/^bytes=(\d*)-(\d*)$/);
|
|
130
|
+
if(!match||(!match[1]&&!match[2])){response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end()}
|
|
131
|
+
let start,end;
|
|
132
|
+
if(!match[1]){
|
|
133
|
+
const suffix=Number(match[2]);
|
|
134
|
+
if(!Number.isSafeInteger(suffix)||suffix<=0)return response.writeHead(416,{'Content-Range':`bytes */${file.stat.size}`}).end();
|
|
135
|
+
start=Math.max(0,file.stat.size-suffix);end=file.stat.size-1;
|
|
136
|
+
}else{
|
|
137
|
+
start=Number(match[1]);end=match[2]?Number(match[2]):file.stat.size-1;
|
|
138
|
+
}
|
|
139
|
+
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||start>=file.stat.size||end<start){
|
|
140
|
+
response.writeHead(416,{...headers,'Content-Range':`bytes */${file.stat.size}`});return response.end();
|
|
141
|
+
}
|
|
142
|
+
end=Math.min(end,file.stat.size-1);
|
|
143
|
+
const length=end-start+1;
|
|
144
|
+
response.writeHead(206,{...headers,'Content-Length':length,'Content-Range':`bytes ${start}-${end}/${file.stat.size}`});
|
|
145
|
+
if(request.method==='HEAD')return response.end();
|
|
146
|
+
fs.createReadStream(file.path,{start,end}).pipe(response);
|
|
147
|
+
}
|
|
148
|
+
|
|
22
149
|
export function createOrbitServer(options = {}) {
|
|
23
150
|
const home = options.home || process.env.ORBIT_CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
24
151
|
const dataDir = options.dataDir || path.join(root, '.runtime');
|
|
@@ -27,6 +154,7 @@ export function createOrbitServer(options = {}) {
|
|
|
27
154
|
let pairingKey = fs.existsSync(credentialPath) ? fs.readFileSync(credentialPath, 'utf8').trim() : crypto.randomBytes(24).toString('base64url');
|
|
28
155
|
fs.writeFileSync(credentialPath, pairingKey, { mode: 0o600 });
|
|
29
156
|
const store = options.store || new NativeStore(home);
|
|
157
|
+
cleanupStaleAttachments(store);
|
|
30
158
|
const endpoint = options.endpoint ?? process.env.ORBIT_APP_SERVER_URL ?? '';
|
|
31
159
|
const rpc = options.rpc || options.desktopRpc || (endpoint ? new CodexRpc({ home, endpoint, tokenFile: process.env.ORBIT_APP_SERVER_TOKEN_FILE }) : new DesktopRpc());
|
|
32
160
|
const executionMode = rpc.mode || (endpoint ? 'endpoint' : 'cold-resume');
|
|
@@ -70,6 +198,7 @@ export function createOrbitServer(options = {}) {
|
|
|
70
198
|
const sessions = new Map();
|
|
71
199
|
const attempts = new Map();
|
|
72
200
|
const dedup = new Map();
|
|
201
|
+
const consumedAttachments = new Map();
|
|
73
202
|
const sockets = new Set();
|
|
74
203
|
const desktopWatchers = new Map();
|
|
75
204
|
const queuedSettingsInFlight = new Set();
|
|
@@ -78,6 +207,7 @@ export function createOrbitServer(options = {}) {
|
|
|
78
207
|
let revision = store.revision();
|
|
79
208
|
let timer;
|
|
80
209
|
let lastDesktopConnect = 0;
|
|
210
|
+
let lastAttachmentCleanup=Date.now();
|
|
81
211
|
|
|
82
212
|
function requestOrigin(request) {
|
|
83
213
|
const hostname = new URL(`http://${request.headers.host || 'invalid'}`).hostname;
|
|
@@ -99,6 +229,65 @@ export function createOrbitServer(options = {}) {
|
|
|
99
229
|
return { ...session, key };
|
|
100
230
|
}
|
|
101
231
|
|
|
232
|
+
function removeAttachment(session,id){
|
|
233
|
+
const attachment=session?.attachments?.get(id)||consumedAttachments.get(id);
|
|
234
|
+
if(!attachment)return false;
|
|
235
|
+
session?.attachments?.delete(id);
|
|
236
|
+
consumedAttachments.delete(id);
|
|
237
|
+
const filename=path.resolve(attachment.path);
|
|
238
|
+
if(path.dirname(filename)===path.join(path.resolve(attachment.projectRoot),'.orbit-attachments')
|
|
239
|
+
&&/^[a-f0-9]{32}(?:\.[a-zA-Z0-9]{1,12})?$/.test(path.basename(filename))){
|
|
240
|
+
try{if(fs.lstatSync(filename).isFile())fs.unlinkSync(filename)}catch{}
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function removeSessionAttachments(session){
|
|
246
|
+
for(const [id,attachment] of session.attachments||[]){
|
|
247
|
+
if(attachment.consumed){consumedAttachments.set(id,attachment);session.attachments.delete(id);}
|
|
248
|
+
else removeAttachment(session,id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function attachmentPath(row,id,label){
|
|
253
|
+
const extension=path.extname(label).slice(1).toLowerCase();
|
|
254
|
+
const projectRoot=fs.realpathSync(row.cwd);
|
|
255
|
+
if(!fs.statSync(projectRoot).isDirectory())throw new Error('会话工作目录不可用。');
|
|
256
|
+
const directory=path.join(projectRoot,'.orbit-attachments');
|
|
257
|
+
try{
|
|
258
|
+
const existing=fs.lstatSync(directory);
|
|
259
|
+
if(existing.isSymbolicLink()||!existing.isDirectory())throw new Error('附件暂存目录不安全。');
|
|
260
|
+
}catch(error){if(error.code!=='ENOENT')throw error;fs.mkdirSync(directory,{mode:0o700});}
|
|
261
|
+
return path.join(directory,`${id}${/^[a-z0-9]{1,12}$/.test(extension)?`.${extension}`:''}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function uploadAttachment(request,response,session,threadId,url){
|
|
265
|
+
const row=store.row(threadId);
|
|
266
|
+
if(!row||row.archived||String(row.source).startsWith('{'))return json(response,400,{error:'归档会话或子代理不能接收文件。'});
|
|
267
|
+
if(executionMode!=='desktop'||!rpc.connected)return json(response,409,{error:'文件传输仅支持已连接的 Codex 桌面原会话。'});
|
|
268
|
+
if(!controlFor(session,threadId))return json(response,403,{error:'请先连接并接管这条原会话。'});
|
|
269
|
+
if(!rpc.runtime(threadId)?.attached)return json(response,409,{error:'请先在 Codex 桌面打开此会话并重新连接。'});
|
|
270
|
+
for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
|
|
271
|
+
if([...session.attachments.values()].filter(item=>item.threadId===threadId&&!item.consumed).length>=maxPendingAttachments)return json(response,429,{error:`这条会话最多可暂存 ${maxPendingAttachments} 个待发送文件。`});
|
|
272
|
+
const rawName=url.searchParams.get('name')||'';
|
|
273
|
+
const label=rawName.replace(/[\\/]/g,'_').replace(/[\u0000-\u001f\u007f<>:"|?*]/g,'_').trim().slice(0,160);
|
|
274
|
+
if(!label||label==='.'||label==='..')return json(response,400,{error:'文件名无效。'});
|
|
275
|
+
const chunks=[];
|
|
276
|
+
let size=0;
|
|
277
|
+
for await(const chunk of request){
|
|
278
|
+
size+=chunk.length;
|
|
279
|
+
if(size>maxAttachmentBytes)return json(response,413,{error:'单个文件不得超过 8 MiB。'});
|
|
280
|
+
chunks.push(chunk);
|
|
281
|
+
}
|
|
282
|
+
if(!size)return json(response,400,{error:'不能发送空文件。'});
|
|
283
|
+
const id=crypto.randomBytes(16).toString('hex');
|
|
284
|
+
const filename=attachmentPath(row,id,label);
|
|
285
|
+
fs.writeFileSync(filename,Buffer.concat(chunks),{flag:'wx',mode:0o600});
|
|
286
|
+
const projectRoot=fs.realpathSync(row.cwd);
|
|
287
|
+
session.attachments.set(id,{id,threadId,label,size,projectRoot,mimeType:String(request.headers['content-type']||'application/octet-stream').split(';')[0],path:filename,expires:Date.now()+attachmentLifetime});
|
|
288
|
+
return json(response,201,{id,threadId,label,size,mimeType:session.attachments.get(id).mimeType});
|
|
289
|
+
}
|
|
290
|
+
|
|
102
291
|
function json(response, status, body, extra = {}) {
|
|
103
292
|
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra });
|
|
104
293
|
response.end(JSON.stringify(body));
|
|
@@ -223,7 +412,7 @@ export function createOrbitServer(options = {}) {
|
|
|
223
412
|
previous.count = 0;
|
|
224
413
|
if (sessions.size >= 64) return json(response, 429, { error: '已连接设备过多,请重启桥接服务后重新配对。' });
|
|
225
414
|
const key = crypto.randomBytes(32).toString('base64url');
|
|
226
|
-
sessions.set(key, { expires: Date.now() + sessionLifetime, enabled: new Map(), preferences: new Map() });
|
|
415
|
+
sessions.set(key, { expires: Date.now() + sessionLifetime, enabled: new Map(), preferences: new Map(), attachments:new Map() });
|
|
227
416
|
const secure = publicUrl ? '; Secure' : '';
|
|
228
417
|
return json(response, 200, { ok: true }, { 'Set-Cookie': `orbit_session=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200${secure}` });
|
|
229
418
|
}
|
|
@@ -235,6 +424,7 @@ export function createOrbitServer(options = {}) {
|
|
|
235
424
|
if (request.method === 'GET' && url.pathname === '/api/models') return json(response, 200, { models: await modelCatalog() });
|
|
236
425
|
if (request.method === 'POST' && url.pathname === '/api/logout') {
|
|
237
426
|
sessions.delete(session.key);
|
|
427
|
+
removeSessionAttachments(session);
|
|
238
428
|
for (const socket of sockets) if (socket.sessionKey === session.key) socket.close(1008, 'Logged out');
|
|
239
429
|
return json(response, 200, { ok: true }, { 'Set-Cookie': 'orbit_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0' });
|
|
240
430
|
}
|
|
@@ -244,6 +434,29 @@ export function createOrbitServer(options = {}) {
|
|
|
244
434
|
if (!connection) return json(response, 400, { error: '请选择桥接服务提供的连接地址。' });
|
|
245
435
|
return json(response, 200, { image: await QRCode.toDataURL(`${connection}/#pair=${encodeURIComponent(pairingKey)}`, { width: 280, margin: 2 }), warning: '扫码者将能查看本机会话。仅向可信设备展示。' });
|
|
246
436
|
}
|
|
437
|
+
const uploadMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments$/);
|
|
438
|
+
if(request.method==='POST'&&uploadMatch)return await uploadAttachment(request,response,session,uploadMatch[1],url);
|
|
439
|
+
const removeAttachmentMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/attachments\/remove$/);
|
|
440
|
+
if(request.method==='POST'&&removeAttachmentMatch){
|
|
441
|
+
const body=await readBody(request);
|
|
442
|
+
if(Object.keys(body).some(key=>key!=='attachmentId')||typeof body.attachmentId!=='string')return json(response,400,{error:'附件移除请求无效。'});
|
|
443
|
+
const attachment=session.attachments.get(body.attachmentId);
|
|
444
|
+
if(!attachment||attachment.threadId!==removeAttachmentMatch[1])return json(response,404,{error:'待发送附件不存在或已过期。'});
|
|
445
|
+
removeAttachment(session,body.attachmentId);
|
|
446
|
+
return json(response,200,{removed:true});
|
|
447
|
+
}
|
|
448
|
+
const filesMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/files$/);
|
|
449
|
+
if(request.method==='GET'&&filesMatch){
|
|
450
|
+
const row=store.row(filesMatch[1]);
|
|
451
|
+
if(!row)return json(response,404,{error:'会话不存在。'});
|
|
452
|
+
return json(response,200,projectFileList(row,url.searchParams.get('path')||''));
|
|
453
|
+
}
|
|
454
|
+
const fileMatch=url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/file$/);
|
|
455
|
+
if(request.method==='GET'&&fileMatch){
|
|
456
|
+
const row=store.row(fileMatch[1]);
|
|
457
|
+
if(!row)return json(response,404,{error:'会话不存在。'});
|
|
458
|
+
return sendFileRange(request,response,row,url.searchParams.get('path')||'');
|
|
459
|
+
}
|
|
247
460
|
const match = url.pathname.match(/^\/api\/threads\/([a-zA-Z0-9_-]{8,120})\/messages$/);
|
|
248
461
|
if (request.method === 'GET' && match) {
|
|
249
462
|
const history = store.historyFor(match[1], url.searchParams.get('before'), url.searchParams.get('limit') || 60);
|
|
@@ -335,7 +548,7 @@ export function createOrbitServer(options = {}) {
|
|
|
335
548
|
}
|
|
336
549
|
const control = controlFor(session, id);
|
|
337
550
|
const approvals = control ? (executionMode === 'desktop' ? runtime?.approvals || [] : [...rpc.approvals.values()].filter(request => request.params.threadId === id).map(request => ({ ...request.params, requestId: String(request.id), method: request.method }))) : [];
|
|
338
|
-
return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || rpc.threads.get(id)?.turnId || null, approvals };
|
|
551
|
+
return { subscribed: id || null, control, runtime, settings: control ? session.preferences.get(id) || null : null, turnId: runtime?.turnId || rpc.threads.get(id)?.turnId || null, approvals, attachments:[...session.attachments.values()].filter(item=>item.threadId===id&&!item.consumed&&item.expires>Date.now()).map(({id,label,size,mimeType})=>({id,label,size,mimeType})) };
|
|
339
552
|
}
|
|
340
553
|
if (message.method === 'thread.enable') {
|
|
341
554
|
const row = store.row(id);
|
|
@@ -365,10 +578,28 @@ export function createOrbitServer(options = {}) {
|
|
|
365
578
|
if (message.method === 'turn.start') {
|
|
366
579
|
const approval = controlFor(session, id);
|
|
367
580
|
if (!approval) throw new Error('请先确认这条会话的接续方式。');
|
|
368
|
-
|
|
369
|
-
if
|
|
581
|
+
const mode=body.mode??'default';
|
|
582
|
+
if(!['default','plan','goal'].includes(mode))throw new Error('对话模式无效。');
|
|
583
|
+
const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
|
|
584
|
+
if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
|
|
585
|
+
||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
|
|
586
|
+
if(typeof body.text!=='string'||(!body.text.trim()&&!attachmentIds.length)||body.text.length>20000)throw new Error('消息或附件必须至少提供一项;文本不得超过 20000 个字符。');
|
|
587
|
+
const attachments=attachmentIds.map(attachmentId=>{
|
|
588
|
+
const attachment=session.attachments.get(attachmentId);
|
|
589
|
+
if(!attachment||attachment.threadId!==id||attachment.consumed||attachment.expires<Date.now())throw new Error('文件附件不存在、属于其他会话或已过期。请重新添加。');
|
|
590
|
+
return attachment;
|
|
591
|
+
});
|
|
592
|
+
let text=body.text.trim()||(attachments.length?`请查看并处理以下附件:${attachments.map(file=>file.label).join('、')}`:'');
|
|
593
|
+
if(mode==='goal')text=`请将本轮请求作为需要完成的目标,主动推进直到完成,并汇报结果;如果受阻,请明确说明原因。\n\n本轮目标:\n${text}`;
|
|
594
|
+
if(body.mode==='plan')text=`请先分析任务并制定清晰的分步计划,说明后等待我确认;在我确认前不要执行任何修改。\n\n任务:\n${text}`;
|
|
595
|
+
if (executionMode === 'desktop') {
|
|
596
|
+
const result=await rpc.startTurn(id,text,{mode,attachments});
|
|
597
|
+
for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
|
|
598
|
+
return result;
|
|
599
|
+
}
|
|
600
|
+
if(mode!=='default'||attachments.length)throw new Error('计划/目标模式和文件附件仅支持 Codex 桌面热接管。');
|
|
370
601
|
if (!rpc.threads.has(id) && !endpoint && store.locked(id)) throw new Error('会话已被其他进程占用,未发送消息。');
|
|
371
|
-
return rpc.startTurn(id,
|
|
602
|
+
return rpc.startTurn(id,text,approval.writable,session.preferences.get(id)||null);
|
|
372
603
|
}
|
|
373
604
|
if (message.method === 'thread.settings') {
|
|
374
605
|
if (!controlFor(session, id) || !store.row(id)) throw new Error('没有此会话的控制授权。');
|
|
@@ -399,9 +630,20 @@ export function createOrbitServer(options = {}) {
|
|
|
399
630
|
if (message.method === 'turn.steer') {
|
|
400
631
|
if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
|
|
401
632
|
if (executionMode !== 'desktop') throw new Error('当前执行通道不支持向桌面任务追加指令。');
|
|
402
|
-
|
|
633
|
+
const attachmentIds=body.attachmentIds===undefined?[]:body.attachmentIds;
|
|
634
|
+
if(!Array.isArray(attachmentIds)||attachmentIds.length>maxPendingAttachments||new Set(attachmentIds).size!==attachmentIds.length
|
|
635
|
+
||attachmentIds.some(attachmentId=>typeof attachmentId!=='string'))throw new Error('文件附件列表无效。');
|
|
636
|
+
if(typeof body.text!=='string'||(!body.text.trim()&&!attachmentIds.length)||body.text.length>20000)throw new Error('消息或附件必须至少提供一项;文本不得超过 20000 个字符。');
|
|
403
637
|
if (typeof body.turnId !== 'string' || !body.turnId) throw new Error('缺少当前任务标识,请刷新后重试。');
|
|
404
|
-
|
|
638
|
+
const attachments=attachmentIds.map(attachmentId=>{
|
|
639
|
+
const attachment=session.attachments.get(attachmentId);
|
|
640
|
+
if(!attachment||attachment.threadId!==id||attachment.consumed||attachment.expires<Date.now())throw new Error('文件附件不存在、属于其他会话或已过期。请重新添加。');
|
|
641
|
+
return attachment;
|
|
642
|
+
});
|
|
643
|
+
const text=body.text.trim()||(attachments.length?`请查看并处理以下附件:${attachments.map(file=>file.label).join('、')}`:'');
|
|
644
|
+
const result=await rpc.steer(id,text,body.turnId,{attachments});
|
|
645
|
+
for(const attachment of attachments){attachment.consumed=true;attachment.expires=Date.now()+attachmentRetention;consumedAttachments.set(attachment.id,attachment);}
|
|
646
|
+
return result;
|
|
405
647
|
}
|
|
406
648
|
if (message.method === 'turn.interrupt') {
|
|
407
649
|
if (!controlFor(session, id)) throw new Error('没有此会话的控制授权。');
|
|
@@ -418,6 +660,12 @@ export function createOrbitServer(options = {}) {
|
|
|
418
660
|
|
|
419
661
|
const eventListener = event => {
|
|
420
662
|
if (!/^(item\/|turn\/|thread\/status|orbit\/|serverRequest\/resolved|error$)/.test(event.method)) return;
|
|
663
|
+
if(event.method==='turn/completed'&&event.params?.threadId){
|
|
664
|
+
for(const session of sessions.values())for(const [id,attachment]of session.attachments){
|
|
665
|
+
if(attachment.threadId===event.params.threadId&&attachment.consumed)removeAttachment(session,id);
|
|
666
|
+
}
|
|
667
|
+
for(const [id,attachment]of consumedAttachments)if(attachment.threadId===event.params.threadId)removeAttachment(null,id);
|
|
668
|
+
}
|
|
421
669
|
if (event.method === 'orbit/desktop' && event.params?.threadId) void applyQueuedDesktopSettings(event.params.threadId,event.params);
|
|
422
670
|
for (const socket of sockets) {
|
|
423
671
|
const threadId = event.params?.threadId;
|
|
@@ -453,7 +701,12 @@ export function createOrbitServer(options = {}) {
|
|
|
453
701
|
}
|
|
454
702
|
for (const [key, value] of dedup) if (Date.now() - value.created > 300000) dedup.delete(key);
|
|
455
703
|
for (const [key, value] of attempts) if (value.reset < Date.now()) attempts.delete(key);
|
|
456
|
-
for (const [key, value] of sessions) if (value.expires < Date.now()) sessions.delete(key);
|
|
704
|
+
for (const [key, value] of sessions) if (value.expires < Date.now()) {removeSessionAttachments(value);sessions.delete(key);}
|
|
705
|
+
for(const session of sessions.values()){
|
|
706
|
+
for(const [id,attachment]of session.attachments)if(attachment.expires<Date.now())removeAttachment(session,id);
|
|
707
|
+
}
|
|
708
|
+
for(const [id,attachment]of consumedAttachments)if(attachment.expires<Date.now())removeAttachment(null,id);
|
|
709
|
+
if(Date.now()-lastAttachmentCleanup>60*60*1000){cleanupStaleAttachments(store);lastAttachmentCleanup=Date.now();}
|
|
457
710
|
} catch {}
|
|
458
711
|
}, 1200);
|
|
459
712
|
timer.unref();
|
|
@@ -469,6 +722,7 @@ export function createOrbitServer(options = {}) {
|
|
|
469
722
|
async function close() {
|
|
470
723
|
clearInterval(timer); clearInterval(heartbeat);
|
|
471
724
|
for (const socket of sockets) unwatchDesktop(socket);
|
|
725
|
+
for(const session of sessions.values())removeSessionAttachments(session);
|
|
472
726
|
for (const socket of sockets) socket.terminate();
|
|
473
727
|
rpc.off('event', eventListener); rpc.off('lost', lostListener); rpc.close();
|
|
474
728
|
await new Promise(resolve => wss.close(resolve));
|