xuym-codex-collab 0.6.0 → 0.8.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 +7 -3
- package/apps/onboarding/manager.mjs +12 -5
- package/apps/onboarding/projects.mjs +99 -0
- package/apps/onboarding/server.mjs +1 -0
- package/apps/onboarding/web/app.js +102 -8
- package/apps/onboarding/web/index.html +5 -4
- package/apps/onboarding/web/style.css +14 -10
- package/docs/architecture.md +13 -5
- package/docs/legacy-center.md +12 -52
- package/docs/onboarding-audit.md +12 -1
- package/docs/onboarding.md +16 -4
- package/docs/protocol.md +1 -1
- package/docs/releases/0.8.0.md +21 -0
- package/package.json +1 -2
- package/schemas/event.schema.json +1 -1
- package/schemas/task.schema.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
把企业微信群的任务交给指定的已有 Codex 对话:管理员统一创建并分配机器人,同事导入个人接入包、绑定对话,即可接单、提交结果、通知真人审核并接力。每位成员只运行本机工具;共享任务协议和交接记录保存在业务仓库的 `agent-collab` 分支。
|
|
4
4
|
|
|
5
|
+
当前只支持企业微信,先集中完善接入、任务协作和稳定性;其他 IM 的接入在企微流程稳定后再评估。
|
|
6
|
+
|
|
5
7
|
## 安装与入口
|
|
6
8
|
|
|
7
|
-
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push
|
|
9
|
+
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。业务仓库需已有至少一个远程业务分支;可以使用独立的本机 clone,也可以让向导拉取新目录。成员无需 clone 本工具源码。
|
|
8
10
|
|
|
9
11
|
```sh
|
|
10
12
|
npm install --global xuym-codex-collab
|
|
@@ -22,7 +24,9 @@ codex-collab
|
|
|
22
24
|
|
|
23
25
|
协作远程与业务基准分支自动识别;不要求 `origin/main`。有多个不同远程或默认分支不明确时,管理员选择一次。成员与新增机器人继承签名团队基准,各自可使用不同远程别名。每个新任务固定创建时的 commit,排队和重试期间不会漂移;管理员可在团队设置里为后续任务修改基准分支。
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
工作台首页按项目汇总,同一仓库的多个本机机器人共用一张项目卡片。打开项目后,所有团队机器人展示在同一列表里,自己的标记“我的”;本机已接入的机器人可继续配置或管理,其他机器人的职责来自签名团队记录。界面使用轻量 CSS 动效,支持系统减少动态效果设置,页面进入后台时暂停动画。
|
|
28
|
+
|
|
29
|
+
同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“添加我的机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
|
|
26
30
|
|
|
27
31
|
机器人在企业微信后台设为多人可用并加入协作群后,已批准入队的成员可以 `@` 任意团队机器人的自然语言需求;未入队的群成员仍会被工具拒绝。普通成员可提交需求、创建任务和查询状态,审核与取消、完成等控制操作继续受原有身份权限限制。接入完成后,团队成员可直接互相派发,不必再改机器人配置。
|
|
28
32
|
|
|
@@ -151,7 +155,7 @@ node bin/codex-collab.mjs setup
|
|
|
151
155
|
- [团队签名协议与目录](docs/team-registry.md)
|
|
152
156
|
- [Peer 机制与底层命令](docs/peer.md)
|
|
153
157
|
- [已有桌面对话轮询机制](docs/desktop-poll.md)
|
|
154
|
-
- [
|
|
158
|
+
- [旧中心服务与企业微信接入](docs/legacy-center.md)
|
|
155
159
|
|
|
156
160
|
2026-10-10 已由用户验证本机真实企业微信群 + Alice/Bob 两个已有 Codex 对话的接力闭环。网页向导与真人审核闭环通过临时 Git 仓库、模拟机器人和实际浏览器操作验证;2026-10-12 这轮测试没有连接真实机器人,原生 @ 尚未验证。跨两台机器的部署尚未验证。
|
|
157
161
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { inspectRepository, RepositoryError, resolveRemote, teamBaseBranch } from '../../packages/peer/repository.mjs';
|
|
2
2
|
import { defaultReviewRules } from '../../packages/peer/review.mjs';
|
|
3
3
|
import { SetupError } from './errors.mjs';
|
|
4
|
+
import { groupProjects, projectId, projectView } from './projects.mjs';
|
|
4
5
|
import { createPublicKey, randomBytes, randomUUID } from 'node:crypto';
|
|
5
6
|
import { chmod, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from 'node:fs/promises';
|
|
6
7
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
@@ -118,12 +119,18 @@ export class SetupManager {
|
|
|
118
119
|
return new PeerGitStore({ remoteName: state.remote_name, remoteUrl: state.remote, repo: state.repo, dataDir: state.data_dir || join(this.path(state.id), 'data'), team: initial,
|
|
119
120
|
agentId: state.agent_id, privateKey: identity.private_key, registryRoot: state.root_hash, enrollmentOnly }).init();
|
|
120
121
|
}
|
|
122
|
+
async localStates() {
|
|
123
|
+
await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
|
|
124
|
+
const ids = (await readdir(join(this.home, 'profiles'))).filter((id) => uuidPattern.test(id)).sort();
|
|
125
|
+
return Promise.all(ids.map((id) => this.state(id)));
|
|
126
|
+
}
|
|
127
|
+
project(id, options) { return projectView(this, id, options); }
|
|
121
128
|
async overview() {
|
|
122
129
|
await mkdir(join(this.home, 'profiles'), { recursive: true, mode: 0o700 });
|
|
123
|
-
const
|
|
130
|
+
const states = await this.localStates();
|
|
124
131
|
const profiles = [], removed_profiles = [];
|
|
125
|
-
for (const
|
|
126
|
-
const
|
|
132
|
+
for (const state of states) {
|
|
133
|
+
const id = state.id;
|
|
127
134
|
(state.phase === 'removed' ? removed_profiles : profiles).push({ id, agent_id: state.agent_id, role: state.role, display_name: state.display_name || state.agent_id, repo: state.repo, phase: state.phase, error: state.last_error || null });
|
|
128
135
|
}
|
|
129
136
|
const drafts = [];
|
|
@@ -133,7 +140,7 @@ export class SetupManager {
|
|
|
133
140
|
if (!draft.profile_id) drafts.push({ id: draft.id, agent_id: draft.input.agent_id, display_name: draft.input.display_name, repo: draft.input.repo });
|
|
134
141
|
}
|
|
135
142
|
let context = {}; try { context = await readPrivateJson(join(this.home, 'context.json')); } catch {}
|
|
136
|
-
return { profiles, removed_profiles, drafts, defaults: await localDefaults(context.cwd || this.home), startup: await startupStatus(this.home) };
|
|
143
|
+
return { projects: groupProjects(states), profiles, removed_profiles, drafts, defaults: await localDefaults(context.cwd || this.home), startup: await startupStatus(this.home) };
|
|
137
144
|
}
|
|
138
145
|
async checkBotAvailable(botId) {
|
|
139
146
|
for (const profile of (await this.overview()).profiles) {
|
|
@@ -707,7 +714,7 @@ export class SetupManager {
|
|
|
707
714
|
return this.detail(id);
|
|
708
715
|
}
|
|
709
716
|
async detail(id, { localOnly = false } = {}) {
|
|
710
|
-
const state = await this.state(id); const result = { id, agent_id: state.agent_id, display_name: state.display_name || state.agent_id, team_name: state.team_name || null, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
717
|
+
const state = await this.state(id); const result = { id, project_id: projectId(state), agent_id: state.agent_id, display_name: state.display_name || state.agent_id, team_name: state.team_name || null, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
711
718
|
if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
|
|
712
719
|
expires_at: state.bundle.invitation.expires_at };
|
|
713
720
|
let synchronized;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createPublicKey } from 'node:crypto';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { digest, readPrivateJson } from '../../packages/peer/config.mjs';
|
|
4
|
+
import { remoteIdentity, teamBaseBranch } from '../../packages/peer/repository.mjs';
|
|
5
|
+
import { readRegistry, validateHistory } from '../../packages/peer/registry.mjs';
|
|
6
|
+
import { SetupError } from './errors.mjs';
|
|
7
|
+
|
|
8
|
+
export function projectId(state) {
|
|
9
|
+
return digest(state.remote && state.root_hash
|
|
10
|
+
? { repository: remoteIdentity(state.remote), root: state.root_hash }
|
|
11
|
+
: { profile: state.id });
|
|
12
|
+
}
|
|
13
|
+
export function groupProjects(states) {
|
|
14
|
+
const groups = new Map();
|
|
15
|
+
for (const state of states) {
|
|
16
|
+
if (state.phase === 'removed') continue;
|
|
17
|
+
const id = projectId(state);
|
|
18
|
+
if (!groups.has(id)) {
|
|
19
|
+
const repository = state.remote ? remoteIdentity(state.remote) : '';
|
|
20
|
+
groups.set(id, { id, name: basename(repository || state.repo || '').replace(/\.git$/, '') || '协作项目', repository, profile_ids: [], local_agent_count: 0 });
|
|
21
|
+
}
|
|
22
|
+
groups.get(id).profile_ids.push(state.id);
|
|
23
|
+
}
|
|
24
|
+
for (const group of groups.values()) {
|
|
25
|
+
group.profile_ids.sort();
|
|
26
|
+
group.local_agent_count = new Set(states.filter((state) => group.profile_ids.includes(state.id)).map((state) => state.agent_id)).size;
|
|
27
|
+
}
|
|
28
|
+
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Project navigation aggregates local connections. It never creates a new team or
|
|
32
|
+
// changes the authorization of the existing per-agent operations.
|
|
33
|
+
export async function projectView(manager, id, { localOnly = false } = {}) {
|
|
34
|
+
const states = await manager.localStates();
|
|
35
|
+
const project = groupProjects(states).find((value) => value.id === id);
|
|
36
|
+
if (!project) throw new SetupError('未找到这个项目,请从“我的项目”重新打开。');
|
|
37
|
+
const local = states.filter((state) => project.profile_ids.includes(state.id));
|
|
38
|
+
const candidates = local.filter((state) => state.phase === 'ready').sort((a, b) => (a.role === 'admin' ? 0 : 1) - (b.role === 'admin' ? 0 : 1));
|
|
39
|
+
let team, synced = false;
|
|
40
|
+
if (!localOnly) {
|
|
41
|
+
for (const state of candidates) {
|
|
42
|
+
try { team = (await manager.profileStore(state)).team; synced = true; break; }
|
|
43
|
+
catch { /* Try another local connection without exposing Git/SDK errors. */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!team) {
|
|
47
|
+
let latest;
|
|
48
|
+
const use = (history) => { if (history && (!latest || history.latest.version > latest.version)) latest = history.latest; };
|
|
49
|
+
for (const state of [...candidates, ...local.filter((value) => value.phase !== 'ready')]) {
|
|
50
|
+
try {
|
|
51
|
+
const registry = await readRegistry(join(state.data_dir || join(manager.path(state.id), 'data'), 'peer-ledger'), state.root_hash);
|
|
52
|
+
use(registry);
|
|
53
|
+
} catch { /* The next source must also pass signature verification. */ }
|
|
54
|
+
try {
|
|
55
|
+
const history = state.bundle?.history || (state.genesis ? [state.genesis] : null);
|
|
56
|
+
if (history) use(validateHistory(history, state.root_hash));
|
|
57
|
+
} catch { /* Unverified membership never reaches the project roster. */ }
|
|
58
|
+
}
|
|
59
|
+
team = latest?.team;
|
|
60
|
+
}
|
|
61
|
+
const identities = new Map();
|
|
62
|
+
for (const state of local) {
|
|
63
|
+
try {
|
|
64
|
+
const identity = await readPrivateJson(state.identity_path);
|
|
65
|
+
identities.set(state.id, createPublicKey(identity.private_key).export({ type: 'spki', format: 'pem' }));
|
|
66
|
+
} catch { /* Missing local identity is visible as a connection problem. */ }
|
|
67
|
+
}
|
|
68
|
+
const matching = (state, agent) => identities.get(state.id) && identities.get(state.id) === agent.public_key;
|
|
69
|
+
const owners = new Set(local.flatMap((state) => {
|
|
70
|
+
const agent = team?.agents[state.agent_id];
|
|
71
|
+
return agent && matching(state, agent) && agent.wecom_userid ? [agent.wecom_userid] : [];
|
|
72
|
+
}));
|
|
73
|
+
const connections = new Map();
|
|
74
|
+
for (const state of local) {
|
|
75
|
+
let detail;
|
|
76
|
+
try { detail = await manager.detail(state.id, { localOnly: true }); } catch { /* Keep broken local connections recoverable. */ }
|
|
77
|
+
connections.set(state.id, { id: state.id, repo: state.repo, phase: state.phase, display_name: state.display_name || state.agent_id,
|
|
78
|
+
has_error: !!state.last_error || state.phase === 'ready' && !detail,
|
|
79
|
+
can_receive_tasks: detail?.can_receive_tasks === true, runner_online: detail?.runner?.online === true,
|
|
80
|
+
bound: !!detail?.desktop?.binding, poll_recent: detail?.poll_recent === true });
|
|
81
|
+
}
|
|
82
|
+
const included = new Set();
|
|
83
|
+
const agents = Object.entries(team?.agents || {}).map(([agentId, agent]) => {
|
|
84
|
+
const own = local.filter((state) => state.agent_id === agentId && matching(state, agent));
|
|
85
|
+
for (const state of own) included.add(state.id);
|
|
86
|
+
return { agent_id: agentId, role: agent.role, capabilities: agent.capabilities, mine: own.length > 0 || !!agent.wecom_userid && owners.has(agent.wecom_userid),
|
|
87
|
+
registered: true, connections: own.map((state) => connections.get(state.id)) };
|
|
88
|
+
});
|
|
89
|
+
for (const state of local) {
|
|
90
|
+
if (included.has(state.id)) continue;
|
|
91
|
+
agents.push({ agent_id: state.agent_id, role: state.role, capabilities: [], mine: true, registered: false,
|
|
92
|
+
identity_conflict: !!team?.agents[state.agent_id], connections: [connections.get(state.id)] });
|
|
93
|
+
}
|
|
94
|
+
agents.sort((a, b) => a.agent_id.localeCompare(b.agent_id));
|
|
95
|
+
const admin = candidates.find((state) => team?.agents[state.agent_id]?.role === 'admin' && matching(state, team.agents[state.agent_id]));
|
|
96
|
+
return { ...project, agents, admin_profile_id: admin?.id || null, base_branch: team ? teamBaseBranch(team) : null,
|
|
97
|
+
sync_status: synced ? 'synced' : team ? 'cached' : 'pending',
|
|
98
|
+
sync_error: !localOnly && !synced && candidates.length > 0 ? '团队同步暂未成功,正在显示本机已验证的记录。请检查 Git 连接后刷新。' : null };
|
|
99
|
+
}
|
|
@@ -36,6 +36,7 @@ export async function startSetupServer({ manager = new SetupManager(), port = 0
|
|
|
36
36
|
if (req.method === 'GET' && url.pathname === '/api/overview') return json(200, await manager.overview());
|
|
37
37
|
if (req.method === 'GET' && url.pathname === '/api/admin-draft') return json(200, await manager.exclusive(() => manager.adminDraft({ id: url.searchParams.get('id') })));
|
|
38
38
|
if (req.method === 'GET' && url.pathname === '/api/discovery') return json(200, manager.discovery.view(url.searchParams.get('id')));
|
|
39
|
+
if (req.method === 'GET' && url.pathname === '/api/project') return json(200, await manager.exclusive(() => manager.project(url.searchParams.get('id'), { localOnly: url.searchParams.get('local') === '1' })));
|
|
39
40
|
if (req.method === 'GET' && url.pathname === '/api/profile') return json(200, await manager.exclusive(() => manager.detail(url.searchParams.get('id'), { localOnly: url.searchParams.get('local') === '1' })));
|
|
40
41
|
if (req.method === 'GET' && url.pathname === '/api/export') {
|
|
41
42
|
const value = await manager.exclusive(() => manager.exportedBundle(url.searchParams.get('id'), url.searchParams.get('invite')));
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
const $ = (id) => document.getElementById(id);
|
|
2
2
|
let currentId, pending, discovery, timer, busy = false, reviewVersion = null, reviewId, ownAdminId, resetPlan, resetDiscovery;
|
|
3
|
-
let repositoryPolicy;
|
|
3
|
+
let repositoryPolicy, currentProjectId, currentProject, projectSignature, ownReturnProject;
|
|
4
4
|
let viewEpoch = 0, currentView = 'home', completing = false, deliveryTarget;
|
|
5
5
|
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) { 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
|
+
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; }
|
|
10
10
|
async function api(path, body) {
|
|
11
11
|
const response = await fetch(`/api/${path}`, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
|
|
12
12
|
const value = await response.json(); if (!response.ok) throw new Error(value.error); return value;
|
|
@@ -36,13 +36,94 @@ async function overview() {
|
|
|
36
36
|
const card = document.createElement('div'); card.className = 'card';
|
|
37
37
|
card.append(text('span', `${draft.display_name || draft.agent_id} · 待完成群验证`), button('继续创建团队', () => openDraft(draft.id))); $('profiles').append(card);
|
|
38
38
|
}
|
|
39
|
+
$('project-count').textContent = value.projects ? `${value.projects.length} 个项目` : '';
|
|
40
|
+
for (const [index, project] of (value.projects || []).entries()) {
|
|
41
|
+
const card = document.createElement('div'); card.className = 'card project-card'; card.style?.setProperty('--order', Math.min(index, 7));
|
|
42
|
+
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);
|
|
46
|
+
}
|
|
39
47
|
if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。选择“连接我的机器人”,或导入同事提供的接入包。'));
|
|
40
|
-
for (const profile of value.profiles) {
|
|
48
|
+
for (const profile of value.projects ? [] : value.profiles) {
|
|
41
49
|
const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
|
|
42
50
|
info.append(text('strong', `${profile.display_name || profile.agent_id} · ${profile.role === 'admin' ? '管理员' : '成员'}`), text('p', profile.repo));
|
|
43
51
|
card.append(info, button('打开项目', () => loadProfile(profile.id))); $('profiles').append(card);
|
|
44
52
|
}
|
|
45
53
|
}
|
|
54
|
+
function connectionStatus(connection) {
|
|
55
|
+
if (connection.has_error) return '需要检查';
|
|
56
|
+
if (connection.phase !== 'ready') return ({ imported: '待群验证', waiting: '待管理员确认', verified: '正在入队', configuring: '正在配置', initializing: '正在初始化' })[connection.phase] || '待完成接入';
|
|
57
|
+
if (connection.can_receive_tasks) return '已就绪';
|
|
58
|
+
if (!connection.runner_online) return '连接已停止';
|
|
59
|
+
if (!connection.bound) return '待绑定对话';
|
|
60
|
+
if (!connection.poll_recent) return '等待对话轮询';
|
|
61
|
+
return '连接检查中';
|
|
62
|
+
}
|
|
63
|
+
function renderProject(value) {
|
|
64
|
+
currentProject = value;
|
|
65
|
+
$('project-title').textContent = value.name;
|
|
66
|
+
$('project-repository').textContent = value.repository;
|
|
67
|
+
$('project-sync').textContent = value.sync_error || (value.sync_status === 'synced' ? '团队信息已同步。连接状态仅显示本机实际记录。' : value.sync_status === 'cached' ? '正在显示本机已验证的团队记录。' : '接入尚未完成,可以继续配置下面的机器人。');
|
|
68
|
+
$('project-add-own').hidden = !value.admin_profile_id; $('project-settings').hidden = !value.admin_profile_id;
|
|
69
|
+
const signature = JSON.stringify(value);
|
|
70
|
+
if (signature === projectSignature) return;
|
|
71
|
+
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
|
+
$('project-agents').replaceChildren();
|
|
77
|
+
for (const [index, agent] of value.agents.entries()) {
|
|
78
|
+
const card = document.createElement('article'); card.className = `robot-card${agent.mine ? ' is-mine' : ''}`; card.style?.setProperty('--order', Math.min(index, 7));
|
|
79
|
+
const heading = document.createElement('div'); heading.className = 'robot-title';
|
|
80
|
+
const connection = agent.connections[0];
|
|
81
|
+
const display = connection?.display_name;
|
|
82
|
+
heading.append(text('h4', display && display !== agent.agent_id ? `${display}(${agent.agent_id})` : agent.agent_id));
|
|
83
|
+
if (agent.mine) { const mine = text('span', '我的'); mine.className = 'badge mine-badge'; heading.append(mine); }
|
|
84
|
+
card.append(heading, text('p', `${agent.role === 'admin' ? '管理员' : '成员'}${agent.capabilities.length ? ' · ' + agent.capabilities.join(' / ') : ''}`));
|
|
85
|
+
if (agent.identity_conflict) { const warning = text('p', '本机身份与团队记录不一致,请检查原接入。'); warning.className = 'warning-text'; card.append(warning); }
|
|
86
|
+
for (const local of agent.connections) {
|
|
87
|
+
const details = document.createElement('div'); details.className = 'robot-connection';
|
|
88
|
+
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);
|
|
91
|
+
}
|
|
92
|
+
if (!agent.connections.length) { const note = text('p', agent.mine ? '我的机器人 · 未在本机接入' : '由同事维护'); note.className = 'muted'; card.append(note); }
|
|
93
|
+
$('project-agents').append(card);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function refreshProjectView(id, epoch) {
|
|
97
|
+
const value = await api(`project?id=${encodeURIComponent(id)}`);
|
|
98
|
+
if (epoch === viewEpoch && currentView === 'project') renderProject(value);
|
|
99
|
+
}
|
|
100
|
+
function watchProject(id, epoch, delay = 15000) {
|
|
101
|
+
timer = setTimeout(async () => {
|
|
102
|
+
if (epoch !== viewEpoch || currentView !== 'project') return;
|
|
103
|
+
if (!busy && document.visibilityState !== 'hidden') {
|
|
104
|
+
try { await refreshProjectView(id, epoch); }
|
|
105
|
+
catch (error) { if (epoch === viewEpoch) $('project-sync').textContent = `自动同步暂未成功,将重试。${error.message}`; }
|
|
106
|
+
}
|
|
107
|
+
if (epoch === viewEpoch) watchProject(id, epoch);
|
|
108
|
+
}, delay);
|
|
109
|
+
}
|
|
110
|
+
async function loadProject(id) {
|
|
111
|
+
currentProjectId = id; projectSignature = null; currentProject = null;
|
|
112
|
+
remember({ project_id: id }); show('project'); const epoch = viewEpoch;
|
|
113
|
+
$('project-title').textContent = '正在打开项目'; $('project-repository').textContent = '';
|
|
114
|
+
$('project-summary').replaceChildren(); $('project-agents').replaceChildren();
|
|
115
|
+
$('project-add-own').hidden = true; $('project-settings').hidden = true; $('project-sync').textContent = '';
|
|
116
|
+
try {
|
|
117
|
+
const value = await api(`project?id=${encodeURIComponent(id)}&local=1`);
|
|
118
|
+
if (epoch !== viewEpoch) return;
|
|
119
|
+
renderProject(value); watchProject(id, epoch, 100);
|
|
120
|
+
} catch (error) { if (epoch === viewEpoch) { show('home'); await overview(); throw error; } }
|
|
121
|
+
}
|
|
122
|
+
$('project-back').onclick = () => action(async () => { remember(null); show('home'); await overview(); });
|
|
123
|
+
$('project-refresh').onclick = () => action(() => refreshProjectView(currentProjectId, viewEpoch));
|
|
124
|
+
$('project-settings').onclick = () => action(() => loadProfile(currentProject.admin_profile_id));
|
|
125
|
+
$('project-add-own').onclick = () => action(async () => { ownAdminId = currentProject.admin_profile_id; ownReturnProject = currentProjectId; show('own'); });
|
|
126
|
+
$('profile-back').onclick = () => action(() => loadProject(currentProjectId));
|
|
46
127
|
async function openDraft(id) {
|
|
47
128
|
const value = await api(`admin-draft?id=${id}`);
|
|
48
129
|
if (value.profile) { pending = null; await loadProfile(value.profile.id); return; }
|
|
@@ -120,6 +201,8 @@ async function loadProfile(id) {
|
|
|
120
201
|
try { value = await api(`profile?id=${id}`); } catch (error) { value = await api(`profile?id=${id}&local=1`); notice(`团队同步暂未成功,本机状态仍可查看。${error.message}`); }
|
|
121
202
|
if (epoch !== viewEpoch) return;
|
|
122
203
|
if (value.phase === 'removed') { show('home'); await overview(); return; }
|
|
204
|
+
currentProjectId = value.project_id || null;
|
|
205
|
+
$('profile-back').hidden = !currentProjectId;
|
|
123
206
|
renderProfile(value); watchProfile(id, epoch); return value;
|
|
124
207
|
}
|
|
125
208
|
function renderProfile(value, automatic = false) {
|
|
@@ -199,7 +282,15 @@ $('review-form').onsubmit = (event) => { event.preventDefault(); action(async ()
|
|
|
199
282
|
await api('review-rules', { id: currentId, expected_version: reviewVersion, review_rules });
|
|
200
283
|
await loadProfile(currentId); notice('审核规则已保存,新任务将使用此规则。');
|
|
201
284
|
}); };
|
|
202
|
-
for (const
|
|
285
|
+
for (const link of document.querySelectorAll('[data-view]')) link.onclick = (event) => {
|
|
286
|
+
event.preventDefault();
|
|
287
|
+
return action(async () => {
|
|
288
|
+
if (resetPlan) { await api('reset-cancel', { id: resetPlan.id, preview_token: resetPlan.preview_token }); resetPlan = null; resetDiscovery = null; $('reset-secret').value = ''; }
|
|
289
|
+
remember(null); show(link.dataset.view);
|
|
290
|
+
if (link.dataset.view === 'home') await overview();
|
|
291
|
+
if (link.dataset.view === 'admin' && $('admin-form').elements.repo.value) await checkProject();
|
|
292
|
+
});
|
|
293
|
+
};
|
|
203
294
|
function repoMode(prefix) {
|
|
204
295
|
const clone = $(`${prefix}-repo-mode`).value === 'clone'; $(`${prefix}-existing-repo`).hidden = clone; $(`${prefix}-clone-repo`).hidden = !clone;
|
|
205
296
|
}
|
|
@@ -282,8 +373,8 @@ async function connectMember(profile) {
|
|
|
282
373
|
try { await verifyView(await api('discovery', { profile_id: profile.id })); }
|
|
283
374
|
catch (error) { await loadProfile(profile.id); throw error; }
|
|
284
375
|
}
|
|
285
|
-
$('add-own-agent').onclick = () => action(async () => { ownAdminId = currentId; show('own'); });
|
|
286
|
-
$('own-cancel').onclick = () => action(() => loadProfile(ownAdminId));
|
|
376
|
+
$('add-own-agent').onclick = () => action(async () => { ownAdminId = currentId; ownReturnProject = null; show('own'); });
|
|
377
|
+
$('own-cancel').onclick = () => action(() => ownReturnProject ? loadProject(ownReturnProject) : loadProfile(ownAdminId));
|
|
287
378
|
$('own-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
288
379
|
const input = values(event.target); input.id = ownAdminId; input.prefer = event.target.elements.prefer.checked;
|
|
289
380
|
let profile;
|
|
@@ -409,7 +500,10 @@ $('hide-delivery').onclick = () => action(async () => { $('delivery-panel').hidd
|
|
|
409
500
|
assignmentRow();
|
|
410
501
|
action(async () => {
|
|
411
502
|
const token = location.hash.slice(1); if (token) { await api('session', { token }); history.replaceState(null, '', '/'); }
|
|
412
|
-
const saved = remembered(); await overview();
|
|
413
|
-
try { if (saved?.draft_id) await openDraft(saved.draft_id); else if (saved?.profile_id) { const value = await loadProfile(saved.profile_id); if (value?.phase === 'imported') await connectMember(value); } }
|
|
503
|
+
const saved = remembered(); show('home'); await overview();
|
|
504
|
+
try { if (saved?.draft_id) await openDraft(saved.draft_id); else if (saved?.profile_id) { const value = await loadProfile(saved.profile_id); if (value?.phase === 'imported') await connectMember(value); } else if (saved?.project_id) await loadProject(saved.project_id); }
|
|
414
505
|
catch (error) { remember(null); show('home'); notice(`上次接入暂时无法恢复,可以从项目列表继续。${error.message}`); }
|
|
415
506
|
});
|
|
507
|
+
|
|
508
|
+
function updateMotion() { if (document.documentElement) document.documentElement.dataset.pageHidden = String(document.visibilityState === 'hidden'); }
|
|
509
|
+
document.addEventListener?.('visibilitychange', updateMotion); updateMotion();
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Codex Collab · 团队接入</title><link rel="stylesheet" href="/style.css"></head><body>
|
|
2
|
-
<header><span class="brand">Codex Collab</span><span
|
|
3
|
-
<main><
|
|
2
|
+
<div class="ambient" aria-hidden="true"><i></i><i></i></div><header><a class="brand" href="/" data-view="home" aria-label="Codex Collab 首页"><span class="brand-mark" aria-hidden="true">C</span>Codex Collab</a><span class="workspace-label"><span class="status-dot" aria-hidden="true"></span>本机协作工作台</span></header>
|
|
3
|
+
<main><div class="hero"><p class="eyebrow">YOUR TEAM, IN SYNC</p><h1>一个项目,<span>一支协作队伍。</span></h1><p class="intro">连接群机器人与 Codex 对话,让任务在团队中有序接力。</p></div><div id="notice" role="status" hidden></div>
|
|
4
4
|
<nav><button data-view="home">我的项目</button><button data-view="admin">连接我的机器人</button><button data-view="join">接受同事邀请</button><button data-view="existing">导入已有配置</button></nav>
|
|
5
|
-
<section id="home"><div class="entry-options"><button data-view="admin">连接我的机器人<small>首次接入,或为团队建立协作项目</small></button><button data-view="join">接受同事邀请<small>已有接入包,自动带入机器人和项目信息</small></button></div><p id="startup-status" class="muted"></p><h2>我的项目</h2><div id="profiles"></div><details id="removed-projects" hidden><summary>已移除的接入</summary><p class="muted">本机身份与恢复记录保留。恢复前请确保原 clone 未绑定其他身份。</p><div id="removed-profiles"></div></details></section>
|
|
5
|
+
<section id="home"><div class="entry-options"><button data-view="admin">连接我的机器人<small>首次接入,或为团队建立协作项目</small></button><button data-view="join">接受同事邀请<small>已有接入包,自动带入机器人和项目信息</small></button></div><p id="startup-status" class="muted"></p><div class="section-heading"><div><p class="eyebrow">PROJECTS</p><h2>我的项目</h2></div><span class="muted" id="project-count"></span></div><div id="profiles" class="project-grid"></div><details id="removed-projects" hidden><summary>已移除的接入</summary><p class="muted">本机身份与恢复记录保留。恢复前请确保原 clone 未绑定其他身份。</p><div id="removed-profiles"></div></details></section>
|
|
6
|
+
<section id="project" hidden><button id="project-back" class="back-button secondary">← 我的项目</button><div class="section-heading"><div><p class="eyebrow">PROJECT WORKSPACE</p><h2 id="project-title"></h2></div><button id="project-refresh" class="secondary">刷新项目</button></div><p id="project-repository" class="repository-path"></p><div id="project-summary" class="project-summary"></div><p id="project-sync" class="muted" role="status"></p><div class="section-heading robot-heading"><h3>项目机器人</h3><div class="manage-actions"><button id="project-add-own" hidden>添加我的机器人</button><button id="project-settings" class="secondary" hidden>团队管理</button></div></div><div id="project-agents" class="robot-grid"></div></section>
|
|
6
7
|
<section id="admin" hidden><h2>连接我的机器人</h2><p>先在企业微信创建智能机器人并加入协作群。这里会通过群验证码自动获取群和你的 userid。</p>
|
|
7
8
|
<form id="admin-form">
|
|
8
9
|
<fieldset><legend>选择业务项目</legend><label>项目来源<select id="admin-repo-mode" name="repo_mode"><option value="existing">使用本机已有项目</option><option value="clone">从 Git 地址拉取新项目</option></select></label>
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
<button type="submit">接入我的机器人</button><p class="muted">工具使用本机 Git 登录拉取代码,不会改写已有目录。导入后请按提示在群里确认一次身份,审批和配置会自动继续。</p></form></section>
|
|
27
28
|
<section id="existing" hidden><h2>导入已有协作项目</h2><p>先停止原 Runner。导入会保留原成员身份、任务和对话绑定,并重新启动机器人。旧团队由管理员先导入。</p><form id="existing-form"><label>已有私有 peer.json 路径<input name="config_path" required placeholder="完整路径"></label><button type="submit">导入已有项目</button></form></section>
|
|
28
29
|
<section id="verification" hidden><h2>在群里确认身份</h2><p id="connection" role="status"></p><p class="muted">发送消息后页面会自动继续,无需回来确认。刷新页面可恢复当前接入进度。</p><p>请用自己的企业微信账号,<strong>@ 刚刚分配的机器人</strong>,发送下面这条消息:</p><pre id="code"></pre><button id="copy-code">复制消息</button><button id="confirm-code">立即检查</button><button id="retry-code" class="secondary" hidden>重新连接</button></section>
|
|
29
|
-
<section id="profile" hidden><div class="row"><h2 id="profile-title"></h2><button id="refresh"
|
|
30
|
+
<section id="profile" hidden><button id="profile-back" class="back-button secondary" hidden>← 返回项目</button><div class="row"><h2 id="profile-title"></h2><button id="refresh" class="secondary">刷新机器人状态</button></div><p id="profile-repo"></p><details id="profile-diagnostics" hidden><summary>查看本机配置路径</summary><pre id="config-path"></pre><button id="copy-config-path">复制配置路径</button></details><p id="next-step" class="next-step" role="status"></p><ol id="setup-steps" class="setup-steps"></ol><details><summary>连接检查详情</summary><div id="checks"></div></details><div id="progress"></div><div id="binding" hidden><h3>最后一步:在你选定的 Codex 对话启用</h3><p>在 Codex 打开上面的项目,信任项目并加载 MCP(已打开的项目请重新加载 MCP),将下面的指令发到要绑定的对话。Skill 会完成身份检查、对话绑定和轮询启用。完成后本页自动更新实际连接状态。</p><pre id="binding-text"></pre><button id="copy-binding">复制启用指令</button><details><summary>无法识别 Skill 时使用详细指令</summary><pre id="binding-details"></pre><button id="copy-binding-details">复制详细指令</button></details></div><div id="diagnostic-actions" hidden><button id="run-diagnostics" class="secondary">检查整条连接</button><button id="repair-project" class="secondary">修复本机配置</button><div id="diagnostic-result" role="status"></div></div><div id="runner-actions" hidden><button id="start">启动机器人连接</button><button id="stop" class="secondary">停止机器人连接</button><p class="muted">停止连接仅断开群机器人。对话中的轮询自动化需要在 Codex 中单独暂停。</p></div>
|
|
30
31
|
<details id="manage-project"><summary>管理与重置</summary><p>选择要调整的配置,先查看影响,再确认。每次只操作当前 Agent 和 clone。</p><div class="manage-actions"><button id="reset-conversation" class="secondary">重新绑定对话</button><button id="reset-robot" class="secondary">更新机器人凭据</button><button id="reset-project" class="danger">移除本机接入</button></div></details>
|
|
31
32
|
<div id="admin-tools" hidden><details><summary>团队分工、审核与通知设置</summary><h3>团队 Agent 与分工</h3><div id="members"></div><div id="owners"></div><button id="add-own-agent" class="secondary">给自己增加机器人</button><div id="routes"></div><h3>任务基准分支</h3><button type="button" id="load-repository-settings" class="secondary">读取并调整基准分支</button><form id="repository-policy-form" hidden><label>业务基准分支<select id="team-base-branch" required></select></label><p class="muted">保存后所有成员的新任务使用此分支。已创建任务保留原来的分支、起始提交和审核规则。</p><button type="submit">保存团队基准分支</button></form><h3>真人审核与通知</h3><p>按职责设置:阶段结果提交后由谁审核、通知谁。关闭中间审核可直接接力;最终提交仍需真人审核,并由管理员完成任务。关闭审核的职责作为最后阶段时由管理员审核。</p><p id="review-policy-note" class="muted"></p><form id="review-form" hidden><div id="review-rules"></div><button type="submit">保存审核规则</button></form><p class="muted">规则保存后用于新任务。现有任务继续使用创建时的规则。审核通知附带分支、提交、测试和可复制的群命令;企业微信原生 @ 的显示效果需要在实际群中确认。</p></details><h3>批量分配机器人给同事</h3><p>每行分配一个独立机器人。同一个同事可以领取多行,分别绑定独立项目和对话。接入包密码自动生成。</p>
|
|
32
33
|
<form id="batch-form"><div id="assignment-rows"></div><div class="row"><button id="add-assignment" type="button" class="secondary">增加一位成员/机器人</button><button type="submit">生成全部接入包</button></div></form>
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
:root{color-scheme:
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
.
|
|
10
|
-
form,label,fieldset,details,input,select{min-width:0}#
|
|
1
|
+
:root{color-scheme:dark;--bg:#080e14;--surface:#101923;--surface-raised:#15212c;--line:#243440;--text:#e8f1f4;--muted:#9baeb9;--accent:#7cebc3;--accent-dark:#0a3b30;--radius:18px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:var(--text);background:var(--bg);font-synthesis:none}
|
|
2
|
+
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(ellipse at 50% 0,#102b313d,transparent 65%)}body::before{content:"";position:fixed;inset:0;pointer-events:none;background-image:linear-gradient(#b6e6df04 1px,transparent 1px),linear-gradient(90deg,#b6e6df04 1px,transparent 1px);background-size:64px 64px;z-index:-1}.ambient{position:fixed;inset:0;overflow:hidden;pointer-events:none;z-index:-1}.ambient i{position:absolute;width:480px;height:480px;border-radius:50%;background:radial-gradient(circle,#25be9e12,transparent 70%);top:-170px;left:8%;animation:ambient-drift 24s ease-in-out infinite alternate}.ambient i+i{width:560px;height:560px;background:radial-gradient(circle,#497bdf0d,transparent 70%);top:10%;left:auto;right:-180px;animation-delay:-12s}
|
|
3
|
+
header{position:relative;border-bottom:1px solid #2434409c;display:flex;justify-content:space-between;align-items:center;gap:20px;padding:20px max(24px,calc((100vw - 1120px)/2));font-size:13px;background:#0c141de8}.brand{display:inline-flex;align-items:center;gap:12px;font-weight:730;letter-spacing:-.4px;color:var(--text);text-decoration:none;font-size:17px;white-space:nowrap}.brand-mark{display:grid;place-items:center;width:32px;height:32px;background:linear-gradient(135deg,#9bf5d5,#50bdb7);color:#0a2521;font-weight:850;border-radius:10px;box-shadow:0 0 26px #65d8ba18}.workspace-label{display:flex;align-items:center;gap:9px;color:var(--muted)}.status-dot{width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 4px #7cebc30d}
|
|
4
|
+
main{max-width:1120px;margin:58px auto 0;padding:0 24px}.hero{max-width:760px;animation:enter-view .55s ease-out backwards}.eyebrow{font-size:10px;font-weight:700;letter-spacing:2.6px;color:var(--accent);margin:0 0 14px;line-height:1.6}h1{font-size:clamp(30px,4.5vw,46px);font-weight:720;line-height:1.28;letter-spacing:-1.4px;margin:0 0 18px}h1 span{color:var(--accent)}h2{font-size:23px;letter-spacing:-.5px;line-height:1.4;margin:0}h3{font-size:16px;margin:30px 0 14px;line-height:1.5}h4{font-size:16px;margin:0;line-height:1.6;overflow-wrap:anywhere}p{line-height:1.8}.intro,.muted{color:var(--muted)}.intro{font-size:15px;margin:0}.muted{font-size:13px}.warning-text{color:#f1bd83}
|
|
5
|
+
nav{display:flex;gap:8px;margin:32px 0 26px;flex-wrap:wrap}button,a.secondary{font:inherit;font-size:13px;font-weight:600;background:var(--accent);color:#092c24;border:1px solid transparent;border-radius:9px;padding:11px 17px;cursor:pointer;line-height:1.5;position:relative;transition:transform .18s ease,background-color .18s ease,border-color .18s ease,box-shadow .18s ease;touch-action:manipulation}button:hover{transform:translateY(-2px);background:#a4f5d9;box-shadow:0 5px 20px #7cebc312}button:active{transform:translateY(0) scale(.98)}button:disabled{opacity:.5;cursor:wait;transform:none;box-shadow:none}nav button,.secondary,a.secondary{background:#111c26;color:#c3d3dc;border:1px solid var(--line)}nav button:hover,.secondary:hover{background:#1b2b37;border-color:#456273;box-shadow:none}nav button[aria-current=page]{background:#1c302e;border-color:#497c6c;color:#b9f5df}a{color:var(--accent);text-underline-offset:3px}a.secondary{display:inline-block;text-decoration:none}button:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:4px}.back-button{margin:0 0 26px;background:transparent;border-color:transparent;color:var(--muted);padding:5px 0}.back-button:hover{background:transparent;border-color:transparent;color:var(--accent);box-shadow:none}
|
|
6
|
+
section{background:linear-gradient(145deg,#111c26,#0e1720);border:1px solid var(--line);border-radius:var(--radius);padding:30px;box-shadow:0 16px 55px #00000012}section:not([hidden]){animation:enter-view .35s cubic-bezier(.2,.7,.2,1) backwards}[hidden]{display:none!important}section>p{font-size:14px;color:var(--muted)}.section-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:0 0 20px}.section-heading .eyebrow{margin:0 0 8px;font-size:9px}.section-heading h3{margin:0}.robot-heading{margin-top:30px}
|
|
7
|
+
.entry-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:0 0 18px}.entry-options button{overflow:hidden;text-align:left;padding:24px 26px;font-size:18px;border-radius:13px;border:1px solid #345b50;background:linear-gradient(120deg,#1b3933,#172a2b);color:#dcfff0}.entry-options button::after{content:"↗";position:absolute;right:22px;top:20px;font-size:20px;color:#87c9b4;transition:transform .2s}.entry-options button:hover::after{transform:translate(3px,-3px)}.entry-options button+button{background:linear-gradient(120deg,#1b2939,#17202f);border-color:#33455e;color:#dfebff}.entry-options button+button::after{color:#9aaed6}.entry-options small{display:block;margin:10px 15px 0 0;font-size:12px;font-weight:400;color:#a9c2b8;line-height:1.8}.entry-options button+button small{color:#a8b5cc}#startup-status{font-size:12px;margin:0 0 30px}
|
|
8
|
+
.project-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,285px),1fr));gap:16px}.project-card{display:flex;flex-direction:column;align-items:stretch;justify-content:space-between;gap:22px;padding:24px;border:1px solid #2a3d49;border-radius:14px;background:linear-gradient(150deg,#172530,#111c26);overflow:hidden;position:relative;transition:border-color .25s,box-shadow .25s}.project-card::after{content:"";position:absolute;inset:0;border-radius:inherit;border-top:1px solid #b1efdd35;pointer-events:none}.project-card:hover{border-color:#4b7f72;box-shadow:0 8px 24px #00000020}.project-card-info{min-width:0}.project-icon{display:grid;place-items:center;width:42px;height:42px;border:1px solid #406659;border-radius:12px;background:#1c3932;color:#a8f2d5;font-size:20px;font-weight:650;margin-bottom:22px}.project-card h3{font-size:19px;line-height:1.5;margin:0 0 7px;overflow-wrap:anywhere}.project-card p{color:var(--muted);font-size:12px;margin:0 0 18px;overflow-wrap:anywhere}.project-card-info>span:last-child{color:#a9bfca;font-size:12px}.project-card>button{align-self:flex-start;background:transparent;color:var(--accent);border-color:#314c46;padding:8px 13px}.project-card>button:hover{background:#7cebc30b;border-color:#608f7f}.project-card,.robot-card{animation:enter-card .4s cubic-bezier(.2,.7,.2,1) backwards;animation-delay:calc(var(--order,0)*45ms)}
|
|
9
|
+
.repository-path,#profile-repo,#reset-target,#invite-remote{overflow-wrap:anywhere;color:var(--muted);font-size:13px}.project-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;border:1px solid var(--line);border-radius:12px;background:var(--line);overflow:hidden;margin:25px 0 15px}.summary-item{background:#111e28;padding:19px 22px;display:flex;flex-direction:column;gap:8px;min-width:0}.summary-item strong{font-size:24px;font-weight:650;letter-spacing:-.5px;overflow-wrap:anywhere}.summary-item:last-child strong{font-size:20px;line-height:1.5}.summary-item span{font-size:11px;color:var(--muted)}.robot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,285px),1fr));gap:15px}.robot-card{border:1px solid var(--line);border-radius:13px;padding:22px;background:#111e28;min-width:0}.robot-card.is-mine{border-color:#365f51;background:linear-gradient(145deg,#162923,#122028)}.robot-title{display:flex;justify-content:space-between;gap:10px;align-items:center}.robot-card>p{margin:9px 0 0;font-size:12px;color:var(--muted)}.robot-connection{border-top:1px solid var(--line);padding-top:18px;margin-top:20px}.robot-connection .repository-path{margin:12px 0 16px;font-size:11px}.robot-connection button{padding:8px 13px;font-size:12px}.badge{display:inline-flex;gap:7px;align-items:center;font-size:11px;font-weight:500;color:var(--accent);background:#1a3b30;padding:5px 9px;border-radius:6px;white-space:nowrap}.mine-badge{border:1px solid #376a54;flex-shrink:0}.pending{color:#f0c27e;background:#3a2e1c}.ready-badge::before{content:"";height:5px;width:5px;border-radius:50%;background:var(--accent);animation:status-pulse 2.8s ease-in-out infinite}
|
|
10
|
+
form{display:grid;gap:18px;margin:25px 0}form,label,fieldset,details,input,select{min-width:0}label{display:grid;gap:9px;font-size:13px;color:#c7d5dd}input,select{font:inherit;font-size:14px;padding:12px 14px;border:1px solid #31424f;border-radius:8px;width:100%;color:var(--text);background:#0c151e;transition:border-color .18s,box-shadow .18s}input::placeholder{color:#708592}input:focus,select:focus{outline:none;border-color:#6dc6a8;box-shadow:0 0 0 3px #7cebc310}input[type=file]{font-size:12px}input[type=file]::file-selector-button{border:1px solid #3a4f5e;border-radius:5px;background:#1d2c38;color:var(--text);padding:6px 10px;margin-right:12px}.row{display:flex;gap:20px;align-items:center}.row>*{flex:1;min-width:0}.row>button{flex:initial}.checkbox{display:flex;align-items:flex-start;line-height:1.7}.checkbox input{width:auto;margin:4px 0 0;accent-color:var(--accent)}fieldset{border:1px solid var(--line);border-radius:11px;padding:20px;margin:0 0 16px;display:grid;gap:16px}legend{font-weight:600;padding:0 8px;color:#d5e4eb}pre{background:#09131b;border:1px solid var(--line);padding:18px;border-radius:10px;white-space:pre-wrap;overflow-wrap:anywhere;color:#c3e5d6;font:12px/1.8 ui-monospace,SFMono-Regular,monospace}details{margin:24px 0}summary{cursor:pointer;font-size:14px;font-weight:550;line-height:1.8;color:#c3d4dd}details[open]>summary{margin-bottom:18px;color:var(--accent)}.card:not(.project-card),.check{padding:17px 0;border-bottom:1px solid var(--line);display:flex;gap:14px;justify-content:space-between;align-items:center}.card p{margin:4px 0;font-size:12px;overflow-wrap:anywhere}.card:not(.project-card)>div{min-width:0}.check{font-size:13px}.check .badge{flex-shrink:0}.manage-actions{display:flex;gap:10px;flex-wrap:wrap}.danger{background:#2a1b20;border:1px solid #6a3a42;color:#f3a7a7}.danger:hover{background:#38212a;border-color:#a6656d}.manage-actions .danger{box-shadow:none}#notice{padding:14px 18px;border:1px solid #725035;background:#30241a;color:#f5cfa9;border-radius:10px;margin:20px 0;font-size:13px;line-height:1.8}#reset-effects{line-height:1.9;padding-left:24px;font-size:14px}#reset-verification{padding:18px;background:#142822;border:1px solid #2a4b3e;border-radius:10px}
|
|
11
|
+
#setup-steps{display:flex;flex-wrap:wrap;gap:9px;list-style:none;padding:0;margin:24px 0}#setup-steps li{font-size:11px;border:1px solid var(--line);border-radius:7px;padding:9px 12px;color:#8fa2ad}#setup-steps .done{color:#99e9bf;background:#153026;border-color:#315d45}#setup-steps .active{color:#f0c27e;background:#30271a;border-color:#6c5330}#next-step{padding:16px 18px;background:#142b26;border:1px solid #2b5144;border-radius:10px;color:#bbe6d3;line-height:1.8;font-size:14px}#binding h3{color:#b0f0d6}#delivery-panel{padding:22px;background:#13241f;border:1px solid #365c4b;border-radius:12px;margin-top:22px}#delivery-panel h3{margin-top:0}#delivery-panel button{margin:6px 6px 6px 0}.assignment details{margin:0}.assignment>button{justify-self:start;background:transparent;color:var(--muted);border-color:var(--line)}#assignment-rows{display:grid;gap:14px}#assignment-rows fieldset{margin:0}#diagnostic-result p{font-size:13px;line-height:1.8}#diagnostic-actions{margin:20px 0}footer{text-align:center;font-size:11px;color:#7e939f;padding:30px 24px 40px;line-height:1.8}
|
|
12
|
+
@keyframes enter-view{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@keyframes enter-card{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}@keyframes ambient-drift{from{transform:translate3d(0,0,0)}to{transform:translate3d(90px,65px,0)}}@keyframes status-pulse{0%,100%{opacity:1}50%{opacity:.4}}html[data-page-hidden=true] *,html[data-page-hidden=true] *::before,html[data-page-hidden=true] *::after{animation-play-state:paused!important}
|
|
13
|
+
@media(max-width:640px){header{padding:17px 20px}.brand{font-size:15px}.workspace-label{font-size:11px;gap:7px}.brand-mark{width:28px;height:28px}main{margin:34px auto 0;padding:0 16px}h1{letter-spacing:-.8px}h1 span{display:block}.hero .eyebrow{font-size:9px}.intro{font-size:13px}nav{gap:7px;margin:25px 0 20px}nav button{font-size:11px;padding:8px 10px}section{padding:22px 18px;border-radius:14px}.entry-options{grid-template-columns:1fr;gap:12px}.entry-options button{font-size:16px;padding:20px}.entry-options small{margin-top:7px}.section-heading{align-items:flex-start;gap:12px}.section-heading h2{font-size:21px}.section-heading>button{font-size:11px;white-space:nowrap}.robot-heading{flex-direction:column;align-items:stretch}.robot-heading .manage-actions{justify-content:flex-start}.project-summary{margin-top:20px}.summary-item{padding:16px 12px}.summary-item strong{font-size:22px}.summary-item:last-child strong{font-size:15px;line-height:1.9}.summary-item span{font-size:10px}.row{flex-direction:column;align-items:stretch;gap:16px}.row>button{align-self:flex-start}.card:not(.project-card){align-items:flex-start;flex-wrap:wrap}.project-card{padding:22px}.project-grid,.robot-grid{grid-template-columns:minmax(0,1fr)}.manage-actions button{font-size:12px}#setup-steps{gap:7px}#setup-steps li{flex:1 1 42%;padding:9px}.ambient i{width:300px;height:300px}.ambient i+i{width:340px;height:340px}fieldset{padding:16px}footer{font-size:10px}}
|
|
14
|
+
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}button:hover,button:active{transform:none}.entry-options button:hover::after{transform:none}}
|
package/docs/architecture.md
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 架构与范围
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
当前只支持企业微信。优先稳定企微的接入、收发、任务领取、交接和真人审核;其他 IM 的接入以后再评估。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 推荐:本机 Peer 与网页向导
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
每位成员运行本机 Peer,通过自己的企业微信智能机器人接收群消息,将任务交给绑定的已有 Codex 对话。管理员在网页向导中配置项目、分配机器人并批准成员;同事导入加密个人接入包、验证企微身份并绑定对话。项目可以包含多个机器人,每个机器人保留独立身份与权限。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
团队目录、任务与交接记录存放在业务仓库的 `agent-collab` 分支,以签名记录校验身份和操作;代码在独立业务分支流转。协作远程与基准分支由向导识别和确认,每个任务固定基准 commit。已有桌面对话依赖显式启用的轮询自动化领取任务,安装 MCP 或 Skill 本身不会唤醒对话。详见 [Peer 机制](peer.md)、[签名团队协议](team-registry.md) 和 [桌面对话轮询](desktop-poll.md)。
|
|
10
|
+
|
|
11
|
+
## 兼容:旧中心服务
|
|
12
|
+
|
|
13
|
+
旧模式从源码启动一个 HTTP/MCP 服务,由它维护 `agent-collab` 的独立 Git worktree,串行追加版本化任务事件。每位同事配置个人 MCP/Skill,按需运行 Runner;Runner 创建任务 worktree 和新的 Codex SDK 会话。代码在 `codex/task-*` 分支,任务事件在 `agent-collab` 分支。群消息只提供任务指令和状态摘要,不共享原始私聊记录。此模式由一个 Bot 代表不同账号发送消息,不提供多个独立机器人路由。
|
|
14
|
+
|
|
15
|
+
消息路径:企微群消息 → 群允许列表与 `wecom_userid` 身份映射 → 命令解析 → 服务权限检查 → Git 提交 → Bot 回复。任务路径:分配 → Runner 领取 → worktree 编码、提交与可信测试 → 审查 → 真人批准 → 管理员完成。只有明确属于当前企微群的任务才能通过群命令访问;历史未知通道数据保留原始来源,不自动转为企微任务。
|
|
16
|
+
|
|
17
|
+
旧中心服务只支持单实例写入,没有持久消息投递队列、租约、失败自动恢复或 Git push 冲突重放。默认监听 localhost;跨机器需配置 HTTPS 与访问限制。企微智能机器人使用出站 WebSocket,无需公网回调;认证成功与实际群收发需分别验证。旧模式操作见 [中心服务指南](legacy-center.md),不与推荐的 Peer 接入步骤混用。
|
package/docs/legacy-center.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
日常团队接入请使用 [本机网页向导](onboarding.md)。本页保留旧 HTTP 服务操作,npm 包只分发 Peer 与本机向导;旧中心服务须从工具源码启动。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
以下为保留的**旧中心服务模式**操作;不要与 Peer 混用机器人连接和命令。Peer 不使用 `localhost:38765`、HTTP 令牌或手工 `协作 分配` / `协作 交接` 命令。
|
|
6
6
|
|
|
7
7
|
## Codex 之间的代码接力
|
|
8
8
|
|
|
@@ -22,14 +22,14 @@ pnpm install --frozen-lockfile
|
|
|
22
22
|
pnpm test
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
演示目标仓库 `codex-collab-demo` 的 `main` 执行 `sh test.sh`,而任务事件在 `agent-collab`。示例任务 T-001 为故意未完成的编程任务;可用两个不同用户通过 API/MCP 和 Runner 测试。现有 T-001
|
|
25
|
+
演示目标仓库 `codex-collab-demo` 的 `main` 执行 `sh test.sh`,而任务事件在 `agent-collab`。示例任务 T-001 为故意未完成的编程任务;可用两个不同用户通过 API/MCP 和 Runner 测试。现有 T-001 未指定审核人,建议通过企微群命令创建**新任务**以获得默认管理员审核人。
|
|
26
26
|
|
|
27
27
|
## 本地启动
|
|
28
28
|
|
|
29
29
|
生成账号文件(不要放进 Git,执行完把终端输出的个人令牌安全地分发给对应用户):
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
|
-
node scripts/create-accounts.mjs /tmp/collab-accounts.json alice:admin:
|
|
32
|
+
node scripts/create-accounts.mjs /tmp/collab-accounts.json alice:admin:wx_alice bob:member:wx_bob
|
|
33
33
|
COLLAB_REPO=/path/to/codex-collab-demo \
|
|
34
34
|
COLLAB_ACCOUNTS_FILE=/tmp/collab-accounts.json \
|
|
35
35
|
COLLAB_DATA_DIR=/tmp/collab-service \
|
|
@@ -37,7 +37,7 @@ COLLAB_REPO_ID=jack506255560/codex-collab-demo \
|
|
|
37
37
|
node apps/server/main.mjs
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
运行成功后 `curl http://127.0.0.1:38765/health`。`
|
|
40
|
+
运行成功后 `curl http://127.0.0.1:38765/health`。`wx_alice` 与 `wx_bob` 需要换为企微成员的真实 `userid`,可用下方发现脚本获取。账号参数格式为 `id:role:wecom_userid`;暂时仅通过 API 使用时填 `id:role:-`。旧四段账号参数不再接受,已有账号文件与个人令牌可继续使用,补充企微身份时使用 `scripts/bind-wecom-users.mjs`。管理员身份为默认审核人;编码负责人不能与审核人为同一人。如需自动领取任务,同事机器运行:
|
|
41
41
|
|
|
42
42
|
```sh
|
|
43
43
|
COLLAB_REPO=/path/to/codex-collab-demo \
|
|
@@ -50,57 +50,17 @@ node apps/runner/main.mjs
|
|
|
50
50
|
|
|
51
51
|
`COLLAB_API` 单机实验可用 `http://127.0.0.1:38765`;不同机器应提供 HTTPS,且每台机器的目标仓库含 `main` 和 `agent-collab` 分支。若跨机器分发代码分支,需要在服务和 Runner 均设置 `COLLAB_PUSH=1`,保证 origin 可访问且个人 Git 凭据已配置。服务向 `agent-collab` 推送账本,Runner 向 `codex/task-*` 推送代码;**没有凭据会失败**。不能直接把 localhost 地址给其他同事。
|
|
52
52
|
|
|
53
|
-
##
|
|
53
|
+
## 企业微信群接入
|
|
54
54
|
|
|
55
|
-
1.
|
|
56
|
-
2.
|
|
57
|
-
3.
|
|
58
|
-
4.
|
|
55
|
+
1. 在企业微信创建支持 API 长连接的智能机器人,取得 Bot ID 和 Secret,并加入协作群。它通过官方 `@wecom/aibot-node-sdk` 建立出站 WebSocket,无需公网回调。一个 Bot 对应这一个中心服务,由私有账号文件区分成员。
|
|
56
|
+
2. 在本机交互终端设置 `WECOM_BOT_ID`、`WECOM_BOT_SECRET` 后,运行 `node scripts/discover-wecom.mjs`。等待认证成功,再在目标群 @机器人发送文本。脚本只输出 `chatid` 和 `userid`,不输出正文。记录群和成员 ID 后,按 `Ctrl-C` 停止发现脚本,再启动服务;同一个机器人只保留一个连接。
|
|
57
|
+
3. 新账号直接按上方三段格式生成;已有账号可运行 `node scripts/bind-wecom-users.mjs <旧账号文件> <新账号文件> 'alice:<真实userid>' 'bob:<真实userid>'`,再将 `COLLAB_ACCOUNTS_FILE` 指向新文件。此脚本保留个人令牌哈希,不改原文件。不同账号不能绑定相同 `userid`。
|
|
58
|
+
4. 启动中心服务时同时设置 `WECOM_BOT_ID`、`WECOM_BOT_SECRET` 和 `WECOM_CHAT_IDS`,多个群 ID 用逗号分隔。凭据保存在本机私有环境,群 ID 和成员 ID 不提交到 Git。未配置机器人时只启用 HTTP/MCP API。
|
|
59
|
+
5. 在允许的群由已映射成员依次验证:`协作 创建 写一个功能 | 验收一;验收二`、管理员 `协作 分配 T-... bob`、`协作 状态 T-...`。Bob 的 Runner 领取后,提交代码与测试证据并请求审查;指定审核人核对结果后执行 `协作 审核 T-... 通过` 或 `协作 审核 T-... 退回`,管理员最终执行 `协作 完成 T-...`。批准和完成均需真人明确操作。
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
认证成功、群消息到达、群回复和主动任务通知需分别验证。排查无回复时可临时设置 `COLLAB_WECOM_DIAGNOSTICS=1` 并重启服务;诊断不输出正文、凭据或群/成员 ID。Runner 需单独启动,HTTP 服务不会代为启动它。
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
当前单机测试目录可以这样找(不要打印里面的令牌或环境文件):
|
|
65
|
-
|
|
66
|
-
```sh
|
|
67
|
-
cd "$HOME/codex-collab"
|
|
68
|
-
# 确保 Node.js >= 20 已安装并且 node 位于 PATH。
|
|
69
|
-
root="$(cat "$HOME/.codex-collab-local-test/current")"
|
|
70
|
-
# 仅在你的本机交互终端输入;不要把实际值放在命令行参数或聊天中。
|
|
71
|
-
printf 'Bot ID: '; IFS= read -r WECOM_BOT_ID
|
|
72
|
-
printf '新 Bot Secret(不回显): '; IFS= read -rs WECOM_BOT_SECRET; echo
|
|
73
|
-
export WECOM_BOT_ID WECOM_BOT_SECRET
|
|
74
|
-
node scripts/discover-wecom.mjs
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
等待输出 `WeCom authenticated`,在目标群里向机器人发送文本,例如 `@机器人 协作 状态 T-NONE`;发现脚本仅输出 `{"chatid":"...","userid":"..."}`,**不会输出正文**。这一步如果只有认证而没有群消息,不代表群已接通:检查群是否已加入机器人、消息触发方式和企业管理员配置。在发现脚本按 `Ctrl-C` 关闭连接,再继续下面步骤;**同一个机器人不要让发现脚本和服务同时建立连接**。至少记下一个真实群 `chatid` 及你(管理员 alice)的 `userid`,若同事要在群里发命令,再收集其 `userid`。不要猜 ID、不要贴到 Git。以下命令的引号和交互输入要保留:
|
|
78
|
-
|
|
79
|
-
```sh
|
|
80
|
-
printf '刚发现的群 chatid: '; IFS= read -r WECOM_CHAT_IDS
|
|
81
|
-
printf '你自己的 userid(alice): '; IFS= read -r ALICE_WECOM_USERID
|
|
82
|
-
export WECOM_CHAT_IDS
|
|
83
|
-
node scripts/bind-wecom-users.mjs "$root/accounts.json" "$root/accounts.wecom.json" "alice:$ALICE_WECOM_USERID"
|
|
84
|
-
# 可选:另一位真人需在群里操作时,用他的 userid 重建私有文件;不要把两人的 userid 填成同一个。
|
|
85
|
-
# node scripts/bind-wecom-users.mjs "$root/accounts.json" "$root/accounts.team.json" "alice:$ALICE_WECOM_USERID" 'bob:<同事的真实userid>'
|
|
86
|
-
# 新文件只保留原有的个人 token 哈希,原有 Runner 令牌不会变化。
|
|
87
|
-
. "$root/private.env"
|
|
88
|
-
export COLLAB_ACCOUNTS_FILE="$root/accounts.wecom.json"
|
|
89
|
-
# 仅停原中心服务,保持两个 Runner;先确认旧 PID 确实是本工具的服务。
|
|
90
|
-
old="$(cat "$root/server.pid")"
|
|
91
|
-
ps -p "$old" -o command=
|
|
92
|
-
# 仅在确认该进程属于当前仓库的 apps/server/main.mjs 后,才手动执行:kill "$old"
|
|
93
|
-
node apps/server/main.mjs
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
上面最后一条**前台运行**,该终端不要退出;另开终端 `curl http://127.0.0.1:38765/health` 检查服务。若提示端口占用,先核对旧服务 PID,勿盲目杀其他进程。若群中收到真实回复才算接通。群里的任务示例:
|
|
97
|
-
|
|
98
|
-
1. `协作 创建 给 greet.sh 增加 --upper 行为 | sh test.sh 通过;sh greet.sh --upper 输出 HELLO, WORLD;保留原有默认输出`,记下新任务 `T-...`;不要重复使用历史任务。
|
|
99
|
-
2. `协作 分配 T-... bob`;本机 Bob Runner 自动领取并在隔离副本创建 `codex/task-*` 代码分支。Alice Runner 可读取审查任务并写私有审查记录。
|
|
100
|
-
3. `协作 状态 T-...`;人工核对 `git -C "$root/demo" branch --list 'codex/task-*'`、`sh test.sh`(需要在**任务分支**的 worktree 执行;`main` 故意未完成,不能直接把它的失败当任务失败)和 `$root/server-data` 的 Git 账本;不能把 Runner 记录当人工批准。
|
|
101
|
-
4. 确认代码和测试证据后,由 **alice 真人**发 `协作 审核 T-... 通过`,再发 `协作 完成 T-...`;这两个动作不会由 Runner 自动执行。失败就 `协作 审核 T-... 退回`。
|
|
102
|
-
|
|
103
|
-
**注意**:发现、认证、模拟 E2E、真实群消息送达、群回复、主动任务通知是不同的成功条件;逐项核实。这里使用 `$root/demo` 隔离副本,不修改或推送原始 demo 仓库。`COLLAB_PUSH` 单机保持默认关闭;源仓库与真实企业账号信息勿放入 Git。若之前本机 Runner 不在运行,查看 `ps` 与 `apps/runner/README.md`;不要以为启动 HTTP 服务就会自动启动 Runner。
|
|
63
|
+
当前只支持企微;新群任务明确写入 `channel: "wecom"`。历史任务的原始来源保留,不会被自动改写成企微来源;缺少通道或属于其他通道的任务不能在企微群查询,也不会向企微群发送通知。API 任务可继续通过已授权 API/MCP 操作。
|
|
104
64
|
|
|
105
65
|
## 同事接入 MCP 和 Skill
|
|
106
66
|
|
package/docs/onboarding-audit.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 网页向导功能检查与测试记录
|
|
2
2
|
|
|
3
|
-
最近检查日期:2026-10-
|
|
3
|
+
最近检查日期:2026-10-24。对象是工具仓库中的网页向导源码,初始记录对应 0.3.1 的发布前验证;后面的补充记录覆盖后续源码变更。
|
|
4
4
|
|
|
5
5
|
## 测试范围
|
|
6
6
|
|
|
@@ -115,3 +115,14 @@ Skill 校验通过。`npm pack --dry-run` 确认发布包包含 `guided.mjs`、`
|
|
|
115
115
|
使用本机 Chrome 实际检查:首页双入口、当前项目与本机名称默认值、验证命令推荐、连接诊断与修复、邀请文件上传和密码输入后自动读取、推荐 clone 目录。桌面与 390px 手机视口检查通过,邀请信息展开后没有横向溢出,页面没有脚本错误。机器人认证仍使用隔离测试连接,未连接真实企业微信群或创建 Codex 自动化。
|
|
116
116
|
|
|
117
117
|
实际 npm tarball 在独立目录安装,命令入口、后台启动、页面资源、带会话鉴权的 API 与服务状态检查均通过。发布包包含 74 个文件,新增运行模块齐全,不包含本机凭据、运行日志或测试目录。Linux 仅验证 systemd 配置生成和失败处理;Windows 登录自启尚不支持。尚未实际退出登录或重启电脑,也未完成跨机器企业微信、真实 Codex 自动化与睡眠恢复的联合验收。
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
## 2026-10-24 · 0.7.0 项目工作台与页面动效
|
|
121
|
+
|
|
122
|
+
完整自动测试通过 203 项,0 失败。新增测试覆盖同仓库不同 clone/HTTPS 与 SSH 地址的聚合、fork 与签名根隔离、统一机器人列表、“我的”归属标记、普通成员只读展示、身份冲突恢复、离线时选择最新的已验证签名记录、真实临时 Git 入队后的聚合、项目 API 会话保护、项目/机器人往返导航、刷新恢复、过期响应隔离及后台同步跳过。API 不返回真人 userid、机器人 Secret 或签名私钥。
|
|
123
|
+
|
|
124
|
+
本机 Chrome 使用隔离的示例 API 与真实页面资源,检查首页、项目列表、机器人详情、添加本人机器人取消返回、刷新恢复、管理员与成员接入表单。在 1440、768、390、320 像素宽度检查无横向溢出,无页面脚本错误。示例项目截图仅保存于本机临时目录,未加入源码和 npm 包。
|
|
125
|
+
|
|
126
|
+
持续动画仅有两处背景光晕位移与就绪状态透明度变化;在动画稳定后的 1.8 秒 Chrome Performance 采样中,新增 LayoutCount、RecalcStyleCount 和 ScriptDuration 均为 0。此记录反映本机隔离示例,不代表所有设备上的帧率。一次性入场动画完成后不保留最终变换。系统减少动态效果设置能关闭全部动画;页面隐藏信号能暂停动画,后台项目轮询不发出同步请求。
|
|
127
|
+
|
|
128
|
+
npm tarball 在独立目录安装后,命令入口、带会话鉴权的 API、新项目读取路由和静态页面资源检查通过。包包含 77 个文件,含新增项目聚合模块,不包含测试目录、本机凭据、运行日志或截图。此轮验证未连接真实企业微信群、修改个人业务 clone 或建立 Codex 自动化;任务协议与既有操作授权保持原有行为。
|
package/docs/onboarding.md
CHANGED
|
@@ -17,13 +17,25 @@ codex-collab
|
|
|
17
17
|
|
|
18
18
|
同一台电脑模拟两位成员时,使用同一业务仓库的两个独立 clone、两个机器人、两个 Codex 对话;同一个 clone 只能接入一个身份。同一个企业微信账号可以拥有多个 Agent,每个 Agent 的机器人、签名身份、clone 和对话独立。真正同事使用自己的企业微信账号和 Git 凭据。
|
|
19
19
|
|
|
20
|
+
## 在项目里管理机器人
|
|
21
|
+
|
|
22
|
+
首页“我的项目”按协作远程仓库与签名团队身份汇总。同一项目的不同 clone、不同远程别名及 HTTPS/SSH 地址会归到同一项目;fork 或不同签名团队继续分别展示。已有接入自动聚合,无需重新导入。
|
|
23
|
+
|
|
24
|
+
打开项目后只有一份“项目机器人”列表,包含自己的和同事的机器人。自己的标记“我的”;标记依据本机签名身份与团队已验证的所有者关系,同一已验证账号的其他机器人也会标记。本机没有接入的机器人会明确提示;同事的机器人展示角色和职责,不推测远端机器是否在线。
|
|
25
|
+
|
|
26
|
+
本机已接入的机器人显示实际连接状态,点击“管理机器人”或“继续接入”打开原接入详情,随后可“返回项目”。项目中的“团队管理”和“添加我的机器人”对已验证且就绪的本机管理员显示。普通成员拥有多个机器人时继续通过管理员分配的独立接入包接入。
|
|
27
|
+
|
|
28
|
+
首页读取本机接入记录;项目页先显示本机签名校验通过的团队记录,再同步远程账本。同步失败保留已验证记录并提示重试。归属标签只用于展示,操作仍检查对应身份的既有权限。
|
|
29
|
+
|
|
30
|
+
深色页面包含光晕缓动、卡片错峰入场和交互反馈,不加载外部字体或动画库。持续动画只使用 `transform` 和 `opacity`;页面进入后台时暂停动画与项目自动同步请求,系统开启“减少动态效果”时关闭动画与过渡。
|
|
31
|
+
|
|
20
32
|
## 管理员操作
|
|
21
33
|
|
|
22
34
|
1. 在企业微信创建独立智能机器人,获取 Bot ID 和 Secret,拉进协作群。为每位成员分别创建机器人并加入同一个群;真人同事也加入群。
|
|
23
35
|
2. 工作台选择“连接我的机器人”。当前目录属于 Git 项目时会自动带入项目根目录,并带入本机用户名。确认项目,或选择 Git 地址拉取新目录(目录可留空自动生成)。已有项目自动检查常见测试入口,也可点击“检查项目并推荐验证命令”;确认或修改推荐命令,例如 `npm test`、`go test ./...` 或 `sh test.sh`。工具自动识别协作远程与远程默认分支;多个不同远程或默认分支不明时,页面要求选择一次,也可主动选择其他基准分支。检查只读取常见测试入口,不执行项目脚本;实际任务使用确认后的固定程序与参数,不支持管道或命令拼接。
|
|
24
36
|
3. 填写自己的显示名称、选择主要职责,输入自己的 Bot ID/Secret。名称可以是 `Alice` 或“张三”;内部 Agent ID 自动生成,自定义身份和标签可在折叠选项中调整。点击“连接并进入群验证”。
|
|
25
37
|
4. 用自己的企业微信账号 @ 此机器人,发送页面显示的 `协作 接入 六位数字`。页面自动发现群 `chatid` 与本人 `userid`,创建身份、签名账本,配置项目 MCP/Skill 并启动本机机器人 Runner;无需再点击继续。群验证中刷新页面可以恢复进度。
|
|
26
|
-
5.
|
|
38
|
+
5. 从项目页点击“团队管理”,找到“批量分配机器人给同事”。每行填写显示名称、选择职责、填写独立机器人的 Bot ID/Secret,点击“增加一位成员/机器人”添加更多行。默认设为该职责的首选执行者并允许所有者群命令;候补与自定义 ID/职责在折叠选项中调整。一次最多 25 行。
|
|
27
39
|
6. 点击“生成全部接入包”。密码自动生成;在“已分配的机器人”逐个下载加密 JSON 文件、查看并复制接入说明。把文件和说明交给指定成员,密码通过另一条可信渠道单独发送。说明不包含密码或 Secret。包默认 7 天有效,同一成员或机器人不会同时分发多个有效邀请;重复 ID/Bot 的批次会在发布前拒绝。
|
|
28
40
|
7. 成员群验证后,“待确认的成员”自动出现申请。核对内部 Agent ID、职责和 userid,明确点击“确认此成员入队”。成员页面自动等待批准并继续配置;批准不会由向导替你做。新的签名策略通过 Git 同步到其他在线成员。
|
|
29
41
|
8. 管理员也按下文把自己的 Codex 对话启用。需要调整审核人时,展开“团队分工、审核与通知设置”,默认阶段审核由执行 Agent 的本人处理;可选管理员或已验证身份的同事。
|
|
@@ -36,7 +48,7 @@ codex-collab
|
|
|
36
48
|
|
|
37
49
|
远程别名属于各自的本机配置:你的 clone 可以叫 `origin`,同事可以叫 `upstream`。成员接入时按邀请中的仓库地址匹配远程,不需要改别名;如果另有个人 fork,不会误选它。团队基准分支写进管理员签名的策略,成员和新增机器人自动继承。每个机器人继续使用独立 clone 和 Codex 对话。
|
|
38
50
|
|
|
39
|
-
|
|
51
|
+
管理员在项目页点击“团队管理”,再展开“团队分工、审核与通知设置”,点击“读取并调整基准分支”即可切换到已有远程业务分支。修改通过签名版本链同步,只对之后创建的任务生效。已有任务保留原基准分支、创建时的完整 commit SHA、分工与审核规则。修改与其他管理员操作冲突时,重新读取后再保存。
|
|
40
52
|
|
|
41
53
|
新任务在创建时从团队远程基准分支固定起始 SHA。排队期间分支继续提交,首次领取和失败后的候补仍从这个 SHA 开始;阶段接力从上一阶段已验证的精确提交继续。创建快照使用私有账本 clone,不改变业务工作区、当前分支或它的 `FETCH_HEAD`。如果远程基准分支被删除、强制改写为不包含起始 SHA 的历史,或无法读取所需提交,任务会报错;不会自动换成最新代码。应先恢复可读历史,或明确终止旧任务并创建新任务。
|
|
42
54
|
|
|
@@ -126,7 +138,7 @@ codex-collab
|
|
|
126
138
|
准备同一业务仓库的两个独立 clone、两个企业微信机器人和两个 Codex 对话。管理员给自己增加 Agent 可以直接在同一工作台完成:
|
|
127
139
|
|
|
128
140
|
1. 以 Alice 初始化团队,完成群验证并进入管理员项目。已有团队可直接打开已就绪的管理员项目。
|
|
129
|
-
2.
|
|
141
|
+
2. 在项目页点击“添加我的机器人”(或团队管理中的“给自己增加机器人”),填写第二个 clone 的本机目录、新 Agent 名称 `bob`、职责 `backend`,以及新机器人的 Bot ID/Secret。
|
|
130
142
|
3. 保留“首选执行者”时,Bob 成为 backend 首选;Alice 若也有 backend 能力则是候补。点击“连接我的新机器人”。无需包密码或下载接入包。
|
|
131
143
|
4. 用初始化 Alice 时的**同一个企业微信账号** @Bob,发送页面验证码,页面自动继续。工具校验本人身份,自动签名批准新 Agent、配置 Bob 项目的 MCP/Skill 并启动连接。Bob 仍是 member,不会继承 Alice 的管理员角色。
|
|
132
144
|
5. 在 Alice clone 和 Bob clone 中分别打开要绑定的已有 Codex 对话,向各自对话粘贴该项目的“启用指令”。每个 Agent 各自创建 heartbeat,确认身份与最近轮询。
|
|
@@ -155,7 +167,7 @@ codex-collab
|
|
|
155
167
|
- 页面“停止机器人连接”只断开群机器人。暂停接单需在对应 Codex 对话暂停 heartbeat;已有任务仍需完成、报告失败或按租约恢复处理。
|
|
156
168
|
- CC Switch 与模型账号设置保持原样;项目 MCP 使用本机 Node 的绝对执行路径。
|
|
157
169
|
|
|
158
|
-
|
|
170
|
+
底层诊断命令仍可用,配置路径可在机器人的接入详情中展开“查看本机配置路径”并复制:
|
|
159
171
|
|
|
160
172
|
```sh
|
|
161
173
|
codex-collab status /absolute/path/to/private-peer.json
|
package/docs/protocol.md
CHANGED
|
@@ -16,6 +16,6 @@ Task IDs are opaque and unique within a repository. `spec.json` defines reposito
|
|
|
16
16
|
|
|
17
17
|
From `open` (the task creator's desktop work), `assigned` (the current owner), or `running`, a verified `handoff` becomes `waiting`. It must contain a concise summary and `refs.branch`, `refs.commit` and `refs.tests`; the server verifies that the pinned commit is the tip of its own `codex/*` branch (on `origin` when Git push is enabled). Only the creator may hand off an open task and only the assignee may hand off assigned/running work. An admin then explicitly assigns the waiting task to the next person; that person's Runner fetches the handoff branch and starts from the pinned commit, not `main`. From `running`, `progress` stays `running`, `blocked` or `run_failed` becomes `blocked`; from `blocked` or `waiting`, `assigned` returns to `assigned`. From `review`, `review_started` records a review run and `review_note` records concise review notes while preserving `review`; `reviewed(changes_requested)` becomes `blocked`. A `review_requested` event must name the reviewing agent. `cancelled` terminates any nonterminal state. A `completed` event is allowed only after approved review, and must carry evidence references as required by the server policy. Run completion and task completion are different events.
|
|
18
18
|
|
|
19
|
-
The server must deduplicate
|
|
19
|
+
WeCom is the only supported chat channel. The server must deduplicate WeCom message IDs with `wecom:<message-id>` idempotency keys. New group-created task `spec.source` includes `chat_id` and `channel: "wecom"`; new group assignments carry the same explicit channel. Unsupported channel values are rejected on create, assign and report. Group commands cannot access a task from another group or a task without explicit WeCom provenance. The schemas describe supported writes; historical metadata can still be read through the API without being rewritten or reinterpreted as WeCom, and tasks from an unsupported or unknown channel do not generate WeCom notifications. For two concurrent modifications, assignments and sequence allocation are serialized at the Git-writing service. A Git commit author string is not an authorization mechanism. Do not commit full chat transcripts, authentication material or private filesystem paths.
|
|
20
20
|
|
|
21
21
|
Schemas: `schemas/task.schema.json`, `schemas/event.schema.json`. A simple executable reducer lives in `packages/protocol/src/index.mjs`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 0.8.0:专注企业微信
|
|
2
|
+
|
|
3
|
+
当前只支持企业微信,优先稳定接入、群收发、任务领取、代码交接与真人审核。其他 IM 的接入在企微流程稳定后再评估。
|
|
4
|
+
|
|
5
|
+
- 移除飞书适配器、群信息发现脚本、身份映射入口及 SDK;安装依赖减少 24 个包。
|
|
6
|
+
- 网页向导和 Peer 保持现有企微接入方式,同一项目多个机器人、个人接入包及已绑定 Codex 对话继续使用。
|
|
7
|
+
- 旧中心服务保留企微、HTTP/MCP、Git 任务账本和 Runner。新群任务与分配明确记录 `channel: "wecom"`;其他通道写入被拒绝。
|
|
8
|
+
- 历史任务保留原始来源。属于其他通道或缺少明确企微来源的任务仍可通过已授权 API 读取,不允许企微群查询或发送群通知。
|
|
9
|
+
|
|
10
|
+
## 升级
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install --global xuym-codex-collab@0.8.0
|
|
14
|
+
codex-collab
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
现有 Peer 配置与业务仓库无需迁移。升级后重新启动本机工作台与已运行的机器人连接,使进程加载新版本;需要时在对应 Codex 对话重新连接 MCP。
|
|
18
|
+
|
|
19
|
+
从源码使用旧中心服务时,账号生成脚本只接受 `id:role:wecom_userid`,仅用 API 则填 `id:role:-`。旧四段参数会报错,避免身份错配。已有账号文件与个人令牌继续有效;补充企微身份可使用 `scripts/bind-wecom-users.mjs`,无需重新生成令牌。
|
|
20
|
+
|
|
21
|
+
本轮验证使用临时 Git 仓库、企微消息与 Codex mock,另检查 npm 安装和向导启动;不新增真实群或跨设备验收结论。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xuym-codex-collab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Git-backed collaboration tools for Codex teams, with MCP, WeCom, and local runners.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,7 +52,6 @@
|
|
|
52
52
|
"peer": "node scripts/peer/cli.mjs"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@larksuiteoapi/node-sdk": "1.74.0",
|
|
56
55
|
"@modelcontextprotocol/node": "2.1.1",
|
|
57
56
|
"@modelcontextprotocol/server": "2.3.1",
|
|
58
57
|
"@openai/codex-sdk": "0.161.0",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"summary": { "type": "string", "maxLength": 4000 },
|
|
35
35
|
"assignee": { "type": "string", "minLength": 1 },
|
|
36
36
|
"chat_id": { "type": "string", "minLength": 1 },
|
|
37
|
-
"channel": { "
|
|
37
|
+
"channel": { "const": "wecom" },
|
|
38
38
|
"run_id": { "type": "string", "minLength": 1 },
|
|
39
39
|
"decision": { "enum": ["approved", "changes_requested"] },
|
|
40
40
|
"refs": {
|
package/schemas/task.schema.json
CHANGED
|
@@ -13,6 +13,6 @@
|
|
|
13
13
|
"description": { "type": "string" },
|
|
14
14
|
"base_branch": { "type": "string", "minLength": 1 },
|
|
15
15
|
"acceptance": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
|
16
|
-
"source": { "type": "object", "additionalProperties": { "type": "string" } }
|
|
16
|
+
"source": { "type": "object", "properties": { "channel": { "const": "wecom" } }, "additionalProperties": { "type": "string" } }
|
|
17
17
|
}
|
|
18
18
|
}
|