pi2web 0.2.0 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +209 -18
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +575 -97
  6. package/dist/auth.js +85 -20
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +221 -0
  9. package/dist/collab/hub.js +2888 -0
  10. package/dist/collab/pi-extension.js +494 -0
  11. package/dist/collab/review-flow.js +459 -0
  12. package/dist/collab/routes.js +285 -0
  13. package/dist/collab/schemas.js +281 -0
  14. package/dist/collab/scoring-flow.js +336 -0
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +918 -0
  17. package/dist/collab/types.js +137 -0
  18. package/dist/collab/validate.js +185 -0
  19. package/dist/drafts.js +50 -0
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +95 -21
  28. package/dist/main.js +141 -17
  29. package/dist/mcp.js +132 -0
  30. package/dist/metadata-store.js +199 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +288 -49
  33. package/dist/server.js +558 -358
  34. package/dist/session-namer.js +163 -0
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +196 -40
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +18 -6
  43. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
  45. package/packages/protocol/openapi.yaml +8 -2
  46. package/packages/protocol/websocket.schema.json +94 -10
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3770 -404
  49. package/web/backend-state.js +269 -0
  50. package/web/collab.html +95 -0
  51. package/web/collab.js +1080 -0
  52. package/web/hid-keyboard.js +453 -0
  53. package/web/index.html +14 -9
  54. package/web/styles.css +73 -0
  55. package/ANDROID_APP_FEATURES.md +0 -256
