@bolloon/bolloon-agent 0.1.34 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.auto-evolve-calls +1 -0
  2. package/.last-auto-evolve-baseline +1 -0
  3. package/Bolloon.md +103 -0
  4. package/dist/agents/pi-sdk.js +264 -12
  5. package/dist/bootstrap/bootstrap.js +114 -0
  6. package/dist/bootstrap/context-collector.js +296 -0
  7. package/dist/bootstrap/lifecycle-hooks.js +109 -0
  8. package/dist/bootstrap/project-context.js +151 -0
  9. package/dist/index.js +11 -0
  10. package/dist/llm/pi-ai.js +31 -21
  11. package/dist/pi-ecosystem-judgment/adaptive-scan.js +231 -0
  12. package/dist/pi-ecosystem-judgment/causal-judge.js +449 -0
  13. package/dist/pi-ecosystem-judgment/detect-hook.js +168 -0
  14. package/dist/pi-ecosystem-judgment/distill-prompt.js +226 -0
  15. package/dist/pi-ecosystem-judgment/evolve-judgment.js +170 -0
  16. package/dist/pi-ecosystem-judgment/human-value-pipeline.js +21 -0
  17. package/dist/pi-ecosystem-judgment/human-value-store.js +283 -22
  18. package/dist/pi-ecosystem-judgment/injection-gate.js +166 -0
  19. package/dist/pi-ecosystem-judgment/monitor-gate.js +188 -0
  20. package/dist/security/builtin-guards.js +124 -0
  21. package/dist/security/context-router-tool.js +106 -0
  22. package/dist/security/react-harness.js +143 -0
  23. package/dist/security/tool-gate.js +235 -0
  24. package/dist/utils/auto-evolve-policy.js +117 -0
  25. package/dist/utils/clamp.js +7 -0
  26. package/dist/utils/double.js +6 -0
  27. package/dist/web/client.js +668 -204
  28. package/dist/web/index.html +24 -4
  29. package/dist/web/server.js +531 -10
  30. package/lefthook.yml +29 -0
  31. package/package.json +3 -2
  32. package/scripts/auto-evolve-loop.ts +376 -0
  33. package/scripts/auto-evolve-oneshot.sh +155 -0
  34. package/scripts/auto-evolve-snapshot.sh +136 -0
  35. package/scripts/detect-schema-changes.sh +48 -0
  36. package/scripts/diff-reviewer.ts +159 -0
  37. package/scripts/weekly-report.ts +364 -0
  38. package/src/agents/pi-sdk.ts +293 -15
  39. package/src/bootstrap/bootstrap.ts +132 -0
  40. package/src/bootstrap/context-collector.ts +342 -0
  41. package/src/bootstrap/lifecycle-hooks.ts +176 -0
  42. package/src/bootstrap/project-context.ts +163 -0
  43. package/src/index.ts +11 -0
  44. package/src/llm/pi-ai.ts +33 -22
  45. package/src/security/builtin-guards.ts +162 -0
  46. package/src/security/context-router-tool.ts +122 -0
  47. package/src/security/react-harness.ts +177 -0
  48. package/src/security/tool-gate.ts +294 -0
  49. package/src/utils/auto-evolve-policy.ts +138 -0
  50. package/src/utils/clamp.ts +5 -0
  51. package/src/web/client.js +668 -204
  52. package/src/web/index.html +24 -4
  53. package/src/web/server.ts +596 -10
  54. package/staging/auto-evolve/clean-001/.review-verdict +9 -0
  55. package/staging/auto-evolve/clean-001/clean-001.patch +14 -0
  56. package/staging/auto-evolve/e2e-001/.patch-id +1 -0
  57. package/staging/auto-evolve/e2e-001/.review-verdict +12 -0
  58. package/staging/auto-evolve/e2e-001/e2e-001.patch +11 -0
  59. package/staging/auto-evolve/test-bad/.review-verdict +12 -0
  60. package/staging/auto-evolve/test-bad/test-bad.patch +11 -0
@@ -4,8 +4,6 @@ if (typeof marked === 'undefined') {
4
4
  }
5
5
 
6
6
  const messagesEl = document.getElementById('messages');
7
- const agentStatusEl = document.getElementById('agent-status');
8
- const agentStatusTextEl = document.getElementById('agent-status-text');
9
7
  const input = document.getElementById('input');
10
8
  const sendBtn = document.getElementById('send');
11
9
  const sidebar = document.getElementById('sidebar');
