@bolloon/bolloon-agent 0.3.8 → 0.3.10

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.
@@ -34,6 +34,10 @@
34
34
  <span class="api-tab-icon">🎵</span>
35
35
  <span>音频生成</span>
36
36
  </button>
37
+ <button class="api-tab" data-tab="engines" onclick="switchTab('engines')">
38
+ <span class="api-tab-icon">🤖</span>
39
+ <span>外部智能体</span>
40
+ </button>
37
41
  </div>
38
42
 
39
43
  <!-- LLM 面板 -->
@@ -63,6 +67,16 @@
63
67
  </div>
64
68
  </div>
65
69
 
70
+ <!-- 外部智能体面板 -->
71
+ <div class="api-panel" id="panel-engines" data-panel="engines" style="display: none;">
72
+ <div class="video-intro">
73
+ <p>Bolloon 自动发现本机已安装的 AI 编码工具(Codex / Claude Code / OpenCode / OpenClaw / Hermes)与实验目录声明的 API。可在此筛选模型并把它们的 API 当作 Bolloon 的供应商一键导入,无需重复填 Key。委派执行请通过对话里的 <code>delegate_to_engine</code> 工具。</p>
74
+ </div>
75
+ <div class="provider-list" id="engineList">
76
+ <div class="loading-state">加载中...</div>
77
+ </div>
78
+ </div>
79
+
66
80
  <!-- 配置弹窗 -->
67
81
  <div class="modal-overlay" id="configModal" style="display: none;">
68
82
  <div class="modal-box">
@@ -186,6 +200,62 @@
186
200
  </div>
187
201
  </div>
188
202
 
203
+ <!-- 外部智能体 配置弹窗 -->
204
+ <div class="modal-overlay" id="engineModal" style="display: none;">
205
+ <div class="modal-box">
206
+ <div class="modal-header">
207
+ <div class="modal-title-group">
208
+ <div class="modal-icon" id="engineModalIcon">A</div>
209
+ <div>
210
+ <h2 id="engineModalTitle">配置</h2>
211
+ <p class="modal-subtitle" id="engineModalSubtitle">描述</p>
212
+ </div>
213
+ </div>
214
+ <button class="modal-close" onclick="closeEngineModal()">×</button>
215
+ </div>
216
+
217
+ <form class="modal-form" id="engineConfigForm">
218
+ <div class="form-group">
219
+ <label>映射供应商</label>
220
+ <select id="engineProviderInput"></select>
221
+ <p class="form-hint">导入时写进该 Bolloon 供应商 (可覆盖发现的映射)</p>
222
+ </div>
223
+
224
+ <div class="form-group">
225
+ <label>API Key</label>
226
+ <input type="password" id="engineApiKeyInput" placeholder="输入 API Key">
227
+ <p class="form-hint" id="engineApiKeyHint">已有 Key 会保留,输入新值以更新</p>
228
+ </div>
229
+
230
+ <div class="form-group">
231
+ <label>Base URL</label>
232
+ <input type="text" id="engineBaseUrlInput" placeholder="https://api.example.com/v1">
233
+ <p class="form-hint">留空使用默认地址</p>
234
+ </div>
235
+
236
+ <div class="form-group">
237
+ <label>模型(可筛选)</label>
238
+ <div class="combobox" id="engineModelCombobox">
239
+ <input type="text" id="engineModelInput" placeholder="输入关键字筛选模型…" autocomplete="off">
240
+ <div class="combobox-list" id="engineModelList" style="display: none;"></div>
241
+ </div>
242
+ <p class="form-hint" id="engineModelHint">从发现到的候选模型里筛选,或手动输入自定义模型名</p>
243
+ </div>
244
+
245
+ <div class="form-group">
246
+ <button type="button" class="test-btn" id="engineImportBtn" onclick="importEngine()">
247
+ ⚡ 导入为供应商
248
+ </button>
249
+ <div class="test-result" id="engineImportResult" style="display: none;"></div>
250
+ </div>
251
+
252
+ <div class="form-actions">
253
+ <button type="button" class="btn-cancel" onclick="closeEngineModal()">取消</button>
254
+ </div>
255
+ </form>
256
+ </div>
257
+ </div>
258
+
189
259
  <script>
