@winspan/claude-forge 8.13.1 → 8.15.0

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 (80) hide show
  1. package/dist/agents/definition.d.ts +53 -0
  2. package/dist/agents/definition.d.ts.map +1 -0
  3. package/dist/agents/definition.js +24 -0
  4. package/dist/agents/definition.js.map +1 -0
  5. package/dist/agents/distributor.d.ts +23 -0
  6. package/dist/agents/distributor.d.ts.map +1 -0
  7. package/dist/agents/distributor.js +85 -0
  8. package/dist/agents/distributor.js.map +1 -0
  9. package/dist/agents/index.d.ts +5 -0
  10. package/dist/agents/index.d.ts.map +1 -0
  11. package/dist/agents/index.js +5 -0
  12. package/dist/agents/index.js.map +1 -0
  13. package/dist/agents/official-agents.d.ts +14 -0
  14. package/dist/agents/official-agents.d.ts.map +1 -0
  15. package/dist/agents/official-agents.js +522 -0
  16. package/dist/agents/official-agents.js.map +1 -0
  17. package/dist/agents/registry.d.ts +27 -0
  18. package/dist/agents/registry.d.ts.map +1 -0
  19. package/dist/agents/registry.js +105 -0
  20. package/dist/agents/registry.js.map +1 -0
  21. package/dist/cli/commands/init.d.ts.map +1 -1
  22. package/dist/cli/commands/init.js +17 -0
  23. package/dist/cli/commands/init.js.map +1 -1
  24. package/dist/core/storage/schema.sql +60 -0
  25. package/dist/core/storage/sqlite.d.ts +73 -0
  26. package/dist/core/storage/sqlite.d.ts.map +1 -1
  27. package/dist/core/storage/sqlite.js +159 -0
  28. package/dist/core/storage/sqlite.js.map +1 -1
  29. package/dist/daemon/auto-disable-scheduler.d.ts +53 -0
  30. package/dist/daemon/auto-disable-scheduler.d.ts.map +1 -0
  31. package/dist/daemon/auto-disable-scheduler.js +114 -0
  32. package/dist/daemon/auto-disable-scheduler.js.map +1 -0
  33. package/dist/daemon/handlers/post-tool-use.d.ts +3 -1
  34. package/dist/daemon/handlers/post-tool-use.d.ts.map +1 -1
  35. package/dist/daemon/handlers/post-tool-use.js +14 -2
  36. package/dist/daemon/handlers/post-tool-use.js.map +1 -1
  37. package/dist/daemon/handlers/stop.d.ts +3 -1
  38. package/dist/daemon/handlers/stop.d.ts.map +1 -1
  39. package/dist/daemon/handlers/stop.js +14 -1
  40. package/dist/daemon/handlers/stop.js.map +1 -1
  41. package/dist/daemon/handlers/user-prompt.d.ts +18 -5
  42. package/dist/daemon/handlers/user-prompt.d.ts.map +1 -1
  43. package/dist/daemon/handlers/user-prompt.js +104 -21
  44. package/dist/daemon/handlers/user-prompt.js.map +1 -1
  45. package/dist/daemon/index.d.ts.map +1 -1
  46. package/dist/daemon/index.js +42 -12
  47. package/dist/daemon/index.js.map +1 -1
  48. package/dist/daemon/routing-observer.d.ts +39 -0
  49. package/dist/daemon/routing-observer.d.ts.map +1 -0
  50. package/dist/daemon/routing-observer.js +156 -0
  51. package/dist/daemon/routing-observer.js.map +1 -0
  52. package/dist/engine/agent-router.d.ts +99 -0
  53. package/dist/engine/agent-router.d.ts.map +1 -0
  54. package/dist/engine/agent-router.js +206 -0
  55. package/dist/engine/agent-router.js.map +1 -0
  56. package/dist/engine/conventions/routing.yaml +74 -0
  57. package/dist/engine/evidence-store.d.ts.map +1 -1
  58. package/dist/engine/evidence-store.js +3 -0
  59. package/dist/engine/evidence-store.js.map +1 -1
  60. package/dist/engine/experiment-router.d.ts +102 -0
  61. package/dist/engine/experiment-router.d.ts.map +1 -0
  62. package/dist/engine/experiment-router.js +289 -0
  63. package/dist/engine/experiment-router.js.map +1 -0
  64. package/dist/engine/recommender.d.ts +52 -0
  65. package/dist/engine/recommender.d.ts.map +1 -0
  66. package/dist/engine/recommender.js +150 -0
  67. package/dist/engine/recommender.js.map +1 -0
  68. package/dist/intelligence/classifier.d.ts +18 -4
  69. package/dist/intelligence/classifier.d.ts.map +1 -1
  70. package/dist/intelligence/classifier.js +90 -20
  71. package/dist/intelligence/classifier.js.map +1 -1
  72. package/dist/skills/registry.d.ts.map +1 -1
  73. package/dist/skills/registry.js +5 -2
  74. package/dist/skills/registry.js.map +1 -1
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +474 -0
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/static/index.html +764 -1
  80. package/package.json +1 -1
@@ -5,6 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Claude Forge 管理后台</title>
7
7
  <script src="vendor/chart.umd.min.js"></script>
8
+ <!-- Monaco Editor CDN (Phase 4 Feature 1) -->
9
+ <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
8
10
  <style>
9
11
  /* === Reset & Theme === */
10
12
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -356,6 +358,10 @@
356
358
  实时
357
359
  </a>
358
360
  <div class="nav-section-title">配置</div>
361
+ <a onclick="nav('routing')" id="nav-routing">
362
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M9 6h9M6 9v9"/></svg>
363
+ Agent 路由
364
+ </a>
359
365
  <a onclick="nav('rules')" id="nav-rules">
360
366
  <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
361
367
  规则
@@ -473,6 +479,152 @@
473
479
  </div>
474
480
  </div>
475
481
 