package/web/collab.js ADDED
@@ -0,0 +1,1080 @@
1
+ /**
2
+ * Collaboration board: the human side of the multi-agent hub.
3
+ * Everything here is done with the pairing code; agent-side endpoints (findings, scores, …) are deliberately absent.
4
+ */
5
+ (() => {
6
+ 'use strict';
7
+ const $ = id => document.getElementById(id);
8
+ const esc = value =>
9
+ String(value ?? '').replace(
10
+ /[&<>"']/g,
11
+ c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[c]
12
+ );
13
+ // Connections are shared with the console: this page must read and write the same saved list,
14
+ // otherwise a Backend paired there cannot be reused here.
15
+ const connections = window.RemotePiBackendState;
16
+ const savedFor = base =>
17
+ connections
18
+ .savedConnections(localStorage, location.origin)
19
+ .find(item => item.base === String(base || '').replace(/\/$/, ''));
20
+ const initial = connections.selectConnection(localStorage, location.origin) || {
21
+ base: location.origin,
22
+ token: ''
23
+ };
24
+ const state = {
25
+ base: initial.base || location.origin,
26
+ token: initial.token || '',
27
+ connected: false,
28
+ sessions: [],
29
+ sessionId: new URLSearchParams(location.search).get('session') || '',
30
+ detail: null,
31
+ escalations: [],
32
+ workspaces: [],
33
+ agents: [],
34
+ ws: null,
35
+ reconnectTimer: null,
36
+ refreshTimer: null,
37
+ modelCatalogs: new Map(),
38
+ agentCapabilities: new Map()
39
+ };
40
+
41
+ const toast = text => {
42
+ $('toast').textContent = text;
43
+ $('toast').hidden = false;
44
+ clearTimeout(toast.timer);
45
+ toast.timer = setTimeout(() => ($('toast').hidden = true), 4000);
46
+ };
47
+ async function request(base, token, url, options = {}) {
48
+ const response = await fetch(base + url, {
49
+ ...options,
50
+ headers: {
51
+ ...(token ? {Authorization: `Bearer ${token}`} : {}),
52
+ ...(options.body ? {'Content-Type': 'application/json'} : {}),
53
+ ...options.headers
54
+ }
55
+ });
56
+ const body = await response.json().catch(() => ({}));
57
+ if (!response.ok) {
58
+ const fields = (body.error?.fieldErrors || [])
59
+ .map(entry => `${entry.path}: ${entry.message}`)
60
+ .join(';');
61
+ throw Error(`${body.error?.message || `HTTP ${response.status}`}${fields ? ` (${fields})` : ''}`);
62
+ }
63
+ return body.data;
64
+ }
65
+ const api = (url, options) => request(state.base, state.token, url, options);
66
+ const requiresAuth = async base => {
67
+ try {
68
+ return (await request(base, '', '/health')).authRequired !== false;
69
+ } catch {
70
+ return true;
71
+ }
72
+ };
73
+ const post = (url, body = {}) => api(url, {method: 'POST', body: JSON.stringify(body)});
74
+
75
+ // ---------------------------------------------------------------- connection
76
+ function openPair() {
77
+ const saved = savedFor(state.base);
78
+ $('pairBase').value = state.base;
79
+ $('pairToken').value = state.token || saved?.token || '';
80
+ $('pairRemember').checked = !!saved?.token;
81
+ if (!$('pairDialog').open) $('pairDialog').showModal();
82
+ }
83
+ // `remember` is undefined for the automatic reconnect: it must leave storage alone, or opening this
84
+ // page would delete the saved pairing code of every page sharing the connection list.
85
+ async function connect(base, token, remember) {
86
+ const candidateBase = base.trim().replace(/\/$/, '') || location.origin;
87
+ let candidateToken = token.trim();
88
+ if (!candidateToken && (await requiresAuth(candidateBase))) throw Error('请先输入配对码');
89
+ const statusInfo = await request(candidateBase, candidateToken, '/api/v1/system/status');
90
+ if (statusInfo.authRequired === false) candidateToken = '';
91
+ state.base = candidateBase;
92
+ state.token = candidateToken;
93
+ state.connected = true;
94
+ // Same rule as the main console: the code is only persisted when the user asks for it on this device.
95
+ // Unchecking must always clear it, including when the saved code is the one just used to reconnect.
96
+ if (remember !== undefined)
97
+ connections.rememberConnection(localStorage, {base: state.base, token: state.token, remember});
98
+ $('status').className = 'ok';
99
+ $('status').textContent = '已配对';
100
+ $('pairDialog').close();
101
+ openSocket();
102
+ await refreshAll();
103
+ }
104
+ function openSocket() {
105
+ state.ws?.close();
106
+ clearTimeout(state.reconnectTimer);
107
+ const url = `${state.base.replace(/^http/, 'ws')}/api/v1/ws`;
108
+ const socket = new WebSocket(url, state.token ? [`access-token.${state.token}`] : []);
109
+ state.ws = socket;
110
+ socket.onopen = () => {
111
+ socket.send(JSON.stringify({type: 'subscribe_collab'}));
112
+ socket.send(JSON.stringify({type: 'subscribe_all', fromNow: true}));
113
+ };
114
+ socket.onmessage = event => {
115
+ const message = JSON.parse(event.data || '{}');
116
+ const lifecycle =
117
+ message.type === 'agent_event' &&
118
+ ['agent_start', 'agent_end', 'agent_settled', 'auto_retry_start', 'auto_retry_end'].includes(
119
+ message.event?.type
120
+ );
121
+ if (message.type !== 'collab_event' && !lifecycle) return;
122
+ // Collaboration changes and Agent lifecycle changes both affect whether a wait is truly stalled.
123
+ clearTimeout(state.refreshTimer);
124
+ state.refreshTimer = setTimeout(() => void refreshAll(), 250);
125
+ };
126
+ socket.onclose = () => {
127
+ if (state.connected) state.reconnectTimer = setTimeout(openSocket, 2000);
128
+ };
129
+ }
130
+
131
+ // ---------------------------------------------------------------- data
132
+ async function refreshAll() {
133
+ if (!state.connected) return;
134
+ try {
135
+ const status = $('statusFilter').value;
136
+ [state.sessions, state.escalations, state.workspaces, state.agents] = await Promise.all([
137
+ api(`/api/v1/collab/sessions?limit=100${status ? `&status=${status}` : ''}`),
138
+ api('/api/v1/collab/escalations?status=pending'),
139
+ api('/api/v1/workspaces').catch(() => []),
140
+ // A silently swallowed agent list used to leave the seat form with nothing to bind to.
141
+ api('/api/v1/agents').catch(error => {
142
+ toast(`本机 Agent 列表加载失败:${error.message}`);
143
+ return [];
144
+ })
145
+ ]);
146
+ if (state.sessionId) {
147
+ const wanted = state.sessionId,
148
+ detail = await loadDetail(wanted).catch(() => null);
149
+ // A slower response for a session the user already left must not overwrite the current one.
150
+ if (state.sessionId === wanted) state.detail = detail;
151
+ }
152
+ renderSessions();
153
+ renderWorkspaces();
154
+ renderDetail();
155
+ } catch (error) {
156
+ $('status').className = 'bad';
157
+ $('status').textContent = error.message;
158
+ }
159
+ }
160
+ async function loadDetail(sessionId) {
161
+ const session = await api(`/api/v1/collab/sessions/${sessionId}`);
162
+ const [issues, events, escalations, criteria, consensus, rounds] = await Promise.all([
163
+ api(`/api/v1/collab/sessions/${sessionId}/issues`).catch(() => []),
164
+ // `tail` asks for the newest events; paging from sequence 0 would freeze the timeline after 200 events.
165
+ api(`/api/v1/collab/sessions/${sessionId}/events?tail=200`).catch(() => []),
166
+ api(`/api/v1/collab/escalations?sessionId=${sessionId}`).catch(() => []),
167
+ session.kind === 'scoring'
168
+ ? api(`/api/v1/collab/sessions/${sessionId}/criteria`).catch(() => [])
169
+ : Promise.resolve([]),
170
+ session.kind === 'review'
171
+ ? api(`/api/v1/collab/sessions/${sessionId}/review-consensus`).catch(() => ({
172
+ issueVotes: [],
173
+ mergeProposals: [],
174
+ discussions: [],
175
+ issueConsensus: []
176
+ }))
177
+ : Promise.resolve(null),
178
+ session.kind === 'review'
179
+ ? api(`/api/v1/collab/sessions/${sessionId}/review-rounds`).catch(() => [])
180
+ : Promise.resolve([])
181
+ ]);
182
+ return {session, issues, events, escalations, criteria, consensus, rounds};
183
+ }
184
+ const select = async sessionId => {
185
+ state.sessionId = sessionId;
186
+ history.replaceState({}, '', `?session=${encodeURIComponent(sessionId)}`);
187
+ const detail = await loadDetail(sessionId).catch(error => {
188
+ toast(error.message);
189
+ return null;
190
+ });
191
+ // Selecting A then B must not end up showing A while every action button targets B.
192
+ if (state.sessionId !== sessionId) return;
193
+ state.detail = detail;
194
+ renderSessions();
195
+ renderDetail();
196
+ };
197
+
198
+ // ---------------------------------------------------------------- render
199
+ const phasePill = session => {
200
+ const tone =
201
+ session.status !== 'active'
202
+ ? 'green'
203
+ : session.stalled
204
+ ? 'red'
205
+ : session.phase === 'awaiting_human'
206
+ ? 'yellow'
207
+ : 'blue';
208
+ return `<span class="pill ${tone}">${esc(session.phase)}</span>`;
209
+ };
210
+ /** Compact live state diagram. Consensus sub-phases share one semantic step instead of exposing old API noise. */
211
+ function reviewFlowChart(session) {
212
+ if (session.kind !== 'review') return '';
213
+ const groups = {
214
+ draft: 0,
215
+ implementing: 1,
216
+ collecting: 2,
217
+ consolidating: 3,
218
+ validating: 3,
219
+ merge_voting: 3,
220
+ issue_discussing: 3,
221
+ issue_reconsidering: 3,
222
+ awaiting_human: 3,
223
+ finished: 4
224
+ };
225
+ const current = groups[session.phase] ?? 0,
226
+ finished = session.status === 'finished';
227
+ const steps = [
228
+ {label: '准备', detail: '人员与范围', group: 0},
229
+ {label: session.round > 1 ? '修复' : '开发', detail: '可选', group: 1, optional: true},
230
+ {label: '独立盲审', detail: `review round ${session.round}`, group: 2},
231
+ {
232
+ label: '问题共识',
233
+ detail: session.phase === 'awaiting_human' ? '等待人工裁定' : '投票 · 合并 · 讨论',
234
+ group: 3
235
+ },
236
+ {label: '评审完成', detail: '输出报告', group: 4}
237
+ ];
238
+ const nodes = steps.map(step => {
239
+ const active = !finished && step.group === current,
240
+ done = finished || step.group < current;
241
+ return `<div class="flow-step ${active ? 'active' : ''} ${done ? 'done' : ''} ${step.optional ? 'optional' : ''}"><b>${done ? '✓ ' : ''}${esc(step.label)}</b><small>${esc(step.detail)}</small></div>`;
242
+ });
243
+ return `<div class="card"><h3>Review 动态流程</h3><div class="flow-chart">${nodes.map((node, index) => `${index ? '<span class="flow-arrow">→</span>' : ''}${node}`).join('')}</div><div class="flow-loop">评审完成后可直接复核当前代码,或先交给选定的开发 Agent 修复;随后回到“独立盲审”,不再经过 responding/adjudicating。</div></div>`;
244
+ }
245
+ function recheckCard(session, issues) {
246
+ if (session.kind !== 'review' || session.status !== 'finished') return '';
247
+ const implementers = (session.participants || []).filter(
248
+ entry => entry.role === 'implementer' && entry.state === 'active'
249
+ );
250
+ const actions = issues.filter(issue =>
251
+ ['confirmed', 'open', 'answered', 'escalated'].includes(issue.status)
252
+ );
253
+ return `<div class="card"><h3>继续这个 Review <span class="grow"></span><span class="pill yellow">${actions.length} 个待复核问题</span></h3>
254
+ <div class="recheck-actions"><p><b>直接复核</b>:代码已由人工或其它 Agent 修改,立即固定新 baseline 并召集原 Reviewer。</p>
255
+ <button type="button" data-action="recheck">复核当前代码</button></div>
256
+ <div class="recheck-actions" style="margin-top:10px"><p><b>修复后复核</b>:先把确认的问题交给一个开发 Agent;它提交 ready 后,中枢自动召集 Reviewer。</p>
257
+ <div class="row"><label>负责修复的开发 Agent<select id="recheckImplementer">${implementers.map(entry => `<option value="${esc(entry.participantId)}">${esc(entry.displayName)}</option>`).join('')}</select></label>
258
+ <button type="button" data-action="fix-recheck" ${implementers.length ? '' : 'disabled'}>推进到修复 → 复核</button></div>
259
+ ${implementers.length ? '' : '<small class="muted">请先在下方参与者区域新增一个 implementer Agent。</small>'}</div>
260
+ </div>`;
261
+ }
262
+ /** 每一轮评审 / 复核一个 section:老问题这轮到底修没修好,以及这轮新冒出来的问题。 */
263
+ const RECHECK_OUTCOME = {
264
+ resolved: {label: '已修复', tone: 'green'},
265
+ still_present: {label: '仍然存在', tone: 'red'},
266
+ pending: {label: '本轮未复核', tone: 'yellow'}
267
+ };
268
+ const ROUND_MODE = {
269
+ initial: '初次评审',
270
+ review_only: '直接复核当前代码',
271
+ fix_then_review: '开发修复 → 复核'
272
+ };
273
+ const issueCell = (
274
+ session,
275
+ entry
276
+ ) => `<b title="${esc(entry.issueId)}">#${esc(entry.number || '?')}</b> ${esc(entry.title)}<br>
277
+ <small class="muted">${esc(entry.location?.path || '')}${entry.location?.startLine ? `:${entry.location.startLine}` : ''} · ${esc(participantName(session, entry.reporterId))}</small>`;
278
+ const severityPill = severity =>
279
+ `<span class="pill ${['blocker', 'critical'].includes(severity) ? 'red' : severity === 'major' ? 'yellow' : ''}">${esc(severity)}</span>`;
280
+ function roundSection(session, round, latest) {
281
+ const counts = round.counts,
282
+ mode = ROUND_MODE[round.mode] || round.mode,
283
+ running = round.status !== 'finished';
284
+ const heading = round.round === 1 ? '第 1 轮 · 初次评审' : `第 ${round.round} 轮 · 复核`;
285
+ const verdict = round.verdict
286
+ ? `<span class="pill ${round.verdict === 'approved' ? 'green' : round.verdict === 'changes_required' ? 'red' : 'yellow'}">${esc(round.verdict)}</span>`
287
+ : '';
288
+ const rechecks = round.rechecks.length
289
+ ? `<table><thead><tr><th>此前确认的问题</th><th>严重程度</th><th>本轮结论</th><th>复核人 / 理由</th></tr></thead><tbody>
290
+ ${round.rechecks
291
+ .map(entry => {
292
+ const outcome = RECHECK_OUTCOME[entry.outcome] || {label: entry.outcome, tone: ''};
293
+ return `<tr><td>${issueCell(session, entry)}<br><small class="muted">首次发现于第 ${esc(entry.foundInRound)} 轮</small></td>
294
+ <td>${severityPill(entry.severity)}</td>
295
+ <td><span class="pill ${outcome.tone}">${esc(outcome.label)}</span></td>
296
+ <td>${esc(participantName(session, entry.reviewerId))}${entry.rationale ? `<br><small>${esc(entry.rationale)}</small>` : entry.outcome === 'pending' ? '<br><small class="muted">尚未回报复核结果</small>' : ''}</td></tr>`;
297
+ })
298
+ .join('')}
299
+ </tbody></table>`
300
+ : `<p class="muted">${round.round === 1 ? '首轮没有需要复核的历史问题。' : '上一轮没有留下需要复核的确认问题。'}</p>`;
301
+ const found = round.newIssues.length
302
+ ? `<table><thead><tr><th>本轮新发现</th><th>严重程度</th><th>当前状态</th><th>后续复核</th></tr></thead><tbody>
303
+ ${round.newIssues
304
+ .map(
305
+ entry => `<tr><td>${issueCell(session, entry)}</td><td>${severityPill(entry.severity)}</td><td>${esc(entry.status)}</td>
306
+ <td>${
307
+ entry.rechecks.length
308
+ ? entry.rechecks
309
+ .map(item => {
310
+ const outcome = RECHECK_OUTCOME[item.outcome] || {label: item.outcome, tone: ''};
311
+ return `<span class="pill ${outcome.tone}">第 ${esc(item.round)} 轮 ${esc(outcome.label)}</span>`;
312
+ })
313
+ .join(' ')
314
+ : '<span class="muted">—</span>'
315
+ }</td></tr>`
316
+ )
317
+ .join('')}
318
+ </tbody></table>`
319
+ : `<p class="muted">${running ? '本轮暂时还没有新问题被提交。' : '本轮没有发现新问题。'}</p>`;
320
+ return `<details class="round-section"${latest ? ' open' : ''}>
321
+ <summary><b>${esc(heading)}</b> <span class="pill">${esc(mode)}</span>
322
+ <span class="pill ${running ? 'blue' : 'green'}">${running ? `进行中 · ${esc(round.phase)}` : '已完成'}</span>${verdict}
323
+ <span class="round-stats"><span class="pill green">修好 ${counts.resolved}</span><span class="pill red">仍存在 ${counts.stillPresent}</span>${counts.pending ? `<span class="pill yellow">未复核 ${counts.pending}</span>` : ''}<span class="pill ${counts.newIssues ? 'yellow' : ''}">新问题 ${counts.newIssues}</span></span>
324
+ </summary>
325
+ <div class="muted" style="margin:6px 0">${round.startedAt ? `开始 ${esc(round.startedAt.slice(0, 19).replace('T', ' '))}` : ''}${round.finishedAt ? ` · 结束 ${esc(round.finishedAt.slice(0, 19).replace('T', ' '))}` : ''}${round.baseline ? ` · baseline ${esc(round.baseline.commit ? round.baseline.commit.slice(0, 10) : round.baseline.baselineId.slice(0, 12))}${round.baseline.dirtyHash ? '(含未提交改动)' : ''}` : ''}</div>
326
+ ${rechecks}${found}
327
+ </details>`;
328
+ }
329
+ /** 复核是叠加的:每多一轮就多一个 section,最新的一轮排在最上面并默认展开。 */
330
+ function reviewRoundsCard(session, rounds) {
331
+ if (session.kind !== 'review' || !rounds.length) return '';
332
+ const last = rounds[rounds.length - 1];
333
+ return `<div class="card"><h3>各轮评审 / 复核结果(${rounds.length} 轮)<span class="grow"></span>
334
+ <span class="pill">最新一轮:修好 ${last.counts.resolved} · 仍存在 ${last.counts.stillPresent} · 新问题 ${last.counts.newIssues}</span></h3>
335
+ ${[...rounds]
336
+ .reverse()
337
+ .map((round, index) => roundSection(session, round, index === 0))
338
+ .join('')}</div>`;
339
+ }
340
+ function renderSessions() {
341
+ $('escalationBadge').innerHTML = state.escalations.length
342
+ ? `<span class="pill red">${state.escalations.length} 项待人工裁定</span>`
343
+ : '<span class="muted">无待裁定项</span>';
344
+ $('sessions').innerHTML = state.sessions.length
345
+ ? state.sessions
346
+ .map(
347
+ session => `
348
+ <button class="session-item ${session.sessionId === state.sessionId ? 'selected' : ''}" data-session="${esc(session.sessionId)}">
349
+ <b>${esc(session.title)}</b>
350
+ <small>${esc(session.kind)} · ${phasePill(session)} · round ${session.round}${session.stalled ? ' · <span class="pill red">停滞</span>' : ''}</small>
351
+ </button>`
352
+ )
353
+ .join('')
354
+ : '<p class="muted">没有会话</p>';
355
+ for (const button of $('sessions').querySelectorAll('[data-session]'))
356
+ button.onclick = () => void select(button.dataset.session);
357
+ }
358
+ function renderWorkspaces() {
359
+ $('newWorkspace').innerHTML = state.workspaces
360
+ .map(workspace => `<option value="${esc(workspace.id)}">${esc(workspace.label)}</option>`)
361
+ .join('');
362
+ }
363
+ /** The hub reports participantIds; a human reading the board wants the names it typed in. */
364
+ const participantName = (session, participantId) =>
365
+ (session.participants || []).find(entry => entry.participantId === participantId)?.displayName ||
366
+ participantId;
367
+ const waitingAgentDetails = session => {
368
+ const progress = session.progress || {};
369
+ const ids = [...new Set([...(progress.waitingOn || []), ...(progress.pendingCrossVotes || [])])];
370
+ return ids.map(participantId => {
371
+ const participant = (session.participants || []).find(entry => entry.participantId === participantId),
372
+ agent = state.agents.find(entry => entry.agentId === participant?.agentId);
373
+ const status = agent?.status || 'unknown',
374
+ activelyRunning = ['starting', 'streaming', 'stopping'].includes(status),
375
+ needsUser = status === 'waiting_for_user';
376
+ return {
377
+ participantId,
378
+ name: participant?.displayName || participantId,
379
+ status,
380
+ activelyRunning,
381
+ retryable: !activelyRunning && !needsUser
382
+ };
383
+ });
384
+ };
385
+ /** Local agents that may still take a seat: the hub rejects the same agentId twice in one session. */
386
+ const availableAgents = session => {
387
+ const taken = new Set(
388
+ (session.participants || []).map(participant => participant.agentId).filter(Boolean)
389
+ );
390
+ // Collaboration tools are loaded only in dedicated profile=collab Pi sessions; never turn a normal session into one implicitly.
391
+ return state.agents.filter(agent => agent.profile === 'collab' && !taken.has(agent.agentId));
392
+ };
393
+ /** innerHTML 重建会清空正在填写的表单;刷新由每条 hub 事件触发,所以必须显式保存草稿。 */
394
+ const PARTICIPANT_FIELDS = ['pRole', 'pAgent', 'pNewModel'];
395
+ const readParticipantDraft = () =>
396
+ PARTICIPANT_FIELDS.map(id => [id, $(id)?.value ?? '']).filter(([, value]) => value !== '');
397
+ const restoreParticipantDraft = draft => {
398
+ for (const [id, value] of draft) {
399
+ const field = $(id);
400
+ if (!field) continue;
401
+ if (field.tagName === 'SELECT' && ![...field.options].some(option => option.value === value)) continue;
402
+ field.value = value;
403
+ }
404
+ };
405
+ const normalizedPath = value =>
406
+ String(value || '')
407
+ .replace(/\\/g, '/')
408
+ .replace(/\/$/, '');
409
+ const relativeCwdFor = session => {
410
+ const workspace = state.workspaces.find(entry => entry.id === session.workspaceId);
411
+ if (!workspace) return '.';
412
+ const root = normalizedPath(workspace.rootPath),
413
+ cwd = normalizedPath(session.cwd);
414
+ return cwd === root ? '.' : cwd.startsWith(`${root}/`) ? cwd.slice(root.length + 1) : '.';
415
+ };
416
+ const modelLabel = model => (model?.provider && model?.id ? `${model.provider}/${model.id}` : '');
417
+ const roleLabel = role => ({reviewer: '评审', implementer: '开发', moderator: '协调'})[role] || role;
418
+ const generatedAgentName = (session, role) => {
419
+ const number = (session.participants || []).filter(entry => entry.role === role).length + 1;
420
+ return `${session.title} · ${roleLabel(role)} Agent ${number}`.slice(0, 100);
421
+ };
422
+ const catalogKey = session => `${session.workspaceId}:${relativeCwdFor(session)}`;
423
+ function modelOptionsHtml(session) {
424
+ const cached = state.modelCatalogs.get(catalogKey(session));
425
+ if (!cached || typeof cached.then === 'function') return '<option value="">正在读取可用模型…</option>';
426
+ if (!(cached.models || []).length) return '<option value="">没有可用模型</option>';
427
+ return cached.models
428
+ .map(
429
+ model =>
430
+ `<option value="${esc(JSON.stringify([model.provider, model.id]))}"${model.provider === cached.model?.provider && model.id === cached.model?.id ? ' selected' : ''}>${esc(model.provider)} / ${esc(model.name || model.id)}</option>`
431
+ )
432
+ .join('');
433
+ }
434
+ async function loadModelCatalog(session) {
435
+ const key = catalogKey(session);
436
+ if (state.modelCatalogs.has(key)) return state.modelCatalogs.get(key);
437
+ const pending = api(
438
+ `/api/v1/agents/models?workspaceId=${encodeURIComponent(session.workspaceId)}&relativeCwd=${encodeURIComponent(relativeCwdFor(session))}`
439
+ )
440
+ .then(value => {
441
+ state.modelCatalogs.set(key, value);
442
+ return value;
443
+ })
444
+ .catch(error => {
445
+ state.modelCatalogs.delete(key);
446
+ throw error;
447
+ });
448
+ state.modelCatalogs.set(key, pending);
449
+ return pending;
450
+ }
451
+ async function agentCapabilities(agentId, force = false) {
452
+ if (!force && state.agentCapabilities.has(agentId)) return state.agentCapabilities.get(agentId);
453
+ const capabilities = await api(`/api/v1/agents/${encodeURIComponent(agentId)}/capabilities`);
454
+ state.agentCapabilities.set(agentId, capabilities);
455
+ return capabilities;
456
+ }
457
+ /**
458
+ * 结算只在 awaiting_human 阶段出现,而且必须为每个争议维度填一个合法分数:
459
+ * 空 rulings 会让中枢用 0 分关掉一个还没结束的会话。
460
+ */
461
+ const finalizeCard = (session, criteria) => {
462
+ if (session.kind !== 'scoring' || session.phase !== 'awaiting_human' || session.status !== 'active')
463
+ return '';
464
+ const contested = (session.progress && session.progress.contested) || [];
465
+ const scale = session.policy?.scoring?.scale || {min: 0, max: 10, step: 1};
466
+ if (!contested.length)
467
+ return `<div class="card"><h3>人工结算评分</h3><p class="muted">没有争议维度需要裁定;请先处理其它待裁定项。</p></div>`;
468
+ const name = criterionId =>
469
+ (criteria.find(entry => entry.criterionId === criterionId) || {}).name || criterionId;
470
+ return `<div class="card">
471
+ <h3><span class="pill yellow">人工结算评分</span></h3>
472
+ <p class="muted">面板在 ${contested.length} 个维度上没有收敛,请为每个维度给出最终分(${scale.min}–${scale.max},步长 ${scale.step})。</p>
473
+ <form id="finalizeForm">
474
+ ${contested
475
+ .map(
476
+ criterionId => `<div class="row" data-criterion="${esc(criterionId)}">
477
+ <label>${esc(name(criterionId))}<input name="score-${esc(criterionId)}" type="number" required
478
+ min="${scale.min}" max="${scale.max}" step="${scale.step}" style="width:90px"></label>
479
+ <label style="flex:1">理由(≥10 字符)<input name="rationale-${esc(criterionId)}" required minlength="10"></label>
480
+ </div>`
481
+ )
482
+ .join('')}
483
+ <div class="row"><button type="submit">结算并结束会话</button></div>
484
+ </form>
485
+ </div>`;
486
+ };
487
+ /**
488
+ * 裁定只能用一次(再次提交返回 409),所以带结构化补救措施的升级必须把参数直接放在表单里:
489
+ * 只填 decision/rationale 的话什么都不会发生,而且那一次机会就用掉了。
490
+ */
491
+ const escalationRemedy = (session, entry) => {
492
+ if (entry.kind === 'budget_exhausted') {
493
+ const seat = (session.participants || []).find(
494
+ participant => participant.participantId === entry.refId
495
+ );
496
+ const used = seat ? seat.tokensUsed : 0;
497
+ return `<label>新预算(须 > 已用 ${used})<input name="tokenBudget" type="number" min="${used + 1}" step="1" value="${Math.max(used * 2, used + 1000)}" style="width:120px"></label>
498
+ <span class="muted">留空 = 不提额(换人 / 强推);提额也可事后单独调 POST /participants/{id}/budget。</span>`;
499
+ }
500
+ if (entry.kind === 'other') {
501
+ const cap = session.policy?.maxTotalRounds ?? 0;
502
+ return `<label>新的最大轮数(须 > ${cap})<input name="maxTotalRounds" type="number" min="${cap + 1}" max="50" step="1" style="width:110px"></label>
503
+ <span class="muted">留空 = 不抬轮次上限。</span>`;
504
+ }
505
+ return '';
506
+ };
507
+ function issueConsensusCells(session, consensus, issueId) {
508
+ const current = (consensus?.issueConsensus || []).find(entry => entry.issueId === issueId),
509
+ discussions = (consensus?.discussions || []).filter(entry => entry.issueId === issueId);
510
+ const realVotes = (current?.positions || []).filter(position => position.stance && !position.implicit);
511
+ const collecting = session.phase === 'collecting';
512
+ const caption = !current?.positions?.length
513
+ ? '<span class="muted">尚未开始投票</span>'
514
+ : collecting && !realVotes.length
515
+ ? '<div class="muted">提出者默认赞成;交叉投票在其他评审员交完自己的 findings 后立即开始,不必等全员盲审结束。</div>'
516
+ : '';
517
+ const votes = current?.positions?.length
518
+ ? `${caption}${current.positions.map(position => `<div><span class="pill ${position.stance === 'approve' ? 'green' : position.stance === 'reject' ? 'red' : 'yellow'}">${esc(position.stance || '未投')}</span> ${esc(participantName(session, position.participantId))}${position.implicit ? ' <small class="muted">(提出者)</small>' : ''}${position.rationale ? `<br><small>${esc(position.rationale)}</small>` : ''}</div>`).join('')}`
519
+ : caption;
520
+ const thread = discussions.length
521
+ ? `<details><summary>${discussions.length} 条讨论</summary>${discussions.map(message => `<div style="margin:6px 0"><b>${esc(participantName(session, message.authorId))}</b> · round ${esc(message.payload?.consensusRound ?? '')}<br>${esc(message.payload?.argument || '')}</div>`).join('')}</details>`
522
+ : '<span class="muted">—</span>';
523
+ return {votes, thread};
524
+ }
525
+ function mergeProposalCard(session, consensus, issues) {
526
+ const proposals = consensus?.mergeProposals || [];
527
+ if (!proposals.length)
528
+ return '<div class="card"><h3>合并申请与投票(0)</h3><p class="muted">尚无重复 Issue 合并申请。</p></div>';
529
+ const label = issueId => {
530
+ const issue = issues.find(entry => entry.issueId === issueId);
531
+ return issue?.number ? `#${issue.number}` : issueId.slice(0, 8);
532
+ };
533
+ const reviewers = (session.participants || []).filter(
534
+ entry => entry.role === 'reviewer' && entry.state !== 'left'
535
+ );
536
+ return `<div class="card"><h3>合并申请与投票(${proposals.length})</h3><table><thead><tr><th>申请</th><th>合并理由</th><th>发起者</th><th>赞成票</th><th>反对票 / 理由</th><th>结果</th></tr></thead><tbody>${proposals
537
+ .map((proposal, index) => {
538
+ const proposers = proposal.votes.filter(vote => vote.proposer),
539
+ approves = proposal.votes.filter(vote => vote.stance === 'approve'),
540
+ rejects = proposal.votes.filter(vote => vote.stance === 'reject'),
541
+ complete = reviewers.every(reviewer =>
542
+ proposal.votes.some(vote => vote.participantId === reviewer.participantId)
543
+ ),
544
+ status = rejects.length ? '未通过' : complete ? '全票通过' : '投票中';
545
+ return `<tr><td><b>合并申请 ${index + 1}</b><br>${proposal.issueIds.map(id => `<span class="pill">${esc(label(id))}</span>`).join(' + ')}</td><td>${esc(proposal.rationale)}</td><td>${proposers.map(vote => esc(participantName(session, vote.participantId))).join('、')}</td><td>${approves.map(vote => esc(participantName(session, vote.participantId))).join('、') || '—'}</td><td>${rejects.map(vote => `<b>${esc(participantName(session, vote.participantId))}</b>${vote.rationale ? `:${esc(vote.rationale)}` : ''}`).join('<br>') || '—'}</td><td><span class="pill ${status === '全票通过' ? 'green' : status === '未通过' ? 'red' : 'yellow'}">${status}</span></td></tr>`;
546
+ })
547
+ .join('')}</tbody></table></div>`;
548
+ }
549
+ function reviewSummaryCard(consensus) {
550
+ const summary = consensus?.summary;
551
+ if (!summary) return '';
552
+ const tone =
553
+ summary.verdict === 'approved' ? 'green' : summary.verdict === 'changes_required' ? 'red' : 'yellow';
554
+ return `<div class="card"><h3>评审总结 <span class="grow"></span><span class="pill ${tone}">${esc(summary.verdict)}</span></h3>
555
+ <p><b>${summary.actionItemCount ? `仍有 ${summary.actionItemCount} 项需要修复或跟进` : '没有剩余修复项'}</b>(共 ${summary.totalIssues} 项)</p>
556
+ <p class="muted">${esc(summary.description)} · 严重程度:${esc(JSON.stringify(summary.bySeverity))} · 处理结果:${esc(JSON.stringify(summary.byDisposition))}</p>
557
+ ${summary.actionItems?.length ? `<table><thead><tr><th>ID / 严重程度</th><th>需要修什么</th><th>位置</th><th>当前处理</th></tr></thead><tbody>${summary.actionItems.map(item => `<tr><td><b>#${esc(item.number)}</b><br><span class="pill ${['blocker', 'critical'].includes(item.severity) ? 'red' : item.severity === 'major' ? 'yellow' : ''}">${esc(item.severity)}</span></td><td><b>${esc(item.title)}</b><br>${esc(item.description)}${item.impact ? `<details><summary>影响</summary>${esc(item.impact)}</details>` : ''}</td><td>${esc(item.location?.path || '')}${item.location?.startLine ? `:${item.location.startLine}` : ''}</td><td><span class="pill yellow">${esc(item.disposition)}</span>${item.response?.rationale ? `<br><small>${esc(item.response.rationale)}</small>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">所有 Issue 都已有终态处理。</p>'}
558
+ </div>`;
559
+ }
560
+ function renderDetail() {
561
+ if (!state.detail) {
562
+ $('detail').innerHTML = '<p class="muted">选择左侧的协作会话,或先创建一个。</p>';
563
+ return;
564
+ }
565
+ const draft = readParticipantDraft();
566
+ const {session, issues, events, escalations, criteria, consensus, rounds} = state.detail;
567
+ const progress = session.progress || {};
568
+ const pending = escalations.filter(entry => entry.status === 'pending'),
569
+ waitingDetails = waitingAgentDetails(session),
570
+ crossVoteIds = progress.pendingCrossVotes || [],
571
+ allRunning = waitingDetails.length > 0 && waitingDetails.every(entry => entry.activelyRunning),
572
+ retryableWaiting = waitingDetails.filter(entry => entry.retryable);
573
+ const displayedVerdict =
574
+ session.kind === 'review'
575
+ ? consensus?.summary?.verdict || session.outcome?.verdict
576
+ : session.outcome?.verdict;
577
+ const waitingStatus = waitingDetails.map(entry => `${entry.name}(${entry.status})`).join('、');
578
+ const stallNotice = !session.stalled
579
+ ? ''
580
+ : allRunning
581
+ ? `<div style="display:block" class="pill yellow"><b>评审耗时较长,但 Agent 仍在运行</b>(超过提醒阈值,自 ${esc(session.stalled.since)})<br>当前状态:${esc(waitingStatus)}<br><small>这些 Agent 仍是 streaming / starting,请继续等待。不要重复提醒;提高超时时间只会延后这条提示,不会加速评审。</small></div>`
582
+ : `<div style="display:block" class="pill red"><b>协作等待提交</b>(自 ${esc(session.stalled.since)})<br>当前状态:${esc(waitingStatus || (session.stalled.waitingOn || []).map(id => participantName(session, id)).join('、'))}<br><small>idle / unloaded / error 表示 Agent 已不再执行但尚未提交当前阶段 API,可重新提醒;waiting_for_user 需要先到 Agent 控制台处理交互。强制推进会跳过未提交结果。</small></div>`;
583
+ $('detail').innerHTML = `
584
+ <div class="card">
585
+ <h3><span class="grow">${esc(session.title)}</span>${phasePill(session)}<span class="pill">round ${session.round}</span><span class="pill">${esc(session.status)}</span></h3>
586
+ <div class="muted">${esc(session.sessionId)} · ${esc(session.kind)} · ${esc(session.cwd)}</div>
587
+ <div class="muted">对象:${esc(session.subject.type)} = ${esc(session.subject.value)}${session.subject.notes ? ` · ${esc(session.subject.notes)}` : ''}</div>
588
+ ${stallNotice}
589
+ <p class="muted">进度:${esc(JSON.stringify(progress))}</p>
590
+ ${crossVoteIds.length ? `<p><span class="pill yellow">交叉投票进行中</span> 已完成盲审、还需给他人 Issue 投票:${esc(crossVoteIds.map(id => participantName(session, id)).join('、'))}</p>` : ''}
591
+ ${session.policy?.implementationFirst ? '<p class="muted">模式:先开发后评审(implementing → collecting 自动切换)</p>' : ''}
592
+ ${session.kind === 'review' ? (session.policy?.consensusReview ? `<p class="muted">共识评审:盲审汇总 → 问题投票 / 合并提议 → 合并投票 → 最多 ${esc(session.policy.maxConsensusRounds)} 轮讨论 → 输出报告</p>` : '<p class="muted">共识评审:关闭(收集完成后直接输出报告)</p>') : ''}
593
+ ${displayedVerdict ? `<p><span class="pill ${displayedVerdict === 'approved' ? 'green' : displayedVerdict === 'changes_required' ? 'red' : 'yellow'}">结论:${esc(displayedVerdict)}</span>${session.outcome?.approval?.unanimous ? ' <span class="pill green">问题认定已全票完成</span>' : ''}</p>` : ''}
594
+ ${
595
+ session.status === 'active'
596
+ ? `<div class="row">
597
+ <button data-action="advance">推进阶段</button>
598
+ ${retryableWaiting.length ? `<button data-action="retry">重新提醒已停止的 Agent(${retryableWaiting.length})</button>` : ''}
599
+ <button data-action="force">强制跳过未提交并推进…</button>
600
+ </div>`
601
+ : ''
602
+ }
603
+ </div>
604
+
605
+ ${reviewFlowChart(session)}
606
+ ${recheckCard(session, issues)}
607
+ ${reviewRoundsCard(session, rounds || [])}
608
+
609
+ <div class="card">
610
+ <h3>参与者 <span class="grow"></span></h3>
611
+ <table><thead><tr><th>名称</th><th>角色</th><th>Agent</th><th>模型</th><th>Token</th><th>状态</th><th>待办</th></tr></thead><tbody>
612
+ ${
613
+ (session.participants || [])
614
+ .map(
615
+ participant => `<tr>
616
+ <td>${esc(participant.displayName)}<br><small class="muted">${esc(participant.participantId)}</small></td>
617
+ <td>${esc(participant.role)}</td>
618
+ <td>${esc(participant.agentId || '—')} <span class="pill ${['streaming', 'starting'].includes(state.agents.find(agent => agent.agentId === participant.agentId)?.status) ? 'blue' : ''}">${esc(state.agents.find(agent => agent.agentId === participant.agentId)?.status || 'unknown')}</span>
619
+ <br><button type="button" class="rebind" data-participant="${esc(participant.participantId)}">改绑…</button></td>
620
+ <td>${esc(participant.model || '-')}</td>
621
+ <td>${participant.tokensUsed}/${participant.tokenBudget}${participant.tokensEstimated ? ' <small class="muted">(估算)</small>' : ''}</td>
622
+ <td>${esc(participant.state)}${participant.state === 'budget_exhausted' ? `<br><button type="button" class="raise-budget" data-participant="${esc(participant.participantId)}" data-used="${participant.tokensUsed}">提额…</button>` : ''}</td>
623
+ <td>${participant.pendingTasks ? `<span class="pill red">${participant.pendingTasks} 未送达</span>` : '<span class="muted">-</span>'}</td></tr>`
624
+ )
625
+ .join('') || '<tr><td colspan="7" class="muted">还没有参与者</td></tr>'
626
+ }
627
+ </tbody></table>
628
+ <form id="participantForm" class="row" style="margin-top:10px">
629
+ <label>角色<select id="pRole">${session.status === 'finished' && session.kind === 'review' ? '<option value="implementer">开发 implementer</option>' : '<option value="reviewer">评审 reviewer</option><option value="implementer">开发 implementer</option>'}</select></label>
630
+ <label>Agent<select id="pAgent" required><option value="__new__">+ 新建 Agent</option>${availableAgents(
631
+ session
632
+ )
633
+ .map(
634
+ agent =>
635
+ `<option value="${esc(agent.agentId)}">${esc(agent.sessionName || agent.agentId)}${agent.status ? ` (${esc(agent.status)})` : ''}</option>`
636
+ )
637
+ .join('')}</select></label>
638
+ <label id="pNewModelWrap" class="participant-config">新 Agent 使用的模型<select id="pNewModel" required>${modelOptionsHtml(session)}</select><small class="field-help">只列出 Pi 当前已配置凭据、在这个项目中实际可用的模型。</small></label>
639
+ <span id="pAgentHint" class="field-help">新 Agent 的名称会根据会话和角色自动生成;模型标注会从实际模型自动填写。</span>
640
+ <button id="participantSubmit" type="submit">新建并加入</button>
641
+ </form>
642
+ </div>
643
+
644
+ ${
645
+ pending.length
646
+ ? `<div class="card">
647
+ <h3><span class="pill red">待人工裁定 ${pending.length}</span></h3>
648
+ ${pending
649
+ .map(
650
+ entry => `<div style="margin-bottom:12px;border-bottom:1px solid var(--line);padding-bottom:10px">
651
+ <b>${esc(entry.kind)}</b> <span class="pill ${entry.urgency === 'high' ? 'red' : 'yellow'}">${esc(entry.urgency)}</span>
652
+ <p>${esc(entry.summary)}</p><p><b>问题:</b>${esc(entry.question)}</p>
653
+ ${entry.options.length ? `<p class="muted">候选:${esc(entry.options.join(' / '))}</p>` : ''}
654
+ ${entry.positions.length ? `<details class="raw"><summary>${entry.kind === 'issue_dispute' ? '完整投票 / 辩论记录' : '各方立场'}</summary><pre>${esc(JSON.stringify(entry.positions, null, 2))}</pre></details>` : ''}
655
+ ${
656
+ entry.kind === 'score_dispute'
657
+ ? '<p class="muted">评分争议在下方“人工结算评分”里一次性裁定(需要每个争议维度的分数),提交后本条自动关闭。</p>'
658
+ : `<form class="row resolve-form" data-escalation="${esc(entry.escalationId)}" style="margin-top:8px">
659
+ <label>裁定<input name="decision" required placeholder="fix in this round"></label>
660
+ <label style="flex:1">理由(≥10 字符)<input name="rationale" required></label>
661
+ ${entry.refId && entry.kind === 'issue_dispute' ? `<label>Issue 处理<select name="issueDecision" required><option value="" disabled selected>选一个</option><option value="reopen">判定为有效问题,交给实现 Agent(reopen)</option><option value="wontfix">判定不是问题 / 无需修复(wontfix)</option><option value="resolved">人工确认已解决(resolved)</option><option value="closed">直接关闭(closed)</option></select></label>` : ''}
662
+ ${escalationRemedy(session, entry)}
663
+ <button type="submit">提交裁定</button>
664
+ </form>`
665
+ }
666
+ </div>`
667
+ )
668
+ .join('')}
669
+ </div>`
670
+ : ''
671
+ }
672
+
673
+ ${finalizeCard(session, criteria)}
674
+
675
+ ${
676
+ session.kind === 'review'
677
+ ? `${reviewSummaryCard(consensus)}<div class="card">
678
+ <h3>Issues(${issues.length})<span class="grow"></span><span class="muted">当前共识轮次 ${session.debateRound || 0} / ${session.policy?.maxConsensusRounds || 3}</span></h3>
679
+ <table><thead><tr><th>ID</th><th>标题 / 提出者</th><th>严重程度</th><th>状态</th><th>问题投票</th><th>Discussion</th></tr></thead><tbody>
680
+ ${
681
+ issues
682
+ .map(issue => {
683
+ const cells = issueConsensusCells(session, consensus, issue.issueId),
684
+ merged = issues.find(entry => entry.issueId === issue.mergedInto),
685
+ summaryItem = consensus?.summary?.issues?.find(entry => entry.issueId === issue.issueId),
686
+ disposition = summaryItem?.disposition || issue.status;
687
+ return `<tr>
688
+ <td><b title="${esc(issue.issueId)}">#${esc(issue.number || '?')}</b></td>
689
+ <td><b>${esc(issue.title)}</b><br><small>${esc(participantName(session, issue.reporterId))}</small><br><small class="muted">${esc(issue.category)} · ${esc(issue.location?.path || '')}${issue.location?.startLine ? `:${issue.location.startLine}` : ''}</small></td>
690
+ <td><span class="pill ${['blocker', 'critical'].includes(issue.severity) ? 'red' : issue.severity === 'major' ? 'yellow' : ''}">${esc(issue.severity)}</span></td>
691
+ <td>${esc(disposition)}${disposition !== issue.status ? `<br><small class="muted">原状态:${esc(issue.status)}</small>` : ''}${issue.mergedInto ? `<br><small>→ #${esc(merged?.number || '?')}</small>` : ''}</td><td>${cells.votes}</td><td>${cells.thread}</td></tr>`;
692
+ })
693
+ .join('') || '<tr><td colspan="6" class="muted">还没有 issue</td></tr>'
694
+ }
695
+ </tbody></table></div>${mergeProposalCard(session, consensus, issues)}`
696
+ : `<div class="card">
697
+ <h3>评分维度(${criteria.length})</h3>
698
+ <table><thead><tr><th>名称</th><th>状态</th><th>权重</th><th>定义</th></tr></thead><tbody>
699
+ ${criteria.map(criterion => `<tr><td>${esc(criterion.name)}</td><td>${esc(criterion.state)}</td><td>${criterion.weight ?? '-'}</td><td>${esc(criterion.definition)}</td></tr>`).join('') || '<tr><td colspan="4" class="muted">还没有维度</td></tr>'}
700
+ </tbody></table>
701
+ ${session.outcome ? `<details class="raw" open><summary>结算结果</summary><pre>${esc(JSON.stringify(session.outcome, null, 2))}</pre></details>` : ''}</div>`
702
+ }
703
+
704
+ <div class="card"><h3>事件(最新 ${Math.min(events.length, 60)} 条)</h3>
705
+ <div class="events">${
706
+ events
707
+ .slice(-60)
708
+ .reverse()
709
+ .map(
710
+ event =>
711
+ `<div><span class="muted">${esc(event.createdAt.slice(11, 19))}</span> <b>${esc(event.type)}</b> ${esc(JSON.stringify(event.payload).slice(0, 240))}</div>`
712
+ )
713
+ .join('') || '<span class="muted">暂无事件</span>'
714
+ }</div>
715
+ </div>`;
716
+ restoreParticipantDraft(draft);
717
+ bindDetail();
718
+ }
719
+
720
+ async function updateParticipantAgentUi(session) {
721
+ const select = $('pAgent'),
722
+ modelSelect = $('pNewModel'),
723
+ wrap = $('pNewModelWrap'),
724
+ hint = $('pAgentHint'),
725
+ submit = $('participantSubmit');
726
+ if (!select || !modelSelect || !wrap || !hint || !submit) return;
727
+ const isNew = select.value === '__new__';
728
+ wrap.hidden = !isNew;
729
+ modelSelect.required = isNew;
730
+ submit.textContent = isNew ? '新建并加入' : '加入协作';
731
+ submit.disabled = false;
732
+ if (isNew) {
733
+ const generated = generatedAgentName(session, $('pRole').value);
734
+ hint.textContent = `将自动命名为“${generated}”;参与者名称和模型标注均自动填写。`;
735
+ const previous = modelSelect.value;
736
+ modelSelect.replaceChildren(
737
+ Object.assign(document.createElement('option'), {value: '', textContent: '正在读取可用模型…'})
738
+ );
739
+ try {
740
+ const capabilities = await loadModelCatalog(session);
741
+ if (state.sessionId !== session.sessionId || $('pAgent')?.value !== '__new__') return;
742
+ const models = capabilities.models || [];
743
+ modelSelect.replaceChildren(
744
+ ...models.map(model => {
745
+ const option = document.createElement('option');
746
+ option.value = JSON.stringify([model.provider, model.id]);
747
+ option.textContent = `${model.provider} / ${model.name || model.id}`;
748
+ option.selected = previous
749
+ ? option.value === previous
750
+ : model.provider === capabilities.model?.provider && model.id === capabilities.model?.id;
751
+ return option;
752
+ })
753
+ );
754
+ if (!models.length) {
755
+ modelSelect.replaceChildren(
756
+ Object.assign(document.createElement('option'), {value: '', textContent: '没有可用模型'})
757
+ );
758
+ submit.disabled = true;
759
+ hint.textContent = 'Pi 当前没有可用模型。请先在 Pi 中配置模型凭据。';
760
+ }
761
+ } catch (error) {
762
+ if (state.sessionId !== session.sessionId) return;
763
+ modelSelect.replaceChildren(
764
+ Object.assign(document.createElement('option'), {value: '', textContent: '模型加载失败'})
765
+ );
766
+ submit.disabled = true;
767
+ hint.textContent = `无法读取模型:${error.message}`;
768
+ }
769
+ return;
770
+ }
771
+ const agentId = select.value,
772
+ agent = state.agents.find(entry => entry.agentId === agentId);
773
+ hint.textContent = `使用“${agent?.sessionName || agent?.agentId || agentId}”;正在读取实际模型…`;
774
+ try {
775
+ const capabilities = await agentCapabilities(agentId, true);
776
+ if (state.sessionId !== session.sessionId || $('pAgent')?.value !== agentId) return;
777
+ const actual = modelLabel(capabilities.model);
778
+ hint.textContent = `使用“${agent?.sessionName || agent?.agentId || agentId}”${actual ? `;模型标注将自动填写为 ${actual}` : ';当前 Agent 未报告模型'}。`;
779
+ } catch (error) {
780
+ if ($('pAgent')?.value === agentId) hint.textContent = `无法读取该 Agent 的实际模型:${error.message}`;
781
+ }
782
+ }
783
+
784
+ function bindDetail() {
785
+ const sessionId = state.sessionId;
786
+ const guard = fn => async event => {
787
+ event?.preventDefault();
788
+ try {
789
+ await fn();
790
+ await refreshAll();
791
+ } catch (error) {
792
+ toast(error.message);
793
+ }
794
+ };
795
+ if ($('pAgent')) $('pAgent').onchange = () => void updateParticipantAgentUi(state.detail.session);
796
+ if ($('pRole')) $('pRole').onchange = () => void updateParticipantAgentUi(state.detail.session);
797
+ if ($('pAgent')) void updateParticipantAgentUi(state.detail.session);
798
+ for (const button of $('detail').querySelectorAll('[data-action]')) {
799
+ const action = button.dataset.action;
800
+ button.onclick = guard(async () => {
801
+ if (action === 'advance') {
802
+ await post(`/api/v1/collab/sessions/${sessionId}/advance`, {});
803
+ toast('已推进');
804
+ }
805
+ if (action === 'retry') {
806
+ const participantIds = waitingAgentDetails(state.detail.session)
807
+ .filter(entry => entry.retryable)
808
+ .map(entry => entry.participantId);
809
+ if (!participantIds.length) {
810
+ toast('等待中的 Agent 仍在运行,无需重复提醒');
811
+ return;
812
+ }
813
+ const result = await post(`/api/v1/collab/sessions/${sessionId}/retry-waiting`, {participantIds});
814
+ toast(
815
+ result.retried.length
816
+ ? `已重新提醒 ${result.retried.length} 个已停止的 Agent`
817
+ : 'Agent 状态已经变化,目前没有可安全重试的对象'
818
+ );
819
+ }
820
+ if (action === 'force') {
821
+ const reason = prompt('强制推进会跳过未提交的参与者。请说明理由(会记入事件日志):');
822
+ if (!reason) return;
823
+ await post(`/api/v1/collab/sessions/${sessionId}/advance`, {force: true, reason});
824
+ toast('已强制推进');
825
+ }
826
+ if (action === 'recheck') {
827
+ if (!confirm('确认当前代码已经准备好复核?中枢会固定新 baseline 并立即召集所有 Reviewer。')) return;
828
+ await post(`/api/v1/collab/sessions/${sessionId}/recheck`, {mode: 'review_only'});
829
+ toast('已开启新一轮复核');
830
+ }
831
+ if (action === 'fix-recheck') {
832
+ const participantId = $('recheckImplementer')?.value;
833
+ if (!participantId) throw Error('请先选择一个开发 Agent');
834
+ await post(`/api/v1/collab/sessions/${sessionId}/recheck`, {
835
+ mode: 'fix_then_review',
836
+ implementerParticipantIds: [participantId]
837
+ });
838
+ toast('已交给开发 Agent 修复;完成后将自动召集 Reviewer');
839
+ }
840
+ });
841
+ }
842
+ const finalizeForm = $('finalizeForm');
843
+ if (finalizeForm)
844
+ finalizeForm.onsubmit = guard(async () => {
845
+ const session = state.detail.session,
846
+ scale = session.policy?.scoring?.scale || {min: 0, max: 10, step: 1};
847
+ const data = new FormData(finalizeForm);
848
+ const rulings = [...finalizeForm.querySelectorAll('[data-criterion]')].map(row => {
849
+ const criterionId = row.dataset.criterion,
850
+ score = Number(data.get(`score-${criterionId}`));
851
+ if (!Number.isFinite(score) || score < scale.min || score > scale.max)
852
+ throw Error(`分数必须在 ${scale.min}–${scale.max} 之间`);
853
+ if (
854
+ Math.abs((score - scale.min) / scale.step - Math.round((score - scale.min) / scale.step)) > 1e-9
855
+ )
856
+ throw Error(`分数必须是 ${scale.step} 的整数倍`);
857
+ const rationale = String(data.get(`rationale-${criterionId}`) || '').trim();
858
+ if (rationale.length < 10) throw Error('每个维度的裁定都需要至少 10 个字符的理由');
859
+ return {criterionId, score, rationale};
860
+ });
861
+ await post(`/api/v1/collab/sessions/${sessionId}/finalize`, {rulings});
862
+ toast('已结算');
863
+ });
864
+ const participantForm = $('participantForm');
865
+ if (participantForm)
866
+ participantForm.onsubmit = guard(async () => {
867
+ const session = state.detail.session,
868
+ role = $('pRole').value,
869
+ choice = $('pAgent').value;
870
+ let agentId = choice,
871
+ displayName = '',
872
+ actualModel = '',
873
+ createdAgent = null;
874
+ if (choice === '__new__') {
875
+ const raw = $('pNewModel').value;
876
+ if (!raw) throw Error('请先选择新 Agent 使用的模型');
877
+ const [provider, modelId] = JSON.parse(raw);
878
+ displayName = generatedAgentName(session, role);
879
+ createdAgent = await post('/api/v1/agents', {
880
+ workspaceId: session.workspaceId,
881
+ relativeCwd: relativeCwdFor(session),
882
+ model: {provider, modelId},
883
+ sessionName: displayName,
884
+ profile: 'collab'
885
+ });
886
+ agentId = createdAgent.agentId;
887
+ actualModel = `${provider}/${modelId}`;
888
+ } else {
889
+ const agent = state.agents.find(entry => entry.agentId === agentId);
890
+ if (!agent) throw Error('所选 Agent 已不存在,请刷新后重试');
891
+ const capabilities = await agentCapabilities(agentId, true);
892
+ actualModel = modelLabel(capabilities.model);
893
+ displayName = (agent.sessionName?.trim() || generatedAgentName(session, role)).slice(0, 100);
894
+ if (!agent.sessionName?.trim()) {
895
+ const summary = await post(`/api/v1/agents/${encodeURIComponent(agentId)}/session-name`, {
896
+ name: displayName
897
+ });
898
+ agent.sessionName = summary.sessionName || displayName;
899
+ }
900
+ }
901
+ try {
902
+ await post(`/api/v1/collab/sessions/${sessionId}/participants`, {
903
+ role,
904
+ displayName,
905
+ agentId,
906
+ ...(actualModel ? {model: actualModel} : {})
907
+ });
908
+ } catch (error) {
909
+ // Do not leave an accidental Agent in the active list if seat registration raced with a phase change.
910
+ if (createdAgent) await post(`/api/v1/agents/${createdAgent.agentId}/archive`).catch(() => {});
911
+ throw error;
912
+ }
913
+ state.agentCapabilities.delete(agentId);
914
+ toast(`“${displayName}”已加入;中枢会在有任务时自动唤醒它`);
915
+ });
916
+ for (const button of $('detail').querySelectorAll('.rebind'))
917
+ button.onclick = guard(async () => {
918
+ const session = state.detail.session,
919
+ choices = availableAgents(session),
920
+ participant = session.participants.find(
921
+ entry => entry.participantId === button.dataset.participant
922
+ );
923
+ const answer = prompt(
924
+ [
925
+ '把这个座位交给哪个协作专用 Agent?输入序号:',
926
+ '0. 新建协作专用 Agent(沿用当前座位的模型;推荐迁移旧 Agent)',
927
+ ...choices.map(
928
+ (agent, index) =>
929
+ `${index + 1}. ${agent.sessionName || agent.agentId}${agent.status ? ` (${agent.status})` : ''}`
930
+ )
931
+ ].join('\n'),
932
+ choices.length ? '' : '0'
933
+ );
934
+ if (answer === null) return;
935
+ let picked = choices[Number(answer.trim()) - 1];
936
+ if (answer.trim() === '0') {
937
+ const current = state.agents.find(agent => agent.agentId === participant?.agentId);
938
+ if (!current) throw Error('找不到当前座位的 Agent,无法推断要使用的模型');
939
+ const capabilities = await agentCapabilities(current.agentId, true),
940
+ model = capabilities.model;
941
+ if (!model?.provider || !model?.id)
942
+ throw Error('当前 Agent 未报告可复用的模型;请先新建协作 Agent 后再改绑');
943
+ const name = `${participant?.displayName || '协作'} · 专用 Agent`.slice(0, 100);
944
+ picked = await post('/api/v1/agents', {
945
+ workspaceId: session.workspaceId,
946
+ relativeCwd: relativeCwdFor(session),
947
+ model: {provider: model.provider, modelId: model.id},
948
+ sessionName: name,
949
+ profile: 'collab'
950
+ });
951
+ }
952
+ if (!picked) throw Error('序号无效');
953
+ const capabilities = await agentCapabilities(picked.agentId, true),
954
+ actualModel = modelLabel(capabilities.model);
955
+ await post(
956
+ `/api/v1/collab/sessions/${sessionId}/participants/${button.dataset.participant}/binding`,
957
+ {agentId: picked.agentId, ...(actualModel ? {model: actualModel} : {})}
958
+ );
959
+ state.agentCapabilities.delete(picked.agentId);
960
+ toast('已改绑为协作专用 Agent;待办任务会立即推送(旧 token 已失效)');
961
+ });
962
+ for (const button of $('detail').querySelectorAll('.raise-budget'))
963
+ button.onclick = guard(async () => {
964
+ const used = Number(button.dataset.used || 0);
965
+ const answer = prompt(
966
+ `新的 token 预算(必须大于已用 ${used}):`,
967
+ String(Math.max(used * 2, used + 1000))
968
+ );
969
+ if (answer === null) return;
970
+ await post(`/api/v1/collab/sessions/${sessionId}/participants/${button.dataset.participant}/budget`, {
971
+ tokenBudget: Number(answer)
972
+ });
973
+ toast('已提额,该座位可以继续提交');
974
+ });
975
+ for (const form of $('detail').querySelectorAll('.resolve-form'))
976
+ form.onsubmit = guard(async () => {
977
+ const data = new FormData(form),
978
+ issueDecision = String(data.get('issueDecision') || '');
979
+ // 空字符串不能当数字发出去:中枢会把非法的 extra 直接 422,而不是默默什么都不做。
980
+ const extra = {};
981
+ const budget = String(data.get('tokenBudget') || '').trim();
982
+ if (budget) extra.tokenBudget = Number(budget);
983
+ const rounds = String(data.get('maxTotalRounds') || '').trim();
984
+ if (rounds) extra.maxTotalRounds = Number(rounds);
985
+ await post(`/api/v1/collab/escalations/${form.dataset.escalation}/resolve`, {
986
+ decision: String(data.get('decision')),
987
+ rationale: String(data.get('rationale')),
988
+ ...(issueDecision ? {issueDecision} : {}),
989
+ ...(Object.keys(extra).length ? {extra} : {})
990
+ });
991
+ toast(Object.keys(extra).length ? '裁定已生效,并已应用到会话' : '裁定已生效');
992
+ });
993
+ }
994
+
995
+ // ---------------------------------------------------------------- events
996
+ const subjectUi = {
997
+ commit_range: {
998
+ label: 'Git 范围',
999
+ placeholder: 'HEAD~1..HEAD',
1000
+ value: 'HEAD~1..HEAD',
1001
+ help: '填写 Git revision range,例如 HEAD~1..HEAD 或 main...feature。中枢会记录这个基线范围并放进 Agent 任务包。'
1002
+ },
1003
+ diff: {
1004
+ label: '改动说明',
1005
+ placeholder: '当前工作区相对 HEAD 的未提交改动',
1006
+ value: '当前工作区相对 HEAD 的未提交改动',
1007
+ help: '用于评审当前工作区的 staged / unstaged / untracked 改动;这里写一句范围说明即可,实际脏状态会自动生成基线哈希。'
1008
+ },
1009
+ paths: {
1010
+ label: '文件 / 目录',
1011
+ placeholder: 'src/pay, test/pay.test.ts',
1012
+ value: 'src/',
1013
+ help: '填写相对于工作目录的文件或目录,多个值用逗号分隔;中枢会把它们记录为基线路径并告知 Agent。'
1014
+ },
1015
+ free: {
1016
+ label: '范围说明',
1017
+ placeholder: '整个工作区,重点关注支付回调',
1018
+ value: '整个工作区',
1019
+ help: '用自然语言描述评审或评分范围。该文字会原样进入所有 Agent 的任务包。'
1020
+ }
1021
+ };
1022
+ const subjectDrafts = {commit_range: $('newSubjectValue').value};
1023
+ let activeSubjectType = $('newSubjectType').value;
1024
+ function updateSubjectUi() {
1025
+ const type = $('newSubjectType').value,
1026
+ config = subjectUi[type];
1027
+ if (activeSubjectType !== type) subjectDrafts[activeSubjectType] = $('newSubjectValue').value;
1028
+ $('newSubjectValueLabel').textContent = config.label;
1029
+ $('newSubjectValue').placeholder = config.placeholder;
1030
+ $('newSubjectValue').value = subjectDrafts[type] ?? config.value;
1031
+ $('newSubjectHelp').textContent = config.help;
1032
+ activeSubjectType = type;
1033
+ }
1034
+ $('newSubjectType').onchange = updateSubjectUi;
1035
+ updateSubjectUi();
1036
+ $('connect').onclick = openPair;
1037
+ $('pairCancel').onclick = () => $('pairDialog').close();
1038
+ $('pairForm').onsubmit = async event => {
1039
+ event.preventDefault();
1040
+ try {
1041
+ await connect($('pairBase').value, $('pairToken').value, $('pairRemember').checked);
1042
+ } catch (error) {
1043
+ toast(error.message);
1044
+ }
1045
+ };
1046
+ $('refresh').onclick = () => void refreshAll();
1047
+ $('statusFilter').onchange = () => void refreshAll();
1048
+ $('createForm').onsubmit = async event => {
1049
+ event.preventDefault();
1050
+ try {
1051
+ const consensusRounds = Number($('newConsensusRounds').value);
1052
+ if (!Number.isInteger(consensusRounds) || consensusRounds < 1 || consensusRounds > 10)
1053
+ throw Error('争议辩论轮数必须是 1–10 的整数');
1054
+ const session = await post('/api/v1/collab/sessions', {
1055
+ kind: $('newKind').value,
1056
+ title: $('newTitle').value.trim(),
1057
+ workspaceId: $('newWorkspace').value,
1058
+ relativeCwd: $('newCwd').value.trim() || '.',
1059
+ subject: {
1060
+ type: $('newSubjectType').value,
1061
+ value: $('newSubjectValue').value.trim(),
1062
+ ...($('newSubjectNotes').value.trim() ? {notes: $('newSubjectNotes').value.trim()} : {})
1063
+ },
1064
+ policy: {
1065
+ consensusReview: $('newKind').value === 'review',
1066
+ maxConsensusRounds: consensusRounds,
1067
+ ...($('newImplementationFirst').checked ? {implementationFirst: true} : {})
1068
+ }
1069
+ });
1070
+ toast('会话已创建');
1071
+ await refreshAll();
1072
+ await select(session.sessionId);
1073
+ } catch (error) {
1074
+ toast(error.message);
1075
+ }
1076
+ };
1077
+ window.addEventListener('beforeunload', () => state.ws?.close());
1078
+
1079
+ connect(state.base, state.token).catch(() => openPair());
1080
+ })();