190
260
  // 当前 tab
191
261
  let currentTab = 'llm';
@@ -194,7 +264,9 @@
194
264
  let llmConfigData = null;
195
265
  let videoConfigData = null;
196
266
  let audioConfigData = null;
267
+ let engineData = null;
197
268
  let currentProvider = null;
269
+ let currentEngine = null;
198
270
  let currentProviderType = 'llm'; // 'llm' | 'video' | 'audio'
199
271
 
200
272
  // ==================== Tab 切换 ====================
@@ -211,6 +283,13 @@
211
283
  }
212
284
 
213
285
  function updateCountBadge() {
286
+ if (currentTab === 'engines') {
287
+ const list = engineData && engineData.engines ? engineData.engines : [];
288
+ const total = list.length;
289
+ const available = list.filter(e => e.available).length;
290
+ document.getElementById('configCount').textContent = available + '/' + total + ' 智能体可用';
291
+ return;
292
+ }
214
293
  const data = currentTab === 'llm' ? llmConfigData :
215
294
  currentTab === 'video' ? videoConfigData : audioConfigData;
216
295
  if (!data) {
@@ -235,7 +314,19 @@
235
314
 
236
315
  // ==================== 加载 ====================
237
316
  async function loadAll() {
238
- await Promise.all([loadLLMConfig(), loadVideoConfig(), loadAudioConfig()]);
317
+ await Promise.all([loadLLMConfig(), loadVideoConfig(), loadAudioConfig(), loadEngines()]);
318
+ }
319
+
320
+ async function loadEngines() {
321
+ try {
322
+ const resp = await fetch('/api/external-engines');
323
+ engineData = await resp.json();
324
+ renderEngines();
325
+ if (currentTab === 'engines') updateCountBadge();
326
+ } catch (err) {
327
+ const el = document.getElementById('engineList');
328
+ if (el) el.innerHTML = '<div class="error-state">加载失败: ' + err.message + '</div>';
329
+ }
239
330
  }
240
331
 
241
332
  async function loadLLMConfig() {
@@ -518,6 +609,164 @@
518
609
  }
519
610
  };
520
611
 
612
+ // ==================== 外部智能体 渲染 ====================
613
+ function renderEngines() {
614
+ const listEl = document.getElementById('engineList');
615
+ if (!listEl) return;
616
+ const list = engineData && engineData.engines ? engineData.engines : [];
617
+ if (list.length === 0) {
618
+ listEl.innerHTML = '<div class="error-state">未发现本机已安装的外部编码智能体,也未找到实验目录 API(BOLLOON_EXPERIMENT_API_DIR)</div>';
619
+ return;
620
+ }
621
+ let html = '';
622
+ for (const e of list) {
623
+ const isExperiment = String(e.id).startsWith('experiment:');
624
+ let statusText, statusClass;
625
+ if (e.available) { statusText = '可用'; statusClass = 'status-green'; }
626
+ else if (e.installed) { statusText = '已装未配'; statusClass = 'status-yellow'; }
627
+ else if (e.configured) { statusText = '已配未装'; statusClass = 'status-yellow'; }
628
+ else { statusText = '未发现'; statusClass = 'status-muted'; }
629
+
630
+ html += `
631
+ <div class="provider-card ${e.available ? 'active' : ''}" onclick="openEngineModal('${e.id}')">
632
+ <div class="provider-header">
633
+ <div class="provider-icon">${isExperiment ? '🧪' : e.displayName.charAt(0).toUpperCase()}</div>
634
+ <div class="provider-info">
635
+ <h3 class="provider-name">${e.displayName}</h3>
636
+ <p class="provider-desc">${e.id}${e.provider ? ' → ' + e.provider : ''}${e.cliPath ? ' · 已安装' : ''}</p>
637
+ </div>
638
+ <div class="status-badge ${statusClass}">
639
+ <span class="status-dot"></span>
640
+ ${statusText}
641
+ </div>
642
+ </div>
643
+ <div class="provider-footer">
644
+ <div class="config-status">
645
+ <span class="status-icon ${e.configured ? 'configured' : ''}">${e.configured ? '✓' : '○'}</span>
646
+ ${e.configured ? '已配置 API' : '未配置 API'}
647
+ </div>
648
+ ${e.model ? '<div class="model-info">模型: ' + e.model + '</div>' : ''}
649
+ ${e.models && e.models.length ? '<div class="model-info">候选模型: ' + e.models.length + ' 个(可筛选)</div>' : ''}
650
+ <div class="arrow-indicator">›</div>
651
+ </div>
652
+ </div>
653
+ `;
654
+ }
655
+ listEl.innerHTML = html;
656
+ }
657
+
658
+ // ==================== 外部智能体 配置弹窗 ====================
659
+ const ENGINE_PROVIDER_FALLBACK = ['openai','anthropic','ollama','openrouter','gemini','minimax','deepseek','kimi','glm','qwen','mimo','local'];
660
+
661
+ function openEngineModal(id) {
662
+ const list = engineData && engineData.engines ? engineData.engines : [];
663
+ const e = list.find(x => x.id === id);
664
+ if (!e) return;
665
+ currentEngine = e;
666
+
667
+ document.getElementById('engineModalIcon').textContent = String(e.displayName).charAt(0).toUpperCase();
668
+ document.getElementById('engineModalTitle').textContent = '配置 ' + e.displayName;
669
+ document.getElementById('engineModalSubtitle').textContent = (e.notes || '') + (e.configPath ? ' · 配置: ' + e.configPath : '');
670
+
671
+ // 映射供应商 select
672
+ const provSel = document.getElementById('engineProviderInput');
673
+ const provKeys = (llmConfigData && llmConfigData.providers) ? Object.keys(llmConfigData.providers) : ENGINE_PROVIDER_FALLBACK;
674
+ const mapped = e.provider || 'openai';
675
+ provSel.innerHTML = provKeys.map(k => `<option value="${k}" ${k === mapped ? 'selected' : ''}>${k}</option>`).join('');
676
+
677
+ document.getElementById('engineApiKeyInput').value = '';
678
+ document.getElementById('engineApiKeyHint').textContent = e.apiKey
679
+ ? '当前已发现 (***' + (e.apiKey.slice(-4) || '') + '),输入新值以更新'
680
+ : '输入 API Key';
681
+ document.getElementById('engineBaseUrlInput').value = e.baseUrl || '';
682
+ document.getElementById('engineModelInput').value = e.model || '';
683
+ document.getElementById('engineModelHint').textContent = (e.models && e.models.length)
684
+ ? '从 ' + e.models.length + ' 个候选模型里筛选,或手动输入'
685
+ : '手动输入模型名';
686
+
687
+ document.getElementById('engineImportResult').style.display = 'none';
688
+ document.getElementById('engineModal').style.display = 'flex';
689
+ // 准备模型筛选下拉
690
+ renderEngineModelList('');
691
+ setTimeout(() => document.getElementById('engineModelInput').focus(), 50);
692
+ }
693
+
694
+ function closeEngineModal() {
695
+ document.getElementById('engineModal').style.display = 'none';
696
+ currentEngine = null;
697
+ document.getElementById('engineModelList').style.display = 'none';
698
+ }
699
+
700
+ // 模型筛选下拉
701
+ function renderEngineModelList(filter) {
702
+ const listEl = document.getElementById('engineModelList');
703
+ if (!currentEngine || !currentEngine.models) { listEl.style.display = 'none'; return; }
704
+ const f = (filter || '').toLowerCase();
705
+ const matched = currentEngine.models.filter(m => m.toLowerCase().includes(f));
706
+ if (matched.length === 0) { listEl.style.display = 'none'; return; }
707
+ listEl.innerHTML = matched.map(m => `<div class="combobox-option" onclick="selectEngineModel('${m.replace(/'/g, "\\'")}')">${m}</div>`).join('');
708
+ listEl.style.display = 'block';
709
+ }
710
+
711
+ function selectEngineModel(model) {
712
+ document.getElementById('engineModelInput').value = model;
713
+ document.getElementById('engineModelList').style.display = 'none';
714
+ }
715
+
716
+ document.getElementById('engineModelInput').addEventListener('input', (ev) => {
717
+ renderEngineModelList(ev.target.value);
718
+ });
719
+ document.getElementById('engineModelInput').addEventListener('focus', (ev) => {
720
+ renderEngineModelList(ev.target.value);
721
+ });
722
+ // 点击空白关闭下拉
723
+ document.addEventListener('click', (ev) => {
724
+ const cb = document.getElementById('engineModelCombobox');
725
+ if (cb && !cb.contains(ev.target)) {
726
+ document.getElementById('engineModelList').style.display = 'none';
727
+ }
728
+ });
729
+ document.getElementById('engineModal').addEventListener('click', (ev) => {
730
+ if (ev.target === document.getElementById('engineModal')) closeEngineModal();
731
+ });
732
+
733
+ async function importEngine() {
734
+ if (!currentEngine) return;
735
+ const btn = document.getElementById('engineImportBtn');
736
+ const result = document.getElementById('engineImportResult');
737
+ btn.disabled = true;
738
+ requestAnimationFrame(() => { btn.textContent = '⚡ 导入中...'; });
739
+ result.style.display = 'none';
740
+
741
+ const body = {
742
+ id: currentEngine.id,
743
+ model: document.getElementById('engineModelInput').value.trim(),
744
+ provider: document.getElementById('engineProviderInput').value,
745
+ };
746
+
747
+ try {
748
+ const resp = await fetch('/api/external-engines/import', {
749
+ method: 'POST',
750
+ headers: { 'Content-Type': 'application/json' },
751
+ body: JSON.stringify(body),
752
+ });
753
+ const data = await resp.json();
754
+ if (!resp.ok) throw new Error(data.error || '导入失败');
755
+ result.className = 'test-result test-success';
756
+ result.innerHTML = '<span>✓</span> 已导入为供应商: ' + (data.provider || '') +
757
+ (data.autoActivated ? '(已激活)' : '') + ',可在「LLM 对话」tab 选用';
758
+ result.style.display = 'flex';
759
+ // 刷新 LLM 配置 + 引擎列表
760
+ await Promise.all([loadLLMConfig(), loadEngines()]);
761
+ } catch (err) {
762
+ result.className = 'test-result test-error';
763
+ result.innerHTML = '<span>✗</span> ' + (err.message || '导入失败');
764
+ result.style.display = 'flex';
765
+ }
766
+ btn.disabled = false;
767
+ requestAnimationFrame(() => { btn.textContent = '⚡ 导入为供应商'; });
768
+ }
769
+
521
770
  // ==================== 关闭弹窗 ====================
522
771
  document.getElementById('configModal').onclick = function(e) {
523
772
  if (e.target === this) closeModal();
@@ -3438,8 +3438,16 @@ ${data.error || "channel not found"}`, "error");
3438
3438
  });
3439
3439
  renderJudgments(lastJudgmentsCache);
3440
3440
  }
3441
+ function switchPolarity(polarity) {
3442
+ currentPolarity = polarity;
3443
+ currentAdvancedFilter = null;
3444
+ document.querySelectorAll(".judgment-polarity-tab").forEach((btn) => {
3445
+ btn.classList.toggle("active", btn.dataset.polarity === polarity);
3446
+ });
3447
+ loadJudgments();
3448
+ }
3441
3449
  function switchStatusFilter(status) {
3442
- currentStatusFilter = status;
3450
+ currentAdvancedFilter = status;
3443
3451
  document.querySelectorAll(".judgment-status-tab").forEach((btn) => {
3444
3452
  btn.classList.toggle("active", btn.dataset.status === status);
3445
3453
  });
@@ -3464,7 +3472,8 @@ ${data.error || "channel not found"}`, "error");
3464
3472
  if (judgmentsModal) judgmentsModal.classList.remove("active");
3465
3473
  }
