@winspan/claude-forge 8.15.0 → 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.
@@ -353,19 +353,15 @@
353
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>
354
354
  注入
355
355
  </a>
356
- <a onclick="nav('live')" id="nav-live">
357
- <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>
358
- 实时
359
- </a>
360
356
  <div class="nav-section-title">配置</div>
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
361
  <a onclick="nav('routing')" id="nav-routing">
362
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
363
  Agent 路由
364
364
  </a>
365
- <a onclick="nav('rules')" id="nav-rules">
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>
367
- 规则
368
- </a>
369
365
  </aside>
370
366
 
371
367
  <!-- Main -->
@@ -459,23 +455,50 @@
459
455
  </div>
460
456
  </div>
461
457
 
462
- <!-- Live -->
463
- <div id="page-live" class="page">
464
- <div class="toolbar">
465
- <span id="live-status" class="badge badge-warn">未连接</span>
466
- <button class="btn btn-primary" id="live-btn" onclick="toggleLive()">连接</button>
467
- <button class="btn" onclick="document.getElementById('live-log').innerHTML=''">清空</button>
468
- </div>
469
- <div class="live-log" id="live-log"></div>
470
- </div>
471
-
472
- <!-- Rules -->
473
- <div id="page-rules" class="page">
474
- <div class="toolbar">
475
- <input class="search-box" id="rules-search" placeholder="搜索规则..." oninput="filterRules()">
476
- </div>
477
- <div class="panel" id="rules-panel">
478
- <div id="rules-list"></div>
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>
479
502
  </div>
480
503
  </div>
481
504
 
@@ -518,9 +541,10 @@
518
541
  <div class="toolbar" style="display:flex;gap:0.5rem">
519
542
  <select class="btn" id="routing-filter-obeyed" onchange="loadRoutingEvents()">
520
543
  <option value="">全部状态</option>
544
+ <option value="forced">已强路由</option>
521
545
  <option value="1">遵守 (obeyed)</option>
522
546
  <option value="0">违抗 (refused)</option>
523
- <option value="null">未判定 (null)</option>
547
+ <option value="null">待判定/未路由</option>
524
548
  </select>
525
549
  <input class="search-box" id="routing-filter-agent" placeholder="按 agent 名过滤..." oninput="loadRoutingEvents()">
526
550
  </div>
@@ -646,8 +670,7 @@
646
670
 
647
671
  <script>
648
672
  const API = '';
649
- let allEvents = [], allSessions = [], allInjections = [], allRules = [];
650
- let liveSource = null;
673
+ let allEvents = [], allSessions = [], allInjections = [];
651
674
  let charts = {};
652
675
 
653
676
  // === Navigation ===
@@ -658,16 +681,15 @@ function nav(page) {
658
681
  const pageEl = document.getElementById('page-' + page);
659
682
  if (navEl) navEl.classList.add('active');
660
683
  if (pageEl) pageEl.classList.add('active');
661
- const titles = { dashboard:'仪表盘', sessions:'会话', events:'事件', injections:'注入', live:'实时', rules:'规则', routing:'Agent 路由' };
684
+ const titles = { dashboard:'仪表盘', sessions:'会话', events:'事件', injections:'注入', 'ai-config':'AI 配置', routing:'Agent 路由' };
662
685
  document.getElementById('topbar-title').textContent = titles[page] || page;
663
686
  closeDrawer();
664
687
  if (page === 'dashboard') loadDashboard();
665
688
  else if (page === 'sessions') loadSessions();
666
689
  else if (page === 'events') loadEvents();
667
690
  else if (page === 'injections') loadInjections();
668
- else if (page === 'rules') loadRules();
691
+ else if (page === 'ai-config') loadAIConfig();
669
692
  else if (page === 'routing') loadRouting();
670
- else if (page === 'live' && !liveSource) toggleLive();
671
693
  }
672
694
 
673
695
  function refreshPage() {
@@ -801,41 +823,6 @@ function renderToolChart(data) {
801
823
  });
802
824
  }
803
825
 
804
- // === Live ===
805
- function toggleLive() {
806
- if (liveSource) {
807
- liveSource.close(); liveSource = null;
808
- document.getElementById('live-status').textContent = '未连接';
809
- document.getElementById('live-status').className = 'badge badge-warn';
810
- document.getElementById('live-btn').textContent = '连接';
811
- document.getElementById('live-btn').className = 'btn btn-primary';
812
- return;
813
- }
814
- liveSource = new EventSource(API + '/api/events/stream');
815
- document.getElementById('live-status').textContent = '实时中';
816
- document.getElementById('live-status').className = 'badge badge-live';
817
- document.getElementById('live-btn').textContent = '断开连接';
818
- document.getElementById('live-btn').className = 'btn';
819
- liveSource.onmessage = function(ev) {
820
- try {
821
- const e = JSON.parse(ev.data);
822
- if (e.type === 'connected') return;
823
- const log = document.getElementById('live-log');
824
- const line = document.createElement('div');
825
- line.className = 'live-log-line';
826
- const tool = e.tool_name ? `<span style="color:#93c5fd">${e.tool_name}</span>` : '';
827
- const detail = (e.user_prompt || e.tool_input?.command || e.tool_input?.file_path || '').toString().slice(0, 60);
828
- line.innerHTML = `<span style="color:#64748b">${fmtTime(e.timestamp)}</span> ${badgeHook(e.hook_type)} ${tool} <span style="color:#cbd5e1">${detail}</span>`;
829
- log.appendChild(line);
830
- log.scrollTop = log.scrollHeight;
831
- } catch {}
832
- };
833
- liveSource.onerror = function() {
834
- document.getElementById('live-status').textContent = '连接错误';
835
- document.getElementById('live-status').className = 'badge badge-block';
836
- };
837
- }
838
-
839
826
  // === Sessions ===