@@ -913,7 +911,7 @@ async function selectChannel(channelId, targetSessionId = null) {
913
911
  const tmpContainer = document.createElement('div');
914
912
  tmpContainer.style.display = 'none';
915
913
  for (const msg of msgs) {
916
- addMessage(msg.content, msg.type, false, tmpContainer);
914
+ addMessage(msg.content, msg.type, false, tmpContainer, msg.metadata?.usedJudgmentIds || []);
917
915
  }
918
916
  while (tmpContainer.firstChild) {
919
917
  frag.appendChild(tmpContainer.firstChild);
@@ -938,7 +936,7 @@ async function loadSession(channelId, sessionId = null) {
938
936
  container.innerHTML = '';
939
937
  if (session.messages && session.messages.length > 0) {
940
938
  session.messages.forEach(msg => {
941
- addMessage(msg.content, msg.type, false, container);
939
+ addMessage(msg.content, msg.type, false, container, msg.metadata?.usedJudgmentIds || []);
942
940
  });
943
941
  } else {
944
942
  addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
@@ -950,7 +948,7 @@ async function loadSession(channelId, sessionId = null) {
950
948
  }
951
949
  }
952
950
 
953
- function addMessage(content, type, save = true, container) {
951
+ function addMessage(content, type, save = true, container, usedJudgmentIds = []) {
954
952
  const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
955
953
 
956
954
  // 浏览器侧内存保护: 单个 channel 的消息容器超过 MAX_MESSAGES_PER_CHANNEL
@@ -981,12 +979,6 @@ function addMessage(content, type, save = true, container) {
981
979
  const div = document.createElement('div');
982
980
  div.className = `message message-${type}`;
983
981
 
984
- // 清理工具结果容器(当新的 AI 消息到达时)
985
- if (type === 'ai' && toolResultContainer) {
986
- toolResultContainer.remove();
987
- toolResultContainer = null;
988
- }
989
-
990
982
  // 清理内容:移除 tool call 标记和其他不应该显示的内容
991
983
  let cleanContent = content
992
984
  .replace(/\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/g, '')
@@ -1170,12 +1162,13 @@ function addMessage(content, type, save = true, container) {
1170
1162
 
1171
1163
  actionsDiv.appendChild(copyBtn);
1172
1164
 
1173
- // "存为判断" 按钮: 把这条消息正文作为 decision 存到判断库
1165
+ // "存为判断" 按钮: AI 蒸馏 (30-80 字) + 自动演化对齐
1174
1166
  const saveJudgmentBtn = document.createElement('button');
1175
1167
  saveJudgmentBtn.className = 'action-btn save-as-judgment';
1176
- saveJudgmentBtn.title = '把这条消息存为判断';
1177
- saveJudgmentBtn.setAttribute('data-decision', rawContent.substring(0, 800)); // 截断防超长
1178
- saveJudgmentBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6l-8-4z"></path><path d="M9 12l2 2 4-4"></path></svg> 存为判断`;
1168
+ saveJudgmentBtn.title = 'AI 蒸馏为 30-80 字判断力 + 自动演化对齐';
1169
+ saveJudgmentBtn.setAttribute('data-decision', rawContent.substring(0, 800));
1170
+ if (currentChannelId) saveJudgmentBtn.setAttribute('data-channel-id', currentChannelId);
1171
+ saveJudgmentBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6l-8-4z"></path><path d="M9 12l2 2 4-4"></path></svg> 蒸馏为判断`;
1179
1172
  actionsDiv.appendChild(saveJudgmentBtn);
1180
1173
  if (type === 'ai') {
1181
1174
  actionsDiv.appendChild(regenerateBtn);
@@ -1183,199 +1176,206 @@ function addMessage(content, type, save = true, container) {
1183
1176
  div.appendChild(actionsDiv);
1184
1177
  }
1185
1178
 
1179
+ // P0.5: 反向引用链接 (AI 消息) — 极简, 点击跳判断力 modal
1180
+ if (type === 'ai' && Array.isArray(usedJudgmentIds) && usedJudgmentIds.length > 0) {
1181
+ const link = document.createElement('a');
1182
+ link.className = 'used-judgments-link';
1183
+ link.style.cssText = 'display:inline-block;margin-top:4px;font-size:11px;color:#0369a1;text-decoration:underline;cursor:pointer;';
1184
+ link.textContent = `📎 参考 ${usedJudgmentIds.length} 条原则`;
1185
+ link.onclick = (e) => {
1186
+ e.preventDefault();
1187
+ openJudgmentsModalWithFilter(usedJudgmentIds);
1188
+ };
1189
+ div.appendChild(link);
1190
+ }
1191
+
1186
1192
  div.appendChild(time);
1187
1193
  msgContainer.appendChild(div);
1188
1194
 
1189
1195
  msgContainer.scrollTop = msgContainer.scrollHeight;
1190
1196
  }
1191
1197
 
1192
- // Agent status bar — sits between the message list and the input box.
1193
- // Two visual states: "planning" (spinner) and "executing" (glowing icon).
1194
- // The text alternates to convey the action loop.
1195
- let agentStatusState = null; // 'planning' | 'executing' | null
1196
- let agentStatusTextIdx = 0;
1197
-
1198
- const AGENT_STATUS_TEXTS = {
1199
- planning: ['正在计划', '正在分析', '正在思考'],
1200
- executing: ['正在执行', '正在调用工具', '正在处理'],
1198
+ // ============================================================
1199
+ // Loop timeline panel (Claude Code 风格)
1200
+ // ============================================================
1201
+
1202
+ let timelinePanelEl = null;
1203
+ let timelineRowsEl = null;
1204
+ let currentTokenRow = null;
1205
+ let currentTokenText = '';
1206
+ let lastUsedJudgmentIds = []; // 用于 finalizeTimelineAsMessage 给 addMessage 第 5 参
1207
+
1208
+ const PHASE_TEXT = {
1209
+ gate_compute: '正在检索相关判断力...',
1210
+ gate_done: '已注入 {usedCount} 条原则',
1211
+ d_detect: 'D 触发: 监测对话...',
1212
+ d_distill: 'D 触发: 蒸馏判断力...',
1213
+ d_done: 'D 触发: 已入库',
1214
+ d_skip: 'D 触发: 跳过',
1215
+ d_error: 'D 触发: 错误',
1201
1216
  };
1202
1217
 
1203
- function setAgentStatus(state) {
1204
- if (!agentStatusEl || !agentStatusTextEl) return;
1205
- if (state === null) {
1206
- agentStatusEl.hidden = true;
1207
- agentStatusEl.removeAttribute('data-mode');
1208
- agentStatusState = null;
1209
- return;
1210
- }
1211
- agentStatusEl.hidden = false;
1212
- agentStatusEl.setAttribute('data-mode', state);
1213
- agentStatusState = state;
1214
- // 重排一下文本, 避免长时间停留过于单调
1215
- agentStatusTextIdx = (agentStatusTextIdx + 1) % AGENT_STATUS_TEXTS[state].length;
1216
- agentStatusTextEl.textContent = AGENT_STATUS_TEXTS[state][agentStatusTextIdx];
1217
- }
1218
-
1219
- function showTyping(container) {
1220
- hideTyping();
1221
- // 兼容旧路径: container 参数保留但不再使用, status bar 是全局唯一的
1222
- void container;
1223
- setAgentStatus('planning');
1224
- }
1225
-
1226
- function hideTyping() {
1227
- setAgentStatus(null);
1228
- // 兜底: 旧版本的 #typing 元素可能还残留在 DOM 里, 顺手清掉
1229
- const old = document.getElementById('typing');
1230
- if (old) old.remove();
1231
- hideStreaming();
1218
+ function initTimelinePanel() {
1219
+ if (timelinePanelEl) return;
1220
+ timelinePanelEl = document.getElementById('loop-timeline-panel');
1221
+ timelineRowsEl = document.getElementById('loop-timeline-rows');
1222
+ const abortBtn = document.getElementById('loop-abort-btn');
1223
+ if (abortBtn) abortBtn.addEventListener('click', abortCurrentRun);
1224
+ }
1225
+
1226
+ function resetTimeline() {
1227
+ if (timelineRowsEl) timelineRowsEl.innerHTML = '';
1228
+ currentTokenRow = null;
1229
+ currentTokenText = '';
1230
+ lastUsedJudgmentIds = [];
1231
+ const title = document.getElementById('loop-timeline-title');
1232
+ if (title) title.textContent = '▸ 运行中';
1233
+ }
1234
+
1235
+ function showTimelinePanel() {
1236
+ initTimelinePanel();
1237
+ resetTimeline();
1238
+ if (timelinePanelEl) timelinePanelEl.hidden = false;
1239
+ }
1240
+
1241
+ function hideTimelinePanel() {
1242
+ if (timelinePanelEl) timelinePanelEl.hidden = true;
1243
+ }
1244
+
1245
+ function scrollTimelineToBottom() {
1246
+ if (timelinePanelEl) timelinePanelEl.scrollTop = timelinePanelEl.scrollHeight;
1247
+ }
1248
+
1249
+ function appendPhaseRow(text, status) {
1250
+ if (!timelineRowsEl) return;
1251
+ const row = document.createElement('div');
1252
+ row.className = 'loop-row loop-row-phase';
1253
+ row.style.cssText = 'padding:2px 0;color:#374151;';
1254
+ const icon = status === 'done' ? '✓' : status === 'error' ? '✗' : '●';
1255
+ row.innerHTML = `<span style="color:#6b7280;">${icon}</span> <span>${escapeHtml(text)}</span>`;
1256
+ timelineRowsEl.appendChild(row);
1257
+ scrollTimelineToBottom();
1258
+ }
1259
+
1260
+ function appendOrUpdateTokenRow(delta) {
1261
+ if (!timelineRowsEl) return;
1262
+ if (!currentTokenRow) {
1263
+ currentTokenRow = document.createElement('div');
1264
+ currentTokenRow.className = 'loop-row loop-row-token';
1265
+ currentTokenRow.style.cssText = 'padding:2px 0 2px 16px;color:#1f2937;white-space:pre-wrap;word-break:break-word;';
1266
+ currentTokenText = '';
1267
+ timelineRowsEl.appendChild(currentTokenRow);
1268
+ }
1269
+ currentTokenText += delta;
1270
+ currentTokenRow.textContent = currentTokenText;
1271
+ scrollTimelineToBottom();
1272
+ }
1273
+
1274
+ function appendToolRow(toolName, status) {
1275
+ if (!timelineRowsEl) return;
1276
+ const row = document.createElement('div');
1277
+ row.className = 'loop-row loop-row-tool';
1278
+ row.dataset.status = status;
1279
+ row.style.cssText = 'padding:2px 0;color:#1e40af;cursor:pointer;user-select:none;';
1280
+ const icon = status === 'done' ? '✓' : status === 'error' ? '✗' : '●';
1281
+ row.innerHTML = `<span style="color:#6b7280;">${icon}</span> ${escapeHtml(toolName)} <span class="toggle" style="color:#6b7280;">▸</span>`;
1282
+ row.addEventListener('click', () => {
1283
+ const detail = row.nextElementSibling;
1284
+ if (detail && detail.classList.contains('loop-row-tool-detail')) {
1285
+ const isHidden = detail.style.display === 'none';
1286
+ detail.style.display = isHidden ? 'block' : 'none';
1287
+ const tg = row.querySelector('.toggle');
1288
+ if (tg) tg.textContent = isHidden ? '▾' : '▸';
1289
+ }
1290
+ });
1291
+ timelineRowsEl.appendChild(row);
1292
+ return row;
1232
1293
  }
1233
1294
 
1234
- let streamingMessageEl = null;
1235
-
1236
- function showStreaming(container) {
1237
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1238
- hideStreaming();
1239
- streamingMessageEl = document.createElement('div');
1240
- streamingMessageEl.className = 'message message-ai';
1241
- streamingMessageEl.id = 'streaming';
1242
- const bubble = document.createElement('div');
1243
- bubble.className = 'bubble bubble-ai streaming-content';
1244
- streamingMessageEl.appendChild(bubble);
1245
- msgContainer.appendChild(streamingMessageEl);
1246
- msgContainer.scrollTop = msgContainer.scrollHeight;
1295
+ function appendToolDetail(row, content) {
1296
+ if (!row || !timelineRowsEl) return;
1297
+ const detail = document.createElement('div');
1298
+ detail.className = 'loop-row loop-row-tool-detail';
1299
+ detail.style.cssText = 'padding:4px 0 6px 24px;color:#4b5563;font-size:11px;white-space:pre-wrap;word-break:break-word;background:#f3f4f6;border-radius:3px;margin-bottom:4px;';
1300
+ detail.textContent = content;
1301
+ detail.style.display = 'none';
1302
+ timelineRowsEl.insertBefore(detail, row.nextSibling);
1303
+ scrollTimelineToBottom();
1247
1304
  }
1248
1305
 
1249
- function hideStreaming() {
1250
- if (streamingMessageEl) {
1251
- streamingMessageEl.remove();
1252
- streamingMessageEl = null;
1306
+ function findLastPendingToolRow() {
1307
+ if (!timelineRowsEl) return null;
1308
+ const rows = timelineRowsEl.querySelectorAll('.loop-row-tool');
1309
+ for (let i = rows.length - 1; i >= 0; i--) {
1310
+ if (rows[i].dataset.status === 'pending') return rows[i];
1253
1311
  }
1312
+ return null;
1254
1313
  }
1255
1314
 
1256
- function updateStreamingContent(content, container) {
1257
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1258
- if (streamingMessageEl) {
1259
- const bubble = streamingMessageEl.querySelector('.streaming-content');
1260
- if (bubble) {
1261
- bubble.textContent = content;
1262
- msgContainer.scrollTop = msgContainer.scrollHeight;
1263
- }
1315
+ function handlePhaseEventTimeline(data) {
1316
+ initTimelinePanel();
1317
+ const tmpl = PHASE_TEXT[data.phase];
1318
+ if (!tmpl) return;
1319
+ if (timelinePanelEl.hidden) showTimelinePanel();
1320
+ let text = tmpl;
1321
+ if (data.usedCount !== undefined) text = text.replace('{usedCount}', String(data.usedCount));
1322
+ if (data.detail && (data.phase === 'd_done' || data.phase === 'd_skip' || data.phase === 'd_error')) {
1323
+ text = `${text} — ${data.detail}`;
1264
1324
  }
1325
+ const status = data.phase.endsWith('_done') || data.phase === 'd_skip' ? 'done'
1326
+ : data.phase === 'd_error' ? 'error' : 'pending';
1327
+ appendPhaseRow(text, status);
1265
1328
  }
1266
1329
 
1267
- function handleStreamEvent(data, container) {
1268
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1269
- // 始终确保有工作流显示区域
1270
- if (!workflowDisplayEl) {
1271
- workflowDisplayEl = createWorkflowDisplay();
1272
- msgContainer.appendChild(workflowDisplayEl);
1273
- }
1274
-
1275
- if (data.streamType === 'thinking') {
1276
- showStreaming(msgContainer);
1277
- updateStreamingContent(data.content || '思考中...', msgContainer);
1278
- } else if (data.streamType === 'token') {
1279
- showStreaming(msgContainer);
1280
- const current = streamingMessageEl?.querySelector('.streaming-content')?.textContent || '';
1281
- updateStreamingContent(current + data.content, msgContainer);
1282
- }
1283
- }
1284
-
1285
- function handleStatusEvent(data, container) {
1286
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1287
- // 检查是否是工具调用结果
1330
+ function handleStatusEventTimeline(data) {
1331
+ initTimelinePanel();
1288
1332
  const content = data.content || '';
1289
1333
  const isJsonResult = content.startsWith('{') && content.includes('"success"');
1290
-
1291
- if (isJsonResult) {
1292
- // 工具结果:折叠显示
1293
- showToolResult(data.tool, content, msgContainer);
1334
+ if (data.tool) {
1335
+ // 工具事件: 折叠行 + 可选挂 output
1336
+ const pending = findLastPendingToolRow();
1337
+ if (pending && pending.textContent && pending.textContent.includes(data.tool) && content.length > 100) {
1338
+ // 把 output 挂到上一个同工具的 pending 行
1339
+ pending.dataset.status = 'done';
1340
+ const iconEl = pending.firstElementChild;
1341
+ if (iconEl) iconEl.textContent = '✓';
1342
+ const tg = pending.querySelector('.toggle');
1343
+ if (tg) tg.textContent = '▸';
1344
+ appendToolDetail(pending, content);
1345
+ } else {
1346
+ const status = isJsonResult ? 'done' : 'pending';
1347
+ const row = appendToolRow(data.tool, status);
1348
+ if (isJsonResult) appendToolDetail(row, content);
1349
+ }
1350
+ scrollTimelineToBottom();
1294
1351
  } else {
1295
- // 普通状态:流式显示
1296
- showStreaming(msgContainer);
1297
- const icon = data.tool ? `🔧 ${data.tool}: ` : '';
1298
- updateStreamingContent(icon + data.content, msgContainer);
1352
+ // 普通 status: 当作 phase done
1353
+ if (timelinePanelEl.hidden) showTimelinePanel();
1354
+ const label = (data.tool ? `🔧 ${data.tool}: ` : '') + content;
1355
+ appendPhaseRow(label, 'done');
1299
1356
  }
1300
1357
  }
1301
1358
 
1302
- // 显示工具调用结果(折叠)
1303
- let toolResultContainer = null;
1304
-
1305
- function showToolResult(toolName, resultJson, container) {
1306
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1307
- // 清理之前的流式显示
1308
- hideStreaming();
1309
-
1310
- // 获取或创建工具结果容器
1311
- if (!toolResultContainer) {
1312
- toolResultContainer = document.createElement('div');
1313
- toolResultContainer.className = 'tool-results-container';
1314
- msgContainer.appendChild(toolResultContainer);
1315
- }
1316
-
1317
- // 尝试解析并格式化 JSON
1318
- let formattedResult = resultJson;
1319
- try {
1320
- const parsed = JSON.parse(resultJson);
1321
- formattedResult = formatToolResult(parsed);
1322
- } catch {}
1323
-
1324
- // 创建折叠项
1325
- const resultEl = document.createElement('div');
1326
- resultEl.className = 'tool-result-item collapsed';
1327
-
1328
- const toolDisplayName = toolName || '工具结果';
1329
- const headerEl = document.createElement('div');
1330
- headerEl.className = 'tool-result-header';
1331
- headerEl.innerHTML = `
1332
- <span class="tool-result-icon">🔧</span>
1333
- <span class="tool-result-name">${toolDisplayName}</span>
1334
- <span class="tool-result-toggle">▸</span>
1335
- `;
1336
- // 绑定事件处理器(避免内联 onclick)
1337
- headerEl.addEventListener('click', () => {
1338
- resultEl.classList.toggle('collapsed');
1339
- resultEl.classList.toggle('expanded');
1340
- });
1341
-
1342
- const contentEl = document.createElement('div');
1343
- contentEl.className = 'tool-result-content';
1344
- contentEl.innerHTML = `<pre>${formattedResult}</pre>`;
1345
-
1346
- resultEl.appendChild(headerEl);
1347
- resultEl.appendChild(contentEl);
1348
- toolResultContainer.appendChild(resultEl);
1349
- msgContainer.scrollTop = msgContainer.scrollHeight;
1359
+ function handleStreamTokenEvent(data) {
1360
+ initTimelinePanel();
1361
+ if (timelinePanelEl.hidden) showTimelinePanel();
1362
+ appendOrUpdateTokenRow(data.content || '');
1350
1363
  }
1351
1364
 
1352
- // 格式化工具结果为易读格式
1353
- function formatToolResult(obj, indent = 0) {
1354
- const spaces = ' '.repeat(indent);
1355
-
1356
- if (obj === null || obj === undefined) {
1357
- return 'null';
1358
- }
1359
-
1360
- if (typeof obj === 'object') {
1361
- if (Array.isArray(obj)) {
1362
- if (obj.length === 0) return '[]';
1363
- return obj.map(item => spaces + '- ' + formatToolResult(item, indent + 1)).join('\n');
1364
- }
1365
-
1366
- const keys = Object.keys(obj);
1367
- if (keys.length === 0) return '{}';
1365
+ function handleQueueUpdateTimeline(data) {
1366
+ const title = document.getElementById('loop-timeline-title');
1367
+ if (!title) return;
1368
+ title.textContent = data.queueLength > 0
1369
+ ? `▸ 运行中 · 队列 +${data.queueLength}`
1370
+ : '▸ 运行中';
1371
+ }
1368
1372
 
1369
- return keys.map(key => {
1370
- const value = obj[key];
1371
- if (typeof value === 'object') {
1372
- return `${spaces}${key}:\n${formatToolResult(value, indent + 1)}`;
1373
- }
1374
- return `${spaces}${key}: ${value}`;
1375
- }).join('\n');
1373
+ function finalizeTimelineAsMessage() {
1374
+ const container = messagesContainers.get(currentChannelId) || messagesEl;
1375
+ if (currentTokenText.trim().length > 0) {
1376
+ addMessage(currentTokenText, 'ai', true, container, lastUsedJudgmentIds);
1376
1377
  }
1377
-
1378
- return String(obj);
1378
+ // tool 折叠行保留在 timeline 内, 用户能回看
1379
1379
  }
1380
1380
 
1381
1381
  // 工作流状态显示
@@ -1567,6 +1567,12 @@ function showUserCommand(command, container, opts) {
1567
1567
  msgContainer.scrollTop = msgContainer.scrollHeight;
1568
1568
  }
1569
1569
 
1570
+ // ============================================================
1571
+ // 状态条 + phase + queue + abort (UI 体验补丁) — 旧版, 已被 timeline panel 取代
1572
+ // ============================================================
1573
+
1574
+
1575
+
1570
1576
  function connect(channelId) {
1571
1577
  const targetChannelId = channelId || currentChannelId;
1572
1578
  if (!targetChannelId) return;
@@ -1674,28 +1680,25 @@ function connect(channelId) {
1674
1680
  }
1675
1681
  // 本地 user 已经由 sendMessage 渲染 + 去重, 这里不再显示
1676
1682
  } else if (data.type === 'ai') {
1677
- addMessage(data.content, 'ai', true, container);
1678
- hideTyping();
1683
+ addMessage(data.content, 'ai', true, container, lastUsedJudgmentIds);
1679
1684
  } else if (data.type === 'stream') {
1680
- handleStreamEvent(data, container);
1681
- setAgentStatus('executing');
1685
+ if (data.streamType === 'thinking' || data.streamType === 'token') {
1686
+ handleStreamTokenEvent(data);
1687
+ }
1682
1688
  } else if (data.type === 'regenerating') {
1689
+ // 删旧的最后一条 AI 消息, 准备重新生成
1683
1690
  const messages = container.querySelectorAll('.message-ai');
1684
1691
  if (messages.length > 0) {
1685
1692
  const lastAiMsg = messages[messages.length - 1];
1686
1693
  lastAiMsg.remove();
1687
1694
  }
1688
- showTyping(container);
1695
+ showTimelinePanel();
1689
1696
  } else if (data.type === 'status') {
1690
- handleStatusEvent(data, container);
1691
- setAgentStatus('executing');
1697
+ handleStatusEventTimeline(data);
1692
1698
  } else if (data.type === 'done') {
1693
- hideTyping();
1694
- // AI 回复完, 把最后一条 ai 消息落盘 (兜底, 避免 server saveSession 漏写)
1695
- const lastAi = container.querySelector('.message-ai:last-of-type .message-content');
1696
- if (lastAi) {
1697
- persistLastMessageToServer('ai', lastAi.textContent || '');
1698
- }
1699
+ // AI 回复生成完, 从 timeline 拿出 token 文本作为正式消息
1700
+ finalizeTimelineAsMessage();
1701
+ hideTimelinePanel();
1699
1702
  } else if (data.type === 'renamed') {
1700
1703
  const channel = channels.find(c => c.id === data.channelId);
1701
1704
  if (channel) {
@@ -1706,7 +1709,6 @@ function connect(channelId) {
1706
1709
  }
1707
1710
  }
1708
1711
  } else if (data.type === 'error') {
1709
- hideTyping();
1710
1712
  addMessage('错误: ' + data.content, 'ai', true, container);
1711
1713
  } else if (data.type === 'task_status') {
1712
1714
  handleTaskStatusEvent(data, container);
@@ -1714,6 +1716,13 @@ function connect(channelId) {
1714
1716
  handleWorkflowStepEvent(data, container);
1715
1717
  } else if (data.type === 'workflow_loop') {
1716
1718
  handleWorkflowLoopEvent(data, container);
1719
+ } else if (data.type === 'phase') {
1720
+ handlePhaseEventTimeline(data);
1721
+ } else if (data.type === 'queue_update') {
1722
+ handleQueueUpdateTimeline(data);
1723
+ } else if (data.type === 'used_judgments' && Array.isArray(data.usedIds)) {
1724
+ // 注入门回传: 保存 usedIds, finalizeTimelineAsMessage 时给 addMessage
1725
+ lastUsedJudgmentIds = data.usedIds;
1717
1726
  }
1718
1727
  } catch (parseErr) {
1719
1728
  console.error('[SSE] 解析错误', parseErr);
@@ -1734,7 +1743,7 @@ async function sendMessage() {
1734
1743
  if (container) container.scrollTop = container.scrollHeight;
1735
1744
 
1736
1745
  input.value = '';
1737
- showTyping();
1746
+ showTimelinePanel();
1738
1747
 
1739
1748
  // 立即把用户消息落盘, 避免切走再切回时丢失
1740
1749
  persistLastMessageToServer('user', text);
@@ -1756,11 +1765,11 @@ async function sendMessage() {
1756
1765
  });
1757
1766
 
1758
1767
  if (!res.ok) {
1759
- hideTyping();
1768
+ hideTimelinePanel();
1760
1769
  addMessage('发送失败', 'ai');
1761
1770
  }
1762
1771
  } catch (err) {
1763
- hideTyping();
1772
+ hideTimelinePanel();
1764
1773
  addMessage('连接错误', 'ai');
1765
1774
  console.error('Send error', err);
1766
1775
  }
@@ -2636,11 +2645,45 @@ function switchJudgmentTab(tab) {
2636
2645
  renderJudgments(lastJudgmentsCache);
2637
2646
  }
2638
2647
 
2648
+ function switchStatusFilter(status) {
2649
+ currentStatusFilter = status;
2650
+ document.querySelectorAll('.judgment-status-tab').forEach(btn => {
2651
+ const active = btn.dataset.status === status;
2652
+ btn.classList.toggle('active', active);
2653
+ btn.style.background = active ? '#2563eb' : '#e5e7eb';
2654
+ btn.style.color = active ? '#fff' : '#374151';
2655
+ });
2656
+ loadJudgments();
2657
+ }
2658
+
2659
+ /**
2660
+ * P0.5: 打开判断力 modal 并 filter 到指定 ids
2661
+ * - 调 openJudgmentsModal() + 等 loadJudgments() 完成
2662
+ * - 然后用 ids filter lastJudgmentsCache
2663
+ */
2664
+ function openJudgmentsModalWithFilter(ids) {
2665
+ if (!Array.isArray(ids) || ids.length === 0) return;
2666
+ if (typeof openJudgmentsModal === 'function') {
2667
+ openJudgmentsModal();
2668
+ } else if (judgmentsModal) {
2669
+ judgmentsModal.classList.add('active');
2670
+ }
2671
+ // 等 loadJudgments 完成 (它会 await fetch 然后 renderJudgments)
2672
+ setTimeout(() => {
2673
+ if (typeof lastJudgmentsCache === 'undefined') return;
2674
+ lastJudgmentsCache = (lastJudgmentsCache || []).filter((j) => ids.includes(j.id));
2675
+ if (typeof renderJudgments === 'function') {
2676
+ renderJudgments(lastJudgmentsCache);
2677
+ }
2678
+ }, 150);
2679
+ }
2680
+
2639
2681
  function hideJudgmentsModal() {
2640
2682
  if (judgmentsModal) judgmentsModal.classList.remove('active');
2641
2683
  }
2642
2684
 
2643
2685
  let currentJudgmentTab = 'channel'; // 'channel' | 'global'
2686
+ let currentStatusFilter = 'all'; // 'all' | 'active' | 'superseded' | 'violations'
2644
2687
  let lastJudgmentsCache = []; // 最近一次 loadJudgments 拿到的原始列表, 切 tab / 切 channel 时复用
2645
2688
 
2646
2689
  /**
@@ -2712,6 +2755,19 @@ function renderJudgmentItems(items, opts) {
2712
2755
  const reason = (j.reasons && j.reasons[0]) ? escapeHtml(j.reasons[0]) : '';
2713
2756
  const domain = (j.context && j.context.domain) ? escapeHtml(j.context.domain) : 'general';
2714
2757
  const stakes = (j.context && j.context.stakes) ? escapeHtml(j.context.stakes) : 'medium';
2758
+ const isSuperseded = j.status === 'superseded';
2759
+ const isRejected = j.status === 'rejected';
2760
+ const dimmedStyle = isSuperseded || isRejected
2761
+ ? 'opacity:0.55;background:#f3f4f6;'
2762
+ : '';
2763
+ const statusTag = isSuperseded
2764
+ ? `<span style="display:inline-block;background:#fef3c7;color:#92400e;font-size:10px;padding:1px 6px;border-radius:3px;margin-left:6px;" title="已被新判断力演化替代">已过时</span>`
2765
+ : isRejected
2766
+ ? `<span style="display:inline-block;background:#fee2e2;color:#991b1b;font-size:10px;padding:1px 6px;border-radius:3px;margin-left:6px;">已拒绝</span>`
2767
+ : '';
2768
+ const evolveNote = isSuperseded && j.supersededBy
2769
+ ? `<div style="font-size:10px;color:#6b7280;margin-top:2px;">被新条替代 · ${escapeHtml(j.evolutionReason || 'merged')} · ${escapeHtml(j.evolvedAt || '').substring(0,10)}</div>`
2770
+ : '';
2715
2771
  const bindBtn = showBindToggle
2716
2772
  ? isBound
2717
2773
  ? `<button class="judgment-toggle-btn" data-id="${escapeHtml(j.id)}" data-action="unbind" title="从当前 channel 移除" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">× 移除</button>`
@@ -2721,17 +2777,18 @@ function renderJudgmentItems(items, opts) {
2721
2777
  <div class="task-item completed judgment-row"
2722
2778
  data-judgment-id="${escapeHtml(j.id)}"
2723
2779
  draggable="true"
2724
- style="cursor:grab;">
2780
+ style="cursor:grab;${dimmedStyle}">
2725
2781
  <div class="task-item-header">
2726
2782
  <label class="judgment-checkbox" style="display:flex;align-items:center;cursor:pointer;margin-right:8px;" onclick="event.stopPropagation();">
2727
2783
  <input type="checkbox" class="judgment-select-cb" data-id="${escapeHtml(j.id)}" style="cursor:pointer;" onclick="event.stopPropagation();">
2728
2784
  </label>
2729
2785
  <div class="task-item-title">
2730
- <span class="judgment-decision">${escapeHtml(j.decision)}</span>
2786
+ <span class="judgment-decision">${escapeHtml(j.decision)}</span>${statusTag}
2731
2787
  </div>
2732
2788
  <span class="task-item-status completed">${stakes}</span>
2733
2789
  </div>
2734
2790
  ${reason ? `<div class="task-item-desc" style="color:#555;font-size:13px;margin-top:4px;">理由: ${reason}</div>` : ''}
2791
+ ${evolveNote}
2735
2792
  <div class="task-item-meta" style="color:#999;font-size:11px;margin-top:4px;display:flex;justify-content:space-between;align-items:center;">
2736
2793
  <span>${domain} · ${escapeHtml(j.timestamp)} · ${escapeHtml(j.id)}</span>
2737
2794
  <span style="display:flex;gap:4px;">
@@ -2748,14 +2805,53 @@ function renderJudgmentItems(items, opts) {
2748
2805
  async function loadJudgments() {
2749
2806
  if (!judgmentsList) return;
2750
2807
  try {
2751
- const res = await fetch('/api/judgments');
2808
+ // P3: 违规记录走单独 API
2809
+ if (currentStatusFilter === 'violations') {
2810
+ const res = await fetch('/api/judgments/violations?limit=50');
2811
+ if (!res.ok) throw new Error('HTTP ' + res.status);
2812
+ const data = await res.json();
2813
+ renderViolations(data.items || []);
2814
+ judgmentsLoaded = true;
2815
+ return;
2816
+ }
2817
+
2818
+ // 类 B: 自适应扫描建议
2819
+ if (currentStatusFilter === 'adaptive') {
2820
+ const res = await fetch('/api/judgments/adaptive-suggestions');
2821
+ if (!res.ok) throw new Error('HTTP ' + res.status);
2822
+ const data = await res.json();
2823
+ renderAdaptiveSuggestions(data);
2824
+ judgmentsLoaded = true;
2825
+ return;
2826
+ }
2827
+
2828
+ // 阶段 2: causal-judge 因果分析
2829
+ if (currentStatusFilter === 'causal') {
2830
+ const res = await fetch('/api/judgments/causal/correlation?topN=10');
2831
+ if (!res.ok) throw new Error('HTTP ' + res.status);
2832
+ const data = await res.json();
2833
+ renderCausalAnalysis(data.items || []);
2834
+ judgmentsLoaded = true;
2835
+ return;
2836
+ }
2837
+
2838
+ const res = await fetch('/api/judgments?status=' + encodeURIComponent(currentStatusFilter));
2752
2839
  if (!res.ok) throw new Error('HTTP ' + res.status);
2753
2840
  const data = await res.json();
2754
2841
  lastJudgmentsCache = data.judgments || [];
2755
2842
  renderJudgments(lastJudgmentsCache);
2756
2843
  if (judgmentsBadge) {
2757
- if (data.count > 0) {
2758
- judgmentsBadge.textContent = data.count;
2844
+ // 徽章永远显示 active 数量; 不跟 filter 变
2845
+ // 当 filter=active 时, 主 fetch 已经返回的就是 active 列表, 直接用 data.count
2846
+ // 当 filter=all/superseded 时, 从主列表本地数 active 条 (status ?? 'active' 兼容老数据)
2847
+ let activeCount;
2848
+ if (currentStatusFilter === 'active') {
2849
+ activeCount = data.count;
2850
+ } else {
2851
+ activeCount = lastJudgmentsCache.filter((j) => (j.status ?? 'active') === 'active').length;
2852
+ }
2853
+ if (activeCount > 0) {
2854
+ judgmentsBadge.textContent = activeCount;
2759
2855
  judgmentsBadge.style.display = '';
2760
2856
  } else {
2761
2857
  judgmentsBadge.style.display = 'none';
@@ -2767,6 +2863,245 @@ async function loadJudgments() {
2767
2863
  }
2768
2864
  }
2769
2865
 
2866
+ /**
2867
+ * P3 渲染违规记录 (与 renderJudgments 同位置, 但内容不同)
2868
+ */
2869
+ function renderViolations(items) {
2870
+ if (!judgmentsList) return;
2871
+ if (!items || items.length === 0) {
2872
+ judgmentsList.innerHTML = '<div class="task-empty">暂无违规记录 (AI 回复未违反注入原则).</div>';
2873
+ return;
2874
+ }
2875
+ judgmentsList.innerHTML = items.map((v) => {
2876
+ const ts = escapeHtml((v.ts || '').substring(0, 19).replace('T', ' '));
2877
+ const userPrev = escapeHtml(v.userInputPreview || '');
2878
+ const aiPrev = escapeHtml(v.aiReplyPreview || '');
2879
+ const principles = (v.result?.violatedPrinciples || []).map((p) =>
2880
+ `<div style="margin-top:3px;padding:4px 8px;background:#fef2f2;border-radius:3px;">
2881
+ <span style="color:#dc2626;">⚠</span> <strong>${escapeHtml(p.principle || '')}</strong>
2882
+ <span style="color:#991b1b;">— ${escapeHtml(p.reason || '')}</span>
2883
+ </div>`
2884
+ ).join('');
2885
+ return `
2886
+ <div class="task-item" style="border-left:3px solid #dc2626;padding:8px 12px;background:#fffbfb;">
2887
+ <div style="font-size:11px;color:#6b7280;margin-bottom:4px;">${ts} · confidence=${escapeHtml(String(v.result?.confidence ?? 0))}</div>
2888
+ <div style="font-size:12px;color:#1f2937;"><strong>用户:</strong> ${userPrev}</div>
2889
+ <div style="font-size:12px;color:#1f2937;margin-top:2px;"><strong>AI:</strong> ${aiPrev}</div>
2890
+ <div style="margin-top:6px;">${principles}</div>
2891
+ </div>
2892
+ `;
2893
+ }).join('');
2894
+ }
2895
+
2896
+ /**
2897
+ * 类 B 自适应建议渲染
2898
+ * - rising (绿色 boost 标记): 7 天使用率高于 30 天均值的 1.5 倍
2899
+ * - stale (黄色 deprecate 标记): 90 天未用 + 总使用 < 3
2900
+ * - unused (灰色 review 标记): 30 天未用 + 总使用 < 5
2901
+ * 每条带 "✓ 接受" / "✗ 拒绝" 按钮, 接受会真改库, 拒绝只留痕
2902
+ */
2903
+ function renderAdaptiveSuggestions(data) {
2904
+ if (!judgmentsList) return;
2905
+ const { judgmentsTotal, usageEntriesScanned, suggestions, scannedAt } = data;
2906
+ const ts = escapeHtml((scannedAt || '').substring(0, 19).replace('T', ' '));
2907
+
2908
+ if (!suggestions || suggestions.length === 0) {
2909
+ judgmentsList.innerHTML = `
2910
+ <div class="task-empty">📊 自适应扫描: 无建议
2911
+ <div style="margin-top:8px;font-size:11px;color:#6b7280;">扫了 ${judgmentsTotal} 条原则, ${usageEntriesScanned} 条使用记录, 都挺健康.</div>
2912
+ <div style="margin-top:4px;font-size:11px;color:#6b7280;">扫描于 ${ts}</div>
2913
+ </div>`;
2914
+ return;
2915
+ }
2916
+
2917
+ const KIND_STYLE = {
2918
+ rising: { color: '#059669', bg: '#ecfdf5', label: '↑ rising', action: 'boost' },
2919
+ stale: { color: '#92400e', bg: '#fef3c7', label: '⏰ stale', action: 'deprecate' },
2920
+ unused: { color: '#6b7280', bg: '#f3f4f6', label: '👀 unused', action: 'review' },
2921
+ };
2922
+
2923
+ const header = `
2924
+ <div style="padding:8px 12px;background:#f9fafb;border-radius:4px;margin-bottom:8px;font-size:11px;color:#374151;">
2925
+ 📊 扫描于 ${ts} · ${judgmentsTotal} 条原则 · ${usageEntriesScanned} 条使用记录 · <strong>${suggestions.length}</strong> 条建议
2926
+ <button class="rescan-btn" style="margin-left:8px;background:none;border:1px solid #6b7280;color:#374151;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">🔄 重新扫描</button>
2927
+ </div>
2928
+ `;
2929
+
2930
+ const rows = suggestions.map((s) => {
2931
+ const style = KIND_STYLE[s.kind] || KIND_STYLE.unused;
2932
+ const m = s.metrics || {};
2933
+ return `
2934
+ <div class="task-item" data-suggestion-key="${escapeHtml(s.key)}"
2935
+ style="border-left:3px solid ${style.color};padding:8px 12px;background:${style.bg};margin-bottom:6px;">
2936
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
2937
+ <span style="color:${style.color};font-weight:600;font-size:12px;">${style.label}</span>
2938
+ <span style="font-size:11px;color:#6b7280;">${s.action === 'boost' ? '建议加权' : s.action === 'deprecate' ? '建议废弃' : '建议审视'}</span>
2939
+ </div>
2940
+ <div style="font-size:12px;color:#1f2937;margin-bottom:4px;"><strong>${escapeHtml(s.decision)}</strong></div>
2941
+ <div style="font-size:11px;color:#6b7280;margin-bottom:6px;">${escapeHtml(s.reason)}</div>
2942
+ <div style="font-size:11px;color:#9ca3af;margin-bottom:6px;">
2943
+ 7天 ${m.usage7d || 0} · 30天 ${m.usage30d || 0} · 共 ${m.totalUsage || 0} · 上次用 ${m.daysSinceLastUse || 0} 天前
2944
+ </div>
2945
+ <div style="display:flex;gap:6px;">
2946
+ <button class="adaptive-accept" data-key="${escapeHtml(s.key)}" data-id="${escapeHtml(s.judgmentId)}" data-action-kind="${escapeHtml(s.action)}"
2947
+ style="background:#059669;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">✓ 接受</button>
2948
+ <button class="adaptive-reject" data-key="${escapeHtml(s.key)}" data-id="${escapeHtml(s.judgmentId)}" data-action-kind="${escapeHtml(s.action)}"
2949
+ style="background:none;border:1px solid #d1d5db;color:#6b7280;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">✗ 拒绝</button>
2950
+ </div>
2951
+ </div>
2952
+ `;
2953
+ }).join('');
2954
+
2955
+ judgmentsList.innerHTML = header + rows;
2956
+
2957
+ // 绑定按钮
2958
+ const rescanBtn = judgmentsList.querySelector('.rescan-btn');
2959
+ if (rescanBtn) {
2960
+ rescanBtn.onclick = async () => {
2961
+ rescanBtn.disabled = true;
2962
+ rescanBtn.textContent = '🔄 扫描中...';
2963
+ try {
2964
+ const r = await fetch('/api/judgments/adaptive-suggestions?force=1');
2965
+ if (r.ok) renderAdaptiveSuggestions(await r.json());
2966
+ } catch (err) {
2967
+ console.error('[adaptive] rescan failed:', err);
2968
+ } finally {
2969
+ rescanBtn.disabled = false;
2970
+ rescanBtn.textContent = '🔄 重新扫描';
2971
+ }
2972
+ };
2973
+ }
2974
+ judgmentsList.querySelectorAll('.adaptive-accept').forEach((btn) => {
2975
+ btn.onclick = () => applyAdaptiveSuggestion(btn.dataset.key, btn.dataset.id, btn.dataset.actionKind, 'accept');
2976
+ });
2977
+ judgmentsList.querySelectorAll('.adaptive-reject').forEach((btn) => {
2978
+ btn.onclick = () => applyAdaptiveSuggestion(btn.dataset.key, btn.dataset.id, btn.dataset.actionKind, 'reject');
2979
+ });
2980
+ }
2981
+
2982
+ async function applyAdaptiveSuggestion(key, judgmentId, actionKind, decision) {
2983
+ const row = judgmentsList?.querySelector(`[data-suggestion-key="${key}"]`);
2984
+ if (row) row.style.opacity = '0.5';
2985
+ try {
2986
+ const res = await fetch('/api/judgments/adaptive-apply', {
2987
+ method: 'POST',
2988
+ headers: { 'Content-Type': 'application/json' },
2989
+ body: JSON.stringify({
2990
+ action: decision,
2991
+ suggestion: {
2992
+ key,
2993
+ judgmentId,
2994
+ kind: actionKind,
2995
+ action: actionKind,
2996
+ decision: '',
2997
+ reason: '',
2998
+ metrics: {},
2999
+ scannedAt: new Date().toISOString(),
3000
+ },
3001
+ }),
3002
+ });
3003
+ if (!res.ok) throw new Error('HTTP ' + res.status);
3004
+ // 视觉反馈: 隐藏该行
3005
+ if (row) row.style.display = 'none';
3006
+ } catch (err) {
3007
+ if (row) row.style.opacity = '';
3008
+ console.error('[adaptive] apply failed:', err);
3009
+ alert('操作失败: ' + (err && err.message || 'unknown'));
3010
+ }
3011
+ }
3012
+
3013
+ // ============================================================
3014
+ // 阶段 2: causal-judge 渲染
3015
+ // ============================================================
3016
+
3017
+ /**
3018
+ * 渲染关联分析 (top 5 互信息对)
3019
+ * - 显示每对: judgmentA ↔ judgmentB + 互信息 + co-occurrence + 因果方向
3020
+ * - 每条 judgment 旁加"🔬 跑 do-calculus"按钮, 异步显示 causalEffect
3021
+ */
3022
+ function renderCausalAnalysis(items) {
3023
+ if (!judgmentsList) return;
3024
+ if (!items || items.length === 0) {
3025
+ judgmentsList.innerHTML = `
3026
+ <div class="task-empty">🔍 因果分析: 无高关联对
3027
+ <div style="margin-top:8px;font-size:11px;color:#6b7280;">usage 数据不足 (至少 3 条同现), 或 LLM 不可用. 多用 bolloon 一段时间后重试.</div>
3028
+ </div>`;
3029
+ return;
3030
+ }
3031
+
3032
+ const rows = items.map((p, idx) => `
3033
+ <div class="task-item" data-causal-idx="${idx}" data-judgment-a="${escapeHtml(p.judgmentA)}" data-judgment-b="${escapeHtml(p.judgmentB)}"
3034
+ style="border-left:3px solid #7c3aed;padding:8px 12px;background:#faf5ff;margin-bottom:6px;">
3035
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
3036
+ <span style="color:#7c3aed;font-weight:600;font-size:12px;">${escapeHtml(p.causalDirection)}</span>
3037
+ <span style="font-size:11px;color:#6b7280;">MI=${p.mutualInfo} · co=${p.coOccurrence}</span>
3038
+ </div>
3039
+ <div style="font-size:11px;color:#374151;margin-bottom:4px;">${escapeHtml(p.explanation || '(无 LLM 解释)')}</div>
3040
+ <div style="font-size:10px;color:#9ca3af;">A: ${escapeHtml(p.judgmentA)} ↔ B: ${escapeHtml(p.judgmentB)}</div>
3041
+ <div style="margin-top:6px;display:flex;gap:6px;">
3042
+ <button class="causal-intervention-a" data-jid="${escapeHtml(p.judgmentA)}"
3043
+ style="background:#7c3aed;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">🔬 do(A)</button>
3044
+ <button class="causal-intervention-b" data-jid="${escapeHtml(p.judgmentB)}"
3045
+ style="background:#7c3aed;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">🔬 do(B)</button>
3046
+ </div>
3047
+ <div class="causal-result" data-jid="" style="display:none;margin-top:6px;padding:6px;background:#f3e8ff;border-radius:3px;font-size:11px;"></div>
3048
+ </div>
3049
+ `).join('');
3050
+
3051
+ judgmentsList.innerHTML = `
3052
+ <div style="padding:8px 12px;background:#f9fafb;border-radius:4px;margin-bottom:8px;font-size:11px;color:#374151;">
3053
+ 🔍 关联分析 (top ${items.length} 互信息对) · <span style="color:#7c3aed;">LLM 推断方向</span>
3054
+ <button class="causal-refresh" style="margin-left:8px;background:none;border:1px solid #7c3aed;color:#7c3aed;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">🔄 重新跑</button>
3055
+ </div>
3056
+ ${rows}
3057
+ `;
3058
+
3059
+ // 按钮: 重新跑
3060
+ const refresh = judgmentsList.querySelector('.causal-refresh');
3061
+ if (refresh) {
3062
+ refresh.onclick = async () => {
3063
+ refresh.disabled = true;
3064
+ refresh.textContent = '🔄 跑中...';
3065
+ try {
3066
+ const r = await fetch('/api/judgments/causal/correlation?topN=10');
3067
+ if (r.ok) renderCausalAnalysis((await r.json()).items || []);
3068
+ } finally {
3069
+ refresh.disabled = false;
3070
+ refresh.textContent = '🔄 重新跑';
3071
+ }
3072
+ };
3073
+ }
3074
+
3075
+ // 按钮: 跑 do-calculus
3076
+ judgmentsList.querySelectorAll('.causal-intervention-a, .causal-intervention-b').forEach((btn) => {
3077
+ btn.onclick = async () => {
3078
+ const jid = btn.getAttribute('data-jid');
3079
+ const resultDiv = btn.closest('.task-item')?.querySelector('.causal-result');
3080
+ if (!resultDiv) return;
3081
+ resultDiv.style.display = 'block';
3082
+ resultDiv.textContent = '🔬 跑 do-calculus (LLM 模拟反事实)...';
3083
+ btn.disabled = true;
3084
+ try {
3085
+ const r = await fetch(`/api/judgments/causal/intervention?judgmentId=${encodeURIComponent(jid)}`);
3086
+ if (!r.ok) throw new Error('HTTP ' + r.status);
3087
+ const data = await r.json();
3088
+ const effect = data.causalEffect;
3089
+ const sign = effect > 0 ? '+' : '';
3090
+ const color = Math.abs(effect) > 0.5 ? '#dc2626' : Math.abs(effect) > 0.2 ? '#d97706' : '#059669';
3091
+ resultDiv.innerHTML = `
3092
+ <div style="color:${color};font-weight:600;">do-calculus: causalEffect = ${sign}${effect} (${data.marginalContribution})</div>
3093
+ <div style="color:#374151;margin-top:4px;">${escapeHtml(data.reasoning)}</div>
3094
+ <div style="color:#9ca3af;margin-top:4px;">confidence=${data.confidence}</div>
3095
+ `;
3096
+ } catch (err) {
3097
+ resultDiv.innerHTML = `<div style="color:#dc2626;">失败: ${escapeHtml(err.message)}</div>`;
3098
+ } finally {
3099
+ btn.disabled = false;
3100
+ }
3101
+ };
3102
+ });
3103
+ }
3104
+
2770
3105
  /** 把 judgment id 加进 / 移出当前 channel.bound_judgment_ids, 然后刷新两边 UI */
2771
3106
  async function toggleChannelJudgment(judgmentId, action) {
2772
3107
  if (!currentChannelId) {
@@ -2833,6 +3168,11 @@ if (judgmentsList) {
2833
3168
  btn.addEventListener('click', () => switchJudgmentTab(btn.dataset.tab));
2834
3169
  });
2835
3170
 
3171
+ // status 过滤
3172
+ document.querySelectorAll('.judgment-status-tab').forEach(btn => {
3173
+ btn.addEventListener('click', () => switchStatusFilter(btn.dataset.status));
3174
+ });
3175
+
2836
3176
  // 拖拽: 每条 judgment 是 drag source, dataTransfer 装 decision text
2837
3177
  judgmentsList.addEventListener('dragstart', (e) => {
2838
3178
  const row = e.target.closest && e.target.closest('.judgment-row');
@@ -3073,13 +3413,84 @@ if (judgmentImportFile) {
3073
3413
  });
3074
3414
  }
3075
3415
 
3076
- // --- 从对话里 "存为判断": 事件委托到消息容器, 匹配 .save-as-judgment ---
3416
+ // --- 从对话里 "蒸馏为判断": 事件委托到消息容器, 匹配 .save-as-judgment ---
3417
+ // 两条路径:
3418
+ // 1. 有 data-channel-id → 调 /api/judgments/distill-from-conversation (B 触发, AI 蒸馏 + 演化对齐)
3419
+ // 2. 没有 channelId (老按钮 / 历史数据) → fallback 到老 /api/judgments (直存)
3077
3420
  document.addEventListener('click', async (e) => {
3078
3421
  const btn = e.target.closest && e.target.closest('.save-as-judgment');
3079
3422
  if (!btn) return;
3080
3423
  e.preventDefault();
3081
3424
  e.stopPropagation();
3425
+
3426
+ const channelId = btn.getAttribute('data-channel-id');
3082
3427
  const decision = (btn.getAttribute('data-decision') || '').trim();
3428
+
3429
+ if (channelId) {
3430
+ btn.classList.add('loading');
3431
+ btn.disabled = true;
3432
+ try {
3433
+ const res = await fetch('/api/judgments/distill-from-conversation', {
3434
+ method: 'POST',
3435
+ headers: { 'Content-Type': 'application/json' },
3436
+ body: JSON.stringify({ channelId }),
3437
+ });
3438
+ const out = await res.json();
3439
+ if (!res.ok) throw new Error(out.error || 'HTTP ' + res.status);
3440
+
3441
+ if (!out.triggered) {
3442
+ btn.classList.remove('loading');
3443
+ btn.disabled = false;
3444
+ btn.title = '蒸馏失败: ' + (out.reason || '无内容');
3445
+ return;
3446
+ }
3447
+
3448
+ const j = out.judgment;
3449
+ const ev = out.evolved || { merged: 0, superseded: 0 };
3450
+ btn.classList.remove('loading');
3451
+ btn.classList.add('saved');
3452
+ btn.title = '已蒸馏为判断';
3453
+
3454
+ // inline 确认弹框 (在按钮下方出现, 5 秒后自动消失)
3455
+ showDistillConfirm(btn, {
3456
+ value: j.decision,
3457
+ evidence: (j.reasons && j.reasons[0]) || '',
3458
+ merged: ev.merged,
3459
+ superseded: ev.superseded,
3460
+ onEdit: async (newText) => {
3461
+ try {
3462
+ await fetch('/api/judgments/' + encodeURIComponent(j.id), {
3463
+ method: 'PATCH',
3464
+ headers: { 'Content-Type': 'application/json' },
3465
+ body: JSON.stringify({ decision: newText }),
3466
+ });
3467
+ } catch (err) {
3468
+ console.error('[judgments] edit failed:', err);
3469
+ }
3470
+ },
3471
+ onReject: async () => {
3472
+ try {
3473
+ await fetch('/api/judgments/' + encodeURIComponent(j.id), {
3474
+ method: 'DELETE',
3475
+ });
3476
+ } catch (err) {
3477
+ console.error('[judgments] reject failed:', err);
3478
+ }
3479
+ },
3480
+ });
3481
+
3482
+ // 刷新判断力库缓存
3483
+ setTimeout(() => loadJudgments(), 100);
3484
+ } catch (err) {
3485
+ console.error('[judgments] distill-from-chat failed:', err);
3486
+ btn.classList.remove('loading');
3487
+ btn.disabled = false;
3488
+ btn.title = '蒸馏失败: ' + err.message;
3489
+ }
3490
+ return;
3491
+ }
3492
+
3493
+ // 老路径 fallback (没有 channelId, 直接存原文)
3083
3494
  if (!decision) return;
3084
3495
  try {
3085
3496
  const res = await fetch('/api/judgments', {
@@ -3091,12 +3502,65 @@ document.addEventListener('click', async (e) => {
3091
3502
  if (!out.ok) throw new Error(out.error || 'failed');
3092
3503
  btn.classList.add('saved');
3093
3504
  btn.title = '已存为判断';
3094
- // 顶部徽章会通过 setInterval 拉新数据, 不用手动触发
3095
3505
  } catch (err) {
3096
3506
  console.error('[judgments] save-from-chat failed:', err);
3097
3507
  btn.title = '保存失败: ' + err.message;
3098
3508
  }
3099
3509
  });
3510
+
3511
+ /**
3512
+ * inline 蒸馏确认弹框 — 在按钮下方出现, 显示凝练结果 + 演化结果
3513
+ * 5 秒后自动消失, 用户可点 "编辑" / "拒绝"
3514
+ */
3515
+ function showDistillConfirm(btn, opts) {
3516
+ const { value, evidence, merged, superseded, onEdit, onReject } = opts;
3517
+ const old = document.getElementById('distill-confirm-popup');
3518
+ if (old) old.remove();
3519
+
3520
+ const popup = document.createElement('div');
3521
+ popup.id = 'distill-confirm-popup';
3522
+ popup.style.cssText = `
3523
+ position:absolute; z-index:1000;
3524
+ background:#fff; border:1px solid #d1d5db; border-radius:6px;
3525
+ box-shadow:0 4px 12px rgba(0,0,0,0.1);
3526
+ padding:10px 12px; min-width:280px; max-width:380px;
3527
+ font-size:13px; color:#1f2937;
3528
+ `;
3529
+ let evolveNote = '';
3530
+ if (merged > 0 || superseded > 0) {
3531
+ evolveNote = `<div style="font-size:11px;color:#059669;margin-top:6px;">✓ 演化对齐: ${merged} 条已合并${superseded > 0 ? `, ${superseded} 条已淘汰` : ''}</div>`;
3532
+ }
3533
+ popup.innerHTML = `
3534
+ <div style="font-weight:600;margin-bottom:4px;">已蒸馏为判断力</div>
3535
+ <div style="background:#f9fafb;padding:6px 8px;border-radius:4px;line-height:1.4;">${escapeHtml(value)}</div>
3536
+ ${evidence ? `<div style="font-size:11px;color:#6b7280;margin-top:4px;">证据: ${escapeHtml(evidence)}</div>` : ''}
3537
+ ${evolveNote}
3538
+ <div style="display:flex;gap:6px;margin-top:8px;">
3539
+ <button class="dc-edit" style="background:none;border:1px solid #d1d5db;color:#374151;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">编辑</button>
3540
+ <button class="dc-reject" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">拒绝</button>
3541
+ <button class="dc-close" style="margin-left:auto;background:none;border:none;color:#6b7280;cursor:pointer;font-size:14px;">×</button>
3542
+ </div>
3543
+ `;
3544
+
3545
+ // 定位
3546
+ const rect = btn.getBoundingClientRect();
3547
+ popup.style.top = (window.scrollY + rect.bottom + 4) + 'px';
3548
+ popup.style.left = (window.scrollX + rect.left) + 'px';
3549
+ document.body.appendChild(popup);
3550
+
3551
+ // 绑定按钮
3552
+ popup.querySelector('.dc-edit').onclick = () => {
3553
+ const newText = prompt('编辑判断力:', value);
3554
+ if (newText && newText.trim() && onEdit) onEdit(newText.trim());
3555
+ popup.remove();
3556
+ };
3557
+ popup.querySelector('.dc-reject').onclick = () => {
3558
+ if (onReject) onReject();
3559
+ popup.remove();
3560
+ };
3561
+ popup.querySelector('.dc-close').onclick = () => popup.remove();
3562
+ setTimeout(() => popup.remove(), 5000);
3563
+ }
3100
3564
  if (judgmentSubmitBtn) judgmentSubmitBtn.addEventListener('click', submitJudgment);
3101
3565
 
3102
3566
  // 启动时拉一次, 让徽章显示总数 (不打开 modal 也能看到)