3466
3474
  var currentJudgmentTab = "channel";
3467
- var currentStatusFilter = "all";
3475
+ var currentPolarity = "positive";
3476
+ var currentAdvancedFilter = null;
3468
3477
  var lastJudgmentsCache = [];
3469
3478
  function renderJudgments(items) {
3470
3479
  if (!judgmentsList) return;
@@ -3554,7 +3563,7 @@ ${data.error || "channel not found"}`, "error");
3554
3563
  async function loadJudgments() {
3555
3564
  if (!judgmentsList) return;
3556
3565
  try {
3557
- if (currentStatusFilter === "violations") {
3566
+ if (currentAdvancedFilter === "violations") {
3558
3567
  const res2 = await fetch("/api/judgments/violations?limit=50");
3559
3568
  if (!res2.ok) throw new Error("HTTP " + res2.status);
3560
3569
  const data2 = await res2.json();
@@ -3562,7 +3571,7 @@ ${data.error || "channel not found"}`, "error");
3562
3571
  judgmentsLoaded = true;
3563
3572
  return;
3564
3573
  }
3565
- if (currentStatusFilter === "adaptive") {
3574
+ if (currentAdvancedFilter === "adaptive") {
3566
3575
  const res2 = await fetch("/api/judgments/adaptive-suggestions");
3567
3576
  if (!res2.ok) throw new Error("HTTP " + res2.status);
3568
3577
  const data2 = await res2.json();
@@ -3570,7 +3579,7 @@ ${data.error || "channel not found"}`, "error");
3570
3579
  judgmentsLoaded = true;
3571
3580
  return;
3572
3581
  }
3573
- if (currentStatusFilter === "causal") {
3582
+ if (currentAdvancedFilter === "causal") {
3574
3583
  const res2 = await fetch("/api/judgments/causal/correlation?topN=10");
3575
3584
  if (!res2.ok) throw new Error("HTTP " + res2.status);
3576
3585
  const data2 = await res2.json();
@@ -3578,17 +3587,25 @@ ${data.error || "channel not found"}`, "error");
3578
3587
  judgmentsLoaded = true;
3579
3588
  return;
3580
3589
  }
