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.
- package/README.md +44 -5
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +518 -141
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- package/dist/http/agent-routes.js +196 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +43 -0
- package/dist/http/web-assets.js +63 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +190 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +221 -45
- package/dist/server.js +444 -620
- package/dist/session-namer.js +66 -17
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +11 -1
- package/packages/protocol/openapi.yaml +6 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3098 -743
- package/web/backend-state.js +59 -27
- package/web/collab.js +668 -255
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +4 -1
- 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 =>
|
|
8
|
+
const esc = value =>
|
|
9
|
+
String(value ?? '').replace(
|
|
10
|
+
/[&<>"']/g,
|
|
11
|
+
c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[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 =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 => {
|
|
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, {
|
|
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 || [])
|
|
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 {
|
|
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;
|
|
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)
|
|
55
|
-
|
|
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();
|
|
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 = () => {
|
|
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 =
|
|
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 = () => {
|
|
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 => {
|
|
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,
|
|
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();
|
|
152
|
+
renderSessions();
|
|
153
|
+
renderWorkspaces();
|
|
154
|
+
renderDetail();
|
|
95
155
|
} catch (error) {
|
|
96
|
-
$('status').className = 'bad';
|
|
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'
|
|
107
|
-
|
|
108
|
-
|
|
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 => {
|
|
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();
|
|
194
|
+
renderSessions();
|
|
195
|
+
renderDetail();
|
|
120
196
|
};
|
|
121
197
|
|
|
122
198
|
// ---------------------------------------------------------------- render
|
|
123
199
|
const phasePill = session => {
|
|
124
|
-
const tone =
|
|
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 = {
|
|
131
|
-
|
|
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
|
|
136
|
-
{
|
|
137
|
-
|
|
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,
|
|
141
|
-
|
|
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)
|
|
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(
|
|
148
|
-
|
|
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
|
|
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 = {
|
|
160
|
-
|
|
161
|
-
|
|
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 =>
|
|
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,
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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]
|
|
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>`
|
|
200
|
-
|
|
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>`
|
|
205
|
-
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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 = () =>
|
|
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 =>
|
|
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),
|
|
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
|
|
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(
|
|
263
|
-
.
|
|
264
|
-
|
|
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')
|
|
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)
|
|
283
|
-
|
|
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
|
|
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>`
|
|
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(
|
|
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),
|
|
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
|
|
320
|
-
|
|
321
|
-
:
|
|
322
|
-
|
|
323
|
-
|
|
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)
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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;
|
|
337
|
-
|
|
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
|
|
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) {
|
|
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'),
|
|
350
|
-
|
|
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
|
|
353
|
-
?
|
|
354
|
-
:
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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>`
|
|
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(
|
|
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
|
-
${
|
|
644
|
+
${
|
|
645
|
+
pending.length
|
|
646
|
+
? `<div class="card">
|
|
400
647
|
<h3><span class="pill red">待人工裁定 ${pending.length}</span></h3>
|
|
401
|
-
${pending
|
|
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
|
-
${
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
416
|
-
|
|
664
|
+
</form>`
|
|
665
|
+
}
|
|
666
|
+
</div>`
|
|
667
|
+
)
|
|
668
|
+
.join('')}
|
|
669
|
+
</div>`
|
|
670
|
+
: ''
|
|
671
|
+
}
|
|
417
672
|
|
|
418
673
|
${finalizeCard(session, criteria)}
|
|
419
674
|
|
|
420
|
-
${
|
|
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
|
-
${
|
|
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
|
|
428
|
-
|
|
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">${
|
|
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'),
|
|
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;
|
|
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(
|
|
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(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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(
|
|
465
|
-
|
|
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(
|
|
470
|
-
|
|
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,
|
|
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 {
|
|
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') {
|
|
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)
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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`, {
|
|
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)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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: {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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,
|
|
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 {
|
|
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)
|
|
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,
|
|
657
|
-
|
|
658
|
-
|
|
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();
|
|
662
|
-
|
|
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
|
|