orbit-codex-bridge 0.1.2 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js 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, goal:null, models: [], contextUsage: null, settingsBusy: false, reopenModelAfterGrant: false, pendingModelChoice: null, cloudMode: false, cloudUser: null, cloudDevices: [], cloudDeviceId: null, approvals: new Map(), pendingAttachments: [], reconnect: 0, loggedOut: false, busy: false, turnId: null };
29
29
  let refreshTimer;
30
30
  let toastTimer;
31
31
  let reconnectTimer;
@@ -314,12 +314,13 @@ function connectSocket() {
314
314
  if (message.method === 'turn/completed') { state.busy = false; state.turnId = null; updateMode(); scheduleRefresh(); }
315
315
  if (message.method === 'orbit/desktop' && state.status?.execution.mode === 'desktop') {
316
316
  state.desktopRuntime = params;
317
+ if(params.threadId===state.selected?.id)state.goal=params.attached?params.goal||null:null;
317
318
  if (!params.attached) { state.enabled.delete(params.threadId); state.selectedSettings = null; }
318
319
  if (params.attached) mergeDesktopMessages(params.messages || []);
319
320
  state.turnId = params.attached ? params.turnId : null;
320
321
  state.busy = Boolean(state.turnId);
321
322
  state.approvals = new Map((params.attached ? params.approvals || [] : []).map(approval => [approval.requestId, approval]));
322
- renderMessages(); renderApprovals(); updateMode();
323
+ renderMessages();renderApprovals();renderList();updateMode();
323
324
  }
324
325
  if (message.method === 'thread/tokenUsage/updated' && state.status?.execution.mode !== 'desktop') {
325
326
  const usage = params.tokenUsage || params.tokenUsageInfo;
@@ -340,6 +341,7 @@ function connectSocket() {
340
341
  state.busy = false; state.turnId = null;
341
342
  state.reopenModelAfterGrant = false;
342
343
  state.desktopRuntime = {threadId:params.threadId,attached:false,reason:params.reason,reasonCode:params.reasonCode};
344
+ state.goal=null;renderGoalStatus();
343
345
  if (select('#model-dialog').open) select('#model-dialog').close();
344
346
  if (select('#control-dialog').open) select('#control-dialog').close();
345
347
  state.approvals.clear(); renderApprovals(); updateMode();
@@ -357,9 +359,15 @@ function connectSocket() {
357
359
  toast(params.error || '下一轮模型参数未应用,请重新选择。');
358
360
  }
359
361
  if (message.method === 'orbit/engine') {
360
- if (state.status?.execution) state.status.execution.connected = Boolean(params.connected);
362
+ if (state.status?.execution) {
363
+ state.status.execution.connected = Boolean(params.connected);
364
+ if(params.desktopConnected!==undefined)state.status.execution.desktopConnected=Boolean(params.desktopConnected);
365
+ else if(params.mode==='desktop')state.status.execution.desktopConnected=Boolean(params.connected);
366
+ if(params.resumedConnected!==undefined)state.status.execution.resumedConnected=Boolean(params.resumedConnected);
367
+ }
361
368
  if (!params.connected) {
362
369
  state.enabled.clear(); state.desktopRuntime = null; state.selectedSettings = null; state.pendingModelChoice = null;
370
+ state.goal=null;renderGoalStatus();
363
371
  if (select('#model-dialog').open) select('#model-dialog').close();
364
372
  if (select('#control-dialog').open) select('#control-dialog').close();
365
373
  state.reopenModelAfterGrant = false;
@@ -368,6 +376,16 @@ function connectSocket() {
368
376
  }
369
377
  updateMode();
370
378
  }
379
+ if(message.method==='thread/goal/updated'&&params.threadId===state.selected?.id){
380
+ state.goal=params.goal||null;
381
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
382
+ renderGoalStatus();
383
+ }
384
+ if(message.method==='thread/goal/cleared'&&params.threadId===state.selected?.id){
385
+ state.goal=null;
386
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:null};
387
+ renderGoalStatus();
388
+ }
371
389
  if (message.method === 'orbit/approval') { state.approvals.set(params.requestId, params); renderApprovals(); }
372
390
  if (message.method === 'serverRequest/resolved') { state.approvals.delete(String(params.requestId)); renderApprovals(); }
373
391
  if (message.method === 'error') toast(params.error?.message || params.message || '执行发生错误,请查看会话状态。');
@@ -377,6 +395,7 @@ function connectSocket() {
377
395
  setConnection(false);
378
396
  for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('连接中断,消息不会自动重发。请先检查会话记录。')); }
379
397
  state.pending.clear();
398
+ state.pendingAttachments=[];
380
399
  scheduleReconnect();
381
400
  });
382
401
  socket.addEventListener('error', () => {});
