xuym-codex-collab 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,16 +2,20 @@
2
2
 
3
3
  把企业微信群的任务交给指定的已有 Codex 对话:管理员统一创建并分配机器人,同事导入个人接入包、绑定对话,即可接单、提交结果、通知真人审核并接力。每位成员只运行本机工具;共享任务协议和交接记录保存在业务仓库的 `agent-collab` 分支。
4
4
 
5
+ 当前只支持企业微信,先集中完善接入、任务协作和稳定性;其他 IM 的接入在企微流程稳定后再评估。
6
+
5
7
  ## 安装与入口
6
8
 
7
9
  需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。业务仓库需已有至少一个远程业务分支;可以使用独立的本机 clone,也可以让向导拉取新目录。成员无需 clone 本工具源码。
8
10
 
9
11
  ```sh
10
- npm install --global xuym-codex-collab
11
- codex-collab
12
+ npm install --global --foreground-scripts xuym-codex-collab
13
+ codex-collab setup
12
14
  ```
13
15
 
14
- `setup` 打开本机网页工作台,所有接入操作集中在一个页面。管理员选择“连接我的机器人”,成员选择“接受同事邀请”;群身份验证、等待审批和本机配置会自动衔接,页面显示当前进度。已在使用旧配置的成员选择“导入已有配置”。命令启动后台工作台后退出,可以关闭终端和浏览器。macOS 默认设置登录自启;Linux 尝试配置 systemd 用户服务,失败时明确提示。工作台每 15 秒检查一次已开启的机器人进程,意外退出时恢复并在失败后退避重试;手动停止的连接保持停止。
16
+ 安装后,先运行 `codex-collab setup`。已有业务项目时,进入项目目录再运行可自动带入信息;没有本机 clone 时可让向导拉取。以后直接运行 `codex-collab` 也会打开向导。上面的 `--foreground-scripts` 会让 npm 显示安装完成后的下一步提示。安装过程不会自动打开浏览器或修改项目;未使用此参数时 npm 可能隐藏提示,使用 `--ignore-scripts` 时会跳过提示,都可以直接运行 `codex-collab setup` 开始。
17
+
18
+ 向导每屏只处理一个动作。管理员选择“接入一个项目”,确认目录和自动识别的分支、验证命令,再填写 Bot ID/Secret;成员选择“接受同事的邀请”,读取接入包后确认项目目录。群身份验证、等待审批和本机配置会自动衔接。最后复制指令到指定 Codex 对话,页面依据实际绑定和轮询记录显示完成。已在使用旧配置的成员可在首页“其他接入方式”中选择“导入已有配置”。命令启动后台工作台后退出,可以关闭终端和浏览器。macOS 默认设置登录自启;Linux 尝试配置 systemd 用户服务,失败时明确提示。工作台每 15 秒检查一次已开启的机器人进程,意外退出时恢复并在失败后退避重试;手动停止的连接保持停止。
15
19
 
16
20
  | 谁 | 在工作台做什么 | 接入后需要保留什么 |
17
21
  | --- | --- | --- |
@@ -22,7 +26,7 @@ codex-collab
22
26
 
23
27
  协作远程与业务基准分支自动识别;不要求 `origin/main`。有多个不同远程或默认分支不明确时,管理员选择一次。成员与新增机器人继承签名团队基准,各自可使用不同远程别名。每个新任务固定创建时的 commit,排队和重试期间不会漂移;管理员可在团队设置里为后续任务修改基准分支。
24
28
 
25
- 工作台首页按项目汇总,同一仓库的多个本机机器人共用一张项目卡片。打开项目后,所有团队机器人展示在同一列表里,自己的标记“我的”;本机已接入的机器人可继续配置或管理,其他机器人的职责来自签名团队记录。界面使用轻量 CSS 动效,支持系统减少动态效果设置,页面进入后台时暂停动画。
29
+ 工作台首页按项目汇总,同一仓库的多个本机机器人共用一张项目卡片。打开项目后,所有团队机器人展示在同一列表里,自己的标记“我的”;本机已接入的机器人可继续配置或管理,其他机器人的职责来自签名团队记录。分支、诊断、重置和团队分配设置按需展开。界面提供纸白、雾绿与夜色配色,使用轻量 CSS 与浏览器原生过渡动画,支持系统减少动态效果设置,页面进入后台时暂停动画。
26
30
 
27
31
  同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“添加我的机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
28
32
 
@@ -129,7 +133,7 @@ Alice 的绑定对话认领后在隔离 worktree 执行前端阶段,提交代
129
133
 
130
134
  停止工作台或机器人连接不会自动暂停 Codex heartbeat;需要暂停协作时,应在对应对话暂停自动化,并检查在执行任务的状态。
131
135
 
