@winspan/claude-forge 8.13.1 → 8.16.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 (89) 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 +510 -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/cli/commands/menu.js +183 -0
  25. package/dist/cli/commands/menu.js.map +1 -1
  26. package/dist/core/constants.d.ts +1 -1
  27. package/dist/core/constants.js +1 -1
  28. package/dist/core/constants.js.map +1 -1
  29. package/dist/core/storage/schema.sql +60 -0
  30. package/dist/core/storage/sqlite.d.ts +73 -0
  31. package/dist/core/storage/sqlite.d.ts.map +1 -1
  32. package/dist/core/storage/sqlite.js +159 -0
  33. package/dist/core/storage/sqlite.js.map +1 -1
  34. package/dist/daemon/auto-disable-scheduler.d.ts +53 -0
  35. package/dist/daemon/auto-disable-scheduler.d.ts.map +1 -0
  36. package/dist/daemon/auto-disable-scheduler.js +114 -0
  37. package/dist/daemon/auto-disable-scheduler.js.map +1 -0
  38. package/dist/daemon/handlers/post-tool-use.d.ts +3 -1
  39. package/dist/daemon/handlers/post-tool-use.d.ts.map +1 -1
  40. package/dist/daemon/handlers/post-tool-use.js +14 -2
  41. package/dist/daemon/handlers/post-tool-use.js.map +1 -1
  42. package/dist/daemon/handlers/stop.d.ts +3 -1
  43. package/dist/daemon/handlers/stop.d.ts.map +1 -1
  44. package/dist/daemon/handlers/stop.js +14 -1
  45. package/dist/daemon/handlers/stop.js.map +1 -1
  46. package/dist/daemon/handlers/user-prompt.d.ts +18 -7
  47. package/dist/daemon/handlers/user-prompt.d.ts.map +1 -1
  48. package/dist/daemon/handlers/user-prompt.js +97 -23
  49. package/dist/daemon/handlers/user-prompt.js.map +1 -1
  50. package/dist/daemon/index.d.ts.map +1 -1
  51. package/dist/daemon/index.js +53 -18
  52. package/dist/daemon/index.js.map +1 -1
  53. package/dist/daemon/routing-observer.d.ts +39 -0
  54. package/dist/daemon/routing-observer.d.ts.map +1 -0
  55. package/dist/daemon/routing-observer.js +156 -0
  56. package/dist/daemon/routing-observer.js.map +1 -0
  57. package/dist/engine/agent-router.d.ts +99 -0
  58. package/dist/engine/agent-router.d.ts.map +1 -0
  59. package/dist/engine/agent-router.js +206 -0
  60. package/dist/engine/agent-router.js.map +1 -0
  61. package/dist/engine/conventions/routing.yaml +84 -0
  62. package/dist/engine/dsl/parser.d.ts +6 -0
  63. package/dist/engine/dsl/parser.d.ts.map +1 -1
  64. package/dist/engine/dsl/parser.js +19 -0
  65. package/dist/engine/dsl/parser.js.map +1 -1
  66. package/dist/engine/evidence-store.d.ts.map +1 -1
  67. package/dist/engine/evidence-store.js +3 -0
  68. package/dist/engine/evidence-store.js.map +1 -1
  69. package/dist/engine/experiment-router.d.ts +102 -0
  70. package/dist/engine/experiment-router.d.ts.map +1 -0
  71. package/dist/engine/experiment-router.js +289 -0
  72. package/dist/engine/experiment-router.js.map +1 -0
  73. package/dist/engine/recommender.d.ts +52 -0
  74. package/dist/engine/recommender.d.ts.map +1 -0
  75. package/dist/engine/recommender.js +150 -0
  76. package/dist/engine/recommender.js.map +1 -0
  77. package/dist/intelligence/classifier.d.ts +19 -5
  78. package/dist/intelligence/classifier.d.ts.map +1 -1
  79. package/dist/intelligence/classifier.js +98 -20
  80. package/dist/intelligence/classifier.js.map +1 -1
  81. package/dist/skills/registry.d.ts.map +1 -1
  82. package/dist/skills/registry.js +5 -2
  83. package/dist/skills/registry.js.map +1 -1
  84. package/dist/web/server.d.ts +4 -0
  85. package/dist/web/server.d.ts.map +1 -1
  86. package/dist/web/server.js +551 -0
  87. package/dist/web/server.js.map +1 -1
  88. package/dist/web/static/index.html +940 -77
  89. 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; }
@@ -351,14 +353,14 @@
351
353
  <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
352
354
  注入
353
355
  </a>
354
- <a onclick="nav('live')" id="nav-live">
355
- <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/></svg>
356
- 实时
357
- </a>
358
356
  <div class="nav-section-title">配置</div>
359
- <a onclick="nav('rules')" id="nav-rules">
360
- <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
- 规则
357
+ <a onclick="nav('ai-config')" id="nav-ai-config">
358
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6M5.6 5.6l4.2 4.2m4.2 4.2l4.2 4.2M1 12h6m6 0h6M5.6 18.4l4.2-4.2m4.2-4.2l4.2-4.2"/></svg>
359
+ AI 配置
360
+ </a>
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 路由
362
364
  </a>
363
365
  </aside>
364
366
 
@@ -453,23 +455,197 @@
453
455
  </div>
454
456
  </div>
455
457
 
456
- <!-- Live -->
457
- <div id="page-live" class="page">
458
- <div class="toolbar">
459
- <span id="live-status" class="badge badge-warn">未连接</span>
460
- <button class="btn btn-primary" id="live-btn" onclick="toggleLive()">连接</button>
461
- <button class="btn" onclick="document.getElementById('live-log').innerHTML=''">清空</button>
458
+ <!-- AI Config -->
459
+ <div id="page-ai-config" class="page">
460
+ <div class="panel">
461
+ <div class="panel-header">
462
+ <span class="panel-title">AI 配置</span>
463
+ <div style="display:flex;gap:0.5rem">
464
+ <button class="btn" onclick="testAIConnection()">🔌 测试连接</button>
465
+ <button class="btn btn-primary" onclick="saveAIConfig()">💾 保存</button>
466
+ </div>
467
+ </div>
468
+ <div class="panel-body">
469
+ <div style="max-width:600px">
470
+ <div style="margin-bottom:1rem">
471
+ <label style="display:block;margin-bottom:0.25rem;font-weight:500;font-size:0.875rem">API Key</label>
472
+ <div style="display:flex;gap:0.5rem">
473
+ <input type="password" id="ai-api-key" class="search-box" style="flex:1" placeholder="sk-...">
474
+ <button class="btn" onclick="toggleAPIKeyVisibility()" id="toggle-api-key-btn">👁️ 显示</button>
475
+ </div>
476
+ </div>
477
+ <div style="margin-bottom:1rem">
478
+ <label style="display:block;margin-bottom:0.25rem;font-weight:500;font-size:0.875rem">Base URL</label>
479
+ <input type="text" id="ai-base-url" class="search-box" placeholder="https://api.anthropic.com">
480
+ </div>
481
+ <div style="margin-bottom:1rem">
482
+ <label style="display:block;margin-bottom:0.25rem;font-weight:500;font-size:0.875rem">Provider</label>
483
+ <input type="text" id="ai-provider" class="search-box" value="anthropic" readonly style="background:var(--bg-secondary)">
484
+ </div>
485
+ <div style="margin-bottom:1rem">
486
+ <label style="display:block;margin-bottom:0.25rem;font-weight:500;font-size:0.875rem">Model</label>
487
+ <div style="display:flex;gap:0.5rem">
488
+ <select id="ai-model" class="btn" style="flex:1">
489
+ <option value="">加载中...</option>
490
+ </select>
491
+ <button class="btn" onclick="refreshAIModels()">↻ 刷新模型列表</button>
492
+ </div>
493
+ </div>
494
+ <div id="ai-config-success" style="margin-top:1rem;padding:0.75rem;background:var(--primary);color:white;border-radius:var(--radius-sm);display:none"></div>
495
+ <div id="ai-config-error" style="margin-top:1rem;padding:0.75rem;background:var(--red);color:white;border-radius:var(--radius-sm);display:none"></div>
496
+ <div style="margin-top:1rem;padding:0.75rem;background:var(--bg-secondary);border-radius:var(--radius-sm);font-size:0.875rem;color:var(--text-muted)">
497
+ <strong>提示:</strong>配置保存后需要重启 daemon 才能生效。<br>
498
+ 运行 <code style="background:var(--bg-card);padding:0.125rem 0.375rem;border-radius:4px">cf daemon stop && cf daemon start</code>
499
+ </div>
500
+ </div>
501
+ </div>
462
502
  </div>
