@bolloon/bolloon-agent 0.5.0 → 0.5.1

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 (106) hide show
  1. package/dist/agents/execution-supervisor.js +87 -5
  2. package/dist/agents/model-policy.js +651 -0
  3. package/dist/agents/pi-sdk-tools.js +84 -33
  4. package/dist/agents/pi-sdk.js +83 -23
  5. package/dist/agents/run-store.js +53 -0
  6. package/dist/agents/task/task-runner.js +8 -1
  7. package/dist/agents/workflow-pivot-loop.js +19 -12
  8. package/dist/cli/clipboard.js +65 -0
  9. package/dist/cli/ink-app.js +145 -17
  10. package/dist/cli/log-gate.js +386 -0
  11. package/dist/cli/mention-data.js +2 -0
  12. package/dist/cli/model-selector.js +538 -0
  13. package/dist/cli/reply-hygiene.js +54 -0
  14. package/dist/cli/setup-wizard.js +686 -69
  15. package/dist/cli/startup-notice.js +160 -0
  16. package/dist/cli/stores.js +22 -1
  17. package/dist/cli/theme.js +49 -0
  18. package/dist/cli/tui-select.js +970 -0
  19. package/dist/cli-entry.js +125 -18
  20. package/dist/electron-build/agents/execution-supervisor.js +86 -4
  21. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  22. package/dist/electron-build/agents/goal-store.js.map +1 -1
  23. package/dist/electron-build/agents/model-policy.js +706 -0
  24. package/dist/electron-build/agents/model-policy.js.map +1 -0
  25. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  26. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  27. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  28. package/dist/electron-build/agents/pi-sdk.js +83 -23
  29. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  30. package/dist/electron-build/agents/run-store.js +55 -1
  31. package/dist/electron-build/agents/run-store.js.map +1 -1
  32. package/dist/electron-build/agents/task/task-runner.js +8 -1
  33. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  34. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  35. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  36. package/dist/electron-build/llm/config-store.js +146 -6
  37. package/dist/electron-build/llm/config-store.js.map +1 -1
  38. package/dist/electron-build/llm/connection-probe.js +764 -0
  39. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  40. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  41. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  42. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  43. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  44. package/dist/electron-build/llm/model-catalog.js +778 -0
  45. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  46. package/dist/electron-build/llm/model-discovery.js +1202 -0
  47. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  48. package/dist/electron-build/llm/model-selection.js +1315 -0
  49. package/dist/electron-build/llm/model-selection.js.map +1 -0
  50. package/dist/electron-build/llm/pi-ai.js +70 -11
  51. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  52. package/dist/electron-build/llm/provider-catalog.js +969 -0
  53. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  54. package/dist/electron-build/llm/provider-registry.js +950 -0
  55. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  56. package/dist/electron-build/llm/tool-name.js +266 -0
  57. package/dist/electron-build/llm/tool-name.js.map +1 -0
  58. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  59. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  60. package/dist/electron-build/utils/dual-source.js +32 -156
  61. package/dist/electron-build/utils/dual-source.js.map +1 -1
  62. package/dist/electron-build/utils/update-manager.js +22 -72
  63. package/dist/electron-build/utils/update-manager.js.map +1 -1
  64. package/dist/electron-build/utils/update-state.js +2 -19
  65. package/dist/electron-build/utils/update-state.js.map +1 -1
  66. package/dist/electron-build/utils/version-identity.js +193 -0
  67. package/dist/electron-build/utils/version-identity.js.map +1 -0
  68. package/dist/electron-build/utils/version-info.js +47 -101
  69. package/dist/electron-build/utils/version-info.js.map +1 -1
  70. package/dist/index.js +282 -96
  71. package/dist/ios/bolloon-web.json +10 -0
  72. package/dist/ios/mobile-contacts.js +44 -11
  73. package/dist/ios/mobile-core.js +1723 -84
  74. package/dist/ios/mobile-tasks.js +339 -0
  75. package/dist/ios/mobile-update.js +1442 -0
  76. package/dist/ios/mobile.js +308 -35
  77. package/dist/ios/routes-llm-config.js +344 -52
  78. package/dist/ios/routes-mobile-tasks.js +353 -0
  79. package/dist/ios/server.js +163 -4
  80. package/dist/ios/sw.js +1 -1
  81. package/dist/llm/config-store.js +146 -6
  82. package/dist/llm/connection-probe.js +744 -0
  83. package/dist/llm/custom-provider-store.js +224 -0
  84. package/dist/llm/data/provider-catalog-baked.js +19 -0
  85. package/dist/llm/model-catalog.js +717 -0
  86. package/dist/llm/model-discovery.js +1137 -0
  87. package/dist/llm/model-selection.js +1241 -0
  88. package/dist/llm/pi-ai.js +70 -11
  89. package/dist/llm/provider-catalog.js +898 -0
  90. package/dist/llm/provider-registry.js +917 -0
  91. package/dist/llm/tool-name.js +220 -0
  92. package/dist/setup/onboard.js +34 -8
  93. package/dist/utils/dual-source-facts.js +223 -0
  94. package/dist/utils/dual-source.js +12 -141
  95. package/dist/utils/update-manager.js +16 -59
  96. package/dist/utils/update-state.js +6 -18
  97. package/dist/utils/version-identity.js +176 -0
  98. package/dist/utils/version-info.js +9 -78
  99. package/dist/web/bolloon-web.json +10 -0
  100. package/dist/web/mobile-core.js +1721 -84
  101. package/dist/web/mobile-update.js +1442 -0
  102. package/dist/web/mobile.js +308 -35
  103. package/dist/web/routes-llm-config.js +344 -52
  104. package/dist/web/sw.js +1 -1
  105. package/package.json +1 -1
  106. package/scripts/build-web.ts +36 -0