132
- 项目页面展开 **管理与重置**,可以先查看影响,再选择:
136
+ 在机器人的接入页面展开 **查看连接详情 → 管理与重置**,可以先查看影响,再选择:
133
137
 
134
138
  - **重新绑定对话**:先在原对话暂停协作 heartbeat,输入当前 Agent 名称确认。向导停止本机机器人连接、解除对话绑定,并提供启用指令;将指令发到目标对话,完成绑定后点击“启动机器人连接”。
135
139
  - **更新机器人凭据**:填写同一个 Bot ID 的新 Secret,用原企业微信本人账号在原群完成验证码验证,确认后保存。保留成员身份、群和对话绑定,机器人保持停止,点击“启动机器人连接”恢复。
@@ -153,7 +157,7 @@ node bin/codex-collab.mjs setup
153
157
  - [团队签名协议与目录](docs/team-registry.md)
154
158
  - [Peer 机制与底层命令](docs/peer.md)
155
159
  - [已有桌面对话轮询机制](docs/desktop-poll.md)
156
- - [旧中心服务与飞书接入](docs/legacy-center.md)
160
+ - [旧中心服务与企业微信接入](docs/legacy-center.md)
157
161
 
158
162
  2026-10-10 已由用户验证本机真实企业微信群 + Alice/Bob 两个已有 Codex 对话的接力闭环。网页向导与真人审核闭环通过临时 Git 仓库、模拟机器人和实际浏览器操作验证;2026-10-12 这轮测试没有连接真实机器人,原生 @ 尚未验证。跨两台机器的部署尚未验证。
159
163
 
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { SetupError } from './errors.mjs';
6
6
  import { SetupManager } from './manager.mjs';
7
- const assets = { '/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/style.css': ['style.css', 'text/css; charset=utf-8'] };
7
+ const assets = { '/favicon.svg': ['favicon.svg', 'image/svg+xml'], '/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/style.css': ['style.css', 'text/css; charset=utf-8'] };
8
8
  const same = (a, b) => typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
