@bolloon/bolloon-agent 0.4.14 → 0.4.15

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 (213) hide show
  1. package/README.md +127 -127
  2. package/bin/bolloon-cli.cjs +183 -183
  3. package/bin/bolloon-daemon.sh +207 -0
  4. package/bin/bolloon.cjs +0 -0
  5. package/dist/agents/constraint-layer.js +19 -19
  6. package/dist/agents/judgment-protocol.js +14 -14
  7. package/dist/agents/pi-sdk-types.js +12 -12
  8. package/dist/agents/pi-sdk.js +101 -101
  9. package/dist/agents/skill-organizer.js +19 -19
  10. package/dist/agents/workflow-pivot-loop.js +2 -2
  11. package/dist/agents/x402/x402Pay.js +4 -0
  12. package/dist/bollharness/src/scripts/deploy-guard.js +8 -8
  13. package/dist/bollharness-integration/context-router-judgment.js +4 -4
  14. package/dist/bollharness-integration/context-router.js +292 -292
  15. package/dist/bollharness-integration/gate-state-machine.js +13 -13
  16. package/dist/bollharness-integration/llm-judgment-engine.js +134 -134
  17. package/dist/bollharness-integration/skill-adapter.js +21 -21
  18. package/dist/bootstrap/chat-archiver.js +14 -14
  19. package/dist/bootstrap/context-os.js +24 -24
  20. package/dist/bootstrap/memory-compressor.js +13 -13
  21. package/dist/cli-entry.js +36 -36
  22. package/dist/constraint-runtime/reference_data/archive_surface_snapshot.json +63 -0
  23. package/dist/constraint-runtime/reference_data/commands_snapshot.json +1037 -0
  24. package/dist/constraint-runtime/reference_data/subsystems/assistant.json +8 -0
  25. package/dist/constraint-runtime/reference_data/subsystems/bootstrap.json +8 -0
  26. package/dist/constraint-runtime/reference_data/subsystems/bridge.json +32 -0
  27. package/dist/constraint-runtime/reference_data/subsystems/buddy.json +13 -0
  28. package/dist/constraint-runtime/reference_data/subsystems/cli.json +26 -0
  29. package/dist/constraint-runtime/reference_data/subsystems/components.json +32 -0
  30. package/dist/constraint-runtime/reference_data/subsystems/constants.json +28 -0
  31. package/dist/constraint-runtime/reference_data/subsystems/coordinator.json +8 -0
  32. package/dist/constraint-runtime/reference_data/subsystems/entrypoints.json +15 -0
  33. package/dist/constraint-runtime/reference_data/subsystems/hooks.json +32 -0
  34. package/dist/constraint-runtime/reference_data/subsystems/keybindings.json +21 -0
  35. package/dist/constraint-runtime/reference_data/subsystems/memdir.json +15 -0
  36. package/dist/constraint-runtime/reference_data/subsystems/migrations.json +18 -0
  37. package/dist/constraint-runtime/reference_data/subsystems/moreright.json +8 -0
  38. package/dist/constraint-runtime/reference_data/subsystems/native_ts.json +11 -0
  39. package/dist/constraint-runtime/reference_data/subsystems/outputStyles.json +8 -0
  40. package/dist/constraint-runtime/reference_data/subsystems/plugins.json +9 -0
  41. package/dist/constraint-runtime/reference_data/subsystems/remote.json +11 -0
  42. package/dist/constraint-runtime/reference_data/subsystems/schemas.json +8 -0
  43. package/dist/constraint-runtime/reference_data/subsystems/screens.json +10 -0
  44. package/dist/constraint-runtime/reference_data/subsystems/server.json +10 -0
  45. package/dist/constraint-runtime/reference_data/subsystems/services.json +32 -0
  46. package/dist/constraint-runtime/reference_data/subsystems/skills.json +27 -0
  47. package/dist/constraint-runtime/reference_data/subsystems/state.json +13 -0
  48. package/dist/constraint-runtime/reference_data/subsystems/types.json +18 -0
  49. package/dist/constraint-runtime/reference_data/subsystems/upstreamproxy.json +9 -0
  50. package/dist/constraint-runtime/reference_data/subsystems/utils.json +32 -0
  51. package/dist/constraint-runtime/reference_data/subsystems/vim.json +12 -0
  52. package/dist/constraint-runtime/reference_data/subsystems/voice.json +8 -0
  53. package/dist/constraint-runtime/reference_data/tools_snapshot.json +922 -0
  54. package/dist/context-compaction/auto-compact.js +7 -7
  55. package/dist/electron/config.js.map +1 -0
  56. package/dist/electron/dialogs.js.map +1 -0
  57. package/dist/electron/first-run.js +54 -54
  58. package/dist/electron/first-run.js.map +1 -0
  59. package/dist/electron/ipc.js.map +1 -0
  60. package/dist/electron/logger.js.map +1 -0
  61. package/dist/electron/main.js.map +1 -0
  62. package/dist/electron/menu.js.map +1 -0
  63. package/dist/electron/paths.js.map +1 -0
  64. package/dist/electron/server.js.map +1 -0
  65. package/dist/electron/tray.js.map +1 -0
  66. package/dist/electron/window.js.map +1 -0
  67. package/dist/electron-build/electron/first-run.js +54 -54
  68. package/dist/electron-preload.js.map +1 -1
  69. package/dist/electron.js.map +1 -1
  70. package/dist/index.js +112 -112
  71. package/dist/llm/llm-judgment-client.js +102 -102
  72. package/dist/llm/pi-ai.js +48 -48
  73. package/dist/llm/system-prompt/layers/channel/human-async.md +41 -41
  74. package/dist/llm/system-prompt/layers/channel/local.md +14 -14
  75. package/dist/llm/system-prompt/layers/channel/p2p-agent.md +18 -18
  76. package/dist/llm/system-prompt/layers/channel/p2p-peer-sync.md +51 -51
  77. package/dist/llm/system-prompt/layers/channel/p2p-proactive.md +43 -43
  78. package/dist/llm/system-prompt/layers/channel/p2p-visitor.md +19 -19
  79. package/dist/llm/system-prompt/layers/channel/session-handoff.md +61 -61
  80. package/dist/llm/system-prompt/layers/core/artifacts_storage.md +89 -89
  81. package/dist/llm/system-prompt/layers/core/evenhandedness.md +21 -21
  82. package/dist/llm/system-prompt/layers/core/external-engagement.md +73 -73
  83. package/dist/llm/system-prompt/layers/core/hibs_reminders.md +15 -15
  84. package/dist/llm/system-prompt/layers/core/identity.md +48 -48
  85. package/dist/llm/system-prompt/layers/core/knowledge.md +17 -17
  86. package/dist/llm/system-prompt/layers/core/memory_system.md +12 -12
  87. package/dist/llm/system-prompt/layers/core/network_filesystem.md +28 -28
  88. package/dist/llm/system-prompt/layers/core/refusal.md +37 -37
  89. package/dist/llm/system-prompt/layers/core/tone.md +31 -31
  90. package/dist/llm/system-prompt/layers/core/tools.thin.md +13 -13
  91. package/dist/llm/system-prompt/layers/core/wellbeing.md +41 -41
  92. package/dist/llm/system-prompt/layers/role/architect.md +20 -20
  93. package/dist/llm/system-prompt/layers/role/expert.md +19 -19
  94. package/dist/llm/system-prompt/layers/role/implementer.md +15 -15
  95. package/dist/llm/system-prompt/layers/role/security.md +15 -15
  96. package/dist/llm/system-prompt/layers/tool/artifacts.md +72 -72
  97. package/dist/llm/system-prompt/layers/tool/bash.md +25 -25
  98. package/dist/llm/system-prompt/layers/tool/goal_handoff.md +77 -77
  99. package/dist/llm/system-prompt/layers/tool/hibs_api.md +171 -171
  100. package/dist/llm/system-prompt/layers/tool/image_search.md +70 -70
  101. package/dist/llm/system-prompt/layers/tool/manifest.md +89 -89
  102. package/dist/llm/system-prompt/layers/tool/mcp_apps.md +53 -53
  103. package/dist/llm/system-prompt/layers/tool/p2p_request.md +61 -61
  104. package/dist/llm/system-prompt/layers/tool/web_search.md +83 -83
  105. package/dist/llm/tool-manifest/ask_user_input.js +1 -1
  106. package/dist/llm/tool-manifest/bash.js +3 -3
  107. package/dist/llm/tool-manifest/create_file.js +4 -4
  108. package/dist/llm/tool-manifest/fetch_sports_data.js +3 -3
  109. package/dist/llm/tool-manifest/image_search.js +2 -2
  110. package/dist/llm/tool-manifest/index.js +59 -0
  111. package/dist/llm/tool-manifest/mcp.js +2 -2
  112. package/dist/llm/tool-manifest/message_compose.js +3 -3
  113. package/dist/llm/tool-manifest/places.js +2 -2
  114. package/dist/llm/tool-manifest/present_files.js +1 -1
  115. package/dist/llm/tool-manifest/recipe.js +2 -2
  116. package/dist/llm/tool-manifest/str_replace.js +5 -5
  117. package/dist/llm/tool-manifest/view.js +3 -3
  118. package/dist/llm/tool-manifest/weather.js +4 -4
  119. package/dist/llm/tool-manifest/web.js +4 -4
  120. package/dist/network/p2p.js +78 -4
  121. package/dist/pi-ecosystem-judgment/causal-judge.js +51 -51
  122. package/dist/pi-ecosystem-judgment/distill-prompt.js +42 -42
  123. package/dist/pi-ecosystem-judgment/distillation.js +14 -14
  124. package/dist/pi-ecosystem-judgment/index.js +1 -1
  125. package/dist/pi-ecosystem-judgment/monitor-gate.js +28 -28
  126. package/dist/pi-ecosystem-judgment/value-injection.js +102 -102
  127. package/dist/security/context-router-tool.js +15 -15
  128. package/dist/utils/auto-update.js.map +1 -0
  129. package/dist/web/a2ui-client.js +1594 -223
  130. package/dist/web/api-config.html +779 -779
  131. package/dist/web/client-hearth.js +10 -10
  132. package/dist/web/client.js.map +7 -0
  133. package/dist/web/components/wallet-viem.mjs +118 -118
  134. package/dist/web/index.html +502 -502
  135. package/dist/web/manifest.json +20 -20
  136. package/dist/web/mobile-agent.js +397 -0
  137. package/dist/web/mobile-core.js +86235 -0
  138. package/dist/web/mobile-data.js +346 -0
  139. package/dist/web/mobile-http-api.js +71 -0
  140. package/dist/web/mobile-p2p.js +250 -0
  141. package/dist/web/mobile-payments.js +80 -0
  142. package/dist/web/mobile-wallet.js +226 -0
  143. package/dist/web/mobile.css +483 -6
  144. package/dist/web/mobile.html +99 -50
  145. package/dist/web/mobile.js +1040 -187
  146. package/dist/web/server.js +13 -13
  147. package/dist/web/style.css +4946 -4946
  148. package/package.json +201 -198
  149. package/scripts/build-cli.js +215 -215
  150. package/scripts/build-web.ts +162 -149
  151. package/scripts/postinstall.js +152 -152
  152. package/bin/bolloon.js +0 -157
  153. package/dist/agents/python-exec.js +0 -115
  154. package/dist/pi-ecosystem-colony/index.js +0 -365
  155. package/dist/social/ant-colony/AdaptiveHeartbeat.js +0 -101
  156. package/dist/social/ant-colony/PheromoneEngine.js +0 -227
  157. package/dist/social/ant-colony/index.js +0 -6
  158. package/dist/social/ant-colony/types.js +0 -24
  159. package/dist/storage/trajectory.js +0 -101
  160. package/dist/test/ai-judgment-test.js +0 -80
  161. package/dist/test/bollharness-integration.test.js +0 -318
  162. package/dist/test/channel-agent-multi-dialogue.js +0 -205
  163. package/dist/test/channel-heartbeat-agent-test.js +0 -201
  164. package/dist/test/constraint-layer.test.js +0 -164
  165. package/dist/test/diap-identity-test.js +0 -172
  166. package/dist/test/diap-quick-test.js +0 -62
  167. package/dist/test/global-shared-context.test.js +0 -315
  168. package/dist/test/harness-judgment-injection.test.js +0 -246
  169. package/dist/test/harness-workflow-integrator-test.js +0 -228
  170. package/dist/test/human-value-store.test.js +0 -243
  171. package/dist/test/hybrid-integration-test.js +0 -118
  172. package/dist/test/hybrid-messenger-verify.js +0 -55
  173. package/dist/test/iroh-bistream-debug.js +0 -38
  174. package/dist/test/iroh-communication.test.js +0 -66
  175. package/dist/test/iroh-debug-test.js +0 -57
  176. package/dist/test/iroh-diap-test.js +0 -71
  177. package/dist/test/iroh-direct-connect.js +0 -55
  178. package/dist/test/iroh-e2e-fixed.js +0 -89
  179. package/dist/test/iroh-e2e-same-process.js +0 -63
  180. package/dist/test/iroh-e2e.js +0 -66
  181. package/dist/test/iroh-final-e2e.js +0 -72
  182. package/dist/test/iroh-relay-test.js +0 -37
  183. package/dist/test/iroh-simple-test.js +0 -41
  184. package/dist/test/iroh-transport-verify.js +0 -54
  185. package/dist/test/iroh-transport.test.js +0 -37
  186. package/dist/test/iroh-two-nodes.js +0 -70
  187. package/dist/test/iroh-verify.js +0 -44
  188. package/dist/test/judgment-decision.test.js +0 -219
  189. package/dist/test/llm-judgment-integration.test.js +0 -220
  190. package/dist/test/p2p-agent-complex-dialogue.js +0 -385
  191. package/dist/test/p2p-agent-dialogue.js +0 -341
  192. package/dist/test/p2p-agent-full-bidirectional.js +0 -510
  193. package/dist/test/p2p-agent-harness-flow.js +0 -437
  194. package/dist/test/p2p-agent-harness-single.js +0 -143
  195. package/dist/test/p2p-ai-dialogue-test.js +0 -318
  196. package/dist/test/p2p-cid-connect-test.js +0 -195
  197. package/dist/test/p2p-connect-receiver.js +0 -69
  198. package/dist/test/p2p-doc-transfer.js +0 -110
  199. package/dist/test/p2p-identity-page-test.js +0 -77
  200. package/dist/test/p2p-iroh-test.js +0 -171
  201. package/dist/test/p2p-minimal-test.js +0 -241
  202. package/dist/test/p2p-node-1.js +0 -148
  203. package/dist/test/p2p-node-2.js +0 -148
  204. package/dist/test/p2p-server.js +0 -281
  205. package/dist/test/p2p-two-nodes-test.js +0 -438
  206. package/dist/test/pi-sdk.test.js +0 -44
  207. package/dist/test/set-persona.js +0 -40
  208. package/dist/test/simple.test.js +0 -9
  209. package/dist/test/storage-integration.test.js +0 -150
  210. package/dist/test/subagent-manager.test.js +0 -276
  211. package/dist/test/test-gate-flow.test.js +0 -81
  212. package/dist/test/workflow-engine.test.js +0 -87
  213. package/dist/test/workflow-pivot-loop.test.js +0 -246
