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