840
827
  function sessionListItem(s) {
841
828
  const prompt = (s.first_prompt || '(无提示词)').slice(0, 60);
@@ -1085,32 +1072,6 @@ function openInjDrawer(inj) {
1085
1072
  }
1086
1073
 
1087
1074
  // === Rules ===
1088
- async function loadRules() {
1089
- document.getElementById('rules-list').innerHTML = loading();
1090
- try {
1091
- const res = await fetch(API + '/api/rules');
1092
- allRules = await res.json();
1093
- renderRules(allRules);
1094
- } catch {
1095
- document.getElementById('rules-list').innerHTML = empty('加载规则失败');
1096
- }
1097
- }
1098
-
1099
- function renderRules(list) {
1100
- 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('');
1101
- }
1102
-
1103
- function filterRules() {
1104
- const q = document.getElementById('rules-search').value.toLowerCase();
1105
- renderRules(allRules.filter(r => (r.name||'').toLowerCase().includes(q) || (r.description||'').toLowerCase().includes(q)));
1106
- }
1107
-
1108
- function openRuleDrawer(r) {
1109
- let html = '<div class="detail-section"><div class="detail-label">名称</div><div class="detail-value" style="font-weight:600">' + (r.name || '—') + '</div></div>';
1110
- html += '<div class="detail-section"><div class="detail-label">描述</div><div class="detail-value">' + (r.description || '—') + '</div></div>';
1111
- 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>';
1112
- openDrawer(r.name || '规则', html);
1113
- }
1114
1075
 
1115
1076
  // === Agent Routing ===
1116
1077
  let routingCurrentTab = 'overview';
@@ -1146,6 +1107,136 @@ async function loadRouting() {
1146
1107
  if (routingCurrentTab === 'recommendations') return loadRecommendations();
1147
1108
  }
1148
1109
 
1110
+ // === AI Config ===
1111
+ let aiApiKeyMasked = true;
1112
+ let aiApiKeyOriginal = '';
1113
+
1114
+ async function loadAIConfig() {
1115
+ try {
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
+
1149
1240
  function routingWindow() {
1150
1241
  const el = document.getElementById('routing-window');
1151
1242
  return el ? el.value : '168';
@@ -1213,19 +1304,28 @@ async function loadRoutingEvents() {
1213
1304
  const obeyed = document.getElementById('routing-filter-obeyed')?.value || '';
1214
1305
  const agent = document.getElementById('routing-filter-agent')?.value || '';
1215
1306
  const params = new URLSearchParams({ limit: '100' });
1216
- if (obeyed !== '') params.set('obeyed', obeyed);
1307
+ // 'forced' is a client-side filter: keep is_forced=1 rows regardless of obeyed.
1308
+ if (obeyed !== '' && obeyed !== 'forced') params.set('obeyed', obeyed);
1217
1309
  if (agent) params.set('agent', agent);
1218
1310
  const r = await fetch(API + '/api/routing/events?' + params.toString());
1219
- const rows = await r.json();
1311
+ let rows = await r.json();
1312
+ if (obeyed === 'forced') rows = rows.filter(e => e.is_forced === 1);
1220
1313
  const tbody = document.getElementById('routing-events-tbody');
1221
1314
  if (!rows || rows.length === 0) {
1222
1315
  tbody.innerHTML = '<tr><td colspan="5">' + empty('暂无路由事件') + '</td></tr>';
1223
1316
  return;
1224
1317
  }
1225
1318
  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>';
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
+ }
1229
1329
  const ts = new Date(e.ts).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
1230
1330
  return '<tr onclick="openRoutingEventDrawer(' + JSON.stringify(e).replace(/"/g,'&quot;') + ')">'
1231
1331
  + '<td style="color:var(--text-dim);font-size:0.8rem">' + ts + '</td>'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@winspan/claude-forge",
3
- "version": "8.15.0",
3
+ "version": "8.16.0",
4
4
  "description": "SDLC intelligent orchestration engine for Claude Code",
5
5
  "main": "dist/cli/index.js",
6
6
  "type": "module",