482
+ <!-- Agent Routing -->
483
+ <div id="page-routing" class="page">
484
+ <div class="toolbar" style="display:flex;gap:0.5rem;align-items:center">
485
+ <div style="display:inline-flex;border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden">
486
+ <button class="btn" id="routing-tab-overview" onclick="routingTab('overview')" style="border-radius:0;border:none">总览</button>
487
+ <button class="btn" id="routing-tab-events" onclick="routingTab('events')" style="border-radius:0;border:none">决策明细</button>
488
+ <button class="btn" id="routing-tab-refusals" onclick="routingTab('refusals')" style="border-radius:0;border:none">违抗聚合</button>
489
+ <button class="btn" id="routing-tab-editor" onclick="routingTab('editor')" style="border-radius:0;border:none">路由调优</button>
490
+ <button class="btn" id="routing-tab-experiments" onclick="routingTab('experiments')" style="border-radius:0;border:none">A/B 测试</button>
491
+ <button class="btn" id="routing-tab-recommendations" onclick="routingTab('recommendations')" style="border-radius:0;border:none">规则推荐</button>
492
+ </div>
493
+ <div style="flex:1"></div>
494
+ <select class="btn" id="routing-window" onchange="loadRouting()" style="display:none">
495
+ <option value="24">近 24 小时</option>
496
+ <option value="168" selected>近 7 天</option>
497
+ <option value="720">近 30 天</option>
498
+ </select>
499
+ </div>
500
+
501
+ <!-- Overview subpanel -->
502
+ <div id="routing-sub-overview">
503
+ <div class="cards" id="routing-cards"></div>
504
+ <div class="grid-2" style="margin-top:1.25rem">
505
+ <div class="panel">
506
+ <div class="panel-header"><span class="panel-title">按 Agent 听话率</span></div>
507
+ <div class="panel-body" id="routing-by-agent"></div>
508
+ </div>
509
+ <div class="panel">
510
+ <div class="panel-header"><span class="panel-title">注入话术版本 A/B</span></div>
511
+ <div class="panel-body" id="routing-by-version"></div>
512
+ </div>
513
+ </div>
514
+ </div>
515
+
516
+ <!-- Events subpanel -->
517
+ <div id="routing-sub-events" style="display:none">
518
+ <div class="toolbar" style="display:flex;gap:0.5rem">
519
+ <select class="btn" id="routing-filter-obeyed" onchange="loadRoutingEvents()">
520
+ <option value="">全部状态</option>
521
+ <option value="1">遵守 (obeyed)</option>
522
+ <option value="0">违抗 (refused)</option>
523
+ <option value="null">未判定 (null)</option>
524
+ </select>
525
+ <input class="search-box" id="routing-filter-agent" placeholder="按 agent 名过滤..." oninput="loadRoutingEvents()">
526
+ </div>
527
+ <div class="panel">
528
+ <table>
529
+ <thead><tr><th>时间</th><th>Agent</th><th>状态</th><th>首个工具</th><th>Prompt 摘要</th></tr></thead>
530
+ <tbody id="routing-events-tbody"></tbody>
531
+ </table>
532
+ </div>
533
+ </div>
534
+
535
+ <!-- Refusals subpanel -->
536
+ <div id="routing-sub-refusals" style="display:none">
537
+ <div class="panel">
538
+ <div class="panel-header"><span class="panel-title">违抗聚合 (taskType × agent)</span></div>
539
+ <div class="panel-body" id="routing-refusals"></div>
540
+ </div>
541
+ </div>
542
+
543
+ <!-- Editor subpanel (Phase 3 Feature 2, upgraded to Monaco in Phase 4 Feature 1) -->
544
+ <div id="routing-sub-editor" style="display:none">
545
+ <div class="panel">
546
+ <div class="panel-header">
547
+ <span class="panel-title">路由配置编辑器</span>
548
+ <div style="display:flex;gap:0.5rem">
549
+ <button class="btn" onclick="loadRoutingConfig()">↻ 重新加载</button>
550
+ <button class="btn btn-primary" onclick="saveRoutingConfig()">💾 保存</button>
551
+ </div>
552
+ </div>
553
+ <div class="panel-body">
554
+ <div style="margin-bottom:0.5rem;font-size:0.875rem;color:var(--text-dim)">
555
+ <span id="routing-config-source"></span>
556
+ <span id="routing-config-path" style="font-family:monospace;margin-left:0.5rem"></span>
557
+ </div>
558
+ <div id="routing-config-editor" style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius-sm)"></div>
559
+ <div id="routing-config-error" style="margin-top:0.5rem;padding:0.75rem;background:var(--danger,#dc2626);color:white;border-radius:var(--radius-sm);display:none"></div>
560
+ <div id="routing-config-success" style="margin-top:0.5rem;padding:0.75rem;background:var(--primary);color:white;border-radius:var(--radius-sm);display:none"></div>
561
+ </div>
562
+ </div>
563
+ </div>
564
+
565
+ <!-- Experiments subpanel (Phase 5 Feature 1) -->
566
+ <div id="routing-sub-experiments" style="display:none">
567
+ <div class="panel" style="margin-bottom:1rem">
568
+ <div class="panel-header">
569
+ <span class="panel-title">实验配置</span>
570
+ <div style="display:flex;gap:0.5rem">
571
+ <button class="btn" onclick="loadExperimentsConfig()">↻ 重新加载</button>
572
+ <button class="btn btn-primary" onclick="saveExperimentsConfig()">💾 保存</button>
573
+ </div>
574
+ </div>
575
+ <div class="panel-body">
576
+ <div style="margin-bottom:0.5rem;font-size:0.875rem;color:var(--text-dim)">
577
+ <span id="experiments-config-source"></span>
578
+ <span id="experiments-config-path" style="font-family:monospace;margin-left:0.5rem"></span>
579
+ </div>
580
+ <div id="experiments-config-editor" style="width:100%;height:400px;border:1px solid var(--border);border-radius:var(--radius-sm)"></div>
581
+ <div id="experiments-config-error" style="margin-top:0.5rem;padding:0.75rem;background:var(--danger,#dc2626);color:white;border-radius:var(--radius-sm);display:none"></div>
582
+ <div id="experiments-config-success" style="margin-top:0.5rem;padding:0.75rem;background:var(--primary);color:white;border-radius:var(--radius-sm);display:none"></div>
583
+ </div>
584
+ </div>
585
+ <div class="panel">
586
+ <div class="panel-header">
587
+ <span class="panel-title">分析对比</span>
588
+ <div style="display:flex;gap:0.5rem">
589
+ <button class="btn" onclick="loadExperimentsAnalysis()">↻ 刷新分析</button>
590
+ </div>
591
+ </div>
592
+ <div class="panel-body">
593
+ <div id="experiments-summary" style="margin-bottom:1rem;font-size:0.875rem"></div>
594
+ <div id="experiments-analysis"></div>
595
+ <div id="experiments-winner" style="margin-top:1rem;display:none"></div>
596
+ </div>
597
+ </div>
598
+ </div>
599
+
600
+ <!-- Recommendations subpanel (Phase 5 Feature 3) -->
601
+ <div id="routing-sub-recommendations" style="display:none">
602
+ <div class="panel">
603
+ <div class="panel-header">
604
+ <span class="panel-title">规则推荐</span>
605
+ <div style="display:flex;gap:0.5rem;align-items:center">
606
+ <label style="font-size:0.875rem;color:var(--text-dim)">窗口
607
+ <select class="btn" id="recommendations-days" onchange="loadRecommendations()">
608
+ <option value="1">1 天</option>
609
+ <option value="7" selected>7 天</option>
610
+ <option value="30">30 天</option>
611
+ </select>
612
+ </label>
613
+ <button class="btn" onclick="loadRecommendations()">↻ 刷新</button>
614
+ </div>
615
+ </div>
616
+ <div class="panel-body">
617
+ <div style="margin-bottom:0.75rem;font-size:0.875rem;color:var(--text-dim)">
618
+ 基于历史决策分析,对比"当前路由规则"与"Claude 实际使用的 agent",
619
+ 推荐更贴近实际行为的路由。<br>
620
+ 样本阈值:单类任务 ≥ 10 条;挑战者使用率 &gt; 50%。
621
+ </div>
622
+ <div id="recommendations-list"></div>
623
+ </div>
624
+ </div>
625
+ </div>
626
+ </div>
627
+
476
628
  </div><!-- /container -->