@@ -416,6 +435,9 @@ async function subscribeThread(id) {
416
435
  state.turnId = result.turnId;
417
436
  state.busy = Boolean(result.turnId);
418
437
  state.approvals = new Map((result.approvals || []).map(approval => [approval.requestId, approval]));
438
+ state.pendingAttachments=result.attachments||[];
439
+ state.goal=result.runtime?.goal||null;
440
+ renderComposerAttachments();
419
441
  if (desktop && result.control) {
420
442
  controlRenewTimer = setTimeout(() => {
421
443
  if (state.selected?.id === id && state.socket?.readyState === WebSocket.OPEN && state.desktopRuntime?.attached) {
@@ -423,12 +445,12 @@ async function subscribeThread(id) {
423
445
  }
424
446
  },Math.max(1000,result.control.expires - Date.now() - 120000));
425
447
  }
426
- renderMessages(); renderApprovals();
448
+ renderMessages();renderApprovals();renderList();
427
449
  } catch (error) {
428
450
  if (id === state.selected?.id && selectionVersion === state.selectionVersion && attempt === desktopConnectionAttempt) {
429
451
  state.enabled.delete(id); state.selectedSettings = null; state.turnId = null; state.busy = false;
430
452
  state.desktopRuntime = {threadId:id,attached:false,reason:error.message,reasonCode:error.code};
431
- state.approvals.clear(); renderApprovals();
453
+ state.approvals.clear();renderApprovals();renderList();
432
454
  }
433
455
  throw error;
434
456
  } finally {
@@ -466,17 +488,21 @@ function renderProjects() {
466
488
  function filteredThreads() {
467
489
  return state.threads.filter(thread => (!state.project || thread.projectId === state.project)
468
490
  && (state.filter !== 'archived' || thread.archived)
469
- && (state.filter !== 'locked' || thread.locked)
470
491
  && (state.filter !== 'pinned' || thread.pinned)
471
492
  && `${thread.title} ${thread.projectName} ${thread.preview}`.toLowerCase().includes(state.search.toLowerCase()))
472
493
  .sort((left, right) => Number(right.pinned) - Number(left.pinned) || right.updatedAt - left.updatedAt);
473
494
  }
495
+ function threadAvailabilityBadge(thread) {
496
+ if(thread.archived)return '<span class="tag archived">已归档</span>';
497
+ if(String(thread.source).startsWith('{'))return '<span class="tag">子代理</span>';
498
+ return '<span class="tag">原会话</span>';
499
+ }
474
500
  function renderList() {
475
501
  const threads = filteredThreads();
476
502
  select('#thread-count').textContent = threads.length;
477
503
  select('#list-title').textContent = state.project ? (state.threads.find(thread => thread.projectId === state.project)?.projectName || '项目会话') : state.filter === 'archived' ? '已归档' : state.filter === 'pinned' ? '已置顶' : '所有会话';
478
504
  select('#list-footer-text').textContent = `${state.threads.length} 条原生记录 · 自动同步`;
479
- select('#thread-list').innerHTML = threads.map(thread => `<button class="thread-item ${state.selected?.id === thread.id ? 'selected' : ''}" data-thread="${escapeHtml(thread.id)}"><div class="thread-meta"><span class="project-initial">${escapeHtml(thread.projectName.slice(0,1).toUpperCase())}</span><span class="project-label">${escapeHtml(thread.projectName)}</span><time>${ago(thread.updatedAt)}</time></div><h3>${escapeHtml(thread.title)}</h3><p>${escapeHtml(thread.preview || '点击查看原生会话记录')}</p><div class="thread-badges">${thread.pinned ? '<span class="tag pinned">已置顶</span>' : ''}${thread.locked ? '<span class="tag busy"><i class="tiny-dot"></i>原客户端占用</span>' : '<span class="tag">可查看</span>'}${thread.archived ? '<span class="tag archived">已归档</span>' : ''}<span class="tag">${escapeHtml(thread.provider || 'Codex')}</span></div></button>`).join('') || '<div class="empty">没有找到匹配的会话。<br>试试其他关键词或筛选条件。</div>';
505
+ select('#thread-list').innerHTML = threads.map(thread => `<button class="thread-item ${state.selected?.id === thread.id ? 'selected' : ''}" data-thread="${escapeHtml(thread.id)}"><div class="thread-meta"><span class="project-initial">${escapeHtml(thread.projectName.slice(0,1).toUpperCase())}</span><span class="project-label">${escapeHtml(thread.projectName)}</span><time>${ago(thread.updatedAt)}</time></div><h3>${escapeHtml(thread.title)}</h3><p>${escapeHtml(thread.preview || '点击查看原生会话记录')}</p><div class="thread-badges">${thread.pinned ? '<span class="tag pinned">已置顶</span>' : ''}${threadAvailabilityBadge(thread)}${thread.archived ? '<span class="tag archived">已归档</span>' : ''}<span class="tag">${escapeHtml(thread.provider || 'Codex')}</span></div></button>`).join('') || '<div class="empty">没有找到匹配的会话。<br>试试其他关键词或筛选条件。</div>';
480
506
  document.querySelectorAll('[data-filter]').forEach(button => {
481
507
  const active = button.dataset.filter === state.filter;
482
508
  button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active));
@@ -486,21 +512,26 @@ function renderWelcome() {
486
512
  const projects = new Set(state.threads.map(thread => thread.projectId));
487
513
  const archived = state.threads.filter(thread => thread.archived).length;
488
514
  select('#welcome').innerHTML = `<div class="welcome-heading"><div><h2>你的工作,都在这里。</h2><p>连接本机 Codex,从上次停下的地方找到灵感。</p></div><span class="date-label">${new Date().toLocaleDateString('zh-CN', { timeZone:'Asia/Shanghai', month:'long', day:'numeric', weekday:'long' })}</span></div><div class="hero"><div class="hero-grid"></div><div class="hero-orbit"><i></i></div><span class="eyebrow">KEEP THE CONTEXT. FIND YOUR FLOW.</span><h2>工作在电脑上,<br>灵感不必。</h2><p>一个入口,查看真实的项目与原生会话。<br>不再复制粘贴,也不必重新解释。</p><span class="hero-status"><i class="tiny-dot"></i> 本机直读 · 默认只读保护</span></div><div class="stats-grid"><div class="stat-card"><span>${icon('inbox')}原生会话</span><strong>${state.threads.length}</strong><small>来自本机,而非演示数据</small></div><div class="stat-card"><span>${icon('folder')}项目空间</span><strong>${projects.size}</strong><small>按原生归属 / 工作目录分组</small></div><div class="stat-card"><span>${icon('archive')}归档记录</span><strong>${archived}</strong><small>旧上下文,也值得保留</small></div></div><div class="section-heading"><h3>最近的上下文</h3><span>从熟悉的对话开始 ↗</span></div>${state.threads.filter(thread => !thread.archived).slice(0,3).map(thread => `<button class="recent-card" data-thread="${escapeHtml(thread.id)}"><span class="recent-icon">${icon('inbox')}</span><div><strong>${escapeHtml(thread.title)}</strong><p>${escapeHtml(thread.projectName)} · ${ago(thread.updatedAt)}</p></div><span>↗</span></button>`).join('')}<div class="honesty-note">${icon('info')}<span>原生会话列表与历史记录直接读取。授权后可通过桌面 IPC 接入已加载的 Codex 会话;尚未加载的会话需先在 Codex 桌面打开,不会新建或迁移执行器。</span></div>`;
515
+ select('#welcome .hero-status').innerHTML=`<i class="tiny-dot"></i> 已连接电脑 · 打开原会话即可继续`;
516
+ select('#welcome .honesty-note span').textContent='点击任意普通原会话,即可从当前上下文继续;不会新建会话。计划和目标使用 Codex 原生功能。';
489
517
  }
490
518
  async function openThread(id) {
491
519
  const thread = state.threads.find(entry => entry.id === id); if (!thread) return;
492
520
  if (select('#model-dialog').open) select('#model-dialog').close();
493
521
  const selectionVersion = ++state.selectionVersion;
494
522
  state.pendingModelChoice = null;
523
+ state.pendingAttachments=[];
524
+ select('#turn-mode').value='default';
495
525
  state.historyStatus = 'loading'; state.historyError = '';
496
526
  state.selected = thread; state.baseMessages = []; state.olderMessages = []; state.liveMessages.clear(); state.desktopMessages = []; state.desktopRuntime = null; state.selectedSettings = null; state.contextUsage = null; state.approvals.clear(); state.cursor = null; state.busy = false; state.turnId = null;
527
+ state.goal=null;
497
528
  select('#welcome').hidden = true; select('#chat').hidden = false; select('#workspace').classList.add('chat-open');
498
529
  select('#chat-title').textContent = thread.title;
499
530
  select('#chat-subtitle').textContent = `${thread.projectName} / ${thread.provider || 'Codex'}${thread.model ? ` · ${thread.model}` : ''}`;
500
531
  select('#chat-details').innerHTML = `<dl><dt>原生会话 ID</dt><dd>${escapeHtml(id)}</dd><dt>工作目录</dt><dd>${escapeHtml(thread.cwd)}</dd><dt>记录来源 / 分支</dt><dd>${escapeHtml(thread.source)} / ${escapeHtml(thread.branch || '—')}</dd></dl>`;
501
532
  select('#chat-details').hidden = true;
502
533
  select('#messages').replaceChildren(); renderMessages();
503
- renderList(); updateMode(); renderApprovals();
534
+ renderComposerAttachments();renderList();updateMode();renderApprovals();
504
535
  subscribeThread(id).catch(error => { if (selectionVersion === state.selectionVersion) toast(error.message); });
505
536
  try { await loadMessages(true); } catch (error) { if (selectionVersion === state.selectionVersion) toast(error.message); }
506
537
  }
@@ -717,18 +748,21 @@ function updateMode() {
717
748
  const desktop = state.status?.execution.mode === 'desktop';
718
749
  const desktopConnected = desktop && state.status.execution.connected;
719
750
  const attached = desktopConnected && state.desktopRuntime?.attached === true;
720
- const blocked = thread.archived || (desktop ? !attached || desktopConnecting : thread.locked && !control && state.status?.execution.mode !== 'endpoint');
751
+ const sameIdResume = attached && state.desktopRuntime?.mode === 'same-id-resume';
752
+ const appServerExecution=['same-id-resume','endpoint','cold-resume'].includes(state.desktopRuntime?.mode)
753
+ || ['endpoint','cold-resume'].includes(state.status?.execution.mode);
754
+ const nativePlanAvailable=Boolean(control&&(desktop?attached:appServerExecution));
755
+ const blocked = thread.archived || String(thread.source).startsWith('{') || desktopConnecting;
721
756
  select('#enable-control').disabled = Boolean(blocked || control);
722
- select('#enable-control').textContent = control ? desktop ? '热接管中' : '接续已授权' : thread.archived ? '归档只读' : desktopConnecting && desktop ? '连接中…' : desktop && !desktopConnected ? '桌面未连接' : desktop && !attached ? '原会话未连接' : blocked ? '原客户端占用' : desktop ? '重新接管' : '接续会话';
757
+ select('#enable-control').textContent = control ? '已连接' : desktopConnecting && desktop ? '连接中…' : thread.archived ? '已归档' : String(thread.source).startsWith('{') ? '请打开父会话' : '继续会话';
723
758
  select('#mode-banner').classList.toggle('controlled', Boolean(control));
724
759
  const banner = desktop
725
- ? desktopConnecting ? '正在连接这条原会话;历史仍可查看。'
726
- : !desktopConnected ? 'Codex 桌面连接已断开。请保持桌面应用运行,再点击“重新连接”。'
727
- : !attached ? state.desktopRuntime?.reason || '这条原会话尚未连接。请先在 Codex 桌面打开它,再点击“重新连接”。'
728
- : control ? '热接管中 · 与电脑共用同一 Codex 执行实例;消息、运行状态和审批实时同步。'
729
- : '这条原会话已连接 · 点击“重新接管”恢复控制。'
730
- : control ? `原 ID 接续 · ${control.writable ? '工作区可写 / 操作需审批' : '只读执行沙箱'} · ${control.sharedExecution ? '目标服务已加载此会话' : '独立执行实例'}`
731
- : thread.locked ? '原客户端持有此会话:独立接续不会抢占桌面执行。' : '只读镜像:查看原生历史不会启动执行实例。';
760
+ ? desktopConnecting ? '正在连接原会话…'
761
+ : !attached ? state.desktopRuntime?.reason || '正在连接原会话…'
762
+ : control ? sameIdResume ? '已连接;在原会话中使用 Codex 原生功能。' : '已连接;操作在电脑上的 Codex 中执行。'
763
+ : '正在连接原会话…'
764
+ : control ? '已连接;继续在原会话中工作。'
765
+ : '连接后直接在这条原会话中继续。';
732
766
  const modeBanner = select('#mode-banner');
733
767
  if (!modeBanner.querySelector('.mode-copy')) modeBanner.innerHTML = `${icon('shield')}<span class="mode-copy"></span><button type="button" class="button small" id="reconnect-desktop">重新连接</button>`;
734
768
  modeBanner.querySelector('.mode-copy').textContent = banner;
@@ -736,15 +770,177 @@ function updateMode() {
736
770
  retryButton.hidden = !desktop || attached && !desktopConnecting;
737
771
  retryButton.disabled = !connected || desktopConnecting;
738
772
  retryButton.textContent = desktopConnecting ? '连接中…' : '重新连接';
739
- select('#composer-mode').innerHTML = `${icon('shield')}${control ? desktop ? '热接管 · 桌面权限' : control.writable ? '工作区可写 · 按需审批' : '接续执行 · 只读沙箱' : '原生记录 · 只读镜像'}`;
740
- const waitingForTurn = state.busy && (!desktop || !state.turnId);
773
+ select('#composer-mode').innerHTML = `${icon('shield')}${control ? '原会话' : '连接中'}`;
774
+ const waitingForTurn = state.busy && (!desktop || !state.turnId || sameIdResume);
741
775
  select('#send-message').disabled = !control || !connected || desktop && (!attached || desktopConnecting) || waitingForTurn || state.settingsBusy || !select('#message-input').value.trim();
742
- select('#send-message').title = state.busy && desktop ? '追加指令到当前桌面任务' : '沿用原会话发送';
776
+ select('#send-message').title = state.busy && desktop && !sameIdResume ? '追加指令到当前桌面任务' : '沿用原会话发送';
743
777
  select('#stop-turn').hidden = !state.busy || !state.turnId || !control;
744
- select('#message-input').placeholder = state.busy && desktop ? '追加指令给电脑上正在运行的任务…' : control ? '沿着原来的上下文继续…' : desktop ? '连接原桌面会话后继续…' : '先确认接续方式,再沿着这条会话继续…';
745
- select('#composer-note').textContent = control ? desktop ? '消息进入原 Codex 桌面会话;任务不会迁移或重启。断线不会自动重发。' : '原会话 ID 不变;断线不会自动重发消息。' : '只读查看不会唤醒或打断电脑上的执行。';
778
+ select('#message-input').placeholder = state.busy && desktop && !sameIdResume ? '追加指令给电脑上正在运行的任务…' : state.busy && sameIdResume ? '原会话正在执行,请等待本轮完成…' : control ? '沿着原来的上下文继续…' : desktop ? '连接后在原会话中继续…' : '继续这条原会话…';
779
+ select('#turn-mode').disabled=!nativePlanAvailable||desktopConnecting||state.busy;
780
+ select('#attach-files').disabled=!control||!desktop||sameIdResume||desktopConnecting||state.pendingAttachments.length>=5||state.settingsBusy;
781
+ select('#file-input').disabled=select('#attach-files').disabled;
782
+ select('#turn-mode').title=state.busy?'当前任务中无法切换模式。':!nativePlanAvailable?'当前执行通道不支持原生协作模式。':'通过 Codex 原生协作模式控制下一轮。';
783
+ const mode=select('#turn-mode').value;
784
+ select('#composer-note').textContent=sameIdResume
785
+ ? state.busy ? '原会话正在执行;请等待当前轮次结束。'
786
+ : mode==='plan'?'Codex 原生计划模式;只作用于本轮。'
787
+ :'消息和目标都直接作用于这条原会话。'
788
+ : mode==='plan'
789
+ ? '使用 Codex 原生计划模式;计划完成后按原生流程继续。'
790
+ : control ? '消息继续发送到这条原会话;不会创建新会话。'
791
+ : '连接原会话后即可继续。';
746
792
  renderModelStatus();
793
+ renderGoalStatus();
794
+ }
795
+ const goalStatusNames={active:'进行中',paused:'已暂停',blocked:'受阻',usageLimited:'用量受限',budgetLimited:'预算已用尽',complete:'已完成'};
796
+ function nativeGoalWritable() {
797
+ const runtime=state.desktopRuntime;
798
+ return Boolean(state.selected&&state.enabled.has(state.selected.id)
799
+ &&(runtime?.mode==='same-id-resume'||state.status?.execution.mode!=='desktop'));
800
+ }
801
+ function renderGoalStatus() {
802
+ const button=select('#goal-button');
803
+ if(!button)return;
804
+ const goal=state.goal||state.desktopRuntime?.goal||null;
805
+ button.textContent=goal?`目标 · ${goalStatusNames[goal.status]||goal.status}`:'设置目标';
806
+ button.classList.toggle('active',Boolean(goal));
807
+ button.disabled=!state.selected||state.selected.archived||String(state.selected.source).startsWith('{');
808
+ button.title='管理这条原会话中的 Codex 原生持续目标。';
809
+ }
810
+ function openGoalDialog() {
811
+ if(!state.selected)return;
812
+ const goal=state.goal||state.desktopRuntime?.goal||null;
813
+ const writable=nativeGoalWritable();
814
+ select('#goal-objective').value=goal?.objective||'';
815
+ select('#goal-token-budget').value=goal?.tokenBudget??'';
816
+ select('#goal-current-status').textContent=goal
817
+ ? `${goalStatusNames[goal.status]||goal.status} · 已用 ${goal.tokensUsed||0} tokens${goal.tokenBudget?` / ${goal.tokenBudget}`:''}`
818
+ : '尚未设置持续目标。';
819
+ const desktopOwned=state.status?.execution.mode==='desktop'&&state.desktopRuntime?.mode!=='same-id-resume';
820
+ select('#goal-description').textContent=desktopOwned
821
+ ? '目标状态与 Codex 桌面同步。目标编辑暂不可用,请在本机 Codex 中管理。'
822
+ : writable
823
+ ? '使用 Codex 原生 Goal;目标会跨轮次保留,不会转换成提示词。'
824
+ : '连接原会话后,可在这里创建和管理 Codex 原生持续目标。';
825
+ select('#goal-objective').disabled=!writable;
826
+ select('#goal-token-budget').disabled=!writable;
827
+ select('#save-goal').disabled=!writable;
828
+ select('#pause-goal').disabled=!writable||!goal;
829
+ select('#clear-goal').disabled=!writable||!goal;
830
+ select('#pause-goal').textContent=goal?.status==='paused'?'继续目标':'暂停目标';
831
+ select('#save-goal').textContent=goal?'更新目标':'开始追求目标';
832
+ select('#goal-error').textContent='';
833
+ select('#goal-dialog').showModal();
834
+ }
835
+ async function updateGoalStatus(status) {
836
+ if(!state.selected||!nativeGoalWritable())return;
837
+ const id=state.selected.id,selectionVersion=state.selectionVersion;
838
+ try{
839
+ const result=await rpc('thread.goal.status',{threadId:id,status});
840
+ if(selectionVersion!==state.selectionVersion||id!==state.selected?.id)return;
841
+ state.goal=result.goal||null;
842
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
843
+ select('#goal-current-status').textContent=state.goal?`${goalStatusNames[state.goal.status]||state.goal.status} · 已用 ${state.goal.tokensUsed||0} tokens${state.goal.tokenBudget?` / ${state.goal.tokenBudget}`:''}`:'尚未设置持续目标。';
844
+ renderGoalStatus();toast(status==='paused'?'已暂停原生目标。':'已继续原生目标。');
845
+ }catch(error){select('#goal-error').textContent=error.message;}
846
+ }
847
+ async function clearGoal() {
848
+ if(!state.selected||!nativeGoalWritable())return;
849
+ try{
850
+ await rpc('thread.goal.clear',{threadId:state.selected.id});
851
+ state.goal=null;
852
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:null};
853
+ select('#goal-dialog').close();renderGoalStatus();toast('已清除原生目标。');
854
+ }catch(error){select('#goal-error').textContent=error.message;}
855
+ }
856
+ function renderComposerAttachments() {
857
+ select('#composer-attachments').innerHTML=state.pendingAttachments.map(file=>
858
+ `<span class="composer-attachment" data-attachment="${escapeHtml(file.id)}">${icon(file.mimeType?.startsWith('image/')?'image':'file')}<span>${escapeHtml(file.label)} · ${escapeHtml((file.size/1024).toFixed(file.size<1024*1024?0:1))} KB</span><button type="button" data-remove-attachment="${escapeHtml(file.id)}" aria-label="移除 ${escapeHtml(file.label)}">×</button></span>`
859
+ ).join('');
860
+ }
861
+
862
+ async function uploadFiles(fileList) {
863
+ if(!state.selected||!state.enabled.has(state.selected.id)||!state.desktopRuntime?.attached)return toast('请先连接并接管原 Codex 会话。');
864
+ const files=[...fileList];
865
+ if(state.pendingAttachments.length+files.length>5)return toast('单条消息最多添加 5 个文件。');
866
+ for(const file of files)if(file.size===0||file.size>8*1024*1024)return toast(`“${file.name}”为空或超过 8 MiB 限制。`);
867
+ const threadId=state.selected.id,selectionVersion=state.selectionVersion;
868
+ const button=select('#attach-files');
869
+ button.disabled=true;
870
+ try{
871
+ for(const file of files){
872
+ const headers={'Content-Type':file.type||'application/octet-stream'};
873
+ if(state.cloudMode&&state.cloudDeviceId)headers['X-Orbit-Device-Id']=state.cloudDeviceId;
874
+ const response=await fetch(`/api/threads/${encodeURIComponent(threadId)}/attachments?name=${encodeURIComponent(file.name)}`,{
875
+ method:'POST',headers,body:file,credentials:'same-origin',signal:AbortSignal.timeout(30000)
876
+ });
877
+ const result=await response.json().catch(()=>({}));
878
+ if(!response.ok)throw new Error(result.error||'文件上传失败。');
879
+ if(threadId!==state.selected?.id||selectionVersion!==state.selectionVersion){
880
+ await api(`/api/threads/${encodeURIComponent(threadId)}/attachments/remove`,{attachmentId:result.id});
881
+ return;
882
+ }
883
+ state.pendingAttachments.push(result);
884
+ renderComposerAttachments();updateMode();
885
+ }
886
+ toast(files.length===1?'文件已附加到待发送消息。':`已附加 ${files.length} 个文件。`);
887
+ }catch(error){toast(error.name==='TimeoutError'?'文件上传超时,请确认网络后重试。':error.message)}
888
+ finally{button.disabled=false;select('#file-input').value='';updateMode()}
889
+ }
890
+
891
+ async function browseProjectFiles(relativePath='') {
892
+ if(!state.selected)return;
893
+ const threadId=state.selected.id,selectionVersion=state.selectionVersion;
894
+ const list=select('#file-browser-list');
895
+ select('#file-browser-error').textContent='';
896
+ list.innerHTML='<p class="file-browser-empty">正在读取项目文件…</p>';
897
+ try{
898
+ const query=relativePath?`?path=${encodeURIComponent(relativePath)}`:'';
899
+ const result=await api(`/api/threads/${encodeURIComponent(threadId)}/files${query}`);
900
+ if(threadId!==state.selected?.id||selectionVersion!==state.selectionVersion)return;
901
+ select('#file-browser-path').textContent=result.path||'项目根目录';
902
+ const rows=[];
903
+ if(result.path){
904
+ const parent=result.path.split(/[\\/]/).slice(0,-1).join('/');
905
+ rows.push(`<button class="file-browser-entry file-browser-parent" type="button" data-file-directory="${escapeHtml(parent)}"><span>↰</span><strong>上一级</strong></button>`);
906
+ }
907
+ for(const entry of result.entries||[]){
908
+ rows.push(entry.type==='directory'
909
+ ? `<button class="file-browser-entry" type="button" data-file-directory="${escapeHtml(entry.path)}"><span>▸</span><strong>${escapeHtml(entry.name)}</strong></button>`
910
+ : `<button class="file-browser-entry" type="button" data-file-download="${escapeHtml(entry.path)}"><span>${icon('file')}</span><strong>${escapeHtml(entry.name)}</strong><small>${escapeHtml((entry.size/1024).toFixed(entry.size<1024*1024?0:1))} KB</small></button>`);
911
+ }
912
+ if(result.truncated)rows.push('<p class="file-browser-empty">列表已达到 1000 项上限,请打开子文件夹细看。</p>');
913
+ list.innerHTML=rows.join('')||'<p class="file-browser-empty">此文件夹为空。</p>';
914
+ }catch(error){
915
+ list.replaceChildren();
916
+ select('#file-browser-error').textContent=error.message;
917
+ }
918
+ }
919
+
920
+ async function downloadProjectFile(relativePath){
921
+ if(!state.selected)return;
922
+ const threadId=state.selected.id;
923
+ const headers={};
924
+ if(state.cloudMode&&state.cloudDeviceId)headers['X-Orbit-Device-Id']=state.cloudDeviceId;
925
+ try{
926
+ const response=await fetch(`/api/threads/${encodeURIComponent(threadId)}/file?path=${encodeURIComponent(relativePath)}`,{
927
+ headers,credentials:'same-origin',cache:'no-store',signal:AbortSignal.timeout(35000)
928
+ });
929
+ if(!response.ok){
930
+ const result=await response.json().catch(()=>({}));
931
+ if(response.status===401&&state.cloudMode){showCloudLogin(result.error);return}
932
+ throw new Error(result.error||'无法下载此项目文件。');
933
+ }
934
+ const disposition=response.headers.get('content-disposition')||'';
935
+ const encoded=disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
936
+ const filename=encoded?decodeURIComponent(encoded):relativePath.split(/[\\/]/).at(-1)||'project-file';
937
+ const url=URL.createObjectURL(await response.blob());
938
+ const anchor=document.createElement('a');
939
+ anchor.href=url;anchor.download=filename;anchor.click();
940
+ setTimeout(()=>URL.revokeObjectURL(url),30000);
941
+ }catch(error){toast(error.name==='TimeoutError'?'项目文件下载超时。':error.message)}
747
942
  }
943
+
748
944
  function renderApprovals() {
749
945
  select('#approvals').innerHTML = [...state.approvals.values()].map(approval => `<section class="approval-card" data-approval="${escapeHtml(approval.requestId)}"><strong>${approval.method === 'item/tool/requestUserInput' ? 'Codex 需要你的回答' : '等待你的审批'}</strong><p>${escapeHtml(approval.reason || '请核对操作范围后决定。')}</p>${approval.command ? `<pre>${escapeHtml(approval.command)}</pre>` : ''}${approval.method !== 'item/tool/requestUserInput' ? `<details><summary>查看完整审批范围</summary><pre>${escapeHtml(JSON.stringify(approval, null, 2))}</pre></details>` : ''}${approval.method === 'item/tool/requestUserInput' ? (approval.questions || []).map(question => `<label>${escapeHtml(question.question)}${question.options ? `<small> ${question.options.map(option => escapeHtml(option.label)).join(' / ')}</small>` : ''}<input data-question="${escapeHtml(question.id)}" placeholder="输入你的回答" maxlength="4000"></label>`).join('') + '<button class="button small" data-decision="answers">提交回答</button>' : `<button class="button small" data-decision="decline">拒绝</button>${approval.method !== 'item/permissions/requestApproval' ? '<button class="button small" data-decision="accept">仅允许本次</button>' : '<p>扩大环境权限请在本机处理,首版仅支持拒绝。</p>'}`}</section>`).join('');
750
946
  }
@@ -753,8 +949,16 @@ function renderDevices() {
753
949
  const status = state.status;
754
950
  const url = status.connectionUrls.find(address => !address.includes('127.0.0.1')) || status.connectionUrls[0];
755
951
  const desktop = status.execution.mode === 'desktop';
756
- const executionStatus = desktop ? status.execution.connected ? 'Codex 桌面 IPC 已连接' : 'Codex 桌面 IPC 未连接' : status.execution.mode === 'endpoint' ? 'App Server 端点' : '独立执行器';
757
- const hotStatus = desktop && status.desktopHotAttachVerified ? '已验证实时会话' : desktop && status.desktopHotAttachReady ? '可授权热接管' : '未连接';
952
+ const desktopConnected = status.execution.desktopConnected ?? status.execution.connected;
953
+ const resumedConnected = Boolean(status.execution.resumedConnected);
954
+ const executionStatus = desktop
955
+ ? desktopConnected ? 'Codex 桌面 IPC 已连接'
956
+ : resumedConnected ? 'Codex App Server 原 ID 接续'
957
+ : '等待本机 Codex 执行器'
958
+ : status.execution.mode === 'endpoint' ? 'App Server 端点' : '独立执行器';
959
+ const hotStatus = desktop && desktopConnected
960
+ ? status.desktopHotAttachVerified ? '已发现实时会话' : '支持自动热接管'
961
+ : desktop && resumedConnected ? '原 ID 接续中' : '打开会话时自动连接';
758
962
  select('#devices-view').innerHTML = `<div class="devices-heading"><span class="eyebrow">CONNECTED TO YOUR WORLD</span><h1>设备与连接</h1><p>电脑负责执行。你的设备,只需要保持连接。</p></div><div class="device-grid"><section class="device-card"><div class="device-big-icon">${icon('monitor')}</div><h2>${escapeHtml(status.device)}<span class="capability-state">本机桥接在线</span></h2><p>直接读取本机原生会话与历史记录,不复制到第三方数据库。</p><dl><dt>原生会话</dt><dd>${state.threads.length} 条 / ${new Set(state.threads.map(thread => thread.projectId)).size} 个空间</dd><dt>数据目录</dt><dd>${escapeHtml(status.home)}</dd><dt>消息通道</dt><dd>${location.protocol === 'https:' ? 'WSS · TLS 加密' : 'WS · 本机 / 可信局域网演示'}</dd><dt>执行连接</dt><dd>${executionStatus}</dd><dt>热接管</dt><dd>${hotStatus}</dd></dl></section><section class="device-card"><div class="device-big-icon">${icon('devices')}</div><h2>把工作台带在身边</h2><p>${status.lanEnabled || status.secureTransport ? '在可信设备上打开地址并配对,即可查看同一份原生记录。二维码包含控制凭据,切勿公开。' : '当前仅监听本机。手机同 Wi-Fi 体验,请停止本次服务,再运行 start.ps1 -Lan。'}</p><div class="connection-url"><code>${escapeHtml(url)}</code><button class="icon-button" id="copy-url" aria-label="复制连接地址">${icon('copy')}</button></div><button id="show-qr" class="button small">显示配对二维码</button><div id="qr-container" class="qr-wrap" hidden></div><p>${status.secureTransport ? '已配置 HTTPS 入口;请确认反向代理正确转发 WebSocket。' : '注意:HTTP / WS 不加密,不要将此端口直接暴露公网。公网必须配置 HTTPS / WSS 与独立鉴权。'}</p></section><section class="device-card wide"><h2>能力状态,透明可见。</h2><div class="capability-row"><span>原生项目 / 全量本机会话 / 归档记录</span><span class="capability-state">真实数据已接入</span></div><div class="capability-row"><span>历史分页 / WebSocket 实时更新</span><span class="capability-state">可体验</span></div><div class="capability-row"><span>接入 Codex 桌面已加载的原会话</span><span class="capability-state ${hotStatus === '未连接' ? 'pending' : ''}">${hotStatus}</span></div><div class="capability-row"><span>追加指令 / 停止任务 / 审批同步</span><span class="capability-state ${status.execution.connected && desktop ? '' : 'pending'}">${desktop && status.execution.connected ? '同一桌面执行实例' : '桌面未连接'}</span></div><div class="capability-row"><span>公网中继 / 多用户 / 计费 / 推送</span><span class="capability-state pending">未实现</span></div><p>Orbit 是独立第三方原型,非 OpenAI 官方产品。桌面 IPC 属内部接口,升级后需重新做兼容测试;商业发布前还需独立安全审计。</p></section></div>`;
759
963
  const cloudRow = select('#devices-view .capability-row:last-of-type');
760
964
  cloudRow.querySelector('span:first-child').textContent = '多用户云中继 / 设备管理后台';
@@ -1087,18 +1291,90 @@ select('#model-form').addEventListener('submit', async event => {
1087
1291
  finally { button.disabled = false; state.settingsBusy = false; updateMode(); }
1088
1292
  });
1089
1293
  select('#message-input').addEventListener('input', updateMode);
1294
+ const goalButton=document.createElement('button');
1295
+ select('#turn-mode option[value="goal"]')?.remove();
1296
+ goalButton.id='goal-button';
1297
+ goalButton.type='button';
1298
+ goalButton.className='button small goal-button';
1299
+ goalButton.textContent='设置目标';
1300
+ goalButton.setAttribute('aria-label','设置 Codex 原生持久目标');
1301
+ select('#turn-mode').insertAdjacentElement('afterend',goalButton);
1302
+ goalButton.onclick=openGoalDialog;
1303
+ select('#turn-mode').addEventListener('change',updateMode);
1304
+ select('#goal-form').addEventListener('submit',async event=>{
1305
+ event.preventDefault();
1306
+ if(!state.selected||!nativeGoalWritable())return;
1307
+ const button=select('#save-goal');
1308
+ const id=state.selected.id,selectionVersion=state.selectionVersion;
1309
+ const budgetText=select('#goal-token-budget').value.trim();
1310
+ const tokenBudget=budgetText?Number(budgetText):null;
1311
+ button.disabled=true;select('#goal-error').textContent='';
1312
+ try{
1313
+ const result=await rpc('thread.goal.set',{
1314
+ threadId:id,objective:select('#goal-objective').value.trim(),tokenBudget
1315
+ });
1316
+ if(selectionVersion!==state.selectionVersion||id!==state.selected?.id)return;
1317
+ state.goal=result.goal||null;
1318
+ if(state.desktopRuntime)state.desktopRuntime={...state.desktopRuntime,goal:state.goal};
1319
+ select('#goal-dialog').close();renderGoalStatus();toast('已设置 Codex 原生持久目标。');
1320
+ }catch(error){select('#goal-error').textContent=error.message;}
1321
+ finally{button.disabled=false;}
1322
+ });
1323
+ select('#pause-goal').onclick=()=>updateGoalStatus(state.goal?.status==='paused'?'active':'paused');
1324
+ select('#clear-goal').onclick=clearGoal;
1325
+ select('#cancel-goal').onclick=()=>select('#goal-dialog').close();
1326
+ select('[data-filter="locked"]')?.remove();
1327
+ renderGoalStatus();
1328
+ select('#browse-files').onclick=()=>{
1329
+ if(!state.selected)return;
1330
+ browseProjectFiles();
1331
+ select('#file-browser-dialog').showModal();
1332
+ };
1333
+ select('#close-file-browser').onclick=()=>select('#file-browser-dialog').close();
1334
+ select('#file-browser-list').addEventListener('click',event=>{
1335
+ const folder=event.target.closest('[data-file-directory]');
1336
+ if(folder){browseProjectFiles(folder.dataset.fileDirectory);return}
1337
+ const file=event.target.closest('[data-file-download]');
1338
+ if(file)downloadProjectFile(file.dataset.fileDownload);
1339
+ });
1340
+ select('#attach-files').onclick=()=>select('#file-input').click();
1341
+ select('#file-input').addEventListener('change',event=>uploadFiles(event.target.files));
1342
+ select('#composer-attachments').addEventListener('click',async event=>{
1343
+ const button=event.target.closest('[data-remove-attachment]');
1344
+ if(!button||!state.selected)return;
1345
+ const id=button.dataset.removeAttachment;
1346
+ const threadId=state.selected.id;
1347
+ try{
1348
+ await api(`/api/threads/${encodeURIComponent(threadId)}/attachments/remove`,{attachmentId:id});
1349
+ state.pendingAttachments=state.pendingAttachments.filter(file=>file.id!==id);
1350
+ renderComposerAttachments();updateMode();
1351
+ }catch(error){toast(error.message)}
1352
+ });
1090
1353
  select('#message-input').addEventListener('keydown', event => {
1091
1354
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !matchMedia('(max-width:760px)').matches) { event.preventDefault(); if (!select('#send-message').disabled) select('#composer').requestSubmit(); }
1092
1355
  });
1093
1356
  select('#composer').addEventListener('submit', async event => {
1094
1357
  event.preventDefault(); if (select('#send-message').disabled) return;
1095
- const id = state.selected.id; const text = select('#message-input').value; const wasBusy = state.busy;
1358
+ const id = state.selected.id; const inputText = select('#message-input').value.trim(); const wasBusy = state.busy;
1096
1359
  const turnId = state.turnId;
1097
- const steering = wasBusy && state.status.execution.mode === 'desktop';
1360
+ const steering = wasBusy && state.status.execution.mode === 'desktop' && state.desktopRuntime?.mode !== 'same-id-resume';
1361
+ const mode=select('#turn-mode').value;
1362
+ const attachmentIds=state.pendingAttachments.map(file=>file.id);
1363
+ const text=inputText|| (attachmentIds.length?`请查看并处理以下附件:${state.pendingAttachments.map(file=>file.label).join('、')}`:'');
1098
1364
  state.busy = true; updateMode();
1099
1365
  try {
1100
- const result = await rpc(steering ? 'turn.steer' : 'turn.start', { threadId:id, text, ...(steering ? { turnId } : {}) });
1101
- if (state.selected?.id === id) { state.turnId = result.completed ? null : result.turnId || (steering ? turnId : null); state.busy = !result.completed; select('#message-input').value = ''; scheduleRefresh(); }
1366
+ const result = await rpc(steering ? 'turn.steer' : 'turn.start', {
1367
+ threadId:id,text,attachmentIds,mode,...(steering?{turnId}:{})
1368
+ });
1369
+ if (state.selected?.id === id) {
1370
+ state.turnId = result.completed ? null : result.turnId || (steering ? turnId : null);
1371
+ state.busy = !result.completed;
1372
+ select('#message-input').value = '';
1373
+ state.pendingAttachments=[];
1374
+ renderComposerAttachments();
1375
+ if(!steering)select('#turn-mode').value='default';
1376
+ scheduleRefresh();
1377
+ }
1102
1378
  } catch (error) { if (state.selected?.id === id) state.busy = wasBusy; toast(error.message); }
1103
1379
  updateMode();
1104
1380
  });
@@ -0,0 +1,7 @@
1
+ .goal-button.active{background:#edf3e3;border-color:#dce7cd;color:#456337}
2
+ #goal-dialog label{display:block;font-size:11px;color:#718563;margin:16px 0 7px}
3
+ #goal-dialog textarea,#goal-dialog input{display:block;width:100%;border:1px solid #dce4d1;border-radius:8px;padding:11px 12px;background:#fff;color:#405139;font-size:12px}
4
+ #goal-dialog textarea{resize:vertical;min-height:100px;line-height:1.7}
5
+ #goal-dialog textarea:disabled,#goal-dialog input:disabled{background:#f0f2ec;color:#929b87}
6
+ #goal-dialog .dialog-actions{flex-wrap:wrap}
7
+ @media(max-width:760px){#goal-dialog{padding:22px 18px}#goal-dialog .dialog-actions{gap:7px}#goal-dialog .dialog-actions button{font-size:10px;padding:8px 10px}}
package/public/index.html CHANGED
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#153c37"><meta name="description" content="Orbit,让原生 Codex 会话随行。"><title>Orbit · 会话随行</title><link rel="icon" href="/icon.svg" type="image/svg+xml"><link rel="manifest" href="/manifest.webmanifest"><link rel="stylesheet" href="/style.css"><script src="/vendor/marked.js" defer></script><script src="/vendor/purify.js" defer></script><script src="/app.js" defer></script>
5
5
  <link rel="stylesheet" href="/layout.css">
6
+ <link rel="stylesheet" href="/goal.css">
6
7
  </head>
7
8
  <body>
8
9
  <section id="cloud-login-screen" class="pair-screen" hidden>
@@ -47,11 +48,13 @@
47
48
  <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
49
  <section id="sessions-view" class="sessions-view">
49
50
  <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="先确认接续方式,再沿着这条会话继续…" rows="2" aria-label="发送消息"></textarea><div class="composer-toolbar"><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
+ <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
52
  </section><section id="devices-view" class="devices-view" hidden></section>
52
53
  </main>
53
54
  </div>
55
+ <dialog id="goal-dialog"><form id="goal-form"><h2>会话目标</h2><p id="goal-description">设置 Codex 持续追求的原生目标;目标会保存在这条原会话中。</p><label for="goal-objective">目标</label><textarea id="goal-objective" rows="4" maxlength="5000" placeholder="描述希望 Codex 持续完成的结果"></textarea><label for="goal-token-budget">Token 预算(可选)</label><input id="goal-token-budget" type="number" min="1" max="10000000" step="1" placeholder="不设置"><p id="goal-current-status" class="model-note"></p><p id="goal-error" class="error" role="alert"></p><div class="dialog-actions"><button id="clear-goal" type="button" class="button">清除目标</button><button id="pause-goal" type="button" class="button">暂停目标</button><button id="cancel-goal" type="button" class="button">取消</button><button id="save-goal" type="submit" class="primary">开始追求目标</button></div></form></dialog>
54
56
  <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
57
  <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>
58
+ <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
59
  <div id="toast" class="toast" role="status" hidden></div>
57
60
  </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}}