@@ -7,9 +7,157 @@
7
7
  import { llmConfigStore } from '../llm/config-store.js';
8
8
  import { videoConfigStore } from '../llm/video-config-store.js';
9
9
  import { audioConfigStore } from '../llm/audio-config-store.js';
10
- import { initMinimax, getMinimax } from '../constraints/index.js';
10
+ import { getMinimax } from '../constraints/index.js';
11
+ /**
12
+ * ============================================================================
13
+ * Web 侧的模型选择: **五个端点, 一个实现**
14
+ * ============================================================================
15
+ *
16
+ * 规矩 (P6):
17
+ * 1. 下面三个写口 (`/api/models/select` · 旧 `/api/llm-provider` · 旧 `/api/llm-config` 的激活分支)
18
+ * **共用 `runModelSelect` 这一个函数** —— 旧接口叫"兼容转发"指的就是这件事: 转发到**同一个函数**,
19
+ * 而不是各自再写一遍"校验 + 写配置 + 重建运行时"。
20
+ * 2. 校验 / 探测 / 落盘 / 重建运行时全部在 `selectModel` (唯一入口) 里发生; Web 这一层只做
21
+ * "把 HTTP 请求体翻译成入口入参"和"把结果翻译成 HTTP 状态码"。
22
+ * 3. **凭据永不出现在响应里**: 只回 `effective.authRef` (`provider:<id>` / `env:<VAR>` / `none`)。
23
+ * 掩码 key (`***abcd`) 会被当作"没给 key" —— 入口会用配置里现有那一把 (与旧行为同一效果)。
24
+ * 4. `provider` 由入口按**注册表**校验 (内置 + 已注册的自定义供应商), 所以从列表点一个自定义
25
+ * 供应商也能落成全局默认。
26
+ */
27
+ /** 请求体 → 统一入口的入参 (白名单: 不把前端对象整个灌进入口) */
28
+ function selectRequestFromBody(body) {
29
+ const b = body && typeof body === 'object' ? body : {};
30
+ const raw = typeof b.apiKey === 'string' ? b.apiKey : undefined;
31
+ return {
32
+ provider: typeof b.provider === 'string' ? b.provider : '',
33
+ model: typeof b.model === 'string' && b.model.trim() ? b.model.trim() : undefined,
34
+ baseUrl: typeof b.baseUrl === 'string' && b.baseUrl.trim() ? b.baseUrl.trim() : undefined,
35
+ // 掩码不是凭据: 当"没给"处理, 由入口沿用配置里那一把
36
+ apiKey: raw && !raw.startsWith('***') ? raw : undefined,
37
+ temperature: typeof b.temperature === 'number' && Number.isFinite(b.temperature) ? b.temperature : undefined,
38
+ reasoningMode: typeof b.reasoningMode === 'boolean' ? b.reasoningMode : undefined,
39
+ scope: b.scope === 'session' ? 'session' : 'global',
40
+ sessionKey: typeof b.sessionKey === 'string' && b.sessionKey.trim() ? b.sessionKey.trim() : undefined,
41
+ verify: b.verify !== false,
42
+ };
43
+ }
44
+ /** 唯一的写口实现: 校验+探测过才落盘, 然后重建运行时, 返回**真正生效**的那一份配置 */
45
+ async function runModelSelect(body, res, opts = {}) {
46
+ try {
47
+ const { selectModel } = await import('../llm/model-selection.js');
48
+ const r = await selectModel(selectRequestFromBody(body));
49
+ if (!r.ok) {
50
+ res.status(409).json({
51
+ ok: false,
52
+ failureClass: r.failureClass,
53
+ message: r.message,
54
+ effective: r.previous, // 失败时生效配置**没变** —— 如实回旧的那一份
55
+ checks: r.checks,
56
+ ...(opts.legacy ? { legacy: opts.legacy } : {}),
57
+ });
58
+ return;
59
+ }
60
+ const out = {
61
+ ok: true,
62
+ effective: r.effective,
63
+ previous: r.previous,
64
+ checks: r.checks,
65
+ ...(opts.legacy ? { legacy: opts.legacy } : {}),
66
+ };
67
+ if (opts.beforeOk)
68
+ Object.assign(out, await opts.beforeOk(out));
69
+ res.json(out);
70
+ }
71
+ catch (err) {
72
+ res.status(500).json({ error: err.message });
73
+ }
74
+ }
75
+ /** 旧 `/api/llm-config` 的两种请求形状 (老 UI 发 `{provider,config}`; 移动端发整份配置) */
76
+ function legacyLlmConfigPayload(body) {
77
+ const b = body && typeof body === 'object' ? body : {};
78
+ if (typeof b.provider === 'string' && b.provider && b.config && typeof b.config === 'object') {
79
+ return { provider: b.provider, config: b.config };
80
+ }
81
+ const active = typeof b.activeProvider === 'string' ? b.activeProvider : '';
82
+ const rows = b.providers && typeof b.providers === 'object' ? b.providers : null;
83
+ if (active && rows && rows[active] && typeof rows[active] === 'object') {
84
+ return { provider: active, config: rows[active] };
85
+ }
86
+ return null;
87
+ }
88
+ /** 旧接口能改、而**切换入口不管**的字段 (照旧保存; 掩码凭据不写回) */
89
+ function legacyExtraFields(config) {
90
+ const out = {};
91
+ const c = config && typeof config === 'object' ? config : {};
92
+ if (typeof c.maxTokens === 'number')
93
+ out.maxTokens = c.maxTokens;
94
+ if (typeof c.reasoning === 'boolean')
95
+ out.reasoning = c.reasoning;
96
+ return out;
97
+ }
98
+ /** 旧接口在"没凭据、只保存"那条路上能写的字段 (不含选择那四个) */
99
+ function legacyEditableFields(config) {
100
+ const c = config && typeof config === 'object' ? config : {};
101
+ const out = { enabled: c.enabled !== false, ...legacyExtraFields(c) };
102
+ if (typeof c.model === 'string' && c.model.trim())
103
+ out.model = c.model.trim();
104
+ if (typeof c.baseUrl === 'string' && c.baseUrl.trim())
105
+ out.baseUrl = c.baseUrl.trim();
106
+ if (typeof c.temperature === 'number')
107
+ out.temperature = c.temperature;
108
+ const raw = typeof c.apiKey === 'string' ? c.apiKey : '';
109
+ if (raw && !raw.startsWith('***'))
110
+ out.apiKey = raw;
111
+ return out;
112
+ }
113
+ /** 唯一的探测实现 (旧 /api/llm-test 与新 /api/models/test 共用) */
114
+ async function runModelTest(body, res, legacy) {
115
+ try {
116
+ const b = body && typeof body === 'object' ? body : {};
117
+ const provider = typeof b.provider === 'string' ? b.provider.trim() : '';
118
+ if (!provider) {
119
+ res.status(400).json({ error: 'provider required' });
120
+ return;
121
+ }
122
+ const raw = typeof b.apiKey === 'string' ? b.apiKey : undefined;
123
+ const sel = await import('../llm/model-selection.js');
124
+ const stored = await llmConfigStore.getProvider(provider).catch(() => null);
125
+ const model = typeof b.model === 'string' && b.model.trim() ? b.model.trim() : (stored?.model || '');
126
+ const r = await sel.runConnectionProbe({
127
+ provider,
128
+ model,
129
+ baseUrl: typeof b.baseUrl === 'string' && b.baseUrl.trim() ? b.baseUrl.trim() : undefined,
130
+ configuredBaseUrl: stored?.baseUrl || undefined,
131
+ apiKey: raw && !raw.startsWith('***') ? raw : (stored?.apiKey || undefined),
132
+ timeoutMs: typeof b.timeoutMs === 'number' && b.timeoutMs > 0 ? b.timeoutMs : undefined,
133
+ });
134
+ const entry = sel.registryEntryOf(provider);
135
+ res.json({
136
+ ok: r.ok,
137
+ success: r.ok, // 旧字段名, 前端仍在读
138
+ provider,
139
+ model,
140
+ baseUrl: r.baseUrl,
141
+ baseUrlSource: r.baseUrlSource,
142
+ protocol: entry ? entry.protocol : null,
143
+ toolCalling: r.toolCalling,
144
+ failureClass: r.failureClass,
145
+ probeClass: r.probeClass,
146
+ unmapped: r.unmapped,
147
+ failureClassZh: r.failureClassZh,
148
+ message: r.message,
149
+ error: r.ok ? undefined : r.message,
150
+ checks: r.checks,
151
+ ...(r.catalog ? { catalog: r.catalog } : {}),
152
+ ...(legacy ? { legacy } : {}),
153
+ });
154
+ }
155
+ catch (err) {
156
+ res.status(500).json({ error: err.message });
157
+ }
158
+ }
11
159
  export function registerLlmConfigRoutes(app) {
12
- // ==================== LLM 配置 API ====================
160
+ // ==================== LLM 配置 API (旧接口, 保留兼容) ====================
13
161
  // 获取所有 LLM 配置
14
162
  app.get('/api/llm-config', async (req, res) => {
15
163
  try {
@@ -30,81 +178,225 @@ export function registerLlmConfigRoutes(app) {
30
178
  res.status(500).json({ error: err.message });
31
179
  }
32
180
  });
33
- // 更新 LLM 配置
181
+ // 更新 LLM 配置 (旧接口, 兼容保留)
182
+ //
183
+ // 旧语义: 存字段 → 若 enabled 且有凭证 → 立即激活。
184
+ // 新语义: 有凭证 ⇒ **整件事转发给统一入口** (与 /api/models/select 同一个函数);
185
+ // 没凭证 ⇒ 只保存字段 (这本来就不是"切换", 是一次配置编辑);
186
+ // enabled=false ⇒ 只关这个开关 (入口不接管"关掉"这件事)。
34
187
  app.post('/api/llm-config', async (req, res) => {
35
188
  try {
36
- const { provider, config } = req.body;
37
- if (!provider || !config) {
189
+ const payload = legacyLlmConfigPayload(req.body);
190
+ if (!payload) {
38
191
  return res.status(400).json({ error: 'provider and config required' });
39
192
  }
40
- // 如果前端发的是掩码(***xxx),从当前配置里取真实 key
41
- const currentConfig = await llmConfigStore.getProvider(provider);
42
- if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
43
- config.apiKey = currentConfig.apiKey;
193
+ const { provider, config } = payload;
194
+ if (config.enabled === false) {
195
+ await llmConfigStore.updateProvider(provider, { enabled: false });
196
+ return res.json({ ok: true, autoActivated: false, disabled: true, legacy: 'llm-config' });
44
197
  }
45
- await llmConfigStore.updateProvider(provider, config);
46
- // v0.2.15: 当用户保存一个 LLM 配置为 enabled + 有 key 时,自动把它切为
47
- // activeProvider 并 rebind runtime singleton。修原来的两个真问题:
48
- // 1) frontend Save 从不调 /api/llm-provider,于是 activeProvider 一直
49
- // 卡在 process 启动时的第一个值,新配置的 provider 永远不接管 chat
50
- // 2) 即使用户手动 active,updateProvider 不替换 modelInstance(只有
51
- // 当前 active 命中才 rebind),所以保存非 active 那个 provider 之后
52
- // runtime 还是指向旧 provider + 旧 key
53
- // 现在:用户每保存一个 enabled 的 LLM 配置,bolloon 立即把这个 provider
54
- // 设成 active + 重新 init MinLLM/Pi SDK,让"配置 + 立刻能跑"成立。
55
- // 用户想保持旧 active,可以显式调 /api/llm-provider 改回去。
56
- const newConfig = await llmConfigStore.getProvider(provider);
57
- const shouldAutoActivate = newConfig?.enabled === true &&
58
- // 如果 provider 不需要 key (如 ollama) 或者已经给了真 key,才激活
59
- (newConfig.apiKey || !newConfig.requiresApiKey);
60
- if (shouldAutoActivate) {
61
- await llmConfigStore.setActiveProvider(provider);
62
- initMinimax({
63
- provider: provider,
64
- apiKey: newConfig.apiKey || undefined,
65
- baseUrl: newConfig.baseUrl || undefined,
66
- model: newConfig.model || undefined
198
+ const raw = typeof config.apiKey === 'string' ? config.apiKey : '';
199
+ const fresh = raw && !raw.startsWith('***') ? raw : '';
200
+ const stored = await llmConfigStore.getProvider(provider).catch(() => null);
201
+ const canActivate = !!fresh || !!(stored && (stored.apiKey || stored.requiresApiKey === false));
202
+ if (!canActivate) {
203
+ // 还没凭据: 存下来, 不激活 (且**不**假装成功切换)
204
+ await llmConfigStore.updateProvider(provider, legacyEditableFields(config));
205
+ return res.json({
206
+ ok: true,
207
+ autoActivated: false,
208
+ saved: true,
209
+ note: '缺凭据 — 字段已保存, 未激活 (激活走 /api/models/select)',
210
+ legacy: 'llm-config',
67
211
  });
68
212
  }
69
- res.json({ ok: true, autoActivated: shouldAutoActivate });
213
+ // 有凭据 ⇒ 转发到同一个写口。选择那四个字段 (model/baseUrl/key/temperature) 由入口写,
214
+ // 入口不管的字段 (如 maxTokens) 在入口成功后照旧保存 —— 但"切换"这件事只有入口一处实现。
215
+ const extras = legacyExtraFields(config);
216
+ return runModelSelect({ provider, model: config.model, baseUrl: config.baseUrl, apiKey: fresh || undefined, temperature: config.temperature }, res, {
217
+ legacy: 'llm-config',
218
+ beforeOk: async () => {
219
+ try {
220
+ if (Object.keys(extras).length)
221
+ await llmConfigStore.updateProvider(provider, extras);
222
+ }
223
+ catch { /* extras 是次要字段, 不影响"切换成功"这个结论 */ }
224
+ return { autoActivated: true };
225
+ },
226
+ });
70
227
  }
71
228
  catch (err) {
72
229
  res.status(500).json({ error: err.message });
73
230
  }
74
231
  });
75
- // 设置活跃供应商
232
+ // 设置活跃供应商 (旧接口 → 转发到统一入口的同一个函数)
76
233
  app.post('/api/llm-provider', async (req, res) => {
234
+ const provider = req.body && typeof req.body.provider === 'string' ? req.body.provider : '';
235
+ if (!provider) {
236
+ return res.status(400).json({ error: 'provider required' });
237
+ }
238
+ // 旧字段名也照收 (老 UI 可能只带 provider + 可选 model/baseUrl)
239
+ return runModelSelect({ ...req.body, provider }, res, { legacy: 'llm-provider' });
240
+ });
241
+ // 测试供应商连接 (旧接口 → 转发到同一个探测函数)
242
+ app.post('/api/llm-test', async (req, res) => {
243
+ return runModelTest(req.body, res, 'llm-test');
244
+ });
245
+ // ==================== 模型选择 API (新端点) ====================
246
+ //
247
+ // 五个端点: providers / options / test / select / discover —— 都只是**薄翻译层**,
248
+ // 真正的事实与写入分别在 provider-registry / model-catalog / connection-probe /
249
+ // model-selection / model-discovery 里, 这里不复制任何一份。
250
+ // 供应商清单 (脱敏): 谁能选、当前生效的是哪一家、协议与能力、发现方式
251
+ app.get('/api/models/providers', async (req, res) => {
77
252
  try {
78
- const { provider } = req.body;
253
+ const sessionKey = typeof req.query.sessionKey === 'string' && req.query.sessionKey ? req.query.sessionKey : undefined;
254
+ const { buildProviderSummaries } = await import('../llm/model-catalog.js');
255
+ const { listProviderRegistry } = await import('../llm/provider-registry.js');
256
+ const { effectiveModelConfig } = await import('../llm/model-selection.js');
257
+ const [providers, effective] = await Promise.all([
258
+ buildProviderSummaries({ sessionKey }),
259
+ effectiveModelConfig({ sessionKey }).catch(() => null),
260
+ ]);
261
+ const registry = listProviderRegistry().map((e) => ({
262
+ id: e.id,
263
+ kind: e.kind,
264
+ protocol: e.protocol,
265
+ discovery: e.discovery,
266
+ toolCalling: e.toolCalling,
267
+ reasoning: e.reasoning,
268
+ requiresApiKey: e.requiresApiKey,
269
+ isLocal: e.isLocal,
270
+ allowsLongRunningExecutor: e.allowsLongRunningExecutor,
271
+ longRunningReason: e.longRunningReason,
272
+ defaultModel: e.defaultModel,
273
+ declaredModelCount: Array.isArray(e.declaredModelIds) ? e.declaredModelIds.length : 0,
274
+ }));
275
+ res.json({ ok: true, effective, count: providers.length, providers, registry });
276
+ }
277
+ catch (err) {
278
+ res.status(500).json({ error: err.message });
279
+ }
280
+ });
281
+ // 可选模型清单 (某一家; 不带 provider 就只回供应商清单与当前生效配置)
282
+ // `refresh=1` 才真去打上游 /models (默认只吃缓存, 不因为一次页面加载就把上游打爆)
283
+ app.get('/api/models/options', async (req, res) => {
284
+ try {
285
+ const q = typeof req.query.q === 'string' ? req.query.q : '';
286
+ const provider = typeof req.query.provider === 'string' ? req.query.provider.trim() : '';
287
+ const sessionKey = typeof req.query.sessionKey === 'string' && req.query.sessionKey ? req.query.sessionKey : undefined;
288
+ const { effectiveModelConfig, registryEntryOf } = await import('../llm/model-selection.js');
289
+ const effective = await effectiveModelConfig({ sessionKey }).catch(() => null);
79
290
  if (!provider) {
80
- return res.status(400).json({ error: 'provider required' });
291
+ const { buildProviderSummaries } = await import('../llm/model-catalog.js');
292
+ return res.json({ ok: true, effective, providers: await buildProviderSummaries({ sessionKey }) });
81
293
  }
82
- await llmConfigStore.setActiveProvider(provider);
83
- // 重新初始化 Pi SDK
84
- const config = await llmConfigStore.getActiveProviderConfig();
85
- if (config) {
86
- initMinimax({
87
- provider: provider,
88
- apiKey: config.apiKey || undefined,
89
- baseUrl: config.baseUrl || undefined,
90
- model: config.model || undefined
91
- });
294
+ if (!registryEntryOf(provider)) {
295
+ return res.status(404).json({ ok: false, failureClass: 'invalid_provider', message: `未知供应商 '${provider}'`, effective });
92
296
  }
93
- res.json({ ok: true, provider });
297
+ const { listModelsFor, searchModelEntries } = await import('../llm/model-catalog.js');
298
+ const { currentDiscoveryCatalog, discoverProviderModels } = await import('../llm/model-discovery.js');
299
+ const refresh = req.query.refresh === '1' || req.query.refresh === 'true';
300
+ let catalog = currentDiscoveryCatalog(provider);
301
+ if (refresh) {
302
+ // 只有显式 refresh 才真打上游 (默认吃缓存, 免得一次页面加载就把上游打爆)
303
+ const d = await discoverProviderModels(provider, { force: true });
304
+ catalog = d || catalog;
305
+ }
306
+ const discovered = catalog && Array.isArray(catalog.models) ? catalog.models : [];
307
+ const models = searchModelEntries(await listModelsFor(provider, {
308
+ sessionKey,
309
+ extra: discovered,
310
+ }), q);
311
+ // 目录里没有凭据 (只有指纹); 这里显式白名单一份, 免得把内部字段漏出去
312
+ const catalogOut = catalog
313
+ ? {
314
+ provider: catalog.provider,
315
+ baseUrl: catalog.baseUrl,
316
+ baseUrlSource: catalog.baseUrlSource,
317
+ origin: catalog.origin,
318
+ discoveryState: catalog.discoveryState,
319
+ fetchedAt: catalog.fetchedAt,
320
+ expiresAt: catalog.expiresAt,
321
+ stale: catalog.stale === true,
322
+ fromCache: catalog.fromCache === true,
323
+ discoveryFailed: catalog.discoveryFailed === true,
324
+ failure: catalog.failure || null,
325
+ count: discovered.length,
326
+ models: discovered,
327
+ notes: catalog.notes || [],
328
+ }
329
+ : null;
330
+ res.json({ ok: true, provider, effective, q, count: models.length, models, catalog: catalogOut });
94
331
  }
95
332
  catch (err) {
96
333
  res.status(500).json({ error: err.message });
97
334
  }
98
335
  });
99
- // 测试供应商连接
100
- app.post('/api/llm-test', async (req, res) => {
336
+ // 连通探测 (不写任何配置)
337
+ app.post('/api/models/test', async (req, res) => {
338
+ return runModelTest(req.body, res);
339
+ });
340
+ // 切换模型选择 (唯一写口)
341
+ app.post('/api/models/select', async (req, res) => {
342
+ return runModelSelect(req.body, res);
343
+ });
344
+ // 模型发现 (P5 的能力面): refresh / list / admit / clear, 一个入口
345
+ // body: { provider?, action?: 'refresh'|'list'|'admit'|'clear', model?, force?, timeoutMs? }
346
+ app.post('/api/models/discover', async (req, res) => {
101
347
  try {
102
- const { provider } = req.body;
103
- if (!provider) {
348
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
349
+ const action = typeof b.action === 'string' && b.action ? b.action : (b.provider ? 'refresh' : 'list');
350
+ const provider = typeof b.provider === 'string' ? b.provider.trim() : '';
351
+ const model = typeof b.model === 'string' ? b.model.trim() : '';
352
+ const d = await import('../llm/model-discovery.js');
353
+ const { registryEntryOf } = await import('../llm/model-selection.js');
354
+ if (action === 'clear') {
355
+ const cleared = await d.clearDiscoveryCache(provider || undefined);
356
+ return res.json({ ok: true, action, provider: provider || null, cleared });
357
+ }
358
+ if (action === 'admit') {
359
+ if (!provider || !model)
360
+ return res.status(400).json({ error: 'provider and model required' });
361
+ if (!registryEntryOf(provider)) {
362
+ return res.status(404).json({ ok: false, failureClass: 'invalid_provider', message: `未知供应商 '${provider}'` });
363
+ }
364
+ const admitted = await d.admitManualModel(provider, model);
365
+ return res.json({ ok: true, action, provider, model, admitted });
366
+ }
367
+ if (action === 'list') {
368
+ if (provider) {
369
+ if (!registryEntryOf(provider)) {
370
+ return res.status(404).json({ ok: false, failureClass: 'invalid_provider', message: `未知供应商 '${provider}'` });
371
+ }
372
+ const listing = await d.listModelCatalog(provider);
373
+ return res.json({ ok: true, action, provider, listing });
374
+ }
375
+ const listing = await d.listModelCatalog();
376
+ return res.json({ ok: true, action, listing });
377
+ }
378
+ // refresh: 真去上游 /models 取一次目录 (结果进缓存; 失败如实报类目)
379
+ if (!provider)
104
380
  return res.status(400).json({ error: 'provider required' });
381
+ if (!registryEntryOf(provider)) {
382
+ return res.status(404).json({ ok: false, failureClass: 'invalid_provider', message: `未知供应商 '${provider}'` });
105
383
  }
106
- const result = await llmConfigStore.testProvider(provider);
107
- res.json(result);
384
+ const r = await d.refreshModelDiscovery(provider, {
385
+ force: b.force !== false,
386
+ timeoutMs: typeof b.timeoutMs === 'number' && b.timeoutMs > 0 ? b.timeoutMs : undefined,
387
+ });
388
+ const entry = r.results.find((c) => c.provider === provider);
389
+ const failure = r.failures.find((f) => f.provider === provider);
390
+ return res.json({
391
+ ok: !!entry && !failure,
392
+ action,
393
+ provider,
394
+ catalog: entry || null,
395
+ failure: failure || null,
396
+ notes: r.notes,
397
+ counts: r.counts,
398
+ refreshedAt: r.refreshedAt,
399
+ });
108
400
  }
109
401
  catch (err) {
110
402
  res.status(500).json({ error: err.message });