xuym-codex-collab 0.4.0 → 0.4.2
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 +27 -5
- package/apps/onboarding/discovery.mjs +4 -0
- package/apps/onboarding/guided.mjs +105 -0
- package/apps/onboarding/manager.mjs +151 -19
- package/apps/onboarding/reset.mjs +247 -0
- package/apps/onboarding/server.mjs +2 -1
- package/apps/onboarding/web/app.js +216 -38
- package/apps/onboarding/web/index.html +30 -7
- package/apps/onboarding/web/style.css +4 -0
- package/apps/peer/desktop.mjs +2 -0
- package/apps/peer/runtime.mjs +2 -1
- package/apps/peer/wecom.mjs +6 -4
- package/docs/desktop-poll.md +1 -1
- package/docs/onboarding-audit.md +27 -8
- package/docs/onboarding.md +49 -24
- package/package.json +1 -1
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +3 -2
- package/packages/peer/inbox.mjs +4 -2
- package/packages/peer/service.mjs +8 -4
- package/schemas/peer-inbox.schema.json +1 -1
- package/scripts/peer/refresh-project.mjs +1 -1
- package/scripts/peer/setup-desktop-project.mjs +3 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
const $ = (id) => document.getElementById(id);
|
|
2
|
-
let currentId, pending, discovery, timer, busy = false, reviewVersion = null, ownAdminId;
|
|
2
|
+
let currentId, pending, discovery, timer, busy = false, reviewVersion = null, reviewId, ownAdminId, resetPlan, resetDiscovery;
|
|
3
|
+
let viewEpoch = 0, currentView = 'home', completing = false;
|
|
4
|
+
const storageKey = 'codex-collab-onboarding';
|
|
5
|
+
function remember(value) { try { if (value) sessionStorage.setItem(storageKey, JSON.stringify(value)); else sessionStorage.removeItem(storageKey); } catch {} }
|
|
6
|
+
function remembered() { try { return JSON.parse(sessionStorage.getItem(storageKey) || 'null'); } catch { return null; } }
|
|
3
7
|
function notice(text) { $('notice').textContent = text; $('notice').hidden = !text; }
|
|
4
|
-
function show(view) { for (const section of document.querySelectorAll('main>section')) section.hidden = section.id !== view; }
|
|
8
|
+
function show(view) { if (view !== 'profile' || currentView === 'profile') { $('delivery-panel').hidden = true; $('delivery-password').value = ''; $('delivery-instructions').textContent = ''; } clearInterval(timer); clearTimeout(timer); viewEpoch++; currentView = view; for (const section of document.querySelectorAll('main>section')) section.hidden = section.id !== view; }
|
|
5
9
|
async function api(path, body) {
|
|
6
10
|
const response = await fetch(`/api/${path}`, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
|
|
7
11
|
const value = await response.json(); if (!response.ok) throw new Error(value.error); return value;
|
|
@@ -14,33 +18,116 @@ const values = (form) => Object.fromEntries(new FormData(form));
|
|
|
14
18
|
function text(tag, value) { const node = document.createElement(tag); node.textContent = value; return node; }
|
|
15
19
|
function button(label, fn) { const node = text('button', label); node.onclick = () => action(fn); return node; }
|
|
16
20
|
async function overview() {
|
|
17
|
-
const value = await api('overview'); $('profiles').replaceChildren();
|
|
18
|
-
|
|
21
|
+
const value = await api('overview'); $('profiles').replaceChildren(); $('removed-profiles').replaceChildren();
|
|
22
|
+
$('removed-projects').hidden = !(value.removed_profiles || []).length;
|
|
23
|
+
for (const profile of value.removed_profiles || []) {
|
|
24
|
+
const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
|
|
25
|
+
info.append(text('strong', profile.agent_id), text('p', profile.repo));
|
|
26
|
+
card.append(info, button('恢复接入', () => openReset(profile.id, 'restore'))); $('removed-profiles').append(card);
|
|
27
|
+
}
|
|
28
|
+
for (const draft of value.drafts || []) {
|
|
29
|
+
const card = document.createElement('div'); card.className = 'card';
|
|
30
|
+
card.append(text('span', `${draft.display_name || draft.agent_id} · 待完成群验证`), button('继续创建团队', () => openDraft(draft.id))); $('profiles').append(card);
|
|
31
|
+
}
|
|
32
|
+
if (!value.profiles.length && !(value.drafts || []).length) $('profiles').append(text('p', '还没有接入项目。管理员选择“初始化团队”,成员选择“加入团队”。'));
|
|
19
33
|
for (const profile of value.profiles) {
|
|
20
34
|
const card = document.createElement('div'); card.className = 'card'; const info = document.createElement('div');
|
|
21
|
-
info.append(text('strong', `${profile.agent_id} · ${profile.role === 'admin' ? '管理员' : '成员'}`), text('p', profile.repo));
|
|
35
|
+
info.append(text('strong', `${profile.display_name || profile.agent_id} · ${profile.role === 'admin' ? '管理员' : '成员'}`), text('p', profile.repo));
|
|
22
36
|
card.append(info, button('打开项目', () => loadProfile(profile.id))); $('profiles').append(card);
|
|
23
37
|
}
|
|
24
38
|
}
|
|
39
|
+
async function openDraft(id) {
|
|
40
|
+
const value = await api(`admin-draft?id=${id}`);
|
|
41
|
+
if (value.profile) { pending = null; await loadProfile(value.profile.id); return; }
|
|
42
|
+
pending = { type: 'admin', draft_id: id }; remember({ draft_id: id }); await verifyView(value);
|
|
43
|
+
}
|
|
25
44
|
async function verifyView(session) {
|
|
26
45
|
discovery = session; show('verification'); $('code').textContent = session.message; updateConnection(session);
|
|
27
|
-
|
|
46
|
+
const epoch = viewEpoch;
|
|
47
|
+
const tick = async () => {
|
|
48
|
+
if (epoch !== viewEpoch) return;
|
|
49
|
+
if (busy) { timer = setTimeout(tick, 2000); return; }
|
|
50
|
+
try {
|
|
51
|
+
const value = await api(`discovery?id=${session.id}`); if (epoch !== viewEpoch) return; updateConnection(value);
|
|
52
|
+
if (value.status === 'verified') { await action(completeVerification); return; }
|
|
53
|
+
if (['failed', 'expired', 'disconnected'].includes(value.status)) return;
|
|
54
|
+
timer = setTimeout(tick, 2000);
|
|
55
|
+
} catch (error) { if (epoch === viewEpoch) { notice(error.message); $('retry-code').hidden = false; } }
|
|
56
|
+
};
|
|
57
|
+
timer = setTimeout(tick, session.status === 'verified' ? 0 : 2000);
|
|
58
|
+
}
|
|
59
|
+
function updateConnection(value) {
|
|
60
|
+
discovery = value; $('retry-code').hidden = !['failed', 'expired', 'disconnected'].includes(value.status);
|
|
61
|
+
const labels = { connecting: '正在连接企业微信…', authenticated: '机器人已连接,请发送下面的验证码。', verified: '群验证成功,正在继续配置…', failed: '连接失败,请检查凭据。', expired: '验证码已过期,请重新连接。', disconnected: '连接已断开,请重新连接。' };
|
|
62
|
+
$('connection').textContent = value.error || labels[value.status] || value.status;
|
|
63
|
+
}
|
|
64
|
+
async function completeVerification() {
|
|
65
|
+
if (completing || !pending) return;
|
|
66
|
+
completing = true; clearTimeout(timer);
|
|
67
|
+
const setup = pending; const epoch = viewEpoch; let profile;
|
|
68
|
+
try {
|
|
69
|
+
const session = await api(`discovery?id=${discovery.id}`);
|
|
70
|
+
if (epoch !== viewEpoch) return;
|
|
71
|
+
updateConnection(session); if (session.status !== 'verified') throw new Error('请先完成群验证码验证。');
|
|
72
|
+
try {
|
|
73
|
+
profile = setup.type === 'admin' ? await api('admin-complete', { draft_id: setup.draft_id, discovery_id: session.id }) : await api('verify', { id: setup.id, discovery_id: session.id });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (epoch !== viewEpoch) return;
|
|
76
|
+
if (setup.type === 'admin') profile = (await api(`admin-draft?id=${setup.draft_id}`)).profile;
|
|
77
|
+
else profile = (await api('overview')).profiles.find((value) => value.id === setup.id);
|
|
78
|
+
if (!profile) { $('retry-code').hidden = false; throw error; }
|
|
79
|
+
pending = null; await loadProfile(profile.id); notice(`${error.message} 可在此继续接入。`); return;
|
|
80
|
+
}
|
|
81
|
+
if (epoch !== viewEpoch) return;
|
|
82
|
+
pending = null; await loadProfile(profile.id);
|
|
83
|
+
} finally { completing = false; }
|
|
84
|
+
}
|
|
85
|
+
function renderSteps(value) {
|
|
86
|
+
const verified = value.phase !== 'imported', approved = ['configuring', 'ready'].includes(value.phase);
|
|
87
|
+
const steps = [['项目与机器人资料', true], ['企业微信群身份验证', verified], ['管理员确认入队', value.role === 'admin' ? verified : approved], ['本机 MCP 与 Skill', value.phase === 'ready' && value.project_setup?.mcp_configured && value.project_setup?.skill_installed], ['绑定 Codex 对话', !!value.desktop?.binding], ['观察到对话轮询', !!value.poll_recent]];
|
|
88
|
+
$('setup-steps').replaceChildren(); let active = false;
|
|
89
|
+
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); }
|
|
90
|
+
const stopped = value.phase === 'ready' && !value.runner?.online;
|
|
91
|
+
$('next-step').textContent = value.error ? '接入暂时停在这里。处理下方问题后点击“继续接入”。' : value.can_receive_tasks ? '已观察到实际轮询,可以在协作群 @ 机器人发送任务。请保持当前对话的协作自动化启用。' : value.phase === 'waiting' ? '申请已提交,正在自动等待管理员确认。管理员工作台会自动显示申请。' : value.phase !== 'ready' ? '按当前提示继续,完成后向导会自动进入下一步。' : stopped ? '机器人连接已停止。需要接任务时点击“启动机器人连接”。' : !value.desktop?.binding ? '在 Codex 打开下方项目,加载项目 MCP 和 Skill,再把启用指令发到要绑定的对话。' : '对话已绑定,正在等待实际轮询记录。';
|
|
92
|
+
}
|
|
93
|
+
function watchProfile(id, epoch) {
|
|
94
|
+
let failures = 0;
|
|
95
|
+
const tick = async () => {
|
|
96
|
+
if (epoch !== viewEpoch || currentId !== id) return;
|
|
97
|
+
if (busy) { timer = setTimeout(tick, 2000); return; }
|
|
98
|
+
try {
|
|
99
|
+
const value = await api('progress', { id, sync: true });
|
|
100
|
+
if (epoch !== viewEpoch || currentId !== id) return;
|
|
101
|
+
failures = 0; renderProfile(value, true);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (epoch !== viewEpoch) return; failures++;
|
|
104
|
+
notice(`自动检查暂未成功,将重试。${error.message}`);
|
|
105
|
+
}
|
|
106
|
+
if (epoch === viewEpoch) timer = setTimeout(tick, Math.min(30000, 5000 * 2 ** failures));
|
|
107
|
+
};
|
|
108
|
+
timer = setTimeout(tick, 5000);
|
|
28
109
|
}
|
|
29
|
-
function updateConnection(value) { discovery = value; $('retry-code').hidden = !['failed', 'expired', 'disconnected'].includes(value.status); const labels = { connecting: '正在连接企业微信…', authenticated: '机器人已连接,请发送下面的验证码。', verified: '群验证成功,点击继续完成配置。', failed: '连接失败,请检查凭据。', expired: '验证码已过期,请重新连接。', disconnected: '连接已断开,请重新连接。' }; $('connection').textContent = value.error || labels[value.status] || value.status; }
|
|
30
110
|
async function loadProfile(id) {
|
|
31
|
-
currentId = id; show('profile'); let value;
|
|
111
|
+
currentId = id; remember({ profile_id: id }); show('profile'); const epoch = viewEpoch; let value;
|
|
32
112
|
try { value = await api(`profile?id=${id}`); } catch (error) { value = await api(`profile?id=${id}&local=1`); notice(`团队同步暂未成功,本机状态仍可查看。${error.message}`); }
|
|
33
|
-
|
|
113
|
+
if (epoch !== viewEpoch) return;
|
|
114
|
+
if (value.phase === 'removed') { show('home'); await overview(); return; }
|
|
115
|
+
renderProfile(value); watchProfile(id, epoch); return value;
|
|
116
|
+
}
|
|
117
|
+
function renderProfile(value, automatic = false) {
|
|
118
|
+
const id = value.id; renderSteps(value);
|
|
119
|
+
$('reset-conversation').hidden = value.phase !== 'ready'; $('reset-robot').hidden = value.phase !== 'ready';
|
|
120
|
+
$('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;
|
|
34
121
|
$('profile-diagnostics').hidden = !value.config_path; $('config-path').textContent = value.config_path || '';
|
|
35
122
|
$('checks').replaceChildren(); $('progress').replaceChildren(); $('binding').hidden = value.phase !== 'ready'; $('runner-actions').hidden = value.phase !== 'ready'; $('admin-tools').hidden = value.role !== 'admin' || value.phase !== 'ready';
|
|
36
123
|
if (value.phase === 'ready') {
|
|
37
|
-
$('binding-text').textContent = value.binding_prompt;
|
|
124
|
+
$('binding-text').textContent = value.binding_prompt; $('binding-details').textContent = value.detailed_binding_prompt || value.binding_prompt;
|
|
38
125
|
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, '尚未满足条件']]) {
|
|
39
126
|
const row = document.createElement('div'); row.className = 'check'; const badge = text('span', ok ? '已就绪' : wait); badge.className = `badge${ok ? '' : ' pending'}`; row.append(text('span', label), badge); $('checks').append(row);
|
|
40
127
|
}
|
|
41
128
|
$('checks').append(text('p', '轮询状态来自实际调用记录。自动化是否启用,请在绑定对话的 Codex 自动化中确认。'));
|
|
42
129
|
} else {
|
|
43
|
-
const phases = { imported: '机器人配置已保存,等待群身份验证。', initializing: '团队初始化尚未完成。', configuring: '正在配置本机项目。', verified: '正在提交入队申请。', waiting: value.own_agent ? '
|
|
130
|
+
const phases = { imported: '机器人配置已保存,等待群身份验证。', initializing: '团队初始化尚未完成。', configuring: '正在配置本机项目。', verified: '正在提交入队申请。', waiting: value.own_agent ? '本人机器人申请已提交,正在自动完成入队。' : '申请已提交,正在自动等待管理员确认。' };
|
|
44
131
|
$('progress').append(text('p', phases[value.phase] || value.phase), button(value.phase === 'imported' ? '连接分配的机器人' : '继续接入', async () => {
|
|
45
132
|
if (value.phase === 'imported') { pending = { type: 'member', id }; await verifyView(await api('discovery', { profile_id: id })); }
|
|
46
133
|
else { try { await api('resume', { id }); } finally { await loadProfile(id); } }
|
|
@@ -51,14 +138,15 @@ async function loadProfile(id) {
|
|
|
51
138
|
for (const member of value.members || []) $('members').append(text('p', `${member.id} · ${member.role === 'admin' ? '管理员' : '成员'} · ${member.capabilities.join(', ')}`));
|
|
52
139
|
for (const [capability, candidates] of Object.entries(value.routes || {})) $('routes').append(text('p', `${capability}:${candidates.join(' → ')}(首选 → 候补)`));
|
|
53
140
|
for (const owner of value.owners || []) $('owners').append(text('p', `${owner.is_you ? '你的机器人' : '同一位同事的机器人'}:${owner.agent_ids.join('、')}`));
|
|
54
|
-
renderReviewRules(value);
|
|
141
|
+
if (!automatic || reviewId !== id || reviewVersion === null) renderReviewRules(value);
|
|
142
|
+
else if (value.version !== reviewVersion) $('review-policy-note').textContent = '团队规则已更新。当前未保存的编辑已保留;保存前请刷新并重新确认规则。';
|
|
55
143
|
$('exports').replaceChildren(); $('requests').replaceChildren();
|
|
56
144
|
for (const invitation of value.exports || []) {
|
|
57
|
-
const row = document.createElement('div'); row.className = 'card'; row.append(text('span', `${invitation.agent_id} · 有效至 ${new Date(invitation.expires_at).toLocaleDateString()}`));
|
|
145
|
+
const row = document.createElement('div'); row.className = 'card'; row.append(text('span', `${invitation.display_name && invitation.display_name !== invitation.agent_id ? `${invitation.display_name}(${invitation.agent_id})` : invitation.agent_id} · 有效至 ${new Date(invitation.expires_at).toLocaleDateString()}`));
|
|
58
146
|
if (invitation.own_agent) row.append(button('继续接入我的机器人', async () => {
|
|
59
147
|
const profile = await api('own-agent-resume', { id, invite_id: invitation.invite_id }); await connectMember(profile);
|
|
60
148
|
}));
|
|
61
|
-
else { const link = text('a', '下载加密接入包'); link.href = `/api/export?id=${id}&invite=${invitation.invite_id}`; link.download = invitation.filename; row.append(link); }
|
|
149
|
+
else { row.append(button('查看接入说明与密码', () => showDelivery(id, invitation.invite_id))); const link = text('a', '下载加密接入包'); link.href = `/api/export?id=${id}&invite=${invitation.invite_id}`; link.download = invitation.filename; row.append(link); }
|
|
62
150
|
$('exports').append(row);
|
|
63
151
|
}
|
|
64
152
|
for (const request of value.requests || []) {
|
|
@@ -67,7 +155,7 @@ async function loadProfile(id) {
|
|
|
67
155
|
if (!(value.requests || []).length) $('requests').append(text('p', '暂无待确认申请。'));
|
|
68
156
|
}
|
|
69
157
|
function renderReviewRules(profile) {
|
|
70
|
-
$('review-rules').replaceChildren(); reviewVersion = null;
|
|
158
|
+
$('review-rules').replaceChildren(); reviewVersion = null; reviewId = profile.id;
|
|
71
159
|
const ready = profile.role === 'admin' && profile.phase === 'ready' && profile.routes && Number.isSafeInteger(profile.version);
|
|
72
160
|
$('review-form').hidden = !ready;
|
|
73
161
|
if (!ready) { $('review-policy-note').textContent = '团队同步成功后可设置审核规则。'; return; }
|
|
@@ -103,14 +191,34 @@ $('review-form').onsubmit = (event) => { event.preventDefault(); action(async ()
|
|
|
103
191
|
await api('review-rules', { id: currentId, expected_version: reviewVersion, review_rules });
|
|
104
192
|
await loadProfile(currentId); notice('审核规则已保存,新任务将使用此规则。');
|
|
105
193
|
}); };
|
|
106
|
-
for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => action(async () => { clearInterval(timer); show(button.dataset.view); if (button.dataset.view === 'home') await overview(); });
|
|
107
|
-
|
|
194
|
+
for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => action(async () => { clearInterval(timer); if (resetPlan) { await api('reset-cancel', { id: resetPlan.id, preview_token: resetPlan.preview_token }); resetPlan = null; resetDiscovery = null; $('reset-secret').value = ''; } remember(null); show(button.dataset.view); if (button.dataset.view === 'home') await overview(); });
|
|
195
|
+
function repoMode(prefix) {
|
|
196
|
+
const clone = $(`${prefix}-repo-mode`).value === 'clone'; $(`${prefix}-existing-repo`).hidden = clone; $(`${prefix}-clone-repo`).hidden = !clone;
|
|
197
|
+
}
|
|
198
|
+
for (const prefix of ['admin', 'join']) $(`${prefix}-repo-mode`).onchange = () => repoMode(prefix);
|
|
199
|
+
$('check-project').onclick = () => action(async () => {
|
|
200
|
+
const selected = await api('project-check', values($('admin-form')));
|
|
201
|
+
const form = $('admin-form'); form.elements.repo.value = selected.repo; $('admin-repo-mode').value = 'existing'; repoMode('admin');
|
|
202
|
+
$('project-recommendation').textContent = `项目已检查:${selected.repo}。${selected.tests.length ? '推荐:' + selected.tests.map((test) => test.command).join(' / ') + ',请确认或修改。' : '未找到常见测试入口,请填写适合项目的验证命令。'}`;
|
|
203
|
+
if (!$('test-command').value && selected.tests.length) $('test-command').value = selected.tests[0].command;
|
|
204
|
+
});
|
|
205
|
+
$('admin-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
206
|
+
const input = values(event.target);
|
|
207
|
+
const session = await api('admin-prepare', input); event.target.elements.secret.value = '';
|
|
208
|
+
pending = { type: 'admin', draft_id: session.draft_id }; remember({ draft_id: session.draft_id }); await verifyView(session);
|
|
209
|
+
}); };
|
|
210
|
+
async function invitationInput(form) { const input = values(form); input.content = await input.file.text(); delete input.file; return input; }
|
|
211
|
+
$('inspect-invite').onclick = () => action(async () => {
|
|
212
|
+
const value = await api('inspect-invite', await invitationInput($('join-form')));
|
|
213
|
+
$('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 : ''}`;
|
|
214
|
+
$('invite-remote').textContent = value.remote;
|
|
215
|
+
if (!$('join-form').elements.destination.value) $('join-form').elements.destination.value = value.suggested_repo;
|
|
216
|
+
});
|
|
108
217
|
$('join-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
109
|
-
const
|
|
110
|
-
const profile = await api('import', input); event.target.elements.password.value = ''; await connectMember(profile);
|
|
218
|
+
const profile = await api('import', await invitationInput(event.target)); event.target.elements.password.value = ''; await connectMember(profile);
|
|
111
219
|
}); };
|
|
112
220
|
async function connectMember(profile) {
|
|
113
|
-
currentId = profile.id;
|
|
221
|
+
currentId = profile.id; remember({ profile_id: profile.id });
|
|
114
222
|
if (profile.phase !== 'imported') { pending = null; await loadProfile(profile.id); return; }
|
|
115
223
|
pending = { type: 'member', id: profile.id };
|
|
116
224
|
try { await verifyView(await api('discovery', { profile_id: profile.id })); }
|
|
@@ -126,29 +234,99 @@ $('own-form').onsubmit = (event) => { event.preventDefault(); action(async () =>
|
|
|
126
234
|
finally { event.target.elements.secret.value = ''; }
|
|
127
235
|
await connectMember(profile);
|
|
128
236
|
}); };
|
|
129
|
-
$('retry-code').onclick = () => action(async () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (session.status !== 'verified') { updateConnection(session); throw new Error('请先完成群验证码验证。'); }
|
|
133
|
-
clearInterval(timer);
|
|
134
|
-
const setup = pending; let profile;
|
|
135
|
-
try {
|
|
136
|
-
profile = setup.type === 'admin' ? await api('admin', { ...setup.input, discovery_id: discovery.id }) : await api('verify', { id: setup.id, discovery_id: discovery.id });
|
|
137
|
-
} catch (error) {
|
|
138
|
-
// Setup may be persisted before Git/MCP work fails; recover the same profile.
|
|
139
|
-
const profiles = (await api('overview')).profiles;
|
|
140
|
-
profile = profiles.find((value) => setup.type === 'member' ? value.id === setup.id : value.agent_id === setup.input.agent_id.trim() && value.role === 'admin' && !setup.existing_ids.includes(value.id));
|
|
141
|
-
if (!profile) throw error;
|
|
142
|
-
pending = null; await loadProfile(profile.id); notice(`${error.message} 可在此继续接入。`); return;
|
|
143
|
-
}
|
|
144
|
-
pending = null; await loadProfile(profile.id);
|
|
237
|
+
$('retry-code').onclick = () => action(async () => {
|
|
238
|
+
if (pending.type === 'admin') { const session = await api('admin-retry', { id: pending.draft_id }); if (session.profile) await loadProfile(session.profile.id); else await verifyView(session); }
|
|
239
|
+
else await verifyView(await api('discovery', { profile_id: pending.id, reconnect: true }));
|
|
145
240
|
});
|
|
241
|
+
$('confirm-code').onclick = () => action(completeVerification);
|
|
146
242
|
$('existing-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const profile = await api('existing', values(event.target)); await loadProfile(profile.id); }); };
|
|
147
|
-
$('issue-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.id = currentId; input.prefer = event.target.elements.prefer.checked; input.commands_enabled = event.target.elements.commands_enabled.checked; await api('issue', input); event.target.reset(); await loadProfile(currentId); notice('接入包已生成。下载文件交给指定成员,密码请单独发送。'); }); };
|
|
243
|
+
$('issue-form').onsubmit = (event) => { event.preventDefault(); action(async () => { const input = values(event.target); input.id = currentId; input.prefer = event.target.elements.prefer.checked; input.commands_enabled = event.target.elements.commands_enabled.checked; const result = await api('issue', input); event.target.reset(); await loadProfile(currentId); await showDelivery(currentId, result.invite_id); notice('接入包已生成。下载文件交给指定成员,密码请单独发送。'); }); };
|
|
148
244
|
$('refresh').onclick = () => action(() => loadProfile(currentId));
|
|
149
245
|
$('start').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'start' }); await loadProfile(currentId); });
|
|
150
246
|
$('stop').onclick = () => action(async () => { await api('runner', { id: currentId, command: 'stop' }); await loadProfile(currentId); });
|
|
151
247
|
$('copy-config-path').onclick = () => action(() => navigator.clipboard.writeText($('config-path').textContent));
|
|
152
248
|
$('copy-code').onclick = () => action(() => navigator.clipboard.writeText($('code').textContent));
|
|
249
|
+
$('copy-binding-details').onclick = () => action(() => navigator.clipboard.writeText($('binding-details').textContent));
|
|
153
250
|
$('copy-binding').onclick = () => action(() => navigator.clipboard.writeText($('binding-text').textContent));
|
|
154
|
-
|
|
251
|
+
async function openReset(id, kind) {
|
|
252
|
+
clearInterval(timer); clearTimeout(timer); viewEpoch++; resetPlan = null; resetDiscovery = null;
|
|
253
|
+
const plan = await api('reset-preview', { id, kind }); resetPlan = plan; currentId = id;
|
|
254
|
+
$('reset-title').textContent = plan.title; $('reset-target').textContent = `${plan.agent_id} · ${plan.repo}`;
|
|
255
|
+
$('reset-effects').replaceChildren(); for (const effect of plan.effects) $('reset-effects').append(text('li', effect));
|
|
256
|
+
$('reset-thread').textContent = plan.thread_id ? `当前对话 ID:${plan.thread_id}` : '当前没有绑定的 Codex 对话。';
|
|
257
|
+
$('reset-pause-label').hidden = !plan.requires_pause; $('reset-paused').checked = false;
|
|
258
|
+
$('reset-confirmation').value = ''; $('reset-confirmation').placeholder = plan.agent_id;
|
|
259
|
+
$('reset-secret-fields').hidden = kind !== 'robot'; $('reset-secret').value = ''; $('reset-verification').hidden = true;
|
|
260
|
+
$('reset-submit').textContent = kind === 'restore' ? '确认恢复' : '确认操作'; show('reset-view');
|
|
261
|
+
}
|
|
262
|
+
$('reset-conversation').onclick = () => action(() => openReset(currentId, 'conversation'));
|
|
263
|
+
$('reset-robot').onclick = () => action(() => openReset(currentId, 'robot'));
|
|
264
|
+
$('reset-project').onclick = () => action(() => openReset(currentId, 'project'));
|
|
265
|
+
$('reset-cancel').onclick = () => action(async () => { const plan = resetPlan; clearInterval(timer); await api('reset-cancel', { id: plan.id, preview_token: plan.preview_token }); resetPlan = null; resetDiscovery = null; $('reset-secret').value = ''; if (plan.kind === 'restore') { show('home'); await overview(); } else { await loadProfile(plan.id); notice('已取消操作。如已验证新凭据,机器人连接仍停止,可在项目页面重新启动。'); } });
|
|
266
|
+
$('reset-copy-code').onclick = () => action(() => navigator.clipboard.writeText($('reset-code').textContent));
|
|
267
|
+
function resetConnection(value) {
|
|
268
|
+
const labels = { connecting: '正在验证新凭据…', authenticated: '连接成功,请发送群验证码。', verified: '群身份已验证,可以确认保存。', failed: '连接失败,请检查新 Secret 后重试。', expired: '验证码已过期,请重新验证。', disconnected: '连接已断开,请重新验证。' };
|
|
269
|
+
$('reset-connection').textContent = value.error || labels[value.status] || value.status;
|
|
270
|
+
}
|
|
271
|
+
$('reset-connect').onclick = () => action(async () => {
|
|
272
|
+
const secret = $('reset-secret').value; if (!secret.trim()) throw new Error('请填写新的机器人 Secret。');
|
|
273
|
+
const session = await api('reset-discovery', { id: resetPlan.id, kind: resetPlan.kind, preview_token: resetPlan.preview_token, secret });
|
|
274
|
+
$('reset-secret').value = ''; resetDiscovery = session; $('reset-verification').hidden = false; $('reset-code').textContent = session.message; resetConnection(session);
|
|
275
|
+
clearInterval(timer); timer = setInterval(async () => { if (busy || !resetPlan || !resetDiscovery) return; try {
|
|
276
|
+
const value = await api(`discovery?id=${resetDiscovery.id}`); resetDiscovery = value; resetConnection(value);
|
|
277
|
+
if (['verified', 'failed', 'expired', 'disconnected'].includes(value.status)) clearInterval(timer);
|
|
278
|
+
} catch (error) { clearInterval(timer); notice(error.message); } }, 2000);
|
|
279
|
+
});
|
|
280
|
+
$('reset-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
281
|
+
if ($('reset-confirmation').value !== resetPlan.agent_id) throw new Error('请填写当前 Agent 名称确认操作。');
|
|
282
|
+
if (resetPlan.requires_pause && !$('reset-paused').checked) throw new Error('请先在原 Codex 对话暂停协作轮询,再勾选确认。');
|
|
283
|
+
const input = { id: resetPlan.id, kind: resetPlan.kind, preview_token: resetPlan.preview_token, confirmation: $('reset-confirmation').value, heartbeat_paused: $('reset-paused').checked };
|
|
284
|
+
if (resetPlan.kind === 'robot') {
|
|
285
|
+
if (!resetDiscovery) throw new Error('请先验证新凭据。');
|
|
286
|
+
const session = await api(`discovery?id=${resetDiscovery.id}`); resetConnection(session);
|
|
287
|
+
if (session.status !== 'verified') throw new Error('请先在原协作群完成验证码验证。');
|
|
288
|
+
input.discovery_id = resetDiscovery.id;
|
|
289
|
+
}
|
|
290
|
+
const result = await api('reset', input); clearInterval(timer); resetPlan = null; resetDiscovery = null;
|
|
291
|
+
if (result.kind === 'project') { show('home'); await overview(); } else await loadProfile(result.id);
|
|
292
|
+
notice(`${result.message} 本机恢复记录:${result.backup_path}`);
|
|
293
|
+
}); };
|
|
294
|
+
|
|
295
|
+
function assignmentRow() {
|
|
296
|
+
const card = document.createElement('fieldset'); card.className = 'assignment'; card.append(text('legend', '成员/机器人'));
|
|
297
|
+
const field = (label, name, { type = 'text', placeholder = '', required = false } = {}) => {
|
|
298
|
+
const wrapper = text('label', label), input = document.createElement('input'); input.name = name; input.dataset.field = name; input.type = type; input.placeholder = placeholder; input.required = required; input.autocomplete = 'off'; wrapper.append(input); return wrapper;
|
|
299
|
+
};
|
|
300
|
+
const row = document.createElement('div'); row.className = 'row';
|
|
301
|
+
const roleLabel = text('label', '职责'), role = document.createElement('select'); role.dataset.field = 'responsibility';
|
|
302
|
+
for (const [value, label] of [['frontend', '前端开发'], ['backend', '后端开发'], ['fullstack', '全栈开发'], ['testing', '测试'], ['docs', '文档'], ['general', '通用开发']]) { const option = text('option', label); option.value = value; role.append(option); }
|
|
303
|
+
role.value = 'backend'; roleLabel.append(role); row.append(field('显示名称', 'display_name', { required: true, placeholder: '例如 Bob 或李四' }), roleLabel); card.append(row);
|
|
304
|
+
const bots = document.createElement('div'); bots.className = 'row'; bots.append(field('Bot ID', 'bot_id', { required: true }), field('Secret', 'secret', { type: 'password', required: true })); card.append(bots);
|
|
305
|
+
const advanced = document.createElement('details'); advanced.append(text('summary', '自定义身份与分工'), field('内部 Agent ID(可选)', 'agent_id'), field('自定义职责(可选)', 'capabilities'));
|
|
306
|
+
for (const [name, label] of [['prefer', '作为这些职责的首选'], ['commands_enabled', '允许所有者通过此机器人发送群命令']]) { const wrapper = text('label', label), input = document.createElement('input'); input.type = 'checkbox'; input.dataset.field = name; input.checked = true; wrapper.className = 'checkbox'; wrapper.prepend(input); advanced.append(wrapper); }
|
|
307
|
+
card.append(advanced, button('移除此行', async () => { card.remove(); })); $('assignment-rows').append(card);
|
|
308
|
+
}
|
|
309
|
+
$('add-assignment').onclick = () => action(async () => { if ($('assignment-rows').children.length >= 25) throw new Error('每次最多分配 25 个机器人。'); assignmentRow(); });
|
|
310
|
+
$('batch-form').onsubmit = (event) => { event.preventDefault(); action(async () => {
|
|
311
|
+
const members = [...$('assignment-rows').children].map((row) => Object.fromEntries([...row.querySelectorAll('[data-field]')].map((field) => [field.dataset.field, field.type === 'checkbox' ? field.checked : field.value])));
|
|
312
|
+
const id = currentId; const result = await api('issue-batch', { id, members });
|
|
313
|
+
$('assignment-rows').replaceChildren(); assignmentRow(); await loadProfile(id);
|
|
314
|
+
if (result.assignments.length === 1) await showDelivery(id, result.assignments[0].invite_id);
|
|
315
|
+
notice(`已分配 ${result.assignments.length} 个机器人。在“已分配的机器人”中逐个下载接入包、复制接入说明和密码。`);
|
|
316
|
+
}); };
|
|
317
|
+
async function showDelivery(id, inviteId) {
|
|
318
|
+
const epoch = viewEpoch, value = await api('delivery', { id, invite_id: inviteId }); if (epoch !== viewEpoch) return;
|
|
319
|
+
$('delivery-title').textContent = `${value.display_name}(${value.agent_id})的接入说明`;
|
|
320
|
+
$('delivery-instructions').textContent = value.instructions; $('delivery-password').value = value.password || '';
|
|
321
|
+
$('copy-password').hidden = !value.password; $('delivery-panel').hidden = false;
|
|
322
|
+
}
|
|
323
|
+
$('copy-delivery').onclick = () => action(() => navigator.clipboard.writeText($('delivery-instructions').textContent));
|
|
324
|
+
$('copy-password').onclick = () => action(() => navigator.clipboard.writeText($('delivery-password').value));
|
|
325
|
+
$('hide-delivery').onclick = () => action(async () => { $('delivery-panel').hidden = true; $('delivery-password').value = ''; });
|
|
326
|
+
assignmentRow();
|
|
327
|
+
action(async () => {
|
|
328
|
+
const token = location.hash.slice(1); if (token) { await api('session', { token }); history.replaceState(null, '', '/'); }
|
|
329
|
+
const saved = remembered(); await overview();
|
|
330
|
+
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); } }
|
|
331
|
+
catch (error) { remember(null); show('home'); notice(`上次接入暂时无法恢复,可以从项目列表继续。${error.message}`); }
|
|
332
|
+
});
|
|
@@ -2,14 +2,37 @@
|
|
|
2
2
|
<header><span class="brand">Codex Collab</span><span>本机团队工作台</span></header>
|
|
3
3
|
<main><h1>把群里的任务,交给你的 Codex 对话</h1><p class="intro">管理员分配机器人,成员导入接入包。项目配置与连接检查集中在这里。</p><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"><h2>我的项目</h2><div id="profiles"></div></section>
|
|
5
|
+
<section id="home"><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>
|
|
6
6
|
<section id="admin" hidden><h2>管理员初始化</h2><p>先在企业微信创建智能机器人并加入协作群。这里会通过群验证码自动获取群和你的 userid。</p>
|
|
7
|
-
<form id="admin-form"
|
|
7
|
+
<form id="admin-form">
|
|
8
|
+
<label>团队名称<input name="team_name" placeholder="例如:产品研发团队" maxlength="80"></label>
|
|
9
|
+
<fieldset><legend>选择业务项目</legend><label>项目来源<select id="admin-repo-mode" name="repo_mode"><option value="existing">使用本机已有项目</option><option value="clone">从 Git 地址拉取新项目</option></select></label>
|
|
10
|
+
<label id="admin-existing-repo">项目目录<input name="repo" placeholder="Git 仓库根目录的完整路径"></label>
|
|
11
|
+
<div id="admin-clone-repo" hidden><label>Git 仓库地址<input name="remote" placeholder="HTTPS 或 SSH 地址,不含 Token"></label><label>保存到新目录<input name="destination" placeholder="新目录的完整路径"></label></div>
|
|
12
|
+
<button id="check-project" type="button" class="secondary">检查项目并推荐验证命令</button><p id="project-recommendation" class="muted">自动拉取使用你本机已有的 Git 登录。仓库需要已有 main 分支。</p>
|
|
13
|
+
<label>验证命令<input id="test-command" name="test_command" required placeholder="例如 npm test、go test ./...、sh test.sh"></label><p class="muted">请确认这个命令适合项目。工具使用固定程序和参数执行,不运行管道或拼接命令。</p></fieldset>
|
|
14
|
+
<div class="row"><label>你的显示名称<input name="display_name" required placeholder="例如 Alice 或张三" maxlength="80"></label><label>主要职责<select name="responsibility"><option value="fullstack">全栈开发</option><option value="frontend">前端开发</option><option value="backend">后端开发</option><option value="testing">测试</option><option value="docs">文档</option><option value="general">通用开发</option></select></label></div>
|
|
15
|
+
<details><summary>自定义内部身份与职责</summary><label>内部 Agent ID(可选)<input name="agent_id" pattern="[a-zA-Z0-9_-]{1,64}" placeholder="留空自动生成"></label><label>自定义职责标签(可选)<input name="capabilities" placeholder="英文标签,以逗号分隔"></label></details>
|
|
16
|
+
<div class="row"><label>你的机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div>
|
|
17
|
+
<button type="submit">创建团队并连接机器人</button></form></section>
|
|
8
18
|
<section id="own" hidden><h2>给自己增加机器人</h2><p>同一个企业微信账号可以拥有多个 Agent。每个 Agent 使用独立机器人、独立 clone 和独立 Codex 对话。验证后自动完成本机接入,无需下载接入包。</p>
|
|
9
19
|
<form id="own-form"><label>另一个 clone 的本机目录<input name="repo" required placeholder="同一业务仓库的另一个独立 clone"></label><div class="row"><label>新的 Agent 名称<input name="agent_id" required pattern="[a-zA-Z0-9_-]{1,64}" placeholder="bob"></label><label>职责标签<input name="capabilities" required placeholder="backend"></label></div><div class="row"><label>新机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>新机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><label class="checkbox"><input name="prefer" type="checkbox" checked>该 Agent 为这些职责的首选执行者</label><button type="submit">连接我的新机器人</button><button id="own-cancel" type="button" class="secondary">返回原项目</button></form></section>
|
|
10
|
-
<section id="join" hidden><h2>加入团队</h2><p
|
|
20
|
+
<section id="join" hidden><h2>加入团队</h2><p>导入管理员提供的加密接入包。机器人、职责和仓库信息会自动带入,密码请向管理员单独获取。</p>
|
|
21
|
+
<form id="join-form"><label>接入包文件<input name="file" type="file" accept=".json" required></label><label>接入包密码<input name="password" type="password" required autocomplete="off"></label>
|
|
22
|
+
<button id="inspect-invite" type="button" class="secondary">读取接入信息</button><div id="invite-info" hidden><p id="invite-summary"></p><p id="invite-remote" class="muted"></p></div>
|
|
23
|
+
<label>准备项目<select id="join-repo-mode" name="repo_mode"><option value="clone">帮我拉取到新目录</option><option value="existing">使用本机已有项目</option></select></label>
|
|
24
|
+
<label id="join-clone-repo">新项目目录<input name="destination" placeholder="读取接入包后自动建议,亦可填写完整路径"></label>
|
|
25
|
+
<label id="join-existing-repo" hidden>已有项目目录<input name="repo" placeholder="Git 仓库根目录的完整路径"></label>
|
|
26
|
+
<button type="submit">接入我的机器人</button><p class="muted">工具使用本机 Git 登录拉取代码,不会改写已有目录。导入后请按提示在群里确认一次身份,审批和配置会自动继续。</p></form></section>
|
|
11
27
|
<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>
|
|
12
|
-
<section id="verification" hidden><h2>在群里确认身份</h2><p id="connection"></p><p>请用自己的企业微信账号,<strong>@ 刚刚分配的机器人</strong>,发送下面这条消息:</p><pre id="code"></pre><button id="copy-code">复制消息</button><button id="confirm-code"
|
|
13
|
-
<section id="profile" hidden><div class="row"><h2 id="profile-title"></h2><button id="refresh">刷新项目状态</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><div id="checks"></div><div id="progress"></div><div id="binding" hidden><h3>最后一步:在你选定的 Codex 对话启用</h3><p>在 Codex 打开上面的项目,信任项目并加载 MCP
|
|
14
|
-
<
|
|
15
|
-
|
|
28
|
+
<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">刷新项目状态</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,将下面的指令发到要绑定的对话。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="runner-actions" hidden><button id="start">启动机器人连接</button><button id="stop" class="secondary">停止机器人连接</button><p class="muted">停止连接仅断开群机器人。对话中的轮询自动化需要在 Codex 中单独暂停。</p></div>
|
|
30
|
+
<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
|
+
<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><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
|
+
<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>
|
|
33
|
+
<div id="delivery-panel" hidden><h3 id="delivery-title"></h3><p>接入包与密码请分开传递。下面的接入说明不包含密码或 Secret。</p><pre id="delivery-instructions"></pre><button id="copy-delivery">复制接入说明</button><label>接入包密码<input id="delivery-password" type="password" readonly></label><button id="copy-password" type="button" class="secondary">复制密码</button><button id="hide-delivery" type="button" class="secondary">收起</button></div>
|
|
34
|
+
<details><summary>单独分配(兼容入口)</summary><form id="issue-form"><div class="row"><label>Agent ID<input name="agent_id" required pattern="[a-zA-Z0-9_-]{1,64}" placeholder="bob"></label><label>职责标签<input name="capabilities" required placeholder="backend"></label></div><div class="row"><label>机器人 Bot ID<input name="bot_id" required autocomplete="off"></label><label>机器人 Secret<input name="secret" type="password" required autocomplete="off"></label></div><label>自定义密码(可选,留空自动生成)<input name="password" type="password" minlength="12" autocomplete="off"></label><label class="checkbox"><input name="prefer" type="checkbox" checked>设为这些职责的首选执行者</label><label class="checkbox"><input name="commands_enabled" type="checkbox" checked>允许所有者通过此机器人发送群命令</label><button type="submit">生成接入包</button></form></details>
|
|
35
|
+
<h3>已分配的机器人</h3><div id="exports"></div><h3>待确认的成员</h3><div id="requests"></div></div>
|
|
36
|
+
</section>
|
|
37
|
+
<section id="reset-view" hidden><h2 id="reset-title"></h2><p id="reset-target"></p><ul id="reset-effects"></ul><p id="reset-thread" class="muted"></p><form id="reset-form"><label id="reset-pause-label" class="checkbox"><input id="reset-paused" name="heartbeat_paused" type="checkbox">我已在原 Codex 对话暂停协作轮询</label><div id="reset-secret-fields" hidden><label>新的机器人 Secret<input id="reset-secret" name="secret" type="password" autocomplete="off"></label><p class="muted">保持原 Bot ID、群和本人身份。更换机器人或成员请移除本机接入后,由管理员提供新的接入包。</p><button id="reset-connect" type="button" class="secondary">验证新凭据</button><div id="reset-verification" hidden><p id="reset-connection"></p><p>用原绑定的企业微信本人账号,在原协作群 @ 当前机器人,发送:</p><pre id="reset-code"></pre><button id="reset-copy-code" type="button" class="secondary">复制验证码</button></div></div><label>输入 Agent 名称确认<input id="reset-confirmation" name="confirmation" required autocomplete="off"></label><div class="row"><button id="reset-submit" type="submit">确认操作</button><button id="reset-cancel" type="button" class="secondary">取消并返回</button></div></form><p class="muted">操作前自动保存本机恢复记录。向导不会删除 Codex 聊天,也无法替你暂停聊天中的自动化。移除接入保留私有身份与历史凭据;需要彻底删除时可按完成页显示的备份路径手动处理。</p></section>
|
|
38
|
+
</main><footer>配置仅保存在本机。任务与签名协议记录在业务仓库的 agent-collab 分支。</footer><script src="/app.js" defer></script></body></html>
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
:root{color-scheme:light;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#18312b;background:#f4f7f5}*{box-sizing:border-box}body{margin:0}header{background:#fff;border-bottom:1px solid #dbe6df;display:flex;gap:22px;padding:22px max(24px,calc((100vw - 980px)/2));font-size:14px;color:#677d72}.brand{font-weight:750;color:#126744;white-space:nowrap}main{max-width:980px;margin:40px auto;padding:0 24px}h1{font-size:30px;line-height:1.35;letter-spacing:-.5px}h2{font-size:22px;margin-top:0}h3{margin-top:30px;font-size:17px}p{line-height:1.8}.intro,.muted{color:#657970}.muted{font-size:13px}nav{display:flex;gap:10px;margin:25px 0;flex-wrap:wrap}nav button,.secondary{background:#fff;color:#285442;border:1px solid #c9dacf}button{font:inherit;background:#176b4a;color:#fff;border:1px solid transparent;border-radius:8px;padding:10px 16px;cursor:pointer;line-height:1.4}button:hover{filter:brightness(.95)}button:disabled{opacity:.55;cursor:wait}section{background:#fff;border:1px solid #dbe6df;border-radius:14px;padding:28px;box-shadow:0 8px 28px #18312b06}section[hidden],div[hidden]{display:none}form{display:grid;gap:17px;margin:24px 0}label{display:grid;gap:8px;font-size:14px}input{font:inherit;padding:12px;border:1px solid #c9dacf;border-radius:7px;width:100%;color:#18312b;background:#fff}input:focus{outline:2px solid #a4d0bb;outline-offset:1px}.row{display:flex;gap:20px;align-items:center}.row>*{flex:1;min-width:0}.row>button{flex:initial}.checkbox{display:flex;align-items:center}.checkbox input{width:auto}pre{background:#f0f5f2;border:1px solid #dce8df;padding:16px;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.65 ui-monospace,SFMono-Regular,monospace}.card,.check{padding:15px 0;border-bottom:1px solid #e5ece7;display:flex;gap:12px;justify-content:space-between;align-items:center}.card p{margin:4px 0;font-size:13px;overflow-wrap:anywhere}.badge{font-size:13px;color:#176b4a;background:#e3f2e8;padding:5px 9px;border-radius:6px;white-space:nowrap}.pending{color:#896229;background:#fff3db}#notice{padding:14px 18px;border:1px solid #ebc8aa;background:#fff3e9;border-radius:8px;margin:20px 0;line-height:1.6}footer{text-align:center;font-size:12px;color:#6a7e72;padding:30px}#profile-repo{overflow-wrap:anywhere;font-size:14px;color:#657970}@media(max-width:640px){main{margin:25px auto;padding:0 16px}h1{font-size:24px}section{padding:20px}.row{flex-direction:column;align-items:stretch;gap:17px}.card{align-items:flex-start;flex-wrap:wrap}nav button{font-size:13px;padding:8px 10px}}
|
|
2
2
|
|
|
3
3
|
form[hidden]{display:none}fieldset{border:1px solid #dbe6df;border-radius:9px;padding:16px;margin:0 0 16px;display:grid;gap:16px}legend{font-weight:600;padding:0 6px}select{font:inherit;padding:12px;border:1px solid #c9dacf;border-radius:7px;width:100%;color:#18312b;background:#fff}select:focus{outline:2px solid #a4d0bb;outline-offset:1px}
|
|
4
|
+
|
|
5
|
+
button[hidden],details[hidden],label[hidden]{display:none}details{margin:24px 0}summary{cursor:pointer;font-weight:600;line-height:1.7}.manage-actions{display:flex;gap:12px;flex-wrap:wrap}.danger{background:#fff;border:1px solid #d9aca4;color:#943c30}#reset-effects{line-height:1.9;padding-left:24px}#reset-target{overflow-wrap:anywhere}#reset-verification{padding:16px;background:#f0f5f2;border-radius:8px}
|
|
6
|
+
|
|
7
|
+
#setup-steps{display:flex;flex-wrap:wrap;gap:10px;list-style:none;padding:0;margin:22px 0}#setup-steps li{font-size:13px;border:1px solid #e0e7e2;border-radius:7px;padding:9px 12px;color:#738379}#setup-steps .done{color:#176b4a;background:#eaf5ed;border-color:#cbe4d3}#setup-steps .active{color:#845d26;background:#fff5e2;border-color:#ebd29f}#next-step{padding:14px 16px;background:#edf6f0;border-radius:8px}#delivery-panel{padding:18px;background:#f6f9f7;border:1px solid #dbe6df;border-radius:9px;margin-top:20px}#delivery-panel h3{margin-top:0}#delivery-panel button{margin:6px 6px 6px 0}.assignment details{margin:0}.assignment>button{justify-self:start;background:#fff;color:#657970;border-color:#c9dacf}#assignment-rows{display:grid;gap:14px}#assignment-rows fieldset{margin:0}@media(max-width:640px){#setup-steps{gap:7px}#setup-steps li{flex:1 1 42%}.card>a{display:inline-block}.card{gap:14px}}
|
package/apps/peer/desktop.mjs
CHANGED
|
@@ -21,12 +21,14 @@ export class DesktopExecutor {
|
|
|
21
21
|
}
|
|
22
22
|
checkBinding(state, threadId) {
|
|
23
23
|
this.checkMode();
|
|
24
|
+
if (state.disabled) throw new Denied('local project integration was removed; restore it in the setup wizard');
|
|
24
25
|
if (!threadPattern.test(threadId || '') || state.binding?.thread_id !== threadId || state.binding.agent_id !== this.service.agentId) throw new Denied('this conversation is not the bound desktop thread');
|
|
25
26
|
}
|
|
26
27
|
async bind(threadId) {
|
|
27
28
|
this.checkMode();
|
|
28
29
|
if (!threadPattern.test(threadId || '')) throw new Conflict('provide the actual target conversation ID; do not invent an ID');
|
|
29
30
|
return this.private.locked(async (state) => {
|
|
31
|
+
if (state.disabled) throw new Denied('local project integration was removed; restore it in the setup wizard');
|
|
30
32
|
if (state.binding && (state.binding.thread_id !== threadId || state.binding.agent_id !== this.service.agentId)) throw new Conflict('identity is already bound; explicitly unbind before choosing another conversation');
|
|
31
33
|
state.binding ||= { agent_id: this.service.agentId, thread_id: threadId, bound_at: new Date().toISOString() };
|
|
32
34
|
await this.private.write(state);
|
package/apps/peer/runtime.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import { isAbsolute, resolve } from 'node:path';
|
|
|
3
3
|
import { makeSigner, readPrivateJson, validateTeam } from '../../packages/peer/config.mjs';
|
|
4
4
|
import { PeerGitStore } from '../../packages/peer/store.mjs';
|
|
5
5
|
import { PeerService } from '../../packages/peer/service.mjs';
|
|
6
|
-
export async function readPeerConfig(path) {
|
|
6
|
+
export async function readPeerConfig(path, { allowDisabled = false } = {}) {
|
|
7
7
|
if (!path) throw new Error('provide --config /absolute/path/to/private-peer.json');
|
|
8
8
|
const config = await readPrivateJson(resolve(path));
|
|
9
|
+
if (config.local_disabled === true && !allowDisabled) throw new Error('此项目已移除本机接入,请通过向导恢复后重试。');
|
|
9
10
|
if (config.schema_version !== 2 || ![config.repo, config.data_dir, config.team_file].every((p) => typeof p === 'string' && isAbsolute(p)) || typeof config.agent_id !== 'string') throw new Error('peer config needs v2 and absolute repo/data_dir/team_file paths');
|
|
10
11
|
const team = validateTeam(JSON.parse(await readFile(config.team_file, 'utf8')));
|
|
11
12
|
if (config.codex_home !== undefined && (typeof config.codex_home !== 'string' || !isAbsolute(config.codex_home))) throw new Error('codex_home must be an absolute path');
|
package/apps/peer/wecom.mjs
CHANGED
|
@@ -37,10 +37,12 @@ export async function dispatchPeerWecom(frame, { service, config, reply }) {
|
|
|
37
37
|
const sender = body.from?.userid;
|
|
38
38
|
const owner = !!sender && sender === service.team.agents[service.agentId]?.wecom_userid;
|
|
39
39
|
const admin = config.allow_admin_commands === true && !!sender && Object.values(service.team.agents).some((agent) => agent.role === 'admin' && agent.wecom_userid === sender);
|
|
40
|
+
const member = !!sender && Object.values(service.team.agents).some((agent) => agent.role === 'member' && agent.wecom_userid === sender);
|
|
40
41
|
// Shared owners do not merge agent roles: the receiving bot selects its signer.
|
|
41
|
-
//
|
|
42
|
-
// the
|
|
43
|
-
|
|
42
|
+
// Enrolled team members may submit work to any bot; review/control mutations
|
|
43
|
+
// still require the bot owner. Admin dispatch retains its explicit config gate.
|
|
44
|
+
const dispatchOnly = ['create', 'status', 'natural'].includes(command?.action || 'natural');
|
|
45
|
+
if (!owner && !(dispatchOnly && (member || admin))) return false;
|
|
44
46
|
const key = `wecom:${body.chatid}:${body.msgid}`;
|
|
45
47
|
let text;
|
|
46
48
|
try {
|
|
@@ -57,7 +59,7 @@ export async function dispatchPeerWecom(frame, { service, config, reply }) {
|
|
|
57
59
|
if (config.execution_mode !== 'desktop_poll') {
|
|
58
60
|
await reply(frame, '自然语言任务需要绑定已有 Codex 对话,并启用 desktop_poll 轮询。'); return true;
|
|
59
61
|
}
|
|
60
|
-
const request = await service.receiveRequest({ chatId: body.chatid, messageId: body.msgid, senderId: sender, content, allowAdmin:
|
|
62
|
+
const request = await service.receiveRequest({ chatId: body.chatid, messageId: body.msgid, senderId: sender, content, allowAdmin: admin });
|
|
61
63
|
text = request.state.task_id ? `该消息已创建任务 ${request.state.task_id},不会重复创建。`
|
|
62
64
|
: request.state.status === 'answered' ? '这条消息已处理。'
|
|
63
65
|
: request.state.status === 'clarification' ? `需求 ${request.spec.id} 等待你补充说明,请查看群里的追问。`
|
package/docs/desktop-poll.md
CHANGED
|
@@ -138,7 +138,7 @@ sh test.sh 要通过,完成后在群里发总结。
|
|
|
138
138
|
已有绑定不需要重新创建身份。先处理正在执行的任务,再对每个 Agent 用其**项目 MCP 实际使用的私有配置路径**操作:
|
|
139
139
|
|
|
140
140
|
```sh
|
|
141
|
-
npm install --global xuym-codex-collab@0.4.
|
|
141
|
+
npm install --global xuym-codex-collab@0.4.2
|
|
142
142
|
codex-collab stop /absolute/private-peer.json
|
|
143
143
|
codex-collab refresh-project /absolute/private-peer.json
|
|
144
144
|
codex-collab start /absolute/private-peer.json
|