477
629
  </main>
478
630
 
@@ -506,7 +658,7 @@ function nav(page) {
506
658
  const pageEl = document.getElementById('page-' + page);
507
659
  if (navEl) navEl.classList.add('active');
508
660
  if (pageEl) pageEl.classList.add('active');
509
- const titles = { dashboard:'仪表盘', sessions:'会话', events:'事件', injections:'注入', live:'实时', rules:'规则' };
661
+ const titles = { dashboard:'仪表盘', sessions:'会话', events:'事件', injections:'注入', live:'实时', rules:'规则', routing:'Agent 路由' };
510
662
  document.getElementById('topbar-title').textContent = titles[page] || page;
511
663
  closeDrawer();
512
664
  if (page === 'dashboard') loadDashboard();
@@ -514,6 +666,7 @@ function nav(page) {
514
666
  else if (page === 'events') loadEvents();
515
667
  else if (page === 'injections') loadInjections();
516
668
  else if (page === 'rules') loadRules();
669
+ else if (page === 'routing') loadRouting();
517
670
  else if (page === 'live' && !liveSource) toggleLive();
518
671
  }
519
672
 
@@ -959,6 +1112,616 @@ function openRuleDrawer(r) {
959
1112
  openDrawer(r.name || '规则', html);
960
1113
  }
961
1114
 
1115
+ // === Agent Routing ===
1116
+ let routingCurrentTab = 'overview';
1117
+
1118
+ function routingTab(tab) {
1119
+ routingCurrentTab = tab;
1120
+ ['overview', 'events', 'refusals', 'editor', 'experiments', 'recommendations'].forEach(t => {
1121
+ const btn = document.getElementById('routing-tab-' + t);
1122
+ const sub = document.getElementById('routing-sub-' + t);
1123
+ if (btn) btn.classList.toggle('active', t === tab);
1124
+ if (sub) sub.style.display = (t === tab) ? '' : 'none';
1125
+ });
1126
+
1127
+ // Show/hide time window selector (not needed for editor/experiments/recommendations tabs)
1128
+ const windowSelector = document.getElementById('routing-window');
1129
+ if (windowSelector) {
1130
+ windowSelector.style.display =
1131
+ (tab === 'editor' || tab === 'experiments' || tab === 'recommendations') ? 'none' : '';
1132
+ }
1133
+
1134
+ loadRouting();
1135
+ }
1136
+
1137
+ async function loadRouting() {
1138
+ if (routingCurrentTab === 'overview') return loadRoutingOverview();
1139
+ if (routingCurrentTab === 'events') return loadRoutingEvents();
1140
+ if (routingCurrentTab === 'refusals') return loadRoutingRefusals();
1141
+ if (routingCurrentTab === 'editor') return loadRoutingConfig();
1142
+ if (routingCurrentTab === 'experiments') {
1143
+ await loadExperimentsConfig();
1144
+ return loadExperimentsAnalysis();
1145
+ }
1146
+ if (routingCurrentTab === 'recommendations') return loadRecommendations();
1147
+ }
1148
+
1149
+ function routingWindow() {
1150
+ const el = document.getElementById('routing-window');
1151
+ return el ? el.value : '168';
1152
+ }
1153
+
1154
+ function pct(n) { return n == null ? '—' : (n * 100).toFixed(1) + '%'; }
1155
+ function ms(n) { return n == null ? '—' : n + 'ms'; }
1156
+
1157
+ async function loadRoutingOverview() {
1158
+ try {
1159
+ const r = await fetch(API + '/api/routing/stats?window=' + routingWindow());
1160
+ const data = await r.json();
1161
+
1162
+ const statusColor =
1163
+ data.obedienceRate == null ? 'badge-info' :
1164
+ data.obedienceRate >= 0.8 ? 'badge-allow' :
1165
+ data.obedienceRate >= 0.6 ? 'badge-warn' : 'badge-block';
1166
+
1167
+ document.getElementById('routing-cards').innerHTML = [
1168
+ card('事件总数', data.total),
1169
+ card('强路由次数', data.forced),
1170
+ card('听话率', pct(data.obedienceRate), statusColor),
1171
+ card('违抗率', pct(data.refusalRate)),
1172
+ card('兜底比例', pct(data.fallbackRate)),
1173
+ card('AI 分类 p95', ms(data.latency.p95)),
1174
+ ].join('');
1175
+
1176
+ const byAgent = Object.entries(data.byAgent || {})
1177
+ .sort((a, b) => b[1].total - a[1].total);
1178
+ document.getElementById('routing-by-agent').innerHTML = byAgent.length === 0
1179
+ ? empty('暂无数据')
1180
+ : '<table><thead><tr><th>Agent</th><th>总数</th><th>遵守</th><th>违抗</th><th>未判定</th><th>听话率</th></tr></thead><tbody>'
1181
+ + byAgent.map(([name, b]) => {
1182
+ const rate = (b.obeyed + b.refused) === 0 ? null : b.obeyed / (b.obeyed + b.refused);
1183
+ return '<tr><td style="font-family:monospace">' + name + '</td><td>' + b.total + '</td>'
1184
+ + '<td style="color:var(--primary)">' + b.obeyed + '</td>'
1185
+ + '<td style="color:var(--danger,#dc2626)">' + b.refused + '</td>'
1186
+ + '<td style="color:var(--text-dim)">' + b.unknown + '</td>'
1187
+ + '<td>' + pct(rate) + '</td></tr>';
1188
+ }).join('')
1189
+ + '</tbody></table>';
1190
+
1191
+ const byVersion = Object.entries(data.byVersion || {});
1192
+ document.getElementById('routing-by-version').innerHTML = byVersion.length === 0
1193
+ ? empty('暂无版本数据')
1194
+ : '<table><thead><tr><th>版本</th><th>总数</th><th>遵守</th><th>听话率</th></tr></thead><tbody>'
1195
+ + byVersion.map(([v, b]) => '<tr><td style="font-family:monospace;font-size:0.8rem">' + v
1196
+ + '</td><td>' + b.total + '</td><td>' + b.obeyed + '</td><td>'
1197
+ + pct(b.total === 0 ? null : b.obeyed / b.total) + '</td></tr>').join('')
1198
+ + '</tbody></table>';
1199
+ } catch (err) {
1200
+ document.getElementById('routing-cards').innerHTML = empty('加载失败: ' + err.message);
1201
+ }
1202
+ }
1203
+
1204
+ function card(label, value, color) {
1205
+ return '<div class="panel" style="padding:1rem">'
1206
+ + '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.25rem">' + label + '</div>'
1207
+ + '<div style="font-size:1.5rem;font-weight:600' + (color ? ';color:var(--primary)' : '') + '">' + value + '</div>'
1208
+ + '</div>';
1209
+ }
1210
+
1211
+ async function loadRoutingEvents() {
1212
+ try {
1213
+ const obeyed = document.getElementById('routing-filter-obeyed')?.value || '';
1214
+ const agent = document.getElementById('routing-filter-agent')?.value || '';
1215
+ const params = new URLSearchParams({ limit: '100' });
1216
+ if (obeyed !== '') params.set('obeyed', obeyed);
1217
+ if (agent) params.set('agent', agent);
1218
+ const r = await fetch(API + '/api/routing/events?' + params.toString());
1219
+ const rows = await r.json();
1220
+ const tbody = document.getElementById('routing-events-tbody');
1221
+ if (!rows || rows.length === 0) {
1222
+ tbody.innerHTML = '<tr><td colspan="5">' + empty('暂无路由事件') + '</td></tr>';
1223
+ return;
1224
+ }
1225
+ tbody.innerHTML = rows.map(e => {
1226
+ const status = e.obeyed === 1 ? '<span class="badge badge-allow">遵守</span>'
1227
+ : e.obeyed === 0 ? '<span class="badge badge-block">违抗</span>'
1228
+ : '<span class="badge badge-info">未判定</span>';
1229
+ const ts = new Date(e.ts).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
1230
+ return '<tr onclick="openRoutingEventDrawer(' + JSON.stringify(e).replace(/"/g,'&quot;') + ')">'
1231
+ + '<td style="color:var(--text-dim);font-size:0.8rem">' + ts + '</td>'
1232
+ + '<td style="font-family:monospace">' + (e.routed_to_name || '—') + '</td>'
1233
+ + '<td>' + status + '</td>'
1234
+ + '<td style="font-size:0.8rem;color:var(--text-dim)">' + (e.first_tool_name || '—') + '</td>'
1235
+ + '<td style="font-size:0.8rem">' + (e.prompt || '').slice(0, 80) + '</td></tr>';
1236
+ }).join('');
1237
+ } catch (err) {
1238
+ document.getElementById('routing-events-tbody').innerHTML = '<tr><td colspan="5">' + empty('加载失败: ' + err.message) + '</td></tr>';
1239
+ }
1240
+ }
1241
+
1242
+ function openRoutingEventDrawer(e) {
1243
+ let intent = {};
1244
+ try { intent = JSON.parse(e.intent_json || '{}'); } catch { /* ignore */ }
1245
+ let chain = [];
1246
+ try { chain = JSON.parse(e.downstream_task_chain || '[]'); } catch { /* ignore */ }
1247
+ const html = [
1248
+ '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">Prompt</div>',
1249
+ '<div style="white-space:pre-wrap;font-size:0.875rem">' + (e.prompt || '—') + '</div></div>',
1250
+ '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">Intent</div>',
1251
+ '<pre style="font-size:0.75rem;background:var(--bg-secondary);padding:0.5rem;border-radius:4px;overflow:auto">'
1252
+ + JSON.stringify(intent, null, 2) + '</pre></div>',
1253
+ '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">路由</div>',
1254
+ '<div>type=<b>' + (e.routed_to_type || '—') + '</b>, name=<b>' + (e.routed_to_name || '—')
1255
+ + '</b>, is_forced=' + (e.is_forced ? 'yes' : 'no') + '</div></div>',
1256
+ '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">判定</div>',
1257
+ '<div>obeyed=<b>' + (e.obeyed === null || e.obeyed === undefined ? 'null' : e.obeyed)
1258
+ + '</b>, first_tool=' + (e.first_tool_name || '—') + '</div></div>',
1259
+ chain.length > 0 ? '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">下游 Task 链</div><code>'
1260
+ + chain.join(' → ') + '</code></div>' : '',
1261
+ e.refusal_reason ? '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">违抗原因</div>'
1262
+ + '<div style="padding:0.5rem;background:var(--bg-secondary);border-left:3px solid var(--danger,#dc2626)">'
1263
+ + e.refusal_reason + '</div></div>' : '',
1264
+ '<div style="font-size:0.75rem;color:var(--text-dim)">classification_ms=' + (e.classification_ms || '—')
1265
+ + ' · fallback_used=' + (e.fallback_used ? 'yes' : 'no')
1266
+ + ' · injection_version=' + (e.injection_version || '—') + '</div>',
1267
+ ].join('');
1268
+ openDrawer('路由事件 #' + e.id, html);
1269
+ }
1270
+
1271
+ async function loadRoutingRefusals() {
1272
+ try {
1273
+ // Load refusals, violations, and rule-states in parallel.
1274
+ const [refusalsRes, violationsRes, rulesRes] = await Promise.all([
1275
+ fetch(API + '/api/routing/refusals?window=' + routingWindow()),
1276
+ fetch(API + '/api/routing/violations?window=' + routingWindow()),
1277
+ fetch(API + '/api/routing/rule-states'),
1278
+ ]);
1279
+ const refusalsData = await refusalsRes.json();
1280
+ const violationsData = await violationsRes.json();
1281
+ const ruleStatesData = await rulesRes.json();
1282
+ const disabledMap = new Map();
1283
+ (ruleStatesData.ruleStates || []).forEach(s => {
1284
+ disabledMap.set(s.task_type + '__' + s.agent, s);
1285
+ });
1286
+
1287
+ const container = document.getElementById('routing-refusals');
1288
+
1289
+ // Show violations alerts first (Phase 3 Feature 3)
1290
+ let html = '';
1291
+ if (violationsData.violations && violationsData.violations.length > 0) {
1292
+ const criticalViolations = violationsData.violations.filter(v => v.severity === 'critical' || v.severity === 'high');
1293
+ if (criticalViolations.length > 0) {
1294
+ html += '<div style="margin-bottom:1rem;padding:1rem;background:var(--danger,#dc2626);color:white;border-radius:var(--radius-sm)">';
1295
+ html += '<div style="font-weight:600;margin-bottom:0.5rem">⚠️ 检测到 ' + criticalViolations.length + ' 个高风险违抗模式</div>';
1296
+ html += '<div style="font-size:0.875rem">以下路由规则的违抗率过高,建议调整或禁用:</div>';
1297
+ html += '<ul style="margin:0.5rem 0 0 1.5rem;padding:0">';
1298
+ criticalViolations.forEach(v => {
1299
+ html += '<li style="margin-bottom:0.25rem"><code>' + v.taskType + '</code> → <code>' + v.agent + '</code> ';
1300
+ html += '(违抗率 ' + (v.refusalRate * 100).toFixed(0) + '%, ' + v.refusals + '/' + v.totalAttempts + ' 次)</li>';
1301
+ });
1302
+ html += '</ul></div>';
1303
+ }
1304
+
1305
+ // Show all violations with severity badges
1306
+ html += '<div style="margin-bottom:1rem"><div style="font-weight:600;margin-bottom:0.5rem">违抗模式分析</div>';
1307
+ violationsData.violations.forEach(v => {
1308
+ const severityColor = {
1309
+ critical: 'background:#dc2626;color:white',
1310
+ high: 'background:#f59e0b;color:white',
1311
+ medium: 'background:#3b82f6;color:white',
1312
+ low: 'background:var(--bg-secondary);color:var(--text-dim)',
1313
+ }[v.severity];
1314
+ const severityLabel = { critical: '严重', high: '高', medium: '中', low: '低' }[v.severity];
1315
+
1316
+ html += '<details style="margin-bottom:0.5rem;border:1px solid var(--border);border-radius:4px;padding:0.5rem">';
1317
+ html += '<summary style="cursor:pointer;display:flex;gap:0.5rem;align-items:center">';
1318
+ html += '<span style="padding:0.25rem 0.5rem;border-radius:4px;font-size:0.75rem;font-weight:600;' + severityColor + '">' + severityLabel + '</span>';
1319
+ html += '<code>' + v.taskType + '</code> → <code>' + v.agent + '</code>';
1320
+ const state = disabledMap.get(v.taskType + '__' + v.agent);
1321
+ if (state && state.disabled === 1) {
1322
+ const tag = state.auto_disabled === 1 ? '自动禁用' : '手动禁用';
1323
+ html += '<span style="padding:0.25rem 0.5rem;border-radius:4px;font-size:0.75rem;background:var(--text-dim);color:white">' + tag + '</span>';
1324
+ }
1325
+ html += '<span style="margin-left:auto;font-size:0.875rem;color:var(--text-dim)">违抗率 ' + (v.refusalRate * 100).toFixed(0) + '% (' + v.refusals + '/' + v.totalAttempts + ')</span>';
1326
+ const isDisabled = state && state.disabled === 1;
1327
+ const btnLabel = isDisabled ? '启用' : '禁用';
1328
+ html += '<button class="btn" style="margin-left:0.5rem;padding:0.25rem 0.5rem;font-size:0.75rem" onclick="event.preventDefault();event.stopPropagation();toggleRuleState(\'' + v.taskType + '\', \'' + v.agent + '\', ' + (!isDisabled) + ')">' + btnLabel + '</button>';
1329
+ html += '</summary>';
1330
+ html += '<div style="margin-top:0.5rem;padding:0.5rem;background:var(--bg-secondary);border-radius:4px;font-size:0.875rem">';
1331
+ html += '<div style="margin-bottom:0.5rem">最近 5 次尝试中违抗 ' + v.recentRefusals + ' 次</div>';
1332
+ if (v.samples.length > 0) {
1333
+ html += '<div style="font-weight:600;margin-bottom:0.25rem">样例:</div><ul style="margin:0;padding:0 0 0 1rem">';
1334
+ v.samples.forEach(s => {
1335
+ html += '<li style="margin-bottom:0.5rem">';
1336
+ html += '<div>' + s.prompt + '</div>';
1337
+ if (s.refusal_reason) {
1338
+ html += '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:2px">↳ ' + s.refusal_reason + '</div>';
1339
+ }
1340
+ html += '</li>';
1341
+ });
1342
+ html += '</ul>';
1343
+ }
1344
+ html += '</div></details>';
1345
+ });
1346
+ html += '</div>';
1347
+ }
1348
+
1349
+ // Show original refusals grouping (for backward compatibility)
1350
+ if (!refusalsData.groups || refusalsData.groups.length === 0) {
1351
+ if (html === '') {
1352
+ container.innerHTML = empty('暂无违抗事件');
1353
+ } else {
1354
+ container.innerHTML = html;
1355
+ }
1356
+ return;
1357
+ }
1358
+
1359
+ html += '<div style="margin-top:1rem;padding-top:1rem;border-top:1px solid var(--border)">';
1360
+ html += '<div style="font-weight:600;margin-bottom:0.5rem">违抗事件列表</div>';
1361
+ html += refusalsData.groups.map(g => {
1362
+ const samples = g.samples.map(s =>
1363
+ '<li style="margin-bottom:0.5rem">'
1364
+ + '<div style="font-size:0.8rem">' + s.prompt + '</div>'
1365
+ + (s.refusal_reason ? '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:2px">↳ ' + s.refusal_reason + '</div>' : '')
1366
+ + '</li>'
1367
+ ).join('');
1368
+ return '<details style="margin-bottom:0.5rem;border:1px solid var(--border);border-radius:4px;padding:0.5rem">'
1369
+ + '<summary style="cursor:pointer;display:flex;gap:0.5rem;align-items:center">'
1370
+ + '<span class="badge badge-block">' + g.count + '</span>'
1371
+ + '<code>' + g.taskType + '</code> → <code>' + g.agent + '</code></summary>'
1372
+ + '<ul style="margin:0.5rem 0 0 1rem;padding:0">' + samples + '</ul>'
1373
+ + '</details>';
1374
+ }).join('');
1375
+ html += '</div>';
1376
+
1377
+ container.innerHTML = html;
1378
+ } catch (err) {
1379
+ document.getElementById('routing-refusals').innerHTML = empty('加载失败: ' + err.message);
1380
+ }
1381
+ }
1382
+
1383
+ async function toggleRuleState(taskType, agent, disabled) {
1384
+ const verb = disabled ? '禁用' : '启用';
1385
+ if (!confirm(verb + ' ' + taskType + ' → ' + agent + ' 的路由规则?')) return;
1386
+ try {
1387
+ const r = await fetch(API + '/api/routing/rule-states', {
1388
+ method: 'PUT',
1389
+ headers: { 'Content-Type': 'application/json' },
1390
+ body: JSON.stringify({ taskType, agent, disabled, reason: disabled ? '手动禁用(Web UI)' : null }),
1391
+ });
1392
+ if (!r.ok) {
1393
+ const err = await r.json();
1394
+ throw new Error(err.error || 'Unknown error');
1395
+ }
1396
+ await loadRoutingRefusals();
1397
+ } catch (err) {
1398
+ alert(verb + '失败: ' + err.message);
1399
+ }
1400
+ }
1401
+
1402
+ // === Routing Config Editor (Phase 3 Feature 2, upgraded to Monaco in Phase 4 Feature 1) ===
1403
+
1404
+ let monacoEditor = null;
1405
+
1406
+ function initMonacoEditor() {
1407
+ if (monacoEditor) return; // already initialized
1408
+
1409
+ require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
1410
+ require(['vs/editor/editor.main'], function () {
1411
+ monacoEditor = monaco.editor.create(document.getElementById('routing-config-editor'), {
1412
+ value: '',
1413
+ language: 'yaml',
1414
+ theme: 'vs',
1415
+ automaticLayout: true,
1416
+ minimap: { enabled: false },
1417
+ scrollBeyondLastLine: false,
1418
+ fontSize: 14,
1419
+ lineNumbers: 'on',
1420
+ renderWhitespace: 'selection',
1421
+ tabSize: 2,
1422
+ });
1423
+ });
1424
+ }
1425
+
1426
+ async function loadRoutingConfig() {
1427
+ try {
1428
+ const r = await fetch(API + '/api/routing/config');
1429
+ const data = await r.json();
1430
+
1431
+ // Initialize Monaco Editor if not already done
1432
+ if (!monacoEditor) {
1433
+ initMonacoEditor();
1434
+ // Wait for Monaco to initialize
1435
+ await new Promise(resolve => {
1436
+ const checkInterval = setInterval(() => {
1437
+ if (monacoEditor) {
1438
+ clearInterval(checkInterval);
1439
+ resolve();
1440
+ }
1441
+ }, 100);
1442
+ });
1443
+ }
1444
+
1445
+ // Set editor content
1446
+ if (monacoEditor) {
1447
+ monacoEditor.setValue(data.content || '');
1448
+ }
1449
+
1450
+ const sourceLabel = data.source === 'user' ? '用户配置' : data.source === 'default' ? '默认配置' : '无配置';
1451
+ document.getElementById('routing-config-source').textContent = sourceLabel;
1452
+ document.getElementById('routing-config-path').textContent = data.source === 'user' ? data.userPath : data.defaultPath;
1453
+
1454
+ // Hide error/success messages
1455
+ document.getElementById('routing-config-error').style.display = 'none';
1456
+ document.getElementById('routing-config-success').style.display = 'none';
1457
+ } catch (err) {
1458
+ document.getElementById('routing-config-error').textContent = '加载失败: ' + err.message;
1459
+ document.getElementById('routing-config-error').style.display = 'block';
1460
+ }
1461
+ }
1462
+
1463
+ async function saveRoutingConfig() {
1464
+ if (!monacoEditor) {
1465
+ alert('编辑器未初始化');
1466
+ return;
1467
+ }
1468
+
1469
+ const content = monacoEditor.getValue();
1470
+ const errorEl = document.getElementById('routing-config-error');
1471
+ const successEl = document.getElementById('routing-config-success');
1472
+
1473
+ errorEl.style.display = 'none';
1474
+ successEl.style.display = 'none';
1475
+
1476
+ try {
1477
+ const r = await fetch(API + '/api/routing/config', {
1478
+ method: 'PUT',
1479
+ headers: { 'Content-Type': 'application/json' },
1480
+ body: JSON.stringify({ content }),
1481
+ });
1482
+
1483
+ if (!r.ok) {
1484
+ const err = await r.json();
1485
+ throw new Error(err.error || 'Unknown error');
1486
+ }
1487
+
1488
+ const result = await r.json();
1489
+ successEl.textContent = '✓ 保存成功!配置已写入 ' + result.path + '(热加载将在几秒内生效)';
1490
+ successEl.style.display = 'block';
1491
+
1492
+ // Reload to show updated source
1493
+ setTimeout(() => loadRoutingConfig(), 1000);
1494
+ } catch (err) {
1495
+ errorEl.textContent = '保存失败: ' + err.message;
1496
+ errorEl.style.display = 'block';
1497
+ }
1498
+ }
1499
+
1500
+ // === A/B Testing (Phase 5 Feature 1) ===
1501
+
1502
+ let experimentsEditor = null;
1503
+
1504
+ function initExperimentsEditor() {
1505
+ if (experimentsEditor) return;
1506
+ require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
1507
+ require(['vs/editor/editor.main'], function () {
1508
+ experimentsEditor = monaco.editor.create(document.getElementById('experiments-config-editor'), {
1509
+ value: '',
1510
+ language: 'yaml',
1511
+ theme: 'vs',
1512
+ automaticLayout: true,
1513
+ minimap: { enabled: false },
1514
+ scrollBeyondLastLine: false,
1515
+ fontSize: 14,
1516
+ lineNumbers: 'on',
1517
+ renderWhitespace: 'selection',
1518
+ tabSize: 2,
1519
+ });
1520
+ });
1521
+ }
1522
+
1523
+ async function loadExperimentsConfig() {
1524
+ try {
1525
+ const r = await fetch(API + '/api/routing/experiments/config');
1526
+ const data = await r.json();
1527
+
1528
+ if (!experimentsEditor) {
1529
+ initExperimentsEditor();
1530
+ await new Promise(resolve => {
1531
+ const tick = setInterval(() => {
1532
+ if (experimentsEditor) { clearInterval(tick); resolve(); }
1533
+ }, 100);
1534
+ });
1535
+ }
1536
+
1537
+ experimentsEditor.setValue(data.content || '');
1538
+ document.getElementById('experiments-config-source').textContent =
1539
+ data.source === 'user' ? '用户配置' : '未配置(保存将创建新文件)';
1540
+ document.getElementById('experiments-config-path').textContent = data.path || '';
1541
+
1542
+ document.getElementById('experiments-config-error').style.display = 'none';
1543
+ document.getElementById('experiments-config-success').style.display = 'none';
1544
+ } catch (err) {
1545
+ document.getElementById('experiments-config-error').textContent = '加载失败: ' + err.message;
1546
+ document.getElementById('experiments-config-error').style.display = 'block';
1547
+ }
1548
+ }
1549
+
1550
+ async function saveExperimentsConfig() {
1551
+ if (!experimentsEditor) { alert('编辑器未初始化'); return; }
1552
+ const content = experimentsEditor.getValue();
1553
+ const errorEl = document.getElementById('experiments-config-error');
1554
+ const successEl = document.getElementById('experiments-config-success');
1555
+ errorEl.style.display = 'none';
1556
+ successEl.style.display = 'none';
1557
+
1558
+ try {
1559
+ const r = await fetch(API + '/api/routing/experiments/config', {
1560
+ method: 'PUT',
1561
+ headers: { 'Content-Type': 'application/json' },
1562
+ body: JSON.stringify({ content }),
1563
+ });
1564
+ if (!r.ok) {
1565
+ const err = await r.json();
1566
+ throw new Error(err.error || 'Unknown error');
1567
+ }
1568
+ const result = await r.json();
1569
+ successEl.textContent = '✓ 保存成功!配置已写入 ' + result.path;
1570
+ successEl.style.display = 'block';
1571
+ setTimeout(() => { loadExperimentsConfig(); loadExperimentsAnalysis(); }, 1000);
1572
+ } catch (err) {
1573
+ errorEl.textContent = '保存失败: ' + err.message;
1574
+ errorEl.style.display = 'block';
1575
+ }
1576
+ }
1577
+
1578
+ async function loadExperimentsAnalysis() {
1579
+ const summaryEl = document.getElementById('experiments-summary');
1580
+ const tableEl = document.getElementById('experiments-analysis');
1581
+ const winnerEl = document.getElementById('experiments-winner');
1582
+ summaryEl.innerHTML = '';
1583
+ tableEl.innerHTML = loading();
1584
+ winnerEl.style.display = 'none';
1585
+ winnerEl.innerHTML = '';
1586
+
1587
+ try {
1588
+ const r = await fetch(API + '/api/routing/experiments/analysis');
1589
+ const data = await r.json();
1590
+
1591
+ if (!data.experimentId) {
1592
+ tableEl.innerHTML = empty('当前没有活跃实验。编辑上方 YAML 配置并保存后即可开始。');
1593
+ return;
1594
+ }
1595
+
1596
+ const endedLabel = data.endedAt ? ' · 已结束 ' + fmt(data.endedAt) : (data.enabled ? ' · 进行中' : ' · 未启用');
1597
+ summaryEl.innerHTML =
1598
+ '<strong>' + (data.experimentName || data.experimentId) + '</strong>' +
1599
+ '<span style="color:var(--text-dim)"> (' + data.experimentId + ')</span>' +
1600
+ '<span style="color:var(--text-dim)">' + endedLabel + '</span>' +
1601
+ '<br><small style="color:var(--text-dim)">开始于 ' + fmt(data.startedAt) + '</small>';
1602
+
1603
+ const rows = (data.groups || []).map(g => {
1604
+ const rate = g.obeyedRate == null ? '—' : (g.obeyedRate * 100).toFixed(1) + '%';
1605
+ const avg = g.avgClassificationMs == null ? '—' : g.avgClassificationMs.toFixed(0) + 'ms';
1606
+ return '<tr>' +
1607
+ '<td><strong>' + g.id + '</strong> <span style="color:var(--text-dim)">(' + g.name + ')</span></td>' +
1608
+ '<td>' + g.weight + '%</td>' +
1609
+ '<td>' + g.total + '</td>' +
1610
+ '<td>' + g.obeyed + '</td>' +
1611
+ '<td>' + g.refused + '</td>' +
1612
+ '<td>' + g.unknown + '</td>' +
1613
+ '<td>' + rate + '</td>' +
1614
+ '<td>' + avg + '</td>' +
1615
+ '</tr>';
1616
+ }).join('');
1617
+
1618
+ tableEl.innerHTML =
1619
+ '<table>' +
1620
+ '<thead><tr>' +
1621
+ '<th>组</th><th>权重</th><th>样本</th><th>听话</th><th>违抗</th><th>未知</th><th>听话率</th><th>平均分类耗时</th>' +
1622
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
1623
+
1624
+ if (data.zScore !== null && data.zScore !== undefined) {
1625
+ const zline =
1626
+ '<div style="margin-top:0.5rem;font-size:0.875rem;color:var(--text-dim)">' +
1627
+ 'z-score = ' + data.zScore.toFixed(3) +
1628
+ (data.sampleAdequate ? ' (样本充足)' : ' (样本不足,需每组 ≥ 50)') +
1629
+ '</div>';
1630
+ tableEl.insertAdjacentHTML('beforeend', zline);
1631
+ }
1632
+
1633
+ if (data.suggestedWinner && !data.endedAt) {
1634
+ winnerEl.style.display = 'block';
1635
+ winnerEl.innerHTML =
1636
+ '<div style="padding:1rem;background:var(--primary);color:white;border-radius:var(--radius-sm)">' +
1637
+ '<div style="margin-bottom:0.5rem"><strong>建议获胜方案:' + data.suggestedWinner + '</strong> (z &gt; 1.96)</div>' +
1638
+ '<button class="btn" style="background:white;color:var(--primary)" onclick="promoteExperiment(\'' + data.suggestedWinner + '\')">一键应用该组规则到 routing.yaml</button>' +
1639
+ '</div>';
1640
+ } else if (!data.endedAt && data.groups && data.groups.length >= 2) {
1641
+ winnerEl.style.display = 'block';
1642
+ const buttons = data.groups.map(g =>
1643
+ '<button class="btn" onclick="promoteExperiment(\'' + g.id + '\')">手动晶升 ' + g.id + '</button>'
1644
+ ).join(' ');
1645
+ winnerEl.innerHTML =
1646
+ '<div style="padding:1rem;background:var(--bg-alt);border:1px solid var(--border);border-radius:var(--radius-sm)">' +
1647
+ '<div style="margin-bottom:0.5rem;color:var(--text-dim)">尚未达到显著差异。如确定要提前收尾实验:</div>' +
1648
+ buttons + '</div>';
1649
+ }
1650
+ } catch (err) {
1651
+ tableEl.innerHTML = empty('加载失败: ' + err.message);
1652
+ }
1653
+ }
1654
+
1655
+ async function promoteExperiment(groupId) {
1656
+ if (!confirm('将 ' + groupId + ' 组的规则写入 routing.yaml 并结束当前实验?原 routing.yaml 会备份为 .bak 文件。')) return;
1657
+ try {
1658
+ const r = await fetch(API + '/api/routing/experiments/promote', {
1659
+ method: 'POST',
1660
+ headers: { 'Content-Type': 'application/json' },
1661
+ body: JSON.stringify({ groupId }),
1662
+ });
1663
+ if (!r.ok) {
1664
+ const err = await r.json();
1665
+ throw new Error(err.error || 'Unknown error');
1666
+ }
1667
+ const result = await r.json();
1668
+ alert('✓ 已晶升 ' + result.promoted + ' 组到 routing.yaml' +
1669
+ (result.backupPath ? '\n备份:' + result.backupPath : '') +
1670
+ '\n实验结束于 ' + result.endedAt);
1671
+ await loadExperimentsConfig();
1672
+ await loadExperimentsAnalysis();
1673
+ } catch (err) {
1674
+ alert('晶升失败: ' + err.message);
1675
+ }
1676
+ }
1677
+
1678
+ // === Rule Recommendations (Phase 5 Feature 3) ===
1679
+
1680
+ async function loadRecommendations() {
1681
+ const container = document.getElementById('recommendations-list');
1682
+ container.innerHTML = loading();
1683
+ const days = document.getElementById('recommendations-days')?.value || '7';
1684
+ try {
1685
+ const r = await fetch(API + '/api/routing/recommendations?days=' + days);
1686
+ const data = await r.json();
1687
+ const list = data.recommendations || [];
1688
+ if (list.length === 0) {
1689
+ container.innerHTML = empty('暂无推荐。样本量不足或当前规则已贴近实际行为。');
1690
+ return;
1691
+ }
1692
+
1693
+ let html = '<table>';
1694
+ html += '<thead><tr>' +
1695
+ '<th>任务类型</th><th>复杂度</th>' +
1696
+ '<th>当前路由</th><th>建议路由</th>' +
1697
+ '<th>样本</th><th>当前听话率</th><th>建议 agent 使用率</th>' +
1698
+ '<th>置信度</th><th>说明</th>' +
1699
+ '</tr></thead><tbody>';
1700
+ list.forEach(rec => {
1701
+ const pctFmt = v => (v * 100).toFixed(0) + '%';
1702
+ const conf = (rec.confidence * 100).toFixed(0) + '%';
1703
+ html += '<tr>' +
1704
+ '<td><code>' + rec.taskType + '</code></td>' +
1705
+ '<td>' + (rec.complexity || '—') + '</td>' +
1706
+ '<td>' + (rec.currentAgent ? '<code>' + rec.currentAgent + '</code>' : '<span style="color:var(--text-dim)">(skill 回退)</span>') + '</td>' +
1707
+ '<td><code style="color:var(--primary)">' + rec.recommendedAgent + '</code></td>' +
1708
+ '<td>' + rec.sampleSize + '</td>' +
1709
+ '<td>' + pctFmt(rec.currentObeyedRate) + '</td>' +
1710
+ '<td>' + pctFmt(rec.recommendedUsageRate) + '</td>' +
1711
+ '<td>' + conf + '</td>' +
1712
+ '<td style="font-size:0.85rem;color:var(--text-dim)">' + rec.reason + '</td>' +
1713
+ '</tr>';
1714
+ });
1715
+ html += '</tbody></table>';
1716
+ html += '<div style="margin-top:0.75rem;font-size:0.85rem;color:var(--text-dim)">' +
1717
+ '要应用某条推荐,打开"路由调优"tab,手动更新对应 when 块的 agent 名称并保存。' +
1718
+ '</div>';
1719
+ container.innerHTML = html;
1720
+ } catch (err) {
1721
+ container.innerHTML = empty('加载推荐失败: ' + err.message);
1722
+ }
1723
+ }
1724
+
962
1725
  // === Init ===
963
1726
  nav('dashboard');
964
1727
  </script>