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.
- package/README.md +53 -6
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +671 -152
- 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 +222 -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 +47 -0
- package/dist/http/web-assets.js +64 -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 +203 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +249 -45
- package/dist/server.js +488 -614
- package/dist/session-namer.js +66 -17
- package/dist/session-performance.js +137 -0
- package/dist/sotto-stt.js +217 -0
- 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 +24 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3647 -756
- package/web/backend-state.js +59 -27
- package/web/collab.js +684 -257
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +7 -2
- package/web/styles.css +21 -2
- 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 =>
|
|
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 || [])
|
|
25
|
-
|
|
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 {
|
|
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;
|
|
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)
|
|
55
|
-
|
|
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();
|
|
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 = () => {
|
|
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 =
|
|
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 = () => {
|
|
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 => {
|
|
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,
|
|
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();
|
|
163
|
+
renderSessions();
|
|
164
|
+
renderWorkspaces();
|
|
165
|
+
renderDetail();
|
|
95
166
|
} catch (error) {
|
|
96
|
-
$('status').className = 'bad';
|
|
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'
|
|
107
|
-
|
|
108
|
-
|
|
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 => {
|
|
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();
|
|
205
|
+
renderSessions();
|
|
206
|
+
renderDetail();
|
|
120
207
|
};
|
|
121
208
|
|
|
122
209
|
// ---------------------------------------------------------------- render
|
|
123
210
|
const phasePill = session => {
|
|
124
|
-
const tone =
|
|
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 = {
|
|
131
|
-
|
|
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
|
|
136
|
-
{
|
|
137
|
-
|
|
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,
|
|
141
|
-
|
|
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)
|
|
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(
|
|
148
|
-
|
|
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
|
|
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 = {
|
|
160
|
-
|
|
161
|
-
|
|
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 =>
|
|
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,
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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]
|
|
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>`
|
|
200
|
-
|
|
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>`
|
|
205
|
-
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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 = () =>
|
|
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 =>
|
|
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),
|
|
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
|
|
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(
|
|
263
|
-
.
|
|
264
|
-
|
|
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')
|
|
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)
|
|
283
|
-
|
|
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
|
|
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>`
|
|
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(
|
|
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),
|
|
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
|
|
320
|
-
|
|
321
|
-
:
|
|
322
|
-
|
|
323
|
-
|
|
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)
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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;
|
|
337
|
-
|
|
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
|
|
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) {
|
|
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'),
|
|
350
|
-
|
|
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
|
|
353
|
-
?
|
|
354
|
-
:
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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>`
|
|
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(
|
|
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
|
-
${
|
|
655
|
+
${
|
|
656
|
+
pending.length
|
|
657
|
+
? `<div class="card">
|
|
400
658
|
<h3><span class="pill red">待人工裁定 ${pending.length}</span></h3>
|
|
401
|
-
${pending
|
|
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
|
-
${
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
416
|
-
|
|
675
|
+
</form>`
|
|
676
|
+
}
|
|
677
|
+
</div>`
|
|
678
|
+
)
|
|
679
|
+
.join('')}
|
|
680
|
+
</div>`
|
|
681
|
+
: ''
|
|
682
|
+
}
|
|
417
683
|
|
|
418
684
|
${finalizeCard(session, criteria)}
|
|
419
685
|
|
|
420
|
-
${
|
|
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
|
-
${
|
|
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
|
|
428
|
-
|
|
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">${
|
|
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'),
|
|
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;
|
|
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(
|
|
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(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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(
|
|
465
|
-
|
|
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(
|
|
470
|
-
|
|
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,
|
|
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 {
|
|
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') {
|
|
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)
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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`, {
|
|
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)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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} : {})
|
|
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: {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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,
|
|
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 {
|
|
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)
|
|
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,
|
|
657
|
-
|
|
658
|
-
|
|
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();
|
|
662
|
-
|
|
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(
|
|
1090
|
+
connect(state.base, state.token).catch(error => {
|
|
1091
|
+
toast(`自动连接失败:${error.message}`);
|
|
1092
|
+
openPair();
|
|
1093
|
+
});
|
|
667
1094
|
})();
|