@@ -1,779 +1,779 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN" data-theme="dark">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>API 配置 - Bolloon</title>
7
- <!-- 2026-06-11: 移除 Google Fonts 外部 CSS — 在大陆/跨公网时经常 30s+ timeout 阻塞首屏
8
- style.css 里只用字体名字符串 (JetBrains Mono / monospace), 浏览器看字面量自动落回系统字体
9
- 2026-06-11 提速: 不再等 Google CDN 即可渲染 -->
10
- <link rel="stylesheet" href="/style.css">
11
- </head>
12
- <body>
13
- <div class="api-config-page">
14
- <!-- 页面标题 -->
15
- <div class="page-header">
16
- <div class="page-title-group">
17
- <h1>API 配置</h1>
18
- <span class="config-count-badge" id="configCount">加载中...</span>
19
- </div>
20
- <a href="/" class="back-link">← 返回主页</a>
21
- </div>
22
-
23
- <!-- 标签切换 -->
24
- <div class="api-tabs">
25
- <button class="api-tab active" data-tab="llm" onclick="switchTab('llm')">
26
- <span class="api-tab-icon">💬</span>
27
- <span>LLM 对话</span>
28
- </button>
29
- <button class="api-tab" data-tab="video" onclick="switchTab('video')">
30
- <span class="api-tab-icon">🎬</span>
31
- <span>视频生成</span>
32
- </button>
33
- <button class="api-tab" data-tab="audio" onclick="switchTab('audio')">
34
- <span class="api-tab-icon">🎵</span>
35
- <span>音频生成</span>
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>
41
- </div>
42
-
43
- <!-- LLM 面板 -->
44
- <div class="api-panel" id="panel-llm" data-panel="llm">
45
- <div class="provider-list" id="providerList">
46
- <div class="loading-state">加载中...</div>
47
- </div>
48
- </div>
49
-
50
- <!-- 视频生成面板 -->
51
- <div class="api-panel" id="panel-video" data-panel="video" style="display: none;">
52
- <div class="video-intro">
53
- <p>视频生成走异步任务流(提交任务 → 轮询结果),与 LLM 实时对话不同。Seedance 由字节火山方舟 (ARK) 提供,支持文生视频 (t2v) 与图生视频 (i2v);MiniMax Video 为同 tab 下的另一选项。</p>
54
- </div>
55
- <div class="provider-list" id="videoProviderList">
56
- <div class="loading-state">加载中...</div>
57
- </div>
58
- </div>
59
-
60
- <!-- 音频生成面板 -->
61
- <div class="api-panel" id="panel-audio" data-panel="audio" style="display: none;">
62
- <div class="video-intro">
63
- <p>音频生成:TTS(文生语音)/ Music(文生音乐)。MiniMax 提供 speech-01 与 music-01 模型,TTS 默认走 OpenAI 兼容 <code>/audio/speech</code> 端点,Music 走 MiniMax 自有 <code>/music_generation</code> 端点。</p>
64
- </div>
65
- <div class="provider-list" id="audioProviderList">
66
- <div class="loading-state">加载中...</div>
67
- </div>
68
- </div>
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
-
80
- <!-- 配置弹窗 -->
81
- <div class="modal-overlay" id="configModal" style="display: none;">
82
- <div class="modal-box">
83
- <div class="modal-header">
84
- <div class="modal-title-group">
85
- <div class="modal-icon" id="modalIcon">A</div>
86
- <div>
87
- <h2 id="modalTitle">配置</h2>
88
- <p class="modal-subtitle" id="modalSubtitle">描述</p>
89
- </div>
90
- </div>
91
- <button class="modal-close" onclick="closeModal()">×</button>
92
- </div>
93
-
94
- <form class="modal-form" id="configForm">
95
- <div class="form-group">
96
- <label>API Key</label>
97
- <input type="password" id="apiKeyInput" placeholder="输入 API Key">
98
- <p class="form-hint" id="apiKeyHint">已有 Key 会保留,输入新值以更新</p>
99
- </div>
100
-
101
- <div class="form-group">
102
- <label>Base URL</label>
103
- <input type="text" id="baseUrlInput" placeholder="https://api.example.com/v1">
104
- <p class="form-hint">留空使用默认地址</p>
105
- </div>
106
-
107
- <div class="form-group">
108
- <label>模型</label>
109
- <input type="text" id="modelInput" placeholder="如 gpt-4" list="modelSuggestList">
110
- <datalist id="modelSuggestList"></datalist>
111
- <p class="form-hint" id="modelHint"></p>
112
- </div>
113
-
114
- <!-- 视频模型专用字段 -->
115
- <div class="video-only-fields" style="display: none;">
116
- <div class="form-row">
117
- <div class="form-group">
118
- <label>分辨率</label>
119
- <select id="resolutionInput">
120
- <option value="480p">480p</option>
121
- <option value="720p">720p</option>
122
- <option value="1080p">1080p</option>
123
- </select>
124
- </div>
125
- <div class="form-group">
126
- <label>宽高比</label>
127
- <select id="ratioInput">
128
- <option value="16:9">16:9</option>
129
- <option value="9:16">9:16</option>
130
- <option value="1:1">1:1</option>
131
- <option value="4:3">4:3</option>
132
- <option value="3:4">3:4</option>
133
- <option value="21:9">21:9</option>
134
- </select>
135
- </div>
136
- </div>
137
- <div class="form-group">
138
- <label>默认时长(秒)</label>
139
- <input type="number" id="durationInput" min="3" max="12" step="1" value="5">
140
- <p class="form-hint">Seedance 支持 3-12 秒</p>
141
- </div>
142
- </div>
143
-
144
- <!-- 音频模型专用字段 -->
145
- <div class="audio-only-fields" style="display: none;">
146
- <div class="form-group">
147
- <label>音色 / 风格</label>
148
- <input type="text" id="voiceInput" placeholder="male-qn-jingying / female-shaonv ...">
149
- <p class="form-hint" id="voiceHint"></p>
150
- </div>
151
- <div class="form-row">
152
- <div class="form-group">
153
- <label>语速</label>
154
- <input type="number" id="speedInput" min="0.5" max="2.0" step="0.1" value="1.0">
155
- </div>
156
- <div class="form-group">
157
- <label>输出格式</label>
158
- <select id="formatInput">
159
- <option value="mp3">mp3</option>
160
- <option value="pcm">pcm</option>
161
- <option value="wav">wav</option>
162
- </select>
163
- </div>
164
- </div>
165
- <div class="form-group" id="audioModeGroup" style="display: none;">
166
- <label>模式</label>
167
- <select id="modeInput">
168
- <option value="instrumental">纯音乐</option>
169
- <option value="lyrics">带歌词</option>
170
- </select>
171
- </div>
172
- <div class="form-group" id="audioDurationGroup" style="display: none;">
173
- <label>默认时长(秒)</label>
174
- <input type="number" id="audioDurationInput" min="5" max="120" step="1" value="30">
175
- <p class="form-hint">music-01 支持 5-120 秒</p>
176
- </div>
177
- </div>
178
-
179
- <!-- LLM 专用字段 -->
180
- <div class="llm-only-fields">
181
- <div class="form-group form-group-small">
182
- <label>温度</label>
183
- <input type="number" id="temperatureInput" value="0.7" min="0" max="2" step="0.1">
184
- </div>
185
- </div>
186
-
187
- <div class="form-group">
188
- <button type="button" class="test-btn" id="testBtn" onclick="testConnection()">
189
- ⚡ 测试连接
190
- </button>
191
- <div class="test-result" id="testResult" style="display: none;"></div>
192
- </div>
193
-
194
- <div class="form-actions">
195
- <button type="button" class="btn-cancel" onclick="closeModal()">取消</button>
196
- <button type="submit" class="btn-save">保存</button>
197
- </div>
198
- </form>
199
- </div>
200
- </div>
201
- </div>
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
-
259
- <script>
260
- // 当前 tab
261
- let currentTab = 'llm';
262
-
263
- // 缓存三份配置
264
- let llmConfigData = null;
265
- let videoConfigData = null;
266
- let audioConfigData = null;
267
- let engineData = null;
268
- let currentProvider = null;
269
- let currentEngine = null;
270
- let currentProviderType = 'llm'; // 'llm' | 'video' | 'audio'
271
-
272
- // ==================== Tab 切换 ====================
273
- function switchTab(tab) {
274
- currentTab = tab;
275
- document.querySelectorAll('.api-tab').forEach(btn => {
276
- btn.classList.toggle('active', btn.dataset.tab === tab);
277
- });
278
- document.querySelectorAll('.api-panel').forEach(panel => {
279
- panel.style.display = panel.dataset.panel === tab ? 'block' : 'none';
280
- });
281
- // 切换 tab 时更新计数 badge
282
- updateCountBadge();
283
- }
284
-
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
- }
293
- const data = currentTab === 'llm' ? llmConfigData :
294
- currentTab === 'video' ? videoConfigData : audioConfigData;
295
- if (!data) {
296
- document.getElementById('configCount').textContent = '加载中...';
297
- return;
298
- }
299
- let configured = 0, total = 0;
300
- for (const key in data.providers) {
301
- total++;
302
- const p = data.providers[key];
303
- if (currentTab === 'llm') {
304
- if (p.enabled && p.apiKey) configured++;
305
- } else {
306
- const info = data.providerInfo[key] || {};
307
- if (p.enabled && (!info.requiresApiKey || p.apiKey)) configured++;
308
- }
309
- }
310
- const labelMap = { llm: '', video: '视频', audio: '音频' };
311
- const label = labelMap[currentTab] || '';
312
- document.getElementById('configCount').textContent = configured + '/' + total + ' ' + label + '已配置';
313
- }
314
-
315
- // ==================== 加载 ====================
316
- async function loadAll() {
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
- }
330
- }
331
-
332
- async function loadLLMConfig() {
333
- try {
334
- const resp = await fetch('/api/llm-config');
335
- llmConfigData = await resp.json();
336
- renderProviders('llm');
337
- if (currentTab === 'llm') updateCountBadge();
338
- } catch (err) {
339
- document.getElementById('providerList').innerHTML =
340
- '<div class="error-state">加载失败: ' + err.message + '</div>';
341
- }
342
- }
343
-
344
- async function loadVideoConfig() {
345
- try {
346
- const resp = await fetch('/api/video-config');
347
- videoConfigData = await resp.json();
348
- renderProviders('video');
349
- if (currentTab === 'video') updateCountBadge();
350
- } catch (err) {
351
- document.getElementById('videoProviderList').innerHTML =
352
- '<div class="error-state">加载失败: ' + err.message + '</div>';
353
- }
354
- }
355
-
356
- async function loadAudioConfig() {
357
- try {
358
- const resp = await fetch('/api/audio-config');
359
- audioConfigData = await resp.json();
360
- renderProviders('audio');
361
- if (currentTab === 'audio') updateCountBadge();
362
- } catch (err) {
363
- document.getElementById('audioProviderList').innerHTML =
364
- '<div class="error-state">加载失败: ' + err.message + '</div>';
365
- }
366
- }
367
-
368
- // ==================== 渲染 ====================
369
- function renderProviders(type) {
370
- const data = type === 'llm' ? llmConfigData :
371
- type === 'video' ? videoConfigData : audioConfigData;
372
- const listEl = document.getElementById(
373
- type === 'llm' ? 'providerList' :
374
- type === 'video' ? 'videoProviderList' : 'audioProviderList'
375
- );
376
- if (!data) return;
377
-
378
- const providers = data.providers;
379
- const info = data.providerInfo;
380
- const isActive = (key) => data.activeProvider === key;
381
-
382
- let html = '';
383
- for (const key in providers) {
384
- const p = providers[key];
385
- const i = info[key] || { name: key, description: '供应商', requiresApiKey: true };
386
- const active = isActive(key);
387
-
388
- let statusText, statusClass;
389
- if (active) {
390
- statusText = '使用中';
391
- statusClass = 'status-accent';
392
- } else if (p.enabled && p.apiKey) {
393
- statusText = '已启用';
394
- statusClass = 'status-green';
395
- } else if (p.enabled) {
396
- statusText = '已启用';
397
- statusClass = 'status-yellow';
398
- } else {
399
- statusText = '未启用';
400
- statusClass = 'status-muted';
401
- }
402
-
403
- const isConfigured = p.enabled && p.apiKey;
404
-
405
- html += `
406
- <div class="provider-card ${active ? 'active' : ''}" onclick="openModal('${type}', '${key}')">
407
- <div class="provider-header">
408
- <div class="provider-icon">${i.name.charAt(0).toUpperCase()}</div>
409
- <div class="provider-info">
410
- <h3 class="provider-name">${i.name}</h3>
411
- <p class="provider-desc">${i.description}</p>
412
- ${i.docs ? '<a class="provider-docs" href="' + i.docs + '" target="_blank" onclick="event.stopPropagation()">📖 文档</a>' : ''}
413
- </div>
414
- <div class="status-badge ${statusClass}">
415
- <span class="status-dot"></span>
416
- ${statusText}
417
- </div>
418
- </div>
419
- <div class="provider-footer">
420
- <div class="config-status">
421
- <span class="status-icon ${isConfigured ? 'configured' : ''}">${isConfigured ? '✓' : '○'}</span>
422
- ${isConfigured ? '已配置 API Key' : '未配置 API Key'}
423
- </div>
424
- ${p.model ? '<div class="model-info">模型: ' + p.model + '</div>' : ''}
425
- <div class="arrow-indicator">›</div>
426
- </div>
427
- </div>
428
- `;
429
- }
430
- listEl.innerHTML = html;
431
- }
432
-
433
- // ==================== 弹窗 ====================
434
- function openModal(type, providerKey) {
435
- currentProviderType = type;
436
- currentProvider = providerKey;
437
- const data = type === 'llm' ? llmConfigData :
438
- type === 'video' ? videoConfigData : audioConfigData;
439
- const p = data.providers[providerKey];
440
- const i = data.providerInfo[providerKey] || { name: providerKey, description: '供应商' };
441
-
442
- document.getElementById('modalIcon').textContent = i.name.charAt(0).toUpperCase();
443
- document.getElementById('modalTitle').textContent = '配置 ' + i.name;
444
- document.getElementById('modalSubtitle').textContent = i.description;
445
-
446
- document.getElementById('apiKeyInput').value = '';
447
- document.getElementById('apiKeyHint').textContent = p.apiKey
448
- ? '当前已配置 (***' + (p.apiKey.slice(-4) || '') + '),输入新值以更新'
449
- : '输入 API Key';
450
- document.getElementById('baseUrlInput').value = p.baseUrl || '';
451
- document.getElementById('modelInput').value = p.model || '';
452
-
453
- // 填充模型下拉建议(仅 LLM 类型有 providerInfo.models)
454
- const datalist = document.getElementById('modelSuggestList');
455
- datalist.innerHTML = '';
456
- if (type === 'llm' && i.models && Array.isArray(i.models)) {
457
- for (const m of i.models) {
458
- const opt = document.createElement('option');
459
- opt.value = m;
460
- datalist.appendChild(opt);
461
- }
462
- }
463
-
464
- // 视频/音频/LLM 专用字段显隐
465
- const isVideo = type === 'video';
466
- const isAudio = type === 'audio';
467
- document.querySelector('.video-only-fields').style.display = isVideo ? 'block' : 'none';
468
- document.querySelector('.audio-only-fields').style.display = isAudio ? 'block' : 'none';
469
- document.querySelector('.llm-only-fields').style.display = (!isVideo && !isAudio) ? 'block' : 'none';
470
-
471
- if (isVideo) {
472
- document.getElementById('resolutionInput').value = p.resolution || '720p';
473
- document.getElementById('ratioInput').value = p.ratio || '16:9';
474
- document.getElementById('durationInput').value = p.duration || 5;
475
- document.getElementById('modelHint').textContent = '示例: doubao-seedance-1-0-lite-t2v-250428 (文生视频) 或 ...-i2v-... (图生视频)';
476
- } else if (isAudio) {
477
- const isMusic = (i.kind === 'music');
478
- // TTS 字段
479
- document.getElementById('voiceInput').parentElement.style.display = isMusic ? 'none' : 'block';
480
- document.getElementById('speedInput').parentElement.parentElement.style.display = isMusic ? 'none' : 'flex';
481
- document.getElementById('modeInput').parentElement.style.display = isMusic ? 'block' : 'none';
482
- document.getElementById('audioDurationInput').parentElement.style.display = isMusic ? 'block' : 'none';
483
-
484
- if (!isMusic) {
485
- document.getElementById('voiceInput').value = p.voice || 'male-qn-jingying';
486
- document.getElementById('voiceHint').textContent = '常用音色: male-qn-jingying, female-shaonv, female-yujie, presenter_male, presenter_female';
487
- document.getElementById('speedInput').value = p.speed || 1.0;
488
- document.getElementById('formatInput').value = p.format || 'mp3';
489
- } else {
490
- document.getElementById('modeInput').value = p.mode || 'instrumental';
491
- document.getElementById('audioDurationInput').value = p.duration || 30;
492
- }
493
- document.getElementById('modelHint').textContent = isMusic
494
- ? '示例: music-01'
495
- : '示例: speech-01 (TTS) / asr-01 (语音转写)';
496
- } else {
497
- document.getElementById('temperatureInput').value = p.temperature || 0.7;
498
- }
499
-
500
- document.getElementById('testResult').style.display = 'none';
501
- document.getElementById('configModal').style.display = 'flex';
502
- }
503
-
504
- function closeModal() {
505
- document.getElementById('configModal').style.display = 'none';
506
- currentProvider = null;
507
- }
508
-
509
- // ==================== 测试连接 ====================
510
- async function testConnection() {
511
- const btn = document.getElementById('testBtn');
512
- const result = document.getElementById('testResult');
513
-
514
- btn.disabled = true;
515
- // v0.2.15: defer label mutation to the next frame. Synchronously
516
- // replacing the button's innerHTML in its own click handler cancels
517
- // the click event in several Chromium-derived engines — the user
518
- // observed the first click "doing nothing" until they closed and
519
- // reopened the modal. Wrapping the mutation in requestAnimationFrame
520
- // lets the click event finish dispatching first.
521
- requestAnimationFrame(() => {
522
- btn.textContent = '⚡ 测试中...';
523
- });
524
- result.style.display = 'none';
525
-
526
- const endpoint = currentProviderType === 'llm' ? '/api/llm-test' :
527
- currentProviderType === 'video' ? '/api/video-test' :
528
- '/api/audio-test';
529
-
530
- try {
531
- const resp = await fetch(endpoint, {
532
- method: 'POST',
533
- headers: { 'Content-Type': 'application/json' },
534
- body: JSON.stringify({ provider: currentProvider })
535
- });
536
- const data = await resp.json();
537
-
538
- result.className = 'test-result ' + (data.success ? 'test-success' : 'test-error');
539
- result.innerHTML = '<span>' + (data.success ? '✓' : '✗') + '</span> ' +
540
- (data.success ? '连接成功!延迟: ' + data.latency + 'ms' : (data.error || '连接失败'));
541
- result.style.display = 'flex';
542
- } catch (err) {
543
- result.className = 'test-result test-error';
544
- result.innerHTML = '<span>✗</span> ' + err.message;
545
- result.style.display = 'flex';
546
- }
547
-
548
- btn.disabled = false;
549
- requestAnimationFrame(() => {
550
- btn.textContent = '⚡ 测试连接';
551
- });
552
- }
553
-
554
- // ==================== 保存 ====================
555
- document.getElementById('configForm').onsubmit = async function(e) {
556
- e.preventDefault();
557
-
558
- const data = currentProviderType === 'llm' ? llmConfigData : videoConfigData;
559
- const existing = data.providers[currentProvider];
560
- const apiKeyVal = document.getElementById('apiKeyInput').value;
561
-
562
- const updateData = {
563
- enabled: true,
564
- apiKey: apiKeyVal || existing.apiKey || '',
565
- baseUrl: document.getElementById('baseUrlInput').value || existing.baseUrl || '',
566
- model: document.getElementById('modelInput').value || existing.model || ''
567
- };
568
-
569
- if (currentProviderType === 'video') {
570
- updateData.resolution = document.getElementById('resolutionInput').value;
571
- updateData.ratio = document.getElementById('ratioInput').value;
572
- updateData.duration = parseInt(document.getElementById('durationInput').value) || 5;
573
- } else if (currentProviderType === 'audio') {
574
- const info = audioConfigData.providerInfo[currentProvider] || {};
575
- const isMusic = info.kind === 'music';
576
- if (isMusic) {
577
- updateData.mode = document.getElementById('modeInput').value;
578
- updateData.duration = parseInt(document.getElementById('audioDurationInput').value) || 30;
579
- } else {
580
- updateData.voice = document.getElementById('voiceInput').value;
581
- updateData.speed = parseFloat(document.getElementById('speedInput').value) || 1.0;
582
- updateData.format = document.getElementById('formatInput').value;
583
- }
584
- } else {
585
- updateData.temperature = parseFloat(document.getElementById('temperatureInput').value) || 0.7;
586
- }
587
-
588
- const endpoint = currentProviderType === 'llm' ? '/api/llm-config' :
589
- currentProviderType === 'video' ? '/api/video-config' :
590
- '/api/audio-config';
591
-
592
- try {
593
- await fetch(endpoint, {
594
- method: 'POST',
595
- headers: { 'Content-Type': 'application/json' },
596
- body: JSON.stringify({ provider: currentProvider, config: updateData })
597
- });
598
-
599
- const saveBtn = this.querySelector('.btn-save');
600
- saveBtn.textContent = '✓ 已保存';
601
- setTimeout(() => {
602
- closeModal();
603
- if (currentProviderType === 'llm') loadLLMConfig();
604
- else if (currentProviderType === 'video') loadVideoConfig();
605
- else loadAudioConfig();
606
- }, 500);
607
- } catch (err) {
608
- alert('保存失败: ' + err.message);
609
- }
610
- };
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
-
770
- // ==================== 关闭弹窗 ====================
771
- document.getElementById('configModal').onclick = function(e) {
772
- if (e.target === this) closeModal();
773
- };
774
-
775
- // 启动
776
- loadAll();
777
- </script>
778
- </body>
779
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>API 配置 - Bolloon</title>
7
+ <!-- 2026-06-11: 移除 Google Fonts 外部 CSS — 在大陆/跨公网时经常 30s+ timeout 阻塞首屏
8
+ style.css 里只用字体名字符串 (JetBrains Mono / monospace), 浏览器看字面量自动落回系统字体
9
+ 2026-06-11 提速: 不再等 Google CDN 即可渲染 -->
10
+ <link rel="stylesheet" href="/style.css">
11
+ </head>
12
+ <body>
13
+ <div class="api-config-page">
14
+ <!-- 页面标题 -->
15
+ <div class="page-header">
16
+ <div class="page-title-group">
17
+ <h1>API 配置</h1>
18
+ <span class="config-count-badge" id="configCount">加载中...</span>
19
+ </div>
20
+ <a href="/" class="back-link">← 返回主页</a>
21
+ </div>
22
+
23
+ <!-- 标签切换 -->
24
+ <div class="api-tabs">
25
+ <button class="api-tab active" data-tab="llm" onclick="switchTab('llm')">
26
+ <span class="api-tab-icon">💬</span>
27
+ <span>LLM 对话</span>
28
+ </button>
29
+ <button class="api-tab" data-tab="video" onclick="switchTab('video')">
30
+ <span class="api-tab-icon">🎬</span>
31
+ <span>视频生成</span>
32
+ </button>
33
+ <button class="api-tab" data-tab="audio" onclick="switchTab('audio')">
34
+ <span class="api-tab-icon">🎵</span>
35
+ <span>音频生成</span>
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>
41
+ </div>
42
+
43
+ <!-- LLM 面板 -->
44
+ <div class="api-panel" id="panel-llm" data-panel="llm">
45
+ <div class="provider-list" id="providerList">
46
+ <div class="loading-state">加载中...</div>
47
+ </div>
48
+ </div>
49
+
50
+ <!-- 视频生成面板 -->
51
+ <div class="api-panel" id="panel-video" data-panel="video" style="display: none;">
52
+ <div class="video-intro">
53
+ <p>视频生成走异步任务流(提交任务 → 轮询结果),与 LLM 实时对话不同。Seedance 由字节火山方舟 (ARK) 提供,支持文生视频 (t2v) 与图生视频 (i2v);MiniMax Video 为同 tab 下的另一选项。</p>
54
+ </div>
55
+ <div class="provider-list" id="videoProviderList">
56
+ <div class="loading-state">加载中...</div>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- 音频生成面板 -->
61
+ <div class="api-panel" id="panel-audio" data-panel="audio" style="display: none;">
62
+ <div class="video-intro">
63
+ <p>音频生成:TTS(文生语音)/ Music(文生音乐)。MiniMax 提供 speech-01 与 music-01 模型,TTS 默认走 OpenAI 兼容 <code>/audio/speech</code> 端点,Music 走 MiniMax 自有 <code>/music_generation</code> 端点。</p>
64
+ </div>
65
+ <div class="provider-list" id="audioProviderList">
66
+ <div class="loading-state">加载中...</div>
67
+ </div>
68
+ </div>
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
+
80
+ <!-- 配置弹窗 -->
81
+ <div class="modal-overlay" id="configModal" style="display: none;">
82
+ <div class="modal-box">
83
+ <div class="modal-header">
84
+ <div class="modal-title-group">
85
+ <div class="modal-icon" id="modalIcon">A</div>
86
+ <div>
87
+ <h2 id="modalTitle">配置</h2>
88
+ <p class="modal-subtitle" id="modalSubtitle">描述</p>
89
+ </div>
90
+ </div>
91
+ <button class="modal-close" onclick="closeModal()">×</button>
92
+ </div>
93
+
94
+ <form class="modal-form" id="configForm">
95
+ <div class="form-group">
96
+ <label>API Key</label>
97
+ <input type="password" id="apiKeyInput" placeholder="输入 API Key">
98
+ <p class="form-hint" id="apiKeyHint">已有 Key 会保留,输入新值以更新</p>
99
+ </div>
100
+
101
+ <div class="form-group">
102
+ <label>Base URL</label>
103
+ <input type="text" id="baseUrlInput" placeholder="https://api.example.com/v1">
104
+ <p class="form-hint">留空使用默认地址</p>
105
+ </div>
106
+
107
+ <div class="form-group">
108
+ <label>模型</label>
109
+ <input type="text" id="modelInput" placeholder="如 gpt-4" list="modelSuggestList">
110
+ <datalist id="modelSuggestList"></datalist>
111
+ <p class="form-hint" id="modelHint"></p>
112
+ </div>
113
+
114
+ <!-- 视频模型专用字段 -->
115
+ <div class="video-only-fields" style="display: none;">
116
+ <div class="form-row">
117
+ <div class="form-group">
118
+ <label>分辨率</label>
119
+ <select id="resolutionInput">
120
+ <option value="480p">480p</option>
121
+ <option value="720p">720p</option>
122
+ <option value="1080p">1080p</option>
123
+ </select>
124
+ </div>
125
+ <div class="form-group">
126
+ <label>宽高比</label>
127
+ <select id="ratioInput">
128
+ <option value="16:9">16:9</option>
129
+ <option value="9:16">9:16</option>
130
+ <option value="1:1">1:1</option>
131
+ <option value="4:3">4:3</option>
132
+ <option value="3:4">3:4</option>
133
+ <option value="21:9">21:9</option>
134
+ </select>
135
+ </div>
136
+ </div>
137
+ <div class="form-group">
138
+ <label>默认时长(秒)</label>
139
+ <input type="number" id="durationInput" min="3" max="12" step="1" value="5">
140
+ <p class="form-hint">Seedance 支持 3-12 秒</p>
141
+ </div>
142
+ </div>
143
+
144
+ <!-- 音频模型专用字段 -->
145
+ <div class="audio-only-fields" style="display: none;">
146
+ <div class="form-group">
147
+ <label>音色 / 风格</label>
148
+ <input type="text" id="voiceInput" placeholder="male-qn-jingying / female-shaonv ...">
149
+ <p class="form-hint" id="voiceHint"></p>
150
+ </div>
151
+ <div class="form-row">
152
+ <div class="form-group">
153
+ <label>语速</label>
154
+ <input type="number" id="speedInput" min="0.5" max="2.0" step="0.1" value="1.0">
155
+ </div>
156
+ <div class="form-group">
157
+ <label>输出格式</label>
158
+ <select id="formatInput">
159
+ <option value="mp3">mp3</option>
160
+ <option value="pcm">pcm</option>
161
+ <option value="wav">wav</option>
162
+ </select>
163
+ </div>
164
+ </div>
165
+ <div class="form-group" id="audioModeGroup" style="display: none;">
166
+ <label>模式</label>
167
+ <select id="modeInput">
168
+ <option value="instrumental">纯音乐</option>
169
+ <option value="lyrics">带歌词</option>
170
+ </select>
171
+ </div>
172
+ <div class="form-group" id="audioDurationGroup" style="display: none;">
173
+ <label>默认时长(秒)</label>
174
+ <input type="number" id="audioDurationInput" min="5" max="120" step="1" value="30">
175
+ <p class="form-hint">music-01 支持 5-120 秒</p>
176
+ </div>
177
+ </div>
178
+
179
+ <!-- LLM 专用字段 -->
180
+ <div class="llm-only-fields">
181
+ <div class="form-group form-group-small">
182
+ <label>温度</label>
183
+ <input type="number" id="temperatureInput" value="0.7" min="0" max="2" step="0.1">
184
+ </div>
185
+ </div>
186
+
187
+ <div class="form-group">
188
+ <button type="button" class="test-btn" id="testBtn" onclick="testConnection()">
189
+ ⚡ 测试连接
190
+ </button>
191
+ <div class="test-result" id="testResult" style="display: none;"></div>
192
+ </div>
193
+
194
+ <div class="form-actions">
195
+ <button type="button" class="btn-cancel" onclick="closeModal()">取消</button>
196
+ <button type="submit" class="btn-save">保存</button>
197
+ </div>
198
+ </form>
199
+ </div>
200
+ </div>
201
+ </div>
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
+
259
+ <script>
260
+ // 当前 tab
261
+ let currentTab = 'llm';
262
+
263
+ // 缓存三份配置
264
+ let llmConfigData = null;
265
+ let videoConfigData = null;
266
+ let audioConfigData = null;
267
+ let engineData = null;
268
+ let currentProvider = null;
269
+ let currentEngine = null;
270
+ let currentProviderType = 'llm'; // 'llm' | 'video' | 'audio'
271
+
272
+ // ==================== Tab 切换 ====================
273
+ function switchTab(tab) {
274
+ currentTab = tab;
275
+ document.querySelectorAll('.api-tab').forEach(btn => {
276
+ btn.classList.toggle('active', btn.dataset.tab === tab);
277
+ });
278
+ document.querySelectorAll('.api-panel').forEach(panel => {
279
+ panel.style.display = panel.dataset.panel === tab ? 'block' : 'none';
280
+ });
281
+ // 切换 tab 时更新计数 badge
282
+ updateCountBadge();
283
+ }
284
+
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
+ }
293
+ const data = currentTab === 'llm' ? llmConfigData :
294
+ currentTab === 'video' ? videoConfigData : audioConfigData;
295
+ if (!data) {
296
+ document.getElementById('configCount').textContent = '加载中...';
297
+ return;
298
+ }
299
+ let configured = 0, total = 0;
300
+ for (const key in data.providers) {
301
+ total++;
302
+ const p = data.providers[key];
303
+ if (currentTab === 'llm') {
304
+ if (p.enabled && p.apiKey) configured++;
305
+ } else {
306
+ const info = data.providerInfo[key] || {};
307
+ if (p.enabled && (!info.requiresApiKey || p.apiKey)) configured++;
308
+ }
309
+ }
310
+ const labelMap = { llm: '', video: '视频', audio: '音频' };
311
+ const label = labelMap[currentTab] || '';
312
+ document.getElementById('configCount').textContent = configured + '/' + total + ' ' + label + '已配置';
313
+ }
314
+
315
+ // ==================== 加载 ====================
316
+ async function loadAll() {
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
+ }
330
+ }
331
+
332
+ async function loadLLMConfig() {
333
+ try {
334
+ const resp = await fetch('/api/llm-config');
335
+ llmConfigData = await resp.json();
336
+ renderProviders('llm');
337
+ if (currentTab === 'llm') updateCountBadge();
338
+ } catch (err) {
339
+ document.getElementById('providerList').innerHTML =
340
+ '<div class="error-state">加载失败: ' + err.message + '</div>';
341
+ }
342
+ }
343
+
344
+ async function loadVideoConfig() {
345
+ try {
346
+ const resp = await fetch('/api/video-config');
347
+ videoConfigData = await resp.json();
348
+ renderProviders('video');
349
+ if (currentTab === 'video') updateCountBadge();
350
+ } catch (err) {
351
+ document.getElementById('videoProviderList').innerHTML =
352
+ '<div class="error-state">加载失败: ' + err.message + '</div>';
353
+ }
354
+ }
355
+
356
+ async function loadAudioConfig() {
357
+ try {
358
+ const resp = await fetch('/api/audio-config');
359
+ audioConfigData = await resp.json();
360
+ renderProviders('audio');
361
+ if (currentTab === 'audio') updateCountBadge();
362
+ } catch (err) {
363
+ document.getElementById('audioProviderList').innerHTML =
364
+ '<div class="error-state">加载失败: ' + err.message + '</div>';
365
+ }
366
+ }
367
+
368
+ // ==================== 渲染 ====================
369
+ function renderProviders(type) {
370
+ const data = type === 'llm' ? llmConfigData :
371
+ type === 'video' ? videoConfigData : audioConfigData;
372
+ const listEl = document.getElementById(
373
+ type === 'llm' ? 'providerList' :
374
+ type === 'video' ? 'videoProviderList' : 'audioProviderList'
375
+ );
376
+ if (!data) return;
377
+
378
+ const providers = data.providers;
379
+ const info = data.providerInfo;
380
+ const isActive = (key) => data.activeProvider === key;
381
+
382
+ let html = '';
383
+ for (const key in providers) {
384
+ const p = providers[key];
385
+ const i = info[key] || { name: key, description: '供应商', requiresApiKey: true };
386
+ const active = isActive(key);
387
+
388
+ let statusText, statusClass;
389
+ if (active) {
390
+ statusText = '使用中';
391
+ statusClass = 'status-accent';
392
+ } else if (p.enabled && p.apiKey) {
393
+ statusText = '已启用';
394
+ statusClass = 'status-green';
395
+ } else if (p.enabled) {
396
+ statusText = '已启用';
397
+ statusClass = 'status-yellow';
398
+ } else {
399
+ statusText = '未启用';
400
+ statusClass = 'status-muted';
401
+ }
402
+
403
+ const isConfigured = p.enabled && p.apiKey;
404
+
405
+ html += `
406
+ <div class="provider-card ${active ? 'active' : ''}" onclick="openModal('${type}', '${key}')">
407
+ <div class="provider-header">
408
+ <div class="provider-icon">${i.name.charAt(0).toUpperCase()}</div>
409
+ <div class="provider-info">
410
+ <h3 class="provider-name">${i.name}</h3>
411
+ <p class="provider-desc">${i.description}</p>
412
+ ${i.docs ? '<a class="provider-docs" href="' + i.docs + '" target="_blank" onclick="event.stopPropagation()">📖 文档</a>' : ''}
413
+ </div>
414
+ <div class="status-badge ${statusClass}">
415
+ <span class="status-dot"></span>
416
+ ${statusText}
417
+ </div>
418
+ </div>
419
+ <div class="provider-footer">
420
+ <div class="config-status">
421
+ <span class="status-icon ${isConfigured ? 'configured' : ''}">${isConfigured ? '✓' : '○'}</span>
422
+ ${isConfigured ? '已配置 API Key' : '未配置 API Key'}
423
+ </div>
424
+ ${p.model ? '<div class="model-info">模型: ' + p.model + '</div>' : ''}
425
+ <div class="arrow-indicator">›</div>
426
+ </div>
427
+ </div>
428
+ `;
429
+ }
430
+ listEl.innerHTML = html;
431
+ }
432
+
433
+ // ==================== 弹窗 ====================
434
+ function openModal(type, providerKey) {
435
+ currentProviderType = type;
436
+ currentProvider = providerKey;
437
+ const data = type === 'llm' ? llmConfigData :
438
+ type === 'video' ? videoConfigData : audioConfigData;
439
+ const p = data.providers[providerKey];
440
+ const i = data.providerInfo[providerKey] || { name: providerKey, description: '供应商' };
441
+
442
+ document.getElementById('modalIcon').textContent = i.name.charAt(0).toUpperCase();
443
+ document.getElementById('modalTitle').textContent = '配置 ' + i.name;
444
+ document.getElementById('modalSubtitle').textContent = i.description;
445
+
446
+ document.getElementById('apiKeyInput').value = '';
447
+ document.getElementById('apiKeyHint').textContent = p.apiKey
448
+ ? '当前已配置 (***' + (p.apiKey.slice(-4) || '') + '),输入新值以更新'
449
+ : '输入 API Key';
450
+ document.getElementById('baseUrlInput').value = p.baseUrl || '';
451
+ document.getElementById('modelInput').value = p.model || '';
452
+
453
+ // 填充模型下拉建议(仅 LLM 类型有 providerInfo.models)
454
+ const datalist = document.getElementById('modelSuggestList');
455
+ datalist.innerHTML = '';
456
+ if (type === 'llm' && i.models && Array.isArray(i.models)) {
457
+ for (const m of i.models) {
458
+ const opt = document.createElement('option');
459
+ opt.value = m;
460
+ datalist.appendChild(opt);
461
+ }
462
+ }
463
+
464
+ // 视频/音频/LLM 专用字段显隐
465
+ const isVideo = type === 'video';
466
+ const isAudio = type === 'audio';
467
+ document.querySelector('.video-only-fields').style.display = isVideo ? 'block' : 'none';
468
+ document.querySelector('.audio-only-fields').style.display = isAudio ? 'block' : 'none';
469
+ document.querySelector('.llm-only-fields').style.display = (!isVideo && !isAudio) ? 'block' : 'none';
470
+
471
+ if (isVideo) {
472
+ document.getElementById('resolutionInput').value = p.resolution || '720p';
473
+ document.getElementById('ratioInput').value = p.ratio || '16:9';
474
+ document.getElementById('durationInput').value = p.duration || 5;
475
+ document.getElementById('modelHint').textContent = '示例: doubao-seedance-1-0-lite-t2v-250428 (文生视频) 或 ...-i2v-... (图生视频)';
476
+ } else if (isAudio) {
477
+ const isMusic = (i.kind === 'music');
478
+ // TTS 字段
479
+ document.getElementById('voiceInput').parentElement.style.display = isMusic ? 'none' : 'block';
480
+ document.getElementById('speedInput').parentElement.parentElement.style.display = isMusic ? 'none' : 'flex';
481
+ document.getElementById('modeInput').parentElement.style.display = isMusic ? 'block' : 'none';
482
+ document.getElementById('audioDurationInput').parentElement.style.display = isMusic ? 'block' : 'none';
483
+
484
+ if (!isMusic) {
485
+ document.getElementById('voiceInput').value = p.voice || 'male-qn-jingying';
486
+ document.getElementById('voiceHint').textContent = '常用音色: male-qn-jingying, female-shaonv, female-yujie, presenter_male, presenter_female';
487
+ document.getElementById('speedInput').value = p.speed || 1.0;
488
+ document.getElementById('formatInput').value = p.format || 'mp3';
489
+ } else {
490
+ document.getElementById('modeInput').value = p.mode || 'instrumental';
491
+ document.getElementById('audioDurationInput').value = p.duration || 30;
492
+ }
493
+ document.getElementById('modelHint').textContent = isMusic
494
+ ? '示例: music-01'
495
+ : '示例: speech-01 (TTS) / asr-01 (语音转写)';
496
+ } else {
497
+ document.getElementById('temperatureInput').value = p.temperature || 0.7;
498
+ }
499
+
500
+ document.getElementById('testResult').style.display = 'none';
501
+ document.getElementById('configModal').style.display = 'flex';
502
+ }
503
+
504
+ function closeModal() {
505
+ document.getElementById('configModal').style.display = 'none';
506
+ currentProvider = null;
507
+ }
508
+
509
+ // ==================== 测试连接 ====================
510
+ async function testConnection() {
511
+ const btn = document.getElementById('testBtn');
512
+ const result = document.getElementById('testResult');
513
+
514
+ btn.disabled = true;
515
+ // v0.2.15: defer label mutation to the next frame. Synchronously
516
+ // replacing the button's innerHTML in its own click handler cancels
517
+ // the click event in several Chromium-derived engines — the user
518
+ // observed the first click "doing nothing" until they closed and
519
+ // reopened the modal. Wrapping the mutation in requestAnimationFrame
520
+ // lets the click event finish dispatching first.
521
+ requestAnimationFrame(() => {
522
+ btn.textContent = '⚡ 测试中...';
523
+ });
524
+ result.style.display = 'none';
525
+
526
+ const endpoint = currentProviderType === 'llm' ? '/api/llm-test' :
527
+ currentProviderType === 'video' ? '/api/video-test' :
528
+ '/api/audio-test';
529
+
530
+ try {
531
+ const resp = await fetch(endpoint, {
532
+ method: 'POST',
533
+ headers: { 'Content-Type': 'application/json' },
534
+ body: JSON.stringify({ provider: currentProvider })
535
+ });
536
+ const data = await resp.json();
537
+
538
+ result.className = 'test-result ' + (data.success ? 'test-success' : 'test-error');
539
+ result.innerHTML = '<span>' + (data.success ? '✓' : '✗') + '</span> ' +
540
+ (data.success ? '连接成功!延迟: ' + data.latency + 'ms' : (data.error || '连接失败'));
541
+ result.style.display = 'flex';
542
+ } catch (err) {
543
+ result.className = 'test-result test-error';
544
+ result.innerHTML = '<span>✗</span> ' + err.message;
545
+ result.style.display = 'flex';
546
+ }
547
+
548
+ btn.disabled = false;
549
+ requestAnimationFrame(() => {
550
+ btn.textContent = '⚡ 测试连接';
551
+ });
552
+ }
553
+
554
+ // ==================== 保存 ====================
555
+ document.getElementById('configForm').onsubmit = async function(e) {
556
+ e.preventDefault();
557
+
558
+ const data = currentProviderType === 'llm' ? llmConfigData : videoConfigData;
559
+ const existing = data.providers[currentProvider];
560
+ const apiKeyVal = document.getElementById('apiKeyInput').value;
561
+
562
+ const updateData = {
563
+ enabled: true,
564
+ apiKey: apiKeyVal || existing.apiKey || '',
565
+ baseUrl: document.getElementById('baseUrlInput').value || existing.baseUrl || '',
566
+ model: document.getElementById('modelInput').value || existing.model || ''
567
+ };
568
+
569
+ if (currentProviderType === 'video') {
570
+ updateData.resolution = document.getElementById('resolutionInput').value;
571
+ updateData.ratio = document.getElementById('ratioInput').value;
572
+ updateData.duration = parseInt(document.getElementById('durationInput').value) || 5;
573
+ } else if (currentProviderType === 'audio') {
574
+ const info = audioConfigData.providerInfo[currentProvider] || {};
575
+ const isMusic = info.kind === 'music';
576
+ if (isMusic) {
577
+ updateData.mode = document.getElementById('modeInput').value;
578
+ updateData.duration = parseInt(document.getElementById('audioDurationInput').value) || 30;
579
+ } else {
580
+ updateData.voice = document.getElementById('voiceInput').value;
581
+ updateData.speed = parseFloat(document.getElementById('speedInput').value) || 1.0;
582
+ updateData.format = document.getElementById('formatInput').value;
583
+ }
584
+ } else {
585
+ updateData.temperature = parseFloat(document.getElementById('temperatureInput').value) || 0.7;
586
+ }
587
+
588
+ const endpoint = currentProviderType === 'llm' ? '/api/llm-config' :
589
+ currentProviderType === 'video' ? '/api/video-config' :
590
+ '/api/audio-config';
591
+
592
+ try {
593
+ await fetch(endpoint, {
594
+ method: 'POST',
595
+ headers: { 'Content-Type': 'application/json' },
596
+ body: JSON.stringify({ provider: currentProvider, config: updateData })
597
+ });
598
+
599
+ const saveBtn = this.querySelector('.btn-save');
600
+ saveBtn.textContent = '✓ 已保存';
601
+ setTimeout(() => {
602
+ closeModal();
603
+ if (currentProviderType === 'llm') loadLLMConfig();
604
+ else if (currentProviderType === 'video') loadVideoConfig();
605
+ else loadAudioConfig();
606
+ }, 500);
607
+ } catch (err) {
608
+ alert('保存失败: ' + err.message);
609
+ }
610
+ };
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
+
770
+ // ==================== 关闭弹窗 ====================
771
+ document.getElementById('configModal').onclick = function(e) {
772
+ if (e.target === this) closeModal();
773
+ };
774
+
775
+ // 启动
776
+ loadAll();
777
+ </script>
778
+ </body>
779
+ </html>