pi2web 0.2.1 → 0.2.8

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