xuym-codex-collab 0.6.0 → 0.7.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 +4 -2
- 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/onboarding-audit.md +12 -1
- package/docs/onboarding.md +16 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 安装与入口
|
|
6
6
|
|
|
7
|
-
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push
|
|
7
|
+
需要 Node.js 20+、Git、Codex 桌面应用,以及业务仓库的读取和 push 权限。业务仓库需已有至少一个远程业务分支;可以使用独立的本机 clone,也可以让向导拉取新目录。成员无需 clone 本工具源码。
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install --global xuym-codex-collab
|
|
@@ -22,7 +22,9 @@ codex-collab
|
|
|
22
22
|
|
|
23
23
|
协作远程与业务基准分支自动识别;不要求 `origin/main`。有多个不同远程或默认分支不明确时,管理员选择一次。成员与新增机器人继承签名团队基准,各自可使用不同远程别名。每个新任务固定创建时的 commit,排队和重试期间不会漂移;管理员可在团队设置里为后续任务修改基准分支。
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
工作台首页按项目汇总,同一仓库的多个本机机器人共用一张项目卡片。打开项目后,所有团队机器人展示在同一列表里,自己的标记“我的”;本机已接入的机器人可继续配置或管理,其他机器人的职责来自签名团队记录。界面使用轻量 CSS 动效,支持系统减少动态效果设置,页面进入后台时暂停动画。
|
|
26
|
+
|
|
27
|
+
同一个真人可以拥有多个 Agent,每个 Agent 使用独立机器人、签名身份、业务 clone 和 Codex 对话。管理员在项目页点击“添加我的机器人”,验证同一个企业微信账号后自动接入,无需下载包或填写包密码;给同事分配时仍使用加密接入包,同事可以领取多份。相同 userid 不会合并 Agent 权限,审核通知会指明应使用的机器人。
|
|
26
28
|
|
|
27
29
|
机器人在企业微信后台设为多人可用并加入协作群后,已批准入队的成员可以 `@` 任意团队机器人的自然语言需求;未入队的群成员仍会被工具拒绝。普通成员可提交需求、创建任务和查询状态,审核与取消、完成等控制操作继续受原有身份权限限制。接入完成后,团队成员可直接互相派发,不必再改机器人配置。
|
|
28
30
|
|
|
@@ -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/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
|