@bolloon/bolloon-agent 0.2.14 → 0.2.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.
package/dist/cli-entry.js CHANGED
@@ -23,7 +23,7 @@ const YELLOW = '\x1b[33m';
23
23
  const GREEN = '\x1b[32m';
24
24
  const MAGENTA = '\x1b[35m';
25
25
  // 版本信息 — 与 package.json:version 同步, 否则 banner 会显示过时版本误导用户
26
- const VERSION = '0.2.14';
26
+ const VERSION = '0.2.15';
27
27
  function log(msg, color = RESET) {
28
28
  console.log(`${color}${msg}${RESET}`);
29
29
  }
@@ -214,6 +214,18 @@ function getDefaultConfig() {
214
214
  class LLMConfigStore {
215
215
  config = null;
216
216
  initialized = false;
217
+ // v0.2.15: single-flight lock around read-modify-write of `~/.bolloon/llm-config.json`.
218
+ // Prevents concurrent save() calls from clobbering each other when the user
219
+ // configures two providers back-to-back (e.g. saving gemini, then anthropic, in
220
+ // quick succession). One operation at a time, in call order.
221
+ writeChain = Promise.resolve();
222
+ async withWriteLock(fn) {
223
+ // Chain the new op after the previous one; swallow the previous op's
224
+ // rejection so a single failed save does not poison subsequent writes.
225
+ const next = this.writeChain.then(fn, fn);
226
+ this.writeChain = next.then(() => undefined, () => undefined);
227
+ return next;
228
+ }
217
229
  async initialize() {
218
230
  if (this.initialized)
219
231
  return;
@@ -275,19 +287,23 @@ class LLMConfigStore {
275
287
  if (providerConfig.requiresApiKey && !providerConfig.apiKey) {
276
288
  throw new Error(`${provider} requires an API key but none is configured`);
277
289
  }
278
- this.config.activeProvider = provider;
279
- await this.save();
290
+ await this.withWriteLock(async () => {
291
+ this.config.activeProvider = provider;
292
+ await this.save();
293
+ });
280
294
  }
281
295
  async updateProvider(provider, updates) {
282
296
  await this.initialize();
283
297
  if (!this.config?.providers[provider]) {
284
298
  throw new Error(`Unknown provider: ${provider}`);
285
299
  }
286
- this.config.providers[provider] = {
287
- ...this.config.providers[provider],
288
- ...updates
289
- };
290
- await this.save();
300
+ await this.withWriteLock(async () => {
301
+ this.config.providers[provider] = {
302
+ ...this.config.providers[provider],
303
+ ...updates
304
+ };
305
+ await this.save();
306
+ });
291
307
  }
292
308
  async testProvider(provider) {
293
309
  await this.initialize();
@@ -300,10 +316,16 @@ class LLMConfigStore {
300
316
  }
301
317
  const startTime = Date.now();
302
318
  try {
303
- const response = await fetch(`${config.baseUrl}/models`, {
304
- method: 'GET',
305
- headers: this.buildHeaders(provider, config)
306
- });
319
+ // v0.2.15: per-provider test endpoint. The old unified `GET baseUrl/models`
320
+ // is wrong on at least two providers:
321
+ // - Anthropic has no GET /v1/models endpoint -> always 404.
322
+ // - Google's GET /v1beta/models returns the public model catalog
323
+ // without auth -> always 200, even with a wrong/expired key, so
324
+ // the user saw "connected" while chat requests still failed.
325
+ // The per-provider branch below uses an endpoint that actually
326
+ // gates on the key.
327
+ const { url, init } = this.buildTestRequest(provider, config);
328
+ const response = await fetch(url, init);
307
329
  const latency = Date.now() - startTime;
308
330
  if (response.ok) {
309
331
  return { success: true, latency };
@@ -312,9 +334,13 @@ class LLMConfigStore {
312
334
  const errorText = await response.text().catch(() => 'Unknown error');
313
335
  const hint = response.status === 401
314
336
  ? '(API Key 无效或不匹配该供应商 — 请检查是否复制完整、有无多余空格)'
315
- : response.status === 404
316
- ? '(端点不存在 — 请检查 baseUrl)'
317
- : '';
337
+ : response.status === 403
338
+ ? '(API Key 没有调用此端点的权限 — 请检查 key scope 或供应商 endpoint)'
339
+ : response.status === 404
340
+ ? '(端点不存在 — 请检查 baseUrl)'
341
+ : response.status === 429
342
+ ? '(供应商限流中 — 稍候再试)'
343
+ : '';
318
344
  return { success: false, error: `HTTP ${response.status}: ${errorText.substring(0, 500)}${hint ? ' ' + hint : ''}`, latency };
319
345
  }
320
346
  }
@@ -322,6 +348,59 @@ class LLMConfigStore {
322
348
  return { success: false, error: error.message || 'Connection failed', latency: Date.now() - startTime };
323
349
  }
324
350
  }
351
+ /**
352
+ * Build the lightest "is the key + baseUrl healthy?" probe for the given
353
+ * provider. Each branch targets an endpoint that *actually* validates the
354
+ * credentials (as opposed to a public catalog or non-existent route).
355
+ */
356
+ buildTestRequest(provider, config) {
357
+ switch (provider) {
358
+ case 'anthropic':
359
+ // No GET /v1/models. Use a minimal /messages ping that fails fast
360
+ // on bad keys (401) and rate-limits (429) without burning quota.
361
+ return {
362
+ url: `${config.baseUrl}/messages`,
363
+ init: {
364
+ method: 'POST',
365
+ headers: this.buildHeaders(provider, config),
366
+ body: JSON.stringify({
367
+ model: config.model || 'claude-sonnet-4-5-20250929',
368
+ max_tokens: 1,
369
+ messages: [{ role: 'user', content: 'ping' }],
370
+ }),
371
+ },
372
+ };
373
+ case 'gemini':
374
+ // listModels with the key in the query string returns 400 for
375
+ // an invalid key, 200 for a valid one. This is the only "light"
376
+ // Gemini endpoint that gates on auth (generateContent would
377
+ // burn quota on a real prompt).
378
+ return {
379
+ url: `${config.baseUrl}/models?key=${encodeURIComponent(config.apiKey)}`,
380
+ init: { method: 'GET' },
381
+ };
382
+ case 'ollama':
383
+ return { url: `${config.baseUrl}/api/tags`, init: { method: 'GET' } };
384
+ case 'openai':
385
+ case 'openrouter':
386
+ case 'deepseek':
387
+ case 'kimi':
388
+ case 'glm':
389
+ case 'qwen':
390
+ case 'mimo':
391
+ case 'minimax':
392
+ case 'local':
393
+ return {
394
+ url: `${config.baseUrl}/models`,
395
+ init: { method: 'GET', headers: this.buildHeaders(provider, config) },
396
+ };
397
+ default:
398
+ return {
399
+ url: `${config.baseUrl}/models`,
400
+ init: { method: 'GET', headers: this.buildHeaders(provider, config) },
401
+ };
402
+ }
403
+ }
325
404
  buildHeaders(provider, config) {
326
405
  const headers = { 'Content-Type': 'application/json' };
327
406
  switch (provider) {
@@ -421,7 +421,15 @@
421
421
  const result = document.getElementById('testResult');
422
422
 
423
423
  btn.disabled = true;
424
- btn.innerHTML = '<span class="spinner"></span> 测试中...';
424
+ // v0.2.15: defer label mutation to the next frame. Synchronously
425
+ // replacing the button's innerHTML in its own click handler cancels
426
+ // the click event in several Chromium-derived engines — the user
427
+ // observed the first click "doing nothing" until they closed and
428
+ // reopened the modal. Wrapping the mutation in requestAnimationFrame
429
+ // lets the click event finish dispatching first.
430
+ requestAnimationFrame(() => {
431
+ btn.textContent = '⚡ 测试中...';
432
+ });
425
433
  result.style.display = 'none';
426
434
 
427
435
  const endpoint = currentProviderType === 'llm' ? '/api/llm-test' :
@@ -447,7 +455,9 @@
447
455
  }
448
456
 
449
457
  btn.disabled = false;
450
- btn.innerHTML = '⚡ 测试连接';
458
+ requestAnimationFrame(() => {
459
+ btn.textContent = '⚡ 测试连接';
460
+ });
451
461
  }
452
462
 
453
463
  // ==================== 保存 ====================
@@ -43,20 +43,30 @@ export function registerLlmConfigRoutes(app) {
43
43
  config.apiKey = currentConfig.apiKey;
44
44
  }
45
45
  await llmConfigStore.updateProvider(provider, config);
46
- // 如果是活跃供应商,重新初始化 Pi SDK
47
- const currentActive = await llmConfigStore.getActiveProvider();
48
- if (provider === currentActive) {
49
- const newConfig = await llmConfigStore.getActiveProviderConfig();
50
- if (newConfig) {
51
- initMinimax({
52
- provider,
53
- apiKey: newConfig.apiKey || undefined,
54
- baseUrl: newConfig.baseUrl || undefined,
55
- model: newConfig.model || undefined
56
- });
57
- }
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
67
+ });
58
68
  }
59
- res.json({ ok: true });
69
+ res.json({ ok: true, autoActivated: shouldAutoActivate });
60
70
  }
61
71
  catch (err) {
62
72
  res.status(500).json({ error: err.message });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",
@@ -48,7 +48,7 @@
48
48
  "src/constraint-runtime"
49
49
  ],
50
50
  "dependencies": {
51
- "@bolloon/bolloon-agent": "^0.2.14",
51
+ "@bolloon/bolloon-agent": "^0.2.15",
52
52
  "@bolloon/constraint-runtime": "0.1.0",
53
53
  "@capacitor/core": "^8.4.1",
54
54
  "@capacitor/ios": "^8.4.1",