463
- <div class="live-log" id="live-log"></div>
464
503
  </div>
465
504
 
466
- <!-- Rules -->
467
- <div id="page-rules" class="page">
468
- <div class="toolbar">
469
- <input class="search-box" id="rules-search" placeholder="搜索规则..." oninput="filterRules()">
505
+ <!-- Agent Routing -->
506
+ <div id="page-routing" class="page">
507
+ <div class="toolbar" style="display:flex;gap:0.5rem;align-items:center">
508
+ <div style="display:inline-flex;border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden">
509
+ <button class="btn" id="routing-tab-overview" onclick="routingTab('overview')" style="border-radius:0;border:none">总览</button>
510
+ <button class="btn" id="routing-tab-events" onclick="routingTab('events')" style="border-radius:0;border:none">决策明细</button>
511
+ <button class="btn" id="routing-tab-refusals" onclick="routingTab('refusals')" style="border-radius:0;border:none">违抗聚合</button>
512
+ <button class="btn" id="routing-tab-editor" onclick="routingTab('editor')" style="border-radius:0;border:none">路由调优</button>
513
+ <button class="btn" id="routing-tab-experiments" onclick="routingTab('experiments')" style="border-radius:0;border:none">A/B 测试</button>
514
+ <button class="btn" id="routing-tab-recommendations" onclick="routingTab('recommendations')" style="border-radius:0;border:none">规则推荐</button>
515
+ </div>
516
+ <div style="flex:1"></div>
517
+ <select class="btn" id="routing-window" onchange="loadRouting()" style="display:none">
518
+ <option value="24">近 24 小时</option>
519
+ <option value="168" selected>近 7 天</option>
520
+ <option value="720">近 30 天</option>
521
+ </select>
522
+ </div>
523
+
524
+ <!-- Overview subpanel -->
525
+ <div id="routing-sub-overview">
526
+ <div class="cards" id="routing-cards"></div>
527
+ <div class="grid-2" style="margin-top:1.25rem">
528
+ <div class="panel">
529
+ <div class="panel-header"><span class="panel-title">按 Agent 听话率</span></div>
530
+ <div class="panel-body" id="routing-by-agent"></div>
531
+ </div>
532
+ <div class="panel">
533
+ <div class="panel-header"><span class="panel-title">注入话术版本 A/B</span></div>
534
+ <div class="panel-body" id="routing-by-version"></div>
535
+ </div>
536
+ </div>
537
+ </div>
538
+
539
+ <!-- Events subpanel -->
540
+ <div id="routing-sub-events" style="display:none">
541
+ <div class="toolbar" style="display:flex;gap:0.5rem">
542
+ <select class="btn" id="routing-filter-obeyed" onchange="loadRoutingEvents()">
543
+ <option value="">全部状态</option>
544
+ <option value="forced">已强路由</option>
545
+ <option value="1">遵守 (obeyed)</option>
546
+ <option value="0">违抗 (refused)</option>
547
+ <option value="null">待判定/未路由</option>
548
+ </select>
549
+ <input class="search-box" id="routing-filter-agent" placeholder="按 agent 名过滤..." oninput="loadRoutingEvents()">
550
+ </div>
551
+ <div class="panel">
552
+ <table>
553
+ <thead><tr><th>时间</th><th>Agent</th><th>状态</th><th>首个工具</th><th>Prompt 摘要</th></tr></thead>
554
+ <tbody id="routing-events-tbody"></tbody>
555
+ </table>
556
+ </div>
557
+ </div>
558
+
559
+ <!-- Refusals subpanel -->
560
+ <div id="routing-sub-refusals" style="display:none">
561
+ <div class="panel">
562
+ <div class="panel-header"><span class="panel-title">违抗聚合 (taskType × agent)</span></div>
563
+ <div class="panel-body" id="routing-refusals"></div>
564
+ </div>
470
565
  </div>
471
- <div class="panel" id="rules-panel">
472
- <div id="rules-list"></div>
566
+
567
+ <!-- Editor subpanel (Phase 3 Feature 2, upgraded to Monaco in Phase 4 Feature 1) -->
568
+ <div id="routing-sub-editor" style="display:none">
569
+ <div class="panel">
570
+ <div class="panel-header">
571
+ <span class="panel-title">路由配置编辑器</span>
572
+ <div style="display:flex;gap:0.5rem">
573
+ <button class="btn" onclick="loadRoutingConfig()">↻ 重新加载</button>
574
+ <button class="btn btn-primary" onclick="saveRoutingConfig()">💾 保存</button>
575
+ </div>
576
+ </div>
577
+ <div class="panel-body">
578
+ <div style="margin-bottom:0.5rem;font-size:0.875rem;color:var(--text-dim)">
579
+ <span id="routing-config-source"></span>
580
+ <span id="routing-config-path" style="font-family:monospace;margin-left:0.5rem"></span>
581
+ </div>
582
+ <div id="routing-config-editor" style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius-sm)"></div>
583
+ <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>
584
+ <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>
585
+ </div>
586
+ </div>
587
+ </div>
588
+
589
+ <!-- Experiments subpanel (Phase 5 Feature 1) -->
590
+ <div id="routing-sub-experiments" style="display:none">
591
+ <div class="panel" style="margin-bottom:1rem">
592
+ <div class="panel-header">
593
+ <span class="panel-title">实验配置</span>
594
+ <div style="display:flex;gap:0.5rem">
595
+ <button class="btn" onclick="loadExperimentsConfig()">↻ 重新加载</button>
596
+ <button class="btn btn-primary" onclick="saveExperimentsConfig()">💾 保存</button>
597
+ </div>
598
+ </div>
599
+ <div class="panel-body">
600
+ <div style="margin-bottom:0.5rem;font-size:0.875rem;color:var(--text-dim)">
601
+ <span id="experiments-config-source"></span>
602
+ <span id="experiments-config-path" style="font-family:monospace;margin-left:0.5rem"></span>
603
+ </div>
604
+ <div id="experiments-config-editor" style="width:100%;height:400px;border:1px solid var(--border);border-radius:var(--radius-sm)"></div>
605
+ <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>
606
+ <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>
607
+ </div>
608
+ </div>
609
+ <div class="panel">
610
+ <div class="panel-header">
611
+ <span class="panel-title">分析对比</span>
612
+ <div style="display:flex;gap:0.5rem">
613
+ <button class="btn" onclick="loadExperimentsAnalysis()">↻ 刷新分析</button>
614
+ </div>
615
+ </div>
616
+ <div class="panel-body">
617
+ <div id="experiments-summary" style="margin-bottom:1rem;font-size:0.875rem"></div>
618
+ <div id="experiments-analysis"></div>
619
+ <div id="experiments-winner" style="margin-top:1rem;display:none"></div>
620
+ </div>
621
+ </div>
622
+ </div>
623
+
624
+ <!-- Recommendations subpanel (Phase 5 Feature 3) -->
625
+ <div id="routing-sub-recommendations" style="display:none">
626
+ <div class="panel">
627
+ <div class="panel-header">
628
+ <span class="panel-title">规则推荐</span>
629
+ <div style="display:flex;gap:0.5rem;align-items:center">
630
+ <label style="font-size:0.875rem;color:var(--text-dim)">窗口
631
+ <select class="btn" id="recommendations-days" onchange="loadRecommendations()">
632
+ <option value="1">1 天</option>
633
+ <option value="7" selected>7 天</option>
634
+ <option value="30">30 天</option>
635
+ </select>
636
+ </label>
637
+ <button class="btn" onclick="loadRecommendations()">↻ 刷新</button>
638
+ </div>
639
+ </div>
640
+ <div class="panel-body">
641
+ <div style="margin-bottom:0.75rem;font-size:0.875rem;color:var(--text-dim)">
642
+ 基于历史决策分析,对比"当前路由规则"与"Claude 实际使用的 agent",
643
+ 推荐更贴近实际行为的路由。<br>
644
+ 样本阈值:单类任务 ≥ 10 条;挑战者使用率 &gt; 50%。
645
+ </div>
646
+ <div id="recommendations-list"></div>
647
+ </div>
648
+ </div>
473
649
  </div>