3581
- const res = await fetch("/api/judgments?status=" + encodeURIComponent(currentStatusFilter));
3590
+ const POSITIVE_TYPES = ["approve", "modify", "escalate"];
3591
+ const fetchStatus = currentPolarity === "positive" ? "active" : "all";
3592
+ const res = await fetch("/api/judgments?status=" + encodeURIComponent(fetchStatus));
3582
3593
  if (!res.ok) throw new Error("HTTP " + res.status);
3583
3594
  const data = await res.json();
3584
- lastJudgmentsCache = data.judgments || [];
3585
- renderJudgments(lastJudgmentsCache);
3595
+ let list = data.judgments || [];
3596
+ if (currentPolarity === "positive") {
3597
+ list = list.filter((j) => POSITIVE_TYPES.includes(j.decision_type) && (j.status ?? "active") === "active");
3598
+ } else {
3599
+ list = list.filter((j) => j.decision_type === "reject" || ["rejected", "superseded"].includes(j.status ?? ""));
3600
+ }
3601
+ lastJudgmentsCache = list;
3602
+ renderJudgments(list);
3586
3603
  if (judgmentsBadge) {
3587
3604
  let activeCount;
3588
- if (currentStatusFilter === "active") {
3605
+ if (currentPolarity === "positive") {
3589
3606
  activeCount = data.count;
3590
3607
  } else {
3591
- activeCount = lastJudgmentsCache.filter((j) => (j.status ?? "active") === "active").length;
3608
+ activeCount = (data.judgments || []).filter((j) => POSITIVE_TYPES.includes(j.decision_type) && (j.status ?? "active") === "active").length;
3592
3609
  }
3593
3610
  if (activeCount > 0) {
3594
3611
  judgmentsBadge.textContent = activeCount;
@@ -3863,6 +3880,9 @@ ${data.error || "channel not found"}`, "error");
3863
3880
  document.querySelectorAll(".judgment-tab").forEach((btn) => {
3864
3881
  btn.addEventListener("click", () => switchJudgmentTab(btn.dataset.tab));
3865
3882
  });
3883
+ document.querySelectorAll(".judgment-polarity-tab").forEach((btn) => {
3884
+ btn.addEventListener("click", () => switchPolarity(btn.dataset.polarity));
3885
+ });
3866
3886
  document.querySelectorAll(".judgment-status-tab").forEach((btn) => {
3867
3887
  btn.addEventListener("click", () => switchStatusFilter(btn.dataset.status));
3868
3888
  });
@@ -3980,12 +4000,14 @@ ${data.error || "channel not found"}`, "error");
3980
4000
  judgmentSubmitBtn.disabled = true;
3981
4001
  if (judgmentError) judgmentError.style.display = "none";
3982
4002
  try {
4003
+ const polarity = document.querySelector('input[name="judgment-polarity"]:checked')?.value || "positive";
3983
4004
  const res = await fetch("/api/judgments", {
3984
4005
  method: "POST",
3985
4006
  headers: { "Content-Type": "application/json" },
3986
4007
  body: JSON.stringify({
3987
4008
  decision,
3988
4009
  reason: reason || void 0,
4010
+ decision_type: polarity === "negative" ? "reject" : "approve",
3989
4011
  context: { domain: judgmentDomain?.value, stakes: judgmentStakes?.value }
3990
4012
  })
3991
4013
  });
@@ -205,21 +205,39 @@
205
205
  <input type="text" id="judgment-reason" placeholder="例: 信任 Bolloon 的判断存储">
206
206
  </div>
207
207
  <div class="form-group judgment-form-row">
208
- <label>领域 (domain)</label>
209
- <select id="judgment-domain">
210
- <option value="general">general</option>
211
- <option value="code">code</option>
212
- <option value="architecture">architecture</option>
213
- <option value="security">security</option>
214
- <option value="testing">testing</option>
215
- </select>
216
- <label>风险 (stakes)</label>
217
- <select id="judgment-stakes">
218
- <option value="medium">medium</option>
219
- <option value="low">low</option>
220
- <option value="high">high</option>
221
- <option value="critical">critical</option>
222
- </select>
208
+ <label>分类</label>
209
+ <div class="judgment-polarity-toggle" id="judgment-polarity-toggle">
210
+ <label class="polarity-opt active" data-polarity="positive">
211
+ <input type="radio" name="judgment-polarity" value="positive" checked>
212
+ <span>▲ 正向 <small>采纳</small></span>
213
+ </label>
214
+ <label class="polarity-opt" data-polarity="negative">
215
+ <input type="radio" name="judgment-polarity" value="negative">
216
+ <span>▼ 负向 <small>否决/避免</small></span>
217
+ </label>
218
+ </div>
219
+ <details class="judgment-advanced-fold" style="margin-left:auto;">
220
+ <summary style="font-size:11px;color:#6b7280;cursor:pointer;list-style:none;">高级 (领域/风险)</summary>
221
+ <div style="display:flex;gap:10px;padding:6px 0 0;flex-wrap:wrap;font-size:11px;">
222
+ <label>领域
223
+ <select id="judgment-domain">
224
+ <option value="general">general</option>
225
+ <option value="code">code</option>
226
+ <option value="architecture">architecture</option>
227
+ <option value="security">security</option>
228
+ <option value="testing">testing</option>
229
+ </select>
230
+ </label>
231
+ <label>风险
232
+ <select id="judgment-stakes">
233
+ <option value="medium">medium</option>
234
+ <option value="low">low</option>
235
+ <option value="high">high</option>
236
+ <option value="critical">critical</option>
237
+ </select>
238
+ </label>
239
+ </div>
240
+ </details>
223
241
  </div>
224
242
  <div class="btn-group">
225
243
  <button id="judgment-submit-btn" class="btn-primary">记录</button>
@@ -235,15 +253,21 @@
235
253
  </button>
236
254
  <button class="judgment-tab" data-tab="global">全局</button>
237
255
  </div>
238
- <!-- AI 演化 status 过滤: active / superseded / all / violations / 自适应 -->
256
+ <!-- 2026-07-22 简化: 正向 / 负向 两个主分类 (替换原 6 status tab)
257
+ 正向 = 采纳类 (approve/modify/escalate), 会注入 prompt 复用
258
+ 负向 = 否决/被推翻 (reject/rejected/superseded), 负向回收为避免清单
259
+ 高级分析 (违规/自适应/因果) 折叠保留, 数据/API 不删 -->
239
260
  <div id="judgments-status-filter" class="judgment-status-bar">
240
- <span class="label">演化过滤:</span>
241
- <button class="judgment-status-tab active" data-status="all">全部</button>
242
- <button class="judgment-status-tab" data-status="active">活跃</button>
243
- <button class="judgment-status-tab" data-status="superseded">已过时</button>
244
- <button class="judgment-status-tab" data-status="violations">违规记录</button>
245
- <button class="judgment-status-tab" data-status="adaptive">📊 自适应</button>
246
- <button class="judgment-status-tab" data-status="causal">🔍 因果分析</button>
261
+ <button class="judgment-polarity-tab active" data-polarity="positive" title="采纳类: approve/modify/escalate, 会注入 prompt 复用">▲ 正向</button>
262
+ <button class="judgment-polarity-tab" data-polarity="negative" title="否决/被推翻: reject/rejected/superseded, 负向回收为避免清单">▼ 负向</button>
263
+ <details class="judgment-advanced-fold" style="margin-left:auto;">
264
+ <summary style="font-size:11px;color:#6b7280;cursor:pointer;list-style:none;">⋯ 高级分析</summary>
265
+ <div style="display:flex;gap:4px;padding:6px 0 0;flex-wrap:wrap;">
266
+ <button class="judgment-status-tab" data-status="violations" style="font-size:11px;">违规记录</button>
267
+ <button class="judgment-status-tab" data-status="adaptive" style="font-size:11px;">📊 自适应</button>
268
+ <button class="judgment-status-tab" data-status="causal" style="font-size:11px;">🔍 因果分析</button>
269
+ </div>
270
+ </details>
247
271
  </div>
248
272
  <h3 class="judgment-list-header">
249
273
  <span id="judgments-list-title">本 channel 的判断力</span>
@@ -0,0 +1,111 @@
1
+ /**
2
+ * routes-external-engines.ts — 外部编码智能体 配置/委派 路由
3
+ *
4
+ * 三个能力:
5
+ * GET /api/external-engines 发现本机已装的引擎 (脱敏)
6
+ * POST /api/external-engines/import 把发现的引擎 API 写进 Bolloon provider 体系 (当供应商)
7
+ * POST /api/external-engines/run 委派编码任务给引擎 CLI (子智能体)
8
+ *
9
+ * 复用 routes-llm-config.ts 的 llmConfigStore + initMinimax 做激活.
10
+ */
11
+ import { discoverEngines, mapEngineToProviderConfig, resolveProvider } from '../external-engines/index.js';
12
+ import { llmConfigStore } from '../llm/config-store.js';
13
+ import { initMinimax } from '../constraints/index.js';
14
+ function maskKey(key) {
15
+ if (!key)
16
+ return '';
17
+ if (key.length <= 4)
18
+ return '***';
19
+ return '***' + key.slice(-4);
20
+ }
21
+ export function registerExternalEngineRoutes(app) {
22
+ // ==================== 发现 ====================
23
+ app.get('/api/external-engines', async (_req, res) => {
24
+ try {
25
+ const engines = await discoverEngines();
26
+ const safe = engines.map((e) => ({
27
+ ...e,
28
+ apiKey: maskKey(e.apiKey),
29
+ }));
30
+ res.json({ engines: safe });
31
+ }
32
+ catch (err) {
33
+ res.status(500).json({ error: err?.message || String(err) });
34
+ }
35
+ });
36
+ // ==================== 导入为供应商 ====================
37
+ app.post('/api/external-engines/import', async (req, res) => {
38
+ try {
39
+ const { id, model, provider: providerOverride } = req.body || {};
40
+ if (!id)
41
+ return res.status(400).json({ error: 'id 必填' });
42
+ const engines = await discoverEngines();
43
+ const engine = engines.find((e) => e.id === id);
44
+ if (!engine)
45
+ return res.status(404).json({ error: `未发现的引擎: ${id}` });
46
+ if (!engine.configured) {
47
+ return res.status(400).json({ error: `引擎 ${id} 未配置 API key / baseUrl, 无法导入为供应商` });
48
+ }
49
+ // 允许前端在导入时覆盖 model / provider (API 配置 UI 里用户筛选模型后传来)
50
+ if (model && typeof model === 'string' && model.trim()) {
51
+ engine.model = model.trim();
52
+ }
53
+ if (providerOverride && typeof providerOverride === 'string' && providerOverride.trim()) {
54
+ const resolved = resolveProvider(providerOverride.trim(), engine.provider || 'openai');
55
+ engine.provider = resolved;
56
+ }
57
+ let importPatch;
58
+ try {
59
+ importPatch = mapEngineToProviderConfig(engine);
60
+ }
61
+ catch (e) {
62
+ return res.status(400).json({ error: e.message });
63
+ }
64
+ const { provider, patch } = importPatch;
65
+ await llmConfigStore.updateProvider(provider, patch);
66
+ // 导入即激活 (若提供了有效 key, 或不需要 key 的 provider)
67
+ const shouldActivate = !!patch.apiKey || !engine.apiKey;
68
+ let autoActivated = false;
69
+ if (shouldActivate) {
70
+ try {
71
+ await llmConfigStore.setActiveProvider(provider);
72
+ initMinimax({
73
+ provider: provider,
74
+ apiKey: patch.apiKey || undefined,
75
+ baseUrl: patch.baseUrl || undefined,
76
+ model: patch.model || undefined,
77
+ });
78
+ autoActivated = true;
79
+ }
80
+ catch (e) {
81
+ // 激活失败不阻断导入 (可能该 provider 仍需别的字段)
82
+ console.warn('[external-engines] 激活失败:', e?.message);
83
+ }
84
+ }
85
+ res.json({ ok: true, provider, autoActivated, imported: { ...patch, apiKey: maskKey(patch.apiKey) } });
86
+ }
87
+ catch (err) {
88
+ res.status(500).json({ error: err?.message || String(err) });
89
+ }
90
+ });
91
+ // ==================== 委派执行 ====================
92
+ app.post('/api/external-engines/run', async (req, res) => {
93
+ try {
94
+ const { id, prompt, cwd, model } = req.body || {};
95
+ if (!id)
96
+ return res.status(400).json({ error: 'id 必填' });
97
+ if (!prompt)
98
+ return res.status(400).json({ error: 'prompt 必填' });
99
+ // 动态 import 避免顶层循环依赖 (delegate -> discovery, 这里反向)
100
+ const { delegateToEngine } = await import('../external-engines/delegate.js');
101
+ const result = await delegateToEngine(id, prompt, {
102
+ cwd,
103
+ ...(model ? { model: String(model) } : {}),
104
+ });
105
+ res.json(result);
106
+ }
107
+ catch (err) {
108
+ res.status(500).json({ error: err?.message || String(err) });
109
+ }
110
+ });
111
+ }
@@ -36,15 +36,21 @@ export function registerJudgmentsRoutes(app) {
36
36
  // 极简版: 只记录 decision + reason; 其它字段可选
37
37
  app.post('/api/judgments', async (req, res) => {
38
38
  try {
39
- const { decision, reason, context } = req.body;
39
+ const { decision, reason, context, decision_type } = req.body;
40
40
  if (!decision || typeof decision !== 'string' || !decision.trim()) {
41
41
  return res.status(400).json({ error: 'decision required' });
42
42
  }
43
+ // 2026-07-22: 接受前端 polarity toggle 传来的 decision_type (正=approve / 负=reject)
44
+ // 不传或非法 → 默认 approve (向后兼容)
45
+ const allowedTypes = ['approve', 'reject', 'modify', 'escalate'];
46
+ const finalType = decision_type && allowedTypes.includes(decision_type)
47
+ ? decision_type
48
+ : 'approve';
43
49
  const { storeHumanJudgment, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
44
50
  await initializeValueStore();
45
51
  const j = await storeHumanJudgment({
46
52
  decision: decision.trim(),
47
- decision_type: 'approve',
53
+ decision_type: finalType,
48
54
  reasons: reason ? [reason.trim()] : [],
49
55
  values_derived: [],
50
56
  context: {