9
9
  export async function startSetupServer({ manager = new SetupManager(), port = 0 } = {}) {
10
10
  const token = randomBytes(32).toString('hex'); const cookieName = `collab_${randomBytes(8).toString('hex')}`; let origin;
@@ -6,14 +6,78 @@ const storageKey = 'codex-collab-onboarding';
6
6
  function remember(value) { try { if (value) sessionStorage.setItem(storageKey, JSON.stringify(value)); else sessionStorage.removeItem(storageKey); } catch {} }
7
7
  function remembered() { try { return JSON.parse(sessionStorage.getItem(storageKey) || 'null'); } catch { return null; } }
8
8
  function notice(text) { $('notice').textContent = text; $('notice').hidden = !text; }
9
- function show(view) { notice(''); const active = ['project', 'profile', 'own', 'reset-view'].includes(view) ? 'home' : view; for (const link of document.querySelectorAll('nav [data-view]')) { if (link.dataset.view === active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current'); } if (view !== 'profile' || currentView === 'profile') { $('delivery-panel').hidden = true; $('delivery-password').value = ''; $('delivery-instructions').textContent = ''; $('reissue-secret').value = ''; $('reissue-form').hidden = true; deliveryTarget = null; } clearInterval(timer); clearTimeout(timer); viewEpoch++; currentView = view; for (const section of document.querySelectorAll('main>section')) section.hidden = section.id !== view; }
9
+ const guideStep = { admin: 0, join: 0, own: 0 };
10
+ let sceneAnimation;
11
+ function animateScene(node) {
12
+ sceneAnimation?.cancel();
13
+ if (!node?.animate || typeof matchMedia !== 'function' || matchMedia('(prefers-reduced-motion: reduce)').matches || document.visibilityState === 'hidden') return;
14
+ sceneAnimation = node.animate([{ opacity: 0, transform: 'translateY(10px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 320, easing: 'cubic-bezier(.2,.75,.2,1)' });
15
+ }
16
+ function guidePanels(name) { return [...$(`${name}-form`).querySelectorAll('[data-wizard-panel]')]; }
17
+ function setGuide(name, step, focus = true) {
18
+ const panels = guidePanels(name); if (!panels.length) return;
19
+ guideStep[name] = step;
20
+ for (const [index, panel] of panels.entries()) panel.hidden = index !== step;
21
+ const label = step === 0 ? '继续 →' : name === 'join' ? '接入项目 →' : '连接机器人 →';
22
+ $(`${name}-continue`).textContent = label;
23
+ $(`${name}-step-count`).textContent = `${String(step + 1).padStart(2, '0')} / 04`;
24
+ const progress = $(`${name}-step-progress`); progress.setAttribute('aria-label', `第 ${step + 1} 步,共 4 步`);
25
+ for (const [index, marker] of [...progress.querySelectorAll('span')].entries()) {
26
+ marker.className = index <= step ? 'filled' : '';
27
+ if (index === step) marker.setAttribute('aria-current', 'step'); else marker.removeAttribute('aria-current');
28
+ }
29
+ if (focus) { globalThis.scrollTo?.({ top: 0, behavior: 'auto' }); animateScene(panels[step]); panels[step].querySelector('h1')?.focus({ preventScroll: true }); }
30
+ }
31
+ function revealField(field) {
32
+ for (let node = field.parentElement; node; node = node.parentElement) if (node.tagName === 'DETAILS') node.open = true;
33
+ }
34
+ function validatePanel(panel, skipCommand = false) {
35
+ for (const field of panel.querySelectorAll('input, select, textarea')) {
36
+ if (!field.willValidate || field.closest('[hidden]') || skipCommand && field.id === 'test-command') continue;
37
+ if (!field.checkValidity()) { revealField(field); field.reportValidity(); return false; }
38
+ }
39
+ return true;
40
+ }
41
+ async function advanceGuide(name) {
42
+ const panels = guidePanels(name); if (!panels.length) return true;
43
+ const step = guideStep[name];
44
+ if (!validatePanel(panels[step], name === 'admin' && step === 0)) return false;
45
+ if (step === 0) {
46
+ if (name === 'admin') {
47
+ const selected = await checkProject(); if (!selected) return false;
48
+ if (selected.selection_required) throw new Error('请选择协作远程和业务基准分支,再继续。');
49
+ if (!validatePanel(panels[0])) return false;
50
+ }
51
+ if (name === 'join' && !await inspectInvite()) return false;
52
+ setGuide(name, 1); return false;
53
+ }
54
+ return true;
55
+ }
56
+ function show(view) {
57
+ notice('');
58
+ if (view !== 'profile' || currentView === 'profile') {
59
+ $('delivery-panel').hidden = true; $('delivery-password').value = ''; $('delivery-instructions').textContent = '';
60
+ $('reissue-secret').value = ''; $('reissue-form').hidden = true; deliveryTarget = null;
61
+ }
62
+ clearInterval(timer); clearTimeout(timer); viewEpoch++; currentView = view;
63
+ for (const section of document.querySelectorAll('main>section')) section.hidden = section.id !== view;
64
+ if (Object.hasOwn(guideStep, view)) setGuide(view, 0, false);
65
+ globalThis.scrollTo?.({ top: 0, behavior: 'auto' });
66
+ const section = $(view); animateScene(section); section.querySelector('h1, h2')?.focus?.({ preventScroll: true });
67
+ }
10
68
  async function api(path, body) {
11
69
  const response = await fetch(`/api/${path}`, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
12
70
  const value = await response.json(); if (!response.ok) throw new Error(value.error); return value;
13
71
  }
14
72
  async function action(fn) {
15
- if (busy) return; busy = true; notice(''); for (const button of document.querySelectorAll('button')) button.disabled = true;
16
- try { await fn(); } catch (error) { notice(error.message); } finally { busy = false; for (const button of document.querySelectorAll('button')) button.disabled = false; }
73
+ if (busy) return; busy = true; notice('');
74
+ const buttons = new Map([...document.querySelectorAll('button')].map((button) => [button, button.disabled]));
75
+ for (const button of buttons.keys()) button.disabled = true;
76
+ document.body?.setAttribute('aria-busy', 'true');
77
+ try { await fn(); } catch (error) { notice(error.message); $('notice').scrollIntoView?.({ block: 'nearest', behavior: 'auto' }); } finally {
78
+ busy = false; for (const [button, disabled] of buttons) button.disabled = disabled;
79
+ document.body?.removeAttribute('aria-busy');
80
+ }
17
81
  }
18
82
  const values = (form) => Object.fromEntries(new FormData(form));
19
83
  function text(tag, value) { const node = document.createElement(tag); node.textContent = value; return node; }
@@ -22,7 +86,7 @@ async function overview() {
22
86
  const value = await api('overview');
23
87
  if (value.defaults) {
24
88
  if (!$('admin-form').elements.repo.value) $('admin-form').elements.repo.value = value.defaults.repo || '';
25
- if (!$('admin-form').elements.display_name.value) $('admin-form').elements.display_name.value = value.defaults.display_name || '';
89
+ if (!$('admin-form').elements.display_name.value) $('admin-form').elements.display_name.value = value.defaults.display_name || '我的开发助手';
26
90
  }
27
91
  $('startup-status').textContent = value.startup?.enabled ? '后台服务已运行,登录时自动恢复已开启的连接。可关闭终端和浏览器。' : value.startup?.error || '后台工作台会保持连接;登录自启状态可用 codex-collab autostart status 查看。';
28
92
  $('profiles').replaceChildren(); $('removed-profiles').replaceChildren();
@@ -36,15 +100,15 @@ async function overview() {
36
100
  const card = document.createElement('div'); card.className = 'card';
37
101
  card.append(text('span', `${draft.display_name || draft.agent_id} · 待完成群验证`), button('继续创建团队', () => openDraft(draft.id))); $('profiles').append(card);
38
102
  }
39
- $('project-count').textContent = value.projects ? `${value.projects.length} 个项目` : '';
40
103
  for (const [index, project] of (value.projects || []).entries()) {
41
104
  const card = document.createElement('div'); card.className = 'card project-card'; card.style?.setProperty('--order', Math.min(index, 7));
42
105
  const info = document.createElement('div'); info.className = 'project-card-info';
43
- const icon = text('span', project.name.slice(0, 1).toUpperCase()); icon.className = 'project-icon'; icon.setAttribute?.('aria-hidden', 'true');
44
- info.append(icon, text('h3', project.name), text('p', project.repository || '本机接入'), text('span', `本机已接入 ${project.local_agent_count} 个机器人`));
45
- card.append(info, button('打开项目 ↗', () => loadProject(project.id))); $('profiles').append(card);
106
+ const icon = text('span', '⌘'); icon.className = 'project-icon'; icon.setAttribute?.('aria-hidden', 'true');
107
+ info.append(icon, text('h3', project.name), text('p', '和团队里的 Codex 一起工作'));
108
+ const open = button('→', () => loadProject(project.id)); open.setAttribute?.('aria-label', `打开项目 ${project.name}`);
109
+ card.append(info, open); $('profiles').append(card);
46
110
  }
47
- if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。选择“连接我的机器人”,或导入同事提供的接入包。'));
111
+ if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。从下面开始,或接受同事的邀请。'));
48
112
  for (const profile of value.projects ? [] : value.profiles) {
49
113
  const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
50
114
  info.append(text('strong', `${profile.display_name || profile.agent_id} · ${profile.role === 'admin' ? '管理员' : '成员'}`), text('p', profile.repo));
@@ -69,25 +133,23 @@ function renderProject(value) {
69
133
  const signature = JSON.stringify(value);
70
134
  if (signature === projectSignature) return;
71
135
  projectSignature = signature;
72
- $('project-summary').replaceChildren();
73
- for (const [number, label] of [[value.agents.length, '项目机器人'], [value.agents.filter((agent) => agent.mine).length, '属于我'], [value.base_branch || '待确认', '任务基准分支']]) {
74
- const stat = document.createElement('div'); stat.className = 'summary-item'; stat.append(text('strong', String(number)), text('span', label)); $('project-summary').append(stat);
75
- }
76
136
  $('project-agents').replaceChildren();
77
137
  for (const [index, agent] of value.agents.entries()) {
78
138
  const card = document.createElement('article'); card.className = `robot-card${agent.mine ? ' is-mine' : ''}`; card.style?.setProperty('--order', Math.min(index, 7));
79
139
  const heading = document.createElement('div'); heading.className = 'robot-title';
80
140
  const connection = agent.connections[0];
81
141
  const display = connection?.display_name;
82
- heading.append(text('h4', display && display !== agent.agent_id ? `${display}(${agent.agent_id})` : agent.agent_id));
142
+ heading.append(text('h4', display || agent.agent_id));
83
143
  if (agent.mine) { const mine = text('span', '我的'); mine.className = 'badge mine-badge'; heading.append(mine); }
84
144
  card.append(heading, text('p', `${agent.role === 'admin' ? '管理员' : '成员'}${agent.capabilities.length ? ' · ' + agent.capabilities.join(' / ') : ''}`));
85
145
  if (agent.identity_conflict) { const warning = text('p', '本机身份与团队记录不一致,请检查原接入。'); warning.className = 'warning-text'; card.append(warning); }
86
146
  for (const local of agent.connections) {
87
147
  const details = document.createElement('div'); details.className = 'robot-connection';
88
148
  const status = text('span', connectionStatus(local)); status.className = `badge${local.can_receive_tasks ? ' ready-badge' : ' pending'}`;
89
- const path = text('p', local.repo); path.className = 'repository-path';
90
- details.append(status, path, button(local.phase === 'ready' ? '管理机器人' : '继续接入', () => loadProfile(local.id))); card.append(details);
149
+ const open = button(local.phase === 'ready' && local.bound ? '查看 →' : '完成连接 →', () => loadProfile(local.id));
150
+ open.className = 'quiet-button'; details.append(status, open);
151
+ if (agent.connections.length > 1) { const path = text('p', local.repo); path.className = 'repository-path'; card.append(path); }
152
+ card.append(details);
91
153
  }
92
154
  if (!agent.connections.length) { const note = text('p', agent.mine ? '我的机器人 · 未在本机接入' : '由同事维护'); note.className = 'muted'; card.append(note); }
93
155
  $('project-agents').append(card);
@@ -111,7 +173,7 @@ async function loadProject(id) {
111
173
  currentProjectId = id; projectSignature = null; currentProject = null;
112
174
  remember({ project_id: id }); show('project'); const epoch = viewEpoch;
113
175
  $('project-title').textContent = '正在打开项目'; $('project-repository').textContent = '';
114
- $('project-summary').replaceChildren(); $('project-agents').replaceChildren();
176
+ $('project-agents').replaceChildren();
115
177
  $('project-add-own').hidden = true; $('project-settings').hidden = true; $('project-sync').textContent = '';
116
178
  try {
117
179
  const value = await api(`project?id=${encodeURIComponent(id)}&local=1`);
@@ -121,7 +183,7 @@ async function loadProject(id) {
121
183
  }
122
184
  $('project-back').onclick = () => action(async () => { remember(null); show('home'); await overview(); });
123
185
  $('project-refresh').onclick = () => action(() => refreshProjectView(currentProjectId, viewEpoch));
124
- $('project-settings').onclick = () => action(() => loadProfile(currentProject.admin_profile_id));
186
+ $('project-settings').onclick = () => action(async () => { await loadProfile(currentProject.admin_profile_id); $('admin-tools').open = true; });
125
187
  $('project-add-own').onclick = () => action(async () => { ownAdminId = currentProject.admin_profile_id; ownReturnProject = currentProjectId; show('own'); });
126
188
  $('profile-back').onclick = () => action(() => loadProject(currentProjectId));
127
189
  async function openDraft(id) {
@@ -130,7 +192,7 @@ async function openDraft(id) {
130
192
  pending = { type: 'admin', draft_id: id }; remember({ draft_id: id }); await verifyView(value);
131
193
  }
132
194
  async function verifyView(session) {
133
- discovery = session; show('verification'); $('code').textContent = session.message; updateConnection(session);
195
+ discovery = session; show('verification'); $('copy-code').textContent = '复制群验证消息'; $('code').textContent = session.message; updateConnection(session);
134
196
  const epoch = viewEpoch;
135
197
  const tick = async () => {
136
198
  if (epoch !== viewEpoch) return;
@@ -176,7 +238,7 @@ function renderSteps(value) {
176
238
  $('setup-steps').replaceChildren(); let active = false;
177
239
  for (const [label, done] of steps) { const row = text('li', `${done ? '✓ ' : ''}${label}`); row.className = done ? 'done' : !active ? 'active' : ''; if (!done) active = true; $('setup-steps').append(row); }
178
240
  const stopped = value.phase === 'ready' && !value.runner?.online;
179
- $('next-step').textContent = value.error ? '接入暂时停在这里。处理下方问题后点击“继续接入”。' : value.can_receive_tasks ? '已观察到实际轮询,可以在协作群 @ 机器人发送任务。请保持当前对话的协作自动化启用。' : value.phase === 'waiting' ? '申请已提交,正在自动等待管理员确认。管理员工作台会自动显示申请。' : value.phase !== 'ready' ? '按当前提示继续,完成后向导会自动进入下一步。' : stopped ? '机器人连接已停止。需要接任务时点击“启动机器人连接”。' : !value.desktop?.binding ? '在 Codex 打开下方项目,加载项目 MCP 和 Skill,再把启用指令发到要绑定的对话。' : '对话已绑定,正在等待实际轮询记录。';
241
+ $('next-step').textContent = value.error ? '接入暂时停在这里。处理下方问题后点击“继续接入”。' : value.can_receive_tasks ? '已观察到实际轮询,可以在协作群 @ 机器人发送任务。请保持当前对话的协作自动化启用。' : value.phase === 'waiting' ? '申请已提交,正在自动等待管理员确认。管理员工作台会自动显示申请。' : value.phase !== 'ready' ? '按当前提示继续,完成后向导会自动进入下一步。' : stopped ? '机器人连接已停止。需要接任务时点击“启动机器人连接”。' : !value.desktop?.binding ? '在 Codex 打开此项目,加载项目 MCP 和 Skill,再把启用指令发到要绑定的对话。' : '对话已绑定,正在等待实际轮询记录。';
180
242
  }
181
243
  function watchProfile(id, epoch) {
182
244
  let failures = 0;
@@ -197,7 +259,14 @@ function watchProfile(id, epoch) {
197
259
  }
198
260
  async function loadProfile(id) {
199
261
  repositoryPolicy = null; $('repository-policy-form').hidden = true;
200
- currentId = id; remember({ profile_id: id }); show('profile'); const epoch = viewEpoch; let value;
262
+ if (currentView !== 'profile' || currentId !== id) {
263
+ $('admin-tools').open = false; $('connection-details').open = false;
264
+ $('profile-heading').textContent = '正在读取连接…'; $('profile-art').dataset.state = 'waiting';
265
+ for (const key of ['binding', 'profile-done', 'profile-start', 'admin-tools', 'pending-requests', 'profile-back']) $(key).hidden = true;
266
+ for (const key of ['profile-title', 'profile-repo', 'next-step', 'config-path']) $(key).textContent = '';
267
+ for (const key of ['checks', 'progress', 'setup-steps']) $(key).replaceChildren();
268
+ }
269
+ currentId = id; remember({ profile_id: id }); show('profile'); $('copy-binding').textContent = '复制启用指令'; const epoch = viewEpoch; let value;
201
270
  try { value = await api(`profile?id=${id}`); } catch (error) { value = await api(`profile?id=${id}&local=1`); notice(`团队同步暂未成功,本机状态仍可查看。${error.message}`); }
202
271
  if (epoch !== viewEpoch) return;
203
272
  if (value.phase === 'removed') { show('home'); await overview(); return; }
@@ -207,10 +276,21 @@ async function loadProfile(id) {
207
276
  }
208
277
  function renderProfile(value, automatic = false) {
209
278
  const id = value.id; renderSteps(value);
279
+ const ready = value.phase === 'ready', bound = !!value.desktop?.binding, complete = !!value.can_receive_tasks;
280
+ $('profile-heading').textContent = complete ? '就这么简单。' : ready && !value.runner?.online ? '连接已暂停。' : ready && !bound ? '最后,连接你的对话。' : ready ? '对话已绑定。' : '正在准备你的项目。';
281
+ const state = complete ? 'success' : 'waiting';
282
+ if ($('profile-art').dataset.state !== state) $('profile-art').dataset.state = state;
283
+ $('profile-step-progress').hidden = complete;
284
+ $('profile-done').hidden = !ready || !bound;
285
+ $('profile-start').hidden = !ready || !!value.runner?.online;
286
+ const requests = value.requests || [];
287
+ $('pending-requests').hidden = !requests.length;
288
+ $('pending-requests').textContent = requests.length ? `${requests.length} 位同事等待你的确认 →` : '';
289
+
210
290
  $('reset-conversation').hidden = value.phase !== 'ready'; $('reset-robot').hidden = value.phase !== 'ready';
211
291
  $('profile-title').textContent = `${value.display_name && value.display_name !== value.agent_id ? `${value.display_name}(${value.agent_id})` : value.agent_id} · ${value.role === 'admin' ? '管理员' : '成员'}`; $('profile-repo').textContent = `${value.repo}${value.base_branch ? ` · 基准:${value.remote_name || '远程'}/${value.base_branch}` : ''}`;
212
292
  $('profile-diagnostics').hidden = !value.config_path; $('config-path').textContent = value.config_path || '';
213
- $('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = value.phase !== 'ready'; $('runner-actions').hidden = value.phase !== 'ready'; $('diagnostic-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
293
+ $('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = !ready || bound; $('runner-actions').hidden = value.phase !== 'ready'; $('diagnostic-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
214
294
  if (value.phase === 'ready') {
215
295
  $('binding-text').textContent = value.binding_prompt; $('binding-details').textContent = value.detailed_binding_prompt || value.binding_prompt;
216
296
  for (const [label, ok, wait] of [['项目 MCP', value.project_setup?.mcp_configured, '待配置'], ['项目 Skill', value.project_setup?.skill_installed, '待配置'], ['企业微信机器人', value.runner?.wecom === 'authenticated', value.runner?.online ? '连接中' : '已停止'], ['目标 Codex 对话', !!value.desktop?.binding, '待绑定'], ['对话实际轮询', value.poll_recent, value.poll_observed ? '最近未观察到轮询' : '待观察'], ['可接收任务', value.can_receive_tasks, '尚未满足条件']]) {
@@ -288,11 +368,14 @@ for (const link of document.querySelectorAll('[data-view]')) link.onclick = (eve
288
368
  if (resetPlan) { await api('reset-cancel', { id: resetPlan.id, preview_token: resetPlan.preview_token }); resetPlan = null; resetDiscovery = null; $('reset-secret').value = ''; }
289
369
  remember(null); show(link.dataset.view);
290
370
  if (link.dataset.view === 'home') await overview();
291
- if (link.dataset.view === 'admin' && $('admin-form').elements.repo.value) await checkProject();
292
371
  });
293
372
  };
294
373
  function repoMode(prefix) {
295
- const clone = $(`${prefix}-repo-mode`).value === 'clone'; $(`${prefix}-existing-repo`).hidden = clone; $(`${prefix}-clone-repo`).hidden = !clone;
374
+ const clone = $(`${prefix}-repo-mode`).value === 'clone';
375
+ $(`${prefix}-existing-repo`).hidden = clone; $(`${prefix}-clone-repo`).hidden = !clone;
376
+ const fields = $(`${prefix}-form`).elements;
377
+ if (fields.repo) fields.repo.required = !clone;
378
+ if (prefix === 'admin' && fields.remote) fields.remote.required = clone;
296
379
  }
297
380
  for (const prefix of ['admin', 'join', 'own']) $(`${prefix}-repo-mode`).onchange = () => {
298
381
  repoMode(prefix);
@@ -300,7 +383,7 @@ for (const prefix of ['admin', 'join', 'own']) $(`${prefix}-repo-mode`).onchange
300
383
  };
301
384
  function clearProjectSelection() {
302
385
  $('admin-remote-name').value = ''; $('admin-base-branch').value = ''; $('repository-options').hidden = true;
303
- $('project-recommendation').textContent = '';
386
+ $('project-recommendation').textContent = ''; $('project-check-status').textContent = '会自动识别仓库、默认分支和验证命令。';
304
387
  }
305
388
  async function checkProject() {
306
389
  const snapshot = values($('admin-form'));
@@ -320,6 +403,9 @@ async function checkProject() {
320
403
  }
321
404
  $('project-recommendation').textContent = selected.selection_required ? '请在下方选择协作远程或业务基准分支,再继续。' : `项目已检查:${selected.repo}。基准:${selected.remote_name}/${selected.base_branch}。${selected.tests.length ? '推荐:' + selected.tests.map((test) => test.command).join(' / ') + ',请确认或修改。' : '未找到常见测试入口,请填写适合项目的验证命令。'}`;
322
405
  if (!$('test-command').value && selected.tests.length) $('test-command').value = selected.tests[0].command;
406
+ $('project-check-status').textContent = selected.selection_required ? '需要你确认一下分支设置。' : `已识别 ${selected.remote_name}/${selected.base_branch}${$('test-command').value ? ` · 验证:${$('test-command').value}` : ''}`;
407
+ if (selected.selection_required || !$('test-command').value) $('project-options').open = true;
408
+ return selected;
323
409
  }
324
410
  $('load-repository-settings').onclick = () => action(async () => {
325
411
  const id = currentId, value = await api('repository-settings', { id });
@@ -336,13 +422,12 @@ $('repository-policy-form').onsubmit = (event) => { event.preventDefault(); acti
336
422
  }); };
337
423
  $('check-project').onclick = () => action(checkProject);
338
424
  for (const id of ['admin-repo', 'admin-remote', 'admin-destination']) $(id).onchange = () => {
339
- const form = $('admin-form');
340
425
  clearProjectSelection();
341
- if ($('admin-repo-mode').value === 'clone' ? form.elements.remote.value && form.elements.destination.value : form.elements.repo.value) action(checkProject);
342
426
  };
343
427
  $('admin-remote-name').onchange = () => { $('admin-base-branch').value = ''; action(checkProject); };
344
428
  $('admin-base-branch').onchange = () => action(checkProject);
345
429
  $('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
430
+ if (!await advanceGuide('admin')) return;
346
431
  const input = values(event.target);
347
432
  const session = await api('admin-prepare', input); event.target.elements.secret.value = '';
348
433
  pending = { type: 'admin', draft_id: session.draft_id }; remember({ draft_id: session.draft_id }); await verifyView(session);
@@ -357,13 +442,15 @@ async function inspectInvite() {
357
442
  $('invite-info').hidden = false; $('invite-summary').textContent = `${value.display_name || value.agent_id}(${value.agent_id})· 职责:${value.capabilities.join('、')}${value.team_name ? ' · ' + value.team_name : ''}`;
358
443
  $('invite-remote').textContent = `${value.remote}${value.base_branch ? ` · 基准分支:${value.base_branch}(自动继承)` : ''}`;
359
444
  if (!$('join-form').elements.destination.value) $('join-form').elements.destination.value = value.suggested_repo;
445
+ return value;
360
446
  }
361
447
  $('inspect-invite').onclick = () => action(inspectInvite);
362
448
  for (const id of ['invite-file', 'invite-password']) $(id).onchange = () => {
363
449
  $('invite-info').hidden = true;
364
- if ($('invite-file').files?.length && $('invite-password').value) action(inspectInvite);
450
+ $('invite-summary').textContent = ''; $('invite-remote').textContent = '';
365
451
  };
366
452
  $('join-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
453
+ if (!await advanceGuide('join')) return;
367
454
  const profile = await api('import', await invitationInput(event.target)); event.target.elements.password.value = ''; await connectMember(profile);
368
455
  }); };
369
456
  async function connectMember(profile) {
@@ -376,6 +463,7 @@ async function connectMember(profile) {
376
463
  $('add-own-agent').onclick = () => action(async () => { ownAdminId = currentId; ownReturnProject = null; show('own'); });
377
464
  $('own-cancel').onclick = () => action(() => ownReturnProject ? loadProject(ownReturnProject) : loadProfile(ownAdminId));
378
465
  $('own-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
466
+ if (!await advanceGuide('own')) return;
379
467
  const input = values(event.target); input.id = ownAdminId; input.prefer = event.target.elements.prefer.checked;
380
468
  let profile;
381
469
  try { profile = await api('own-agent', input); }
@@ -402,9 +490,9 @@ $('refresh').onclick = () => action(() => loadProfile(currentId));
402
490
  $('start').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'start' }); await loadProfile(currentId); });
403
491
  $('stop').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'stop' }); await loadProfile(currentId); });
404
492
  $('copy-config-path').onclick = () => action(() => navigator.clipboard.writeText($('config-path').textContent));
405
- $('copy-code').onclick = () => action(() => navigator.clipboard.writeText($('code').textContent));
493
+ $('copy-code').onclick = () => copyFeedback('copy-code', '已复制,去群里发送');
406
494
  $('copy-binding-details').onclick = () => action(() => navigator.clipboard.writeText($('binding-details').textContent));
407
- $('copy-binding').onclick = () => action(() => navigator.clipboard.writeText($('binding-text').textContent));
495
+ $('copy-binding').onclick = () => copyFeedback('copy-binding', '已复制,发到 Codex 对话');
408
496
  async function openReset(id, kind) {
409
497
  clearInterval(timer); clearTimeout(timer); viewEpoch++; resetPlan = null; resetDiscovery = null;
410
498
  const plan = await api('reset-preview', { id, kind }); resetPlan = plan; currentId = id;
@@ -497,6 +585,39 @@ $('reissue-form').onsubmit = (event) => { event.preventDefault(); action(async (
497
585
  notice('新接入包与密码已生成,旧包已失效。请下载新文件,在“加入团队”中使用新密码。');
498
586
  }); };
499
587
  $('hide-delivery').onclick = () => action(async () => { $('delivery-panel').hidden = true; $('delivery-password').value = ''; $('reissue-secret').value = ''; $('reissue-form').hidden = true; deliveryTarget = null; watchProfile(currentId, viewEpoch); });
588
+ async function copyFeedback(id, copiedLabel) {
589
+ return action(async () => {
590
+ await navigator.clipboard.writeText($(id === 'copy-code' ? 'code' : 'binding-text').textContent);
591
+ $(id).textContent = copiedLabel;
592
+ animateScene($(id));
593
+ });
594
+ }
595
+ for (const name of Object.keys(guideStep)) {
596
+ $(`${name}-back`).onclick = () => {
597
+ if (busy) return;
598
+ if (guideStep[name] > 0) { notice(''); setGuide(name, guideStep[name] - 1); }
599
+ else if (name === 'own') return $('own-cancel').onclick();
600
+ else return action(async () => { show('home'); await overview(); });
601
+ };
602
+ }
603
+ $('profile-done').onclick = () => action(async () => { if (currentProjectId) await loadProject(currentProjectId); else { remember(null); show('home'); await overview(); } });
604
+ $('profile-start').onclick = () => $('start').onclick();
605
+ $('pending-requests').onclick = () => { $('admin-tools').open = true; $('requests').scrollIntoView?.({ block: 'center', behavior: 'auto' }); };
606
+ function initializeAppearance() {
607
+ if (!document.body) return;
608
+ function apply(theme) {
609
+ if (!['day', 'studio', 'night'].includes(theme)) theme = 'day';
610
+ document.body.dataset.theme = theme;
611
+ for (const choice of document.querySelectorAll('[data-theme-choice]')) choice.setAttribute('aria-pressed', String(choice.dataset.themeChoice === theme));
612
+ try { localStorage.setItem('codex-collab-theme', theme); } catch {}
613
+ }
614
+ let theme = 'day';
615
+ try { theme = new URL(location.href).searchParams.get('theme') || localStorage.getItem('codex-collab-theme') || theme; } catch {}
616
+ apply(theme);
617
+ for (const choice of document.querySelectorAll('[data-theme-choice]')) choice.onclick = () => apply(choice.dataset.themeChoice);
618
+ for (const name of Object.keys(guideStep)) repoMode(name);
619
+ }
620
+ initializeAppearance();
500
621
  assignmentRow();
501
622
  action(async () => {
502
623
  const token = location.hash.slice(1); if (token) { await api('session', { token }); history.replaceState(null, '', '/'); }
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
2
+ <rect width="48" height="48" rx="14" fill="#3f7057"/>
3
+ <g fill="none" stroke="#faf9f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4
+ <path d="m17 15-9 9 9 9m14-18 9 9-9 9m-5-22-4 26"/>
5
+ </g>
6
+ </svg>