474
650
  </div>
475
651
 
@@ -494,8 +670,7 @@
494
670
 
495
671
  <script>
496
672
  const API = '';
497
- let allEvents = [], allSessions = [], allInjections = [], allRules = [];
498
- let liveSource = null;
673
+ let allEvents = [], allSessions = [], allInjections = [];
499
674
  let charts = {};
500
675
 
501
676
  // === Navigation ===
@@ -506,15 +681,15 @@ function nav(page) {
506
681
  const pageEl = document.getElementById('page-' + page);
507
682
  if (navEl) navEl.classList.add('active');
508
683
  if (pageEl) pageEl.classList.add('active');
509
- const titles = { dashboard:'仪表盘', sessions:'会话', events:'事件', injections:'注入', live:'实时', rules:'规则' };
684
+ const titles = { dashboard:'仪表盘', sessions:'会话', events:'事件', injections:'注入', 'ai-config':'AI 配置', routing:'Agent 路由' };
510
685
  document.getElementById('topbar-title').textContent = titles[page] || page;
511
686
  closeDrawer();
512
687
  if (page === 'dashboard') loadDashboard();
513
688
  else if (page === 'sessions') loadSessions();
514
689
  else if (page === 'events') loadEvents();
515
690
  else if (page === 'injections') loadInjections();
516
- else if (page === 'rules') loadRules();
517
- else if (page === 'live' && !liveSource) toggleLive();
691
+ else if (page === 'ai-config') loadAIConfig();
692
+ else if (page === 'routing') loadRouting();
518
693
  }
519
694
 
520
695
  function refreshPage() {
@@ -648,41 +823,6 @@ function renderToolChart(data) {
648
823
  });
649
824
  }
650
825
 
651
- // === Live ===
652
- function toggleLive() {
653
- if (liveSource) {
654
- liveSource.close(); liveSource = null;
655
- document.getElementById('live-status').textContent = '未连接';
656
- document.getElementById('live-status').className = 'badge badge-warn';
657
- document.getElementById('live-btn').textContent = '连接';
658
- document.getElementById('live-btn').className = 'btn btn-primary';
659
- return;
660
- }
661
- liveSource = new EventSource(API + '/api/events/stream');
662
- document.getElementById('live-status').textContent = '实时中';
663
- document.getElementById('live-status').className = 'badge badge-live';
664
- document.getElementById('live-btn').textContent = '断开连接';
665
- document.getElementById('live-btn').className = 'btn';
666
- liveSource.onmessage = function(ev) {
667
- try {
668
- const e = JSON.parse(ev.data);
669
- if (e.type === 'connected') return;
670
- const log = document.getElementById('live-log');
671
- const line = document.createElement('div');
672
- line.className = 'live-log-line';
673
- const tool = e.tool_name ? `<span style="color:#93c5fd">${e.tool_name}</span>` : '';
674
- const detail = (e.user_prompt || e.tool_input?.command || e.tool_input?.file_path || '').toString().slice(0, 60);
675
- line.innerHTML = `<span style="color:#64748b">${fmtTime(e.timestamp)}</span> ${badgeHook(e.hook_type)} ${tool} <span style="color:#cbd5e1">${detail}</span>`;
676
- log.appendChild(line);
677
- log.scrollTop = log.scrollHeight;
678
- } catch {}
679
- };
680
- liveSource.onerror = function() {
681
- document.getElementById('live-status').textContent = '连接错误';
682
- document.getElementById('live-status').className = 'badge badge-block';
683
- };
684
- }
685
-
686
826
  // === Sessions ===
687
827
  function sessionListItem(s) {
688
828
  const prompt = (s.first_prompt || '(无提示词)').slice(0, 60);
@@ -932,31 +1072,754 @@ function openInjDrawer(inj) {
932
1072
  }
933
1073
 
934
1074
  // === Rules ===
935
- async function loadRules() {
936
- document.getElementById('rules-list').innerHTML = loading();
1075
+
1076
+ // === Agent Routing ===
1077
+ let routingCurrentTab = 'overview';
1078
+
1079
+ function routingTab(tab) {
1080
+ routingCurrentTab = tab;
1081
+ ['overview', 'events', 'refusals', 'editor', 'experiments', 'recommendations'].forEach(t => {
1082
+ const btn = document.getElementById('routing-tab-' + t);
1083
+ const sub = document.getElementById('routing-sub-' + t);
1084
+ if (btn) btn.classList.toggle('active', t === tab);
1085
+ if (sub) sub.style.display = (t === tab) ? '' : 'none';
1086
+ });
1087
+
1088
+ // Show/hide time window selector (not needed for editor/experiments/recommendations tabs)
1089
+ const windowSelector = document.getElementById('routing-window');
1090
+ if (windowSelector) {
1091
+ windowSelector.style.display =
1092
+ (tab === 'editor' || tab === 'experiments' || tab === 'recommendations') ? 'none' : '';
1093
+ }
1094
+
1095
+ loadRouting();
1096
+ }
1097
+
1098
+ async function loadRouting() {
1099
+ if (routingCurrentTab === 'overview') return loadRoutingOverview();
1100
+ if (routingCurrentTab === 'events') return loadRoutingEvents();
1101
+ if (routingCurrentTab === 'refusals') return loadRoutingRefusals();
1102
+ if (routingCurrentTab === 'editor') return loadRoutingConfig();
1103
+ if (routingCurrentTab === 'experiments') {
1104
+ await loadExperimentsConfig();
1105
+ return loadExperimentsAnalysis();
1106
+ }
1107
+ if (routingCurrentTab === 'recommendations') return loadRecommendations();
1108
+ }
1109
+
1110
+ // === AI Config ===
1111
+ let aiApiKeyMasked = true;
1112
+ let aiApiKeyOriginal = '';
1113
+
1114
+ async function loadAIConfig() {
937
1115
  try {
938
- const res = await fetch(API + '/api/rules');
939
- allRules = await res.json();
940
- renderRules(allRules);
941
- } catch {
942
- document.getElementById('rules-list').innerHTML = empty('加载规则失败');
1116
+ const r = await fetch(API + '/api/config/ai');
1117
+ if (!r.ok) throw new Error('Failed to load config');
1118
+ const data = await r.json();
1119
+
1120
+ document.getElementById('ai-api-key').value = data.api_key || '';
1121
+ document.getElementById('ai-base-url').value = data.base_url || '';
1122
+ document.getElementById('ai-provider').value = data.provider || 'anthropic';
1123
+ aiApiKeyOriginal = data.api_key || '';
1124
+ aiApiKeyMasked = true;
1125
+ document.getElementById('ai-api-key').type = 'password';
1126
+ document.getElementById('toggle-api-key-btn').textContent = '👁️ 显示';
1127
+
1128
+ // Pre-fill model dropdown with current model
1129
+ const modelSelect = document.getElementById('ai-model');
1130
+ modelSelect.innerHTML = `<option value="${data.model}">${data.model}</option>`;
1131
+ } catch (err) {
1132
+ showAIError(`加载配置失败: ${err.message}`);
1133
+ }
1134
+ }
1135
+
1136
+ function toggleAPIKeyVisibility() {
1137
+ const input = document.getElementById('ai-api-key');
1138
+ const btn = document.getElementById('toggle-api-key-btn');
1139
+ if (input.type === 'password') {
1140
+ input.type = 'text';
1141
+ btn.textContent = '🙈 隐藏';
1142
+ } else {
1143
+ input.type = 'password';
1144
+ btn.textContent = '👁️ 显示';
1145
+ }
1146
+ }
1147
+
1148
+ async function refreshAIModels() {
1149
+ const modelSelect = document.getElementById('ai-model');
1150
+ const currentValue = modelSelect.value;
1151
+ modelSelect.innerHTML = '<option value="">加载中...</option>';
1152
+ try {
1153
+ const r = await fetch(API + '/api/ai/models');
1154
+ if (!r.ok) {
1155
+ const err = await r.json().catch(() => ({ error: r.statusText }));
1156
+ throw new Error(err.error || r.statusText);
1157
+ }
1158
+ const data = await r.json();
1159
+ const models = data.data || [];
1160
+ if (models.length === 0) {
1161
+ modelSelect.innerHTML = '<option value="">无可用模型</option>';
1162
+ showAIError('未找到可用模型');
1163
+ return;
1164
+ }
1165
+ modelSelect.innerHTML = models.map(m => {
1166
+ const selected = m.id === currentValue ? 'selected' : '';
1167
+ return `<option value="${m.id}" ${selected}>${m.id}</option>`;
1168
+ }).join('');
1169
+ showAISuccess(`已加载 ${models.length} 个模型`);
1170
+ } catch (err) {
1171
+ modelSelect.innerHTML = `<option value="${currentValue}">${currentValue}</option>`;
1172
+ showAIError(`加载模型列表失败: ${err.message}`);
1173
+ }
1174
+ }
1175
+
1176
+ async function saveAIConfig() {
1177
+ const apiKeyInput = document.getElementById('ai-api-key').value;
1178
+ const baseUrl = document.getElementById('ai-base-url').value;
1179
+ const provider = document.getElementById('ai-provider').value;
1180
+ const model = document.getElementById('ai-model').value;
1181
+
1182
+ const body = { base_url: baseUrl, provider, model };
1183
+
1184
+ // Only send api_key if user changed it (not the masked placeholder)
1185
+ if (apiKeyInput && apiKeyInput !== aiApiKeyOriginal && !apiKeyInput.includes('***')) {
1186
+ body.api_key = apiKeyInput;
1187
+ }
1188
+
1189
+ try {
1190
+ const r = await fetch(API + '/api/config/ai', {
1191
+ method: 'PUT',
1192
+ headers: { 'Content-Type': 'application/json' },
1193
+ body: JSON.stringify(body),
1194
+ });
1195
+ if (!r.ok) {
1196
+ const err = await r.json().catch(() => ({ error: r.statusText }));
1197
+ throw new Error(err.error || r.statusText);
1198
+ }
1199
+ showAISuccess('✓ 保存成功!重启 daemon 后生效');
1200
+ loadAIConfig();
1201
+ } catch (err) {
1202
+ showAIError(`保存失败: ${err.message}`);
1203
+ }
1204
+ }
1205
+
1206
+ async function testAIConnection() {
1207
+ showAISuccess('正在测试连接...');
1208
+ try {
1209
+ // Use the /api/ai/models endpoint as a connectivity test (less costly than /messages)
1210
+ const r = await fetch(API + '/api/ai/models');
1211
+ if (!r.ok) {
1212
+ const err = await r.json().catch(() => ({ error: r.statusText }));
1213
+ throw new Error(err.error || r.statusText);
1214
+ }
1215
+ const data = await r.json();
1216
+ const count = (data.data || []).length;
1217
+ showAISuccess(`✓ 连接成功!上游返回 ${count} 个模型`);
1218
+ } catch (err) {
1219
+ showAIError(`连接失败: ${err.message}`);
1220
+ }
1221
+ }
1222
+
1223
+ function showAISuccess(msg) {
1224
+ const el = document.getElementById('ai-config-success');
1225
+ const errEl = document.getElementById('ai-config-error');
1226
+ el.textContent = msg;
1227
+ el.style.display = 'block';
1228
+ errEl.style.display = 'none';
1229
+ setTimeout(() => { el.style.display = 'none'; }, 5000);
1230
+ }
1231
+
1232
+ function showAIError(msg) {
1233
+ const el = document.getElementById('ai-config-error');
1234
+ const okEl = document.getElementById('ai-config-success');
1235
+ el.textContent = msg;
1236
+ el.style.display = 'block';
1237
+ okEl.style.display = 'none';
1238
+ }
1239
+
1240
+ function routingWindow() {
1241
+ const el = document.getElementById('routing-window');
1242
+ return el ? el.value : '168';
1243
+ }
1244
+
1245
+ function pct(n) { return n == null ? '—' : (n * 100).toFixed(1) + '%'; }
1246
+ function ms(n) { return n == null ? '—' : n + 'ms'; }
1247
+
1248
+ async function loadRoutingOverview() {
1249
+ try {
1250
+ const r = await fetch(API + '/api/routing/stats?window=' + routingWindow());
1251
+ const data = await r.json();
1252
+
1253
+ const statusColor =
1254
+ data.obedienceRate == null ? 'badge-info' :
1255
+ data.obedienceRate >= 0.8 ? 'badge-allow' :
1256
+ data.obedienceRate >= 0.6 ? 'badge-warn' : 'badge-block';
1257
+
1258
+ document.getElementById('routing-cards').innerHTML = [
1259
+ card('事件总数', data.total),
1260
+ card('强路由次数', data.forced),
1261
+ card('听话率', pct(data.obedienceRate), statusColor),
1262
+ card('违抗率', pct(data.refusalRate)),
1263
+ card('兜底比例', pct(data.fallbackRate)),
1264
+ card('AI 分类 p95', ms(data.latency.p95)),
1265
+ ].join('');
1266
+
1267
+ const byAgent = Object.entries(data.byAgent || {})
1268
+ .sort((a, b) => b[1].total - a[1].total);
1269
+ document.getElementById('routing-by-agent').innerHTML = byAgent.length === 0
1270
+ ? empty('暂无数据')
1271
+ : '<table><thead><tr><th>Agent</th><th>总数</th><th>遵守</th><th>违抗</th><th>未判定</th><th>听话率</th></tr></thead><tbody>'
1272
+ + byAgent.map(([name, b]) => {
1273
+ const rate = (b.obeyed + b.refused) === 0 ? null : b.obeyed / (b.obeyed + b.refused);
1274
+ return '<tr><td style="font-family:monospace">' + name + '</td><td>' + b.total + '</td>'
1275
+ + '<td style="color:var(--primary)">' + b.obeyed + '</td>'
1276
+ + '<td style="color:var(--danger,#dc2626)">' + b.refused + '</td>'
1277
+ + '<td style="color:var(--text-dim)">' + b.unknown + '</td>'
1278
+ + '<td>' + pct(rate) + '</td></tr>';
1279
+ }).join('')
1280
+ + '</tbody></table>';
1281
+
1282
+ const byVersion = Object.entries(data.byVersion || {});
1283
+ document.getElementById('routing-by-version').innerHTML = byVersion.length === 0
1284
+ ? empty('暂无版本数据')
1285
+ : '<table><thead><tr><th>版本</th><th>总数</th><th>遵守</th><th>听话率</th></tr></thead><tbody>'
1286
+ + byVersion.map(([v, b]) => '<tr><td style="font-family:monospace;font-size:0.8rem">' + v
1287
+ + '</td><td>' + b.total + '</td><td>' + b.obeyed + '</td><td>'
1288
+ + pct(b.total === 0 ? null : b.obeyed / b.total) + '</td></tr>').join('')
1289
+ + '</tbody></table>';
1290
+ } catch (err) {
1291
+ document.getElementById('routing-cards').innerHTML = empty('加载失败: ' + err.message);
943
1292
  }
944
1293
  }
945
1294
 
946
- function renderRules(list) {
947
- document.getElementById('rules-list').innerHTML = list.length === 0 ? empty('未找到规则') : list.map(r => '<div class="list-item fade-in" onclick="openRuleDrawer(' + JSON.stringify(r).replace(/"/g,'&quot;') + ')"><div style="flex:1;min-width:0"><div style="font-size:0.875rem;font-weight:500;margin-bottom:2px">' + (r.name || r.id) + '</div><div style="font-size:0.75rem;color:var(--text-dim)">' + (r.description || '') + '</div></div><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-dim);flex-shrink:0"><polyline points="9 18 15 12 9 6"/></svg></div>').join('');
1295
+ function card(label, value, color) {
1296
+ return '<div class="panel" style="padding:1rem">'
1297
+ + '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.25rem">' + label + '</div>'
1298
+ + '<div style="font-size:1.5rem;font-weight:600' + (color ? ';color:var(--primary)' : '') + '">' + value + '</div>'
1299
+ + '</div>';
948
1300
  }
949
1301
 
950
- function filterRules() {
951
- const q = document.getElementById('rules-search').value.toLowerCase();
952
- renderRules(allRules.filter(r => (r.name||'').toLowerCase().includes(q) || (r.description||'').toLowerCase().includes(q)));
1302
+ async function loadRoutingEvents() {
1303
+ try {
1304
+ const obeyed = document.getElementById('routing-filter-obeyed')?.value || '';
1305
+ const agent = document.getElementById('routing-filter-agent')?.value || '';
1306
+ const params = new URLSearchParams({ limit: '100' });
1307
+ // 'forced' is a client-side filter: keep is_forced=1 rows regardless of obeyed.
1308
+ if (obeyed !== '' && obeyed !== 'forced') params.set('obeyed', obeyed);
1309
+ if (agent) params.set('agent', agent);
1310
+ const r = await fetch(API + '/api/routing/events?' + params.toString());
1311
+ let rows = await r.json();
1312
+ if (obeyed === 'forced') rows = rows.filter(e => e.is_forced === 1);
1313
+ const tbody = document.getElementById('routing-events-tbody');
1314
+ if (!rows || rows.length === 0) {
1315
+ tbody.innerHTML = '<tr><td colspan="5">' + empty('暂无路由事件') + '</td></tr>';
1316
+ return;
1317
+ }
1318
+ tbody.innerHTML = rows.map(e => {
1319
+ let status;
1320
+ if (!e.is_forced) {
1321
+ status = '<span class="badge" style="background:var(--bg-secondary);color:var(--text-dim)">未路由</span>';
1322
+ } else if (e.obeyed === 1) {
1323
+ status = '<span class="badge badge-allow">遵守</span>';
1324
+ } else if (e.obeyed === 0) {
1325
+ status = '<span class="badge badge-block">违抗</span>';
1326
+ } else {
1327
+ status = '<span class="badge badge-info">未判定</span>';
1328
+ }
1329
+ const ts = new Date(e.ts).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
1330
+ return '<tr onclick="openRoutingEventDrawer(' + JSON.stringify(e).replace(/"/g,'&quot;') + ')">'
1331
+ + '<td style="color:var(--text-dim);font-size:0.8rem">' + ts + '</td>'
1332
+ + '<td style="font-family:monospace">' + (e.routed_to_name || '—') + '</td>'
1333
+ + '<td>' + status + '</td>'
1334
+ + '<td style="font-size:0.8rem;color:var(--text-dim)">' + (e.first_tool_name || '—') + '</td>'
1335
+ + '<td style="font-size:0.8rem">' + (e.prompt || '').slice(0, 80) + '</td></tr>';
1336
+ }).join('');
1337
+ } catch (err) {
1338
+ document.getElementById('routing-events-tbody').innerHTML = '<tr><td colspan="5">' + empty('加载失败: ' + err.message) + '</td></tr>';
1339
+ }
953
1340
  }
954
1341
 
955
- function openRuleDrawer(r) {
956
- let html = '<div class="detail-section"><div class="detail-label">名称</div><div class="detail-value" style="font-weight:600">' + (r.name || '—') + '</div></div>';
957
- html += '<div class="detail-section"><div class="detail-label">描述</div><div class="detail-value">' + (r.description || '') + '</div></div>';
958
- if (r.stats) html += '<div class="detail-section"><div class="detail-label">统计</div><div class="detail-value">触发 ' + (r.stats.totalTriggers || 0) + ' 次 | 阻断 ' + (r.stats.blockCount || 0) + ' 次 | 警告 ' + (r.stats.warnCount || 0) + ' 次</div></div>';
959
- openDrawer(r.name || '规则', html);
1342
+ function openRoutingEventDrawer(e) {
1343
+ let intent = {};
1344
+ try { intent = JSON.parse(e.intent_json || '{}'); } catch { /* ignore */ }
1345
+ let chain = [];
1346
+ try { chain = JSON.parse(e.downstream_task_chain || '[]'); } catch { /* ignore */ }
1347
+ const html = [
1348
+ '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">Prompt</div>',
1349
+ '<div style="white-space:pre-wrap;font-size:0.875rem">' + (e.prompt || '—') + '</div></div>',
1350
+ '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">Intent</div>',
1351
+ '<pre style="font-size:0.75rem;background:var(--bg-secondary);padding:0.5rem;border-radius:4px;overflow:auto">'
1352
+ + JSON.stringify(intent, null, 2) + '</pre></div>',
1353
+ '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">路由</div>',
1354
+ '<div>type=<b>' + (e.routed_to_type || '—') + '</b>, name=<b>' + (e.routed_to_name || '—')
1355
+ + '</b>, is_forced=' + (e.is_forced ? 'yes' : 'no') + '</div></div>',
1356
+ '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">判定</div>',
1357
+ '<div>obeyed=<b>' + (e.obeyed === null || e.obeyed === undefined ? 'null' : e.obeyed)
1358
+ + '</b>, first_tool=' + (e.first_tool_name || '—') + '</div></div>',
1359
+ chain.length > 0 ? '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">下游 Task 链</div><code>'
1360
+ + chain.join(' → ') + '</code></div>' : '',
1361
+ e.refusal_reason ? '<div style="margin-bottom:1rem"><div style="font-size:0.75rem;color:var(--text-dim)">违抗原因</div>'
1362
+ + '<div style="padding:0.5rem;background:var(--bg-secondary);border-left:3px solid var(--danger,#dc2626)">'
1363
+ + e.refusal_reason + '</div></div>' : '',
1364
+ '<div style="font-size:0.75rem;color:var(--text-dim)">classification_ms=' + (e.classification_ms || '—')
1365
+ + ' · fallback_used=' + (e.fallback_used ? 'yes' : 'no')
1366
+ + ' · injection_version=' + (e.injection_version || '—') + '</div>',
1367
+ ].join('');
1368
+ openDrawer('路由事件 #' + e.id, html);
1369
+ }
1370
+
1371
+ async function loadRoutingRefusals() {
1372
+ try {
1373
+ // Load refusals, violations, and rule-states in parallel.
1374
+ const [refusalsRes, violationsRes, rulesRes] = await Promise.all([
1375
+ fetch(API + '/api/routing/refusals?window=' + routingWindow()),
1376
+ fetch(API + '/api/routing/violations?window=' + routingWindow()),
1377
+ fetch(API + '/api/routing/rule-states'),
1378
+ ]);
1379
+ const refusalsData = await refusalsRes.json();
1380
+ const violationsData = await violationsRes.json();
1381
+ const ruleStatesData = await rulesRes.json();
1382
+ const disabledMap = new Map();
1383
+ (ruleStatesData.ruleStates || []).forEach(s => {
1384
+ disabledMap.set(s.task_type + '__' + s.agent, s);
1385
+ });
1386
+
1387
+ const container = document.getElementById('routing-refusals');
1388
+
1389
+ // Show violations alerts first (Phase 3 Feature 3)
1390
+ let html = '';
1391
+ if (violationsData.violations && violationsData.violations.length > 0) {
1392
+ const criticalViolations = violationsData.violations.filter(v => v.severity === 'critical' || v.severity === 'high');
1393
+ if (criticalViolations.length > 0) {
1394
+ html += '<div style="margin-bottom:1rem;padding:1rem;background:var(--danger,#dc2626);color:white;border-radius:var(--radius-sm)">';
1395
+ html += '<div style="font-weight:600;margin-bottom:0.5rem">⚠️ 检测到 ' + criticalViolations.length + ' 个高风险违抗模式</div>';
1396
+ html += '<div style="font-size:0.875rem">以下路由规则的违抗率过高,建议调整或禁用:</div>';
1397
+ html += '<ul style="margin:0.5rem 0 0 1.5rem;padding:0">';
1398
+ criticalViolations.forEach(v => {
1399
+ html += '<li style="margin-bottom:0.25rem"><code>' + v.taskType + '</code> → <code>' + v.agent + '</code> ';
1400
+ html += '(违抗率 ' + (v.refusalRate * 100).toFixed(0) + '%, ' + v.refusals + '/' + v.totalAttempts + ' 次)</li>';
1401
+ });
1402
+ html += '</ul></div>';
1403
+ }
1404
+
1405
+ // Show all violations with severity badges
1406
+ html += '<div style="margin-bottom:1rem"><div style="font-weight:600;margin-bottom:0.5rem">违抗模式分析</div>';
1407
+ violationsData.violations.forEach(v => {
1408
+ const severityColor = {
1409
+ critical: 'background:#dc2626;color:white',
1410
+ high: 'background:#f59e0b;color:white',
1411
+ medium: 'background:#3b82f6;color:white',
1412
+ low: 'background:var(--bg-secondary);color:var(--text-dim)',
1413
+ }[v.severity];
1414
+ const severityLabel = { critical: '严重', high: '高', medium: '中', low: '低' }[v.severity];
1415
+
1416
+ html += '<details style="margin-bottom:0.5rem;border:1px solid var(--border);border-radius:4px;padding:0.5rem">';
1417
+ html += '<summary style="cursor:pointer;display:flex;gap:0.5rem;align-items:center">';
1418
+ html += '<span style="padding:0.25rem 0.5rem;border-radius:4px;font-size:0.75rem;font-weight:600;' + severityColor + '">' + severityLabel + '</span>';
1419
+ html += '<code>' + v.taskType + '</code> → <code>' + v.agent + '</code>';
1420
+ const state = disabledMap.get(v.taskType + '__' + v.agent);
1421
+ if (state && state.disabled === 1) {
1422
+ const tag = state.auto_disabled === 1 ? '自动禁用' : '手动禁用';
1423
+ html += '<span style="padding:0.25rem 0.5rem;border-radius:4px;font-size:0.75rem;background:var(--text-dim);color:white">' + tag + '</span>';
1424
+ }
1425
+ html += '<span style="margin-left:auto;font-size:0.875rem;color:var(--text-dim)">违抗率 ' + (v.refusalRate * 100).toFixed(0) + '% (' + v.refusals + '/' + v.totalAttempts + ')</span>';
1426
+ const isDisabled = state && state.disabled === 1;
1427
+ const btnLabel = isDisabled ? '启用' : '禁用';
1428
+ 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>';
1429
+ html += '</summary>';
1430
+ html += '<div style="margin-top:0.5rem;padding:0.5rem;background:var(--bg-secondary);border-radius:4px;font-size:0.875rem">';
1431
+ html += '<div style="margin-bottom:0.5rem">最近 5 次尝试中违抗 ' + v.recentRefusals + ' 次</div>';
1432
+ if (v.samples.length > 0) {
1433
+ html += '<div style="font-weight:600;margin-bottom:0.25rem">样例:</div><ul style="margin:0;padding:0 0 0 1rem">';
1434
+ v.samples.forEach(s => {
1435
+ html += '<li style="margin-bottom:0.5rem">';
1436
+ html += '<div>' + s.prompt + '</div>';
1437
+ if (s.refusal_reason) {
1438
+ html += '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:2px">↳ ' + s.refusal_reason + '</div>';
1439
+ }
1440
+ html += '</li>';
1441
+ });
1442
+ html += '</ul>';
1443
+ }
1444
+ html += '</div></details>';
1445
+ });
1446
+ html += '</div>';
1447
+ }
1448
+
1449
+ // Show original refusals grouping (for backward compatibility)
1450
+ if (!refusalsData.groups || refusalsData.groups.length === 0) {
1451
+ if (html === '') {
1452
+ container.innerHTML = empty('暂无违抗事件');
1453
+ } else {
1454
+ container.innerHTML = html;
1455
+ }
1456
+ return;
1457
+ }
1458
+
1459
+ html += '<div style="margin-top:1rem;padding-top:1rem;border-top:1px solid var(--border)">';
1460
+ html += '<div style="font-weight:600;margin-bottom:0.5rem">违抗事件列表</div>';
1461
+ html += refusalsData.groups.map(g => {
1462
+ const samples = g.samples.map(s =>
1463
+ '<li style="margin-bottom:0.5rem">'
1464
+ + '<div style="font-size:0.8rem">' + s.prompt + '</div>'
1465
+ + (s.refusal_reason ? '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:2px">↳ ' + s.refusal_reason + '</div>' : '')
1466
+ + '</li>'
1467
+ ).join('');
1468
+ return '<details style="margin-bottom:0.5rem;border:1px solid var(--border);border-radius:4px;padding:0.5rem">'
1469
+ + '<summary style="cursor:pointer;display:flex;gap:0.5rem;align-items:center">'
1470
+ + '<span class="badge badge-block">' + g.count + '</span>'
1471
+ + '<code>' + g.taskType + '</code> → <code>' + g.agent + '</code></summary>'
1472
+ + '<ul style="margin:0.5rem 0 0 1rem;padding:0">' + samples + '</ul>'
1473
+ + '</details>';
1474
+ }).join('');
1475
+ html += '</div>';
1476
+
1477
+ container.innerHTML = html;
1478
+ } catch (err) {
1479
+ document.getElementById('routing-refusals').innerHTML = empty('加载失败: ' + err.message);
1480
+ }
1481
+ }
1482
+
1483
+ async function toggleRuleState(taskType, agent, disabled) {
1484
+ const verb = disabled ? '禁用' : '启用';
1485
+ if (!confirm(verb + ' ' + taskType + ' → ' + agent + ' 的路由规则?')) return;
1486
+ try {
1487
+ const r = await fetch(API + '/api/routing/rule-states', {
1488
+ method: 'PUT',
1489
+ headers: { 'Content-Type': 'application/json' },
1490
+ body: JSON.stringify({ taskType, agent, disabled, reason: disabled ? '手动禁用(Web UI)' : null }),
1491
+ });
1492
+ if (!r.ok) {
1493
+ const err = await r.json();
1494
+ throw new Error(err.error || 'Unknown error');
1495
+ }
1496
+ await loadRoutingRefusals();
1497
+ } catch (err) {
1498
+ alert(verb + '失败: ' + err.message);
1499
+ }
1500
+ }
1501
+
1502
+ // === Routing Config Editor (Phase 3 Feature 2, upgraded to Monaco in Phase 4 Feature 1) ===
1503
+
1504
+ let monacoEditor = null;
1505
+
1506
+ function initMonacoEditor() {
1507
+ if (monacoEditor) return; // already initialized
1508
+
1509
+ require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
1510
+ require(['vs/editor/editor.main'], function () {
1511
+ monacoEditor = monaco.editor.create(document.getElementById('routing-config-editor'), {
1512
+ value: '',
1513
+ language: 'yaml',
1514
+ theme: 'vs',
1515
+ automaticLayout: true,
1516
+ minimap: { enabled: false },
1517
+ scrollBeyondLastLine: false,
1518
+ fontSize: 14,
1519
+ lineNumbers: 'on',
1520
+ renderWhitespace: 'selection',
1521
+ tabSize: 2,
1522
+ });
1523
+ });
1524
+ }
1525
+
1526
+ async function loadRoutingConfig() {
1527
+ try {
1528
+ const r = await fetch(API + '/api/routing/config');
1529
+ const data = await r.json();
1530
+
1531
+ // Initialize Monaco Editor if not already done
1532
+ if (!monacoEditor) {
1533
+ initMonacoEditor();
1534
+ // Wait for Monaco to initialize
1535
+ await new Promise(resolve => {
1536
+ const checkInterval = setInterval(() => {
1537
+ if (monacoEditor) {
1538
+ clearInterval(checkInterval);
1539
+ resolve();
1540
+ }
1541
+ }, 100);
1542
+ });
1543
+ }
1544
+
1545
+ // Set editor content
1546
+ if (monacoEditor) {
1547
+ monacoEditor.setValue(data.content || '');
1548
+ }
1549
+
1550
+ const sourceLabel = data.source === 'user' ? '用户配置' : data.source === 'default' ? '默认配置' : '无配置';
1551
+ document.getElementById('routing-config-source').textContent = sourceLabel;
1552
+ document.getElementById('routing-config-path').textContent = data.source === 'user' ? data.userPath : data.defaultPath;
1553
+
1554
+ // Hide error/success messages
1555
+ document.getElementById('routing-config-error').style.display = 'none';
1556
+ document.getElementById('routing-config-success').style.display = 'none';
1557
+ } catch (err) {
1558
+ document.getElementById('routing-config-error').textContent = '加载失败: ' + err.message;
1559
+ document.getElementById('routing-config-error').style.display = 'block';
1560
+ }
1561
+ }
1562
+
1563
+ async function saveRoutingConfig() {
1564
+ if (!monacoEditor) {
1565
+ alert('编辑器未初始化');
1566
+ return;
1567
+ }
1568
+
1569
+ const content = monacoEditor.getValue();
1570
+ const errorEl = document.getElementById('routing-config-error');
1571
+ const successEl = document.getElementById('routing-config-success');
1572
+
1573
+ errorEl.style.display = 'none';
1574
+ successEl.style.display = 'none';
1575
+
1576
+ try {
1577
+ const r = await fetch(API + '/api/routing/config', {
1578
+ method: 'PUT',
1579
+ headers: { 'Content-Type': 'application/json' },
1580
+ body: JSON.stringify({ content }),
1581
+ });
1582
+
1583
+ if (!r.ok) {
1584
+ const err = await r.json();
1585
+ throw new Error(err.error || 'Unknown error');
1586
+ }
1587
+
1588
+ const result = await r.json();
1589
+ successEl.textContent = '✓ 保存成功!配置已写入 ' + result.path + '(热加载将在几秒内生效)';
1590
+ successEl.style.display = 'block';
1591
+
1592
+ // Reload to show updated source
1593
+ setTimeout(() => loadRoutingConfig(), 1000);
1594
+ } catch (err) {
1595
+ errorEl.textContent = '保存失败: ' + err.message;
1596
+ errorEl.style.display = 'block';
1597
+ }
1598
+ }
1599
+
1600
+ // === A/B Testing (Phase 5 Feature 1) ===
1601
+
1602
+ let experimentsEditor = null;
1603
+
1604
+ function initExperimentsEditor() {
1605
+ if (experimentsEditor) return;
1606
+ require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
1607
+ require(['vs/editor/editor.main'], function () {
1608
+ experimentsEditor = monaco.editor.create(document.getElementById('experiments-config-editor'), {
1609
+ value: '',
1610
+ language: 'yaml',
1611
+ theme: 'vs',
1612
+ automaticLayout: true,
1613
+ minimap: { enabled: false },
1614
+ scrollBeyondLastLine: false,
1615
+ fontSize: 14,
1616
+ lineNumbers: 'on',
1617
+ renderWhitespace: 'selection',
1618
+ tabSize: 2,
1619
+ });
1620
+ });
1621
+ }
1622
+
1623
+ async function loadExperimentsConfig() {
1624
+ try {
1625
+ const r = await fetch(API + '/api/routing/experiments/config');
1626
+ const data = await r.json();
1627
+
1628
+ if (!experimentsEditor) {
1629
+ initExperimentsEditor();
1630
+ await new Promise(resolve => {
1631
+ const tick = setInterval(() => {
1632
+ if (experimentsEditor) { clearInterval(tick); resolve(); }
1633
+ }, 100);
1634
+ });
1635
+ }
1636
+
1637
+ experimentsEditor.setValue(data.content || '');
1638
+ document.getElementById('experiments-config-source').textContent =
1639
+ data.source === 'user' ? '用户配置' : '未配置(保存将创建新文件)';
1640
+ document.getElementById('experiments-config-path').textContent = data.path || '';
1641
+
1642
+ document.getElementById('experiments-config-error').style.display = 'none';
1643
+ document.getElementById('experiments-config-success').style.display = 'none';
1644
+ } catch (err) {
1645
+ document.getElementById('experiments-config-error').textContent = '加载失败: ' + err.message;
1646
+ document.getElementById('experiments-config-error').style.display = 'block';
1647
+ }
1648
+ }
1649
+
1650
+ async function saveExperimentsConfig() {
1651
+ if (!experimentsEditor) { alert('编辑器未初始化'); return; }
1652
+ const content = experimentsEditor.getValue();
1653
+ const errorEl = document.getElementById('experiments-config-error');
1654
+ const successEl = document.getElementById('experiments-config-success');
1655
+ errorEl.style.display = 'none';
1656
+ successEl.style.display = 'none';
1657
+
1658
+ try {
1659
+ const r = await fetch(API + '/api/routing/experiments/config', {
1660
+ method: 'PUT',
1661
+ headers: { 'Content-Type': 'application/json' },
1662
+ body: JSON.stringify({ content }),
1663
+ });
1664
+ if (!r.ok) {
1665
+ const err = await r.json();
1666
+ throw new Error(err.error || 'Unknown error');
1667
+ }
1668
+ const result = await r.json();
1669
+ successEl.textContent = '✓ 保存成功!配置已写入 ' + result.path;
1670
+ successEl.style.display = 'block';
1671
+ setTimeout(() => { loadExperimentsConfig(); loadExperimentsAnalysis(); }, 1000);
1672
+ } catch (err) {
1673
+ errorEl.textContent = '保存失败: ' + err.message;
1674
+ errorEl.style.display = 'block';
1675
+ }
1676
+ }
1677
+
1678
+ async function loadExperimentsAnalysis() {
1679
+ const summaryEl = document.getElementById('experiments-summary');
1680
+ const tableEl = document.getElementById('experiments-analysis');
1681
+ const winnerEl = document.getElementById('experiments-winner');
1682
+ summaryEl.innerHTML = '';
1683
+ tableEl.innerHTML = loading();
1684
+ winnerEl.style.display = 'none';
1685
+ winnerEl.innerHTML = '';
1686
+
1687
+ try {
1688
+ const r = await fetch(API + '/api/routing/experiments/analysis');
1689
+ const data = await r.json();
1690
+
1691
+ if (!data.experimentId) {
1692
+ tableEl.innerHTML = empty('当前没有活跃实验。编辑上方 YAML 配置并保存后即可开始。');
1693
+ return;
1694
+ }
1695
+
1696
+ const endedLabel = data.endedAt ? ' · 已结束 ' + fmt(data.endedAt) : (data.enabled ? ' · 进行中' : ' · 未启用');
1697
+ summaryEl.innerHTML =
1698
+ '<strong>' + (data.experimentName || data.experimentId) + '</strong>' +
1699
+ '<span style="color:var(--text-dim)"> (' + data.experimentId + ')</span>' +
1700
+ '<span style="color:var(--text-dim)">' + endedLabel + '</span>' +
1701
+ '<br><small style="color:var(--text-dim)">开始于 ' + fmt(data.startedAt) + '</small>';
1702
+
1703
+ const rows = (data.groups || []).map(g => {
1704
+ const rate = g.obeyedRate == null ? '—' : (g.obeyedRate * 100).toFixed(1) + '%';
1705
+ const avg = g.avgClassificationMs == null ? '—' : g.avgClassificationMs.toFixed(0) + 'ms';
1706
+ return '<tr>' +
1707
+ '<td><strong>' + g.id + '</strong> <span style="color:var(--text-dim)">(' + g.name + ')</span></td>' +
1708
+ '<td>' + g.weight + '%</td>' +
1709
+ '<td>' + g.total + '</td>' +
1710
+ '<td>' + g.obeyed + '</td>' +
1711
+ '<td>' + g.refused + '</td>' +
1712
+ '<td>' + g.unknown + '</td>' +
1713
+ '<td>' + rate + '</td>' +
1714
+ '<td>' + avg + '</td>' +
1715
+ '</tr>';
1716
+ }).join('');
1717
+
1718
+ tableEl.innerHTML =
1719
+ '<table>' +
1720
+ '<thead><tr>' +
1721
+ '<th>组</th><th>权重</th><th>样本</th><th>听话</th><th>违抗</th><th>未知</th><th>听话率</th><th>平均分类耗时</th>' +
1722
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
1723
+
1724
+ if (data.zScore !== null && data.zScore !== undefined) {
1725
+ const zline =
1726
+ '<div style="margin-top:0.5rem;font-size:0.875rem;color:var(--text-dim)">' +
1727
+ 'z-score = ' + data.zScore.toFixed(3) +
1728
+ (data.sampleAdequate ? ' (样本充足)' : ' (样本不足,需每组 ≥ 50)') +
1729
+ '</div>';
1730
+ tableEl.insertAdjacentHTML('beforeend', zline);
1731
+ }
1732
+
1733
+ if (data.suggestedWinner && !data.endedAt) {
1734
+ winnerEl.style.display = 'block';
1735
+ winnerEl.innerHTML =
1736
+ '<div style="padding:1rem;background:var(--primary);color:white;border-radius:var(--radius-sm)">' +
1737
+ '<div style="margin-bottom:0.5rem"><strong>建议获胜方案:' + data.suggestedWinner + '</strong> (z &gt; 1.96)</div>' +
1738
+ '<button class="btn" style="background:white;color:var(--primary)" onclick="promoteExperiment(\'' + data.suggestedWinner + '\')">一键应用该组规则到 routing.yaml</button>' +
1739
+ '</div>';
1740
+ } else if (!data.endedAt && data.groups && data.groups.length >= 2) {
1741
+ winnerEl.style.display = 'block';
1742
+ const buttons = data.groups.map(g =>
1743
+ '<button class="btn" onclick="promoteExperiment(\'' + g.id + '\')">手动晶升 ' + g.id + '</button>'
1744
+ ).join(' ');
1745
+ winnerEl.innerHTML =
1746
+ '<div style="padding:1rem;background:var(--bg-alt);border:1px solid var(--border);border-radius:var(--radius-sm)">' +
1747
+ '<div style="margin-bottom:0.5rem;color:var(--text-dim)">尚未达到显著差异。如确定要提前收尾实验:</div>' +
1748
+ buttons + '</div>';
1749
+ }
1750
+ } catch (err) {
1751
+ tableEl.innerHTML = empty('加载失败: ' + err.message);
1752
+ }
1753
+ }
1754
+
1755
+ async function promoteExperiment(groupId) {
1756
+ if (!confirm('将 ' + groupId + ' 组的规则写入 routing.yaml 并结束当前实验?原 routing.yaml 会备份为 .bak 文件。')) return;
1757
+ try {
1758
+ const r = await fetch(API + '/api/routing/experiments/promote', {
1759
+ method: 'POST',
1760
+ headers: { 'Content-Type': 'application/json' },
1761
+ body: JSON.stringify({ groupId }),
1762
+ });
1763
+ if (!r.ok) {
1764
+ const err = await r.json();
1765
+ throw new Error(err.error || 'Unknown error');
1766
+ }
1767
+ const result = await r.json();
1768
+ alert('✓ 已晶升 ' + result.promoted + ' 组到 routing.yaml' +
1769
+ (result.backupPath ? '\n备份:' + result.backupPath : '') +
1770
+ '\n实验结束于 ' + result.endedAt);
1771
+ await loadExperimentsConfig();
1772
+ await loadExperimentsAnalysis();
1773
+ } catch (err) {
1774
+ alert('晶升失败: ' + err.message);
1775
+ }
1776
+ }
1777
+
1778
+ // === Rule Recommendations (Phase 5 Feature 3) ===
1779
+
1780
+ async function loadRecommendations() {
1781
+ const container = document.getElementById('recommendations-list');
1782
+ container.innerHTML = loading();
1783
+ const days = document.getElementById('recommendations-days')?.value || '7';
1784
+ try {
1785
+ const r = await fetch(API + '/api/routing/recommendations?days=' + days);
1786
+ const data = await r.json();
1787
+ const list = data.recommendations || [];
1788
+ if (list.length === 0) {
1789
+ container.innerHTML = empty('暂无推荐。样本量不足或当前规则已贴近实际行为。');
1790
+ return;
1791
+ }
1792
+
1793
+ let html = '<table>';
1794
+ html += '<thead><tr>' +
1795
+ '<th>任务类型</th><th>复杂度</th>' +
1796
+ '<th>当前路由</th><th>建议路由</th>' +
1797
+ '<th>样本</th><th>当前听话率</th><th>建议 agent 使用率</th>' +
1798
+ '<th>置信度</th><th>说明</th>' +
1799
+ '</tr></thead><tbody>';
1800
+ list.forEach(rec => {
1801
+ const pctFmt = v => (v * 100).toFixed(0) + '%';
1802
+ const conf = (rec.confidence * 100).toFixed(0) + '%';
1803
+ html += '<tr>' +
1804
+ '<td><code>' + rec.taskType + '</code></td>' +
1805
+ '<td>' + (rec.complexity || '—') + '</td>' +
1806
+ '<td>' + (rec.currentAgent ? '<code>' + rec.currentAgent + '</code>' : '<span style="color:var(--text-dim)">(skill 回退)</span>') + '</td>' +
1807
+ '<td><code style="color:var(--primary)">' + rec.recommendedAgent + '</code></td>' +
1808
+ '<td>' + rec.sampleSize + '</td>' +
1809
+ '<td>' + pctFmt(rec.currentObeyedRate) + '</td>' +
1810
+ '<td>' + pctFmt(rec.recommendedUsageRate) + '</td>' +
1811
+ '<td>' + conf + '</td>' +
1812
+ '<td style="font-size:0.85rem;color:var(--text-dim)">' + rec.reason + '</td>' +
1813
+ '</tr>';
1814
+ });
1815
+ html += '</tbody></table>';
1816
+ html += '<div style="margin-top:0.75rem;font-size:0.85rem;color:var(--text-dim)">' +
1817
+ '要应用某条推荐,打开"路由调优"tab,手动更新对应 when 块的 agent 名称并保存。' +
1818
+ '</div>';
1819
+ container.innerHTML = html;
1820
+ } catch (err) {
1821
+ container.innerHTML = empty('加载推荐失败: ' + err.message);
1822
+ }
960
1823
  }
961
1824
 
962
1825
  // === Init ===