@bolloon/bolloon-agent 0.2.13 → 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.13';
26
+ const VERSION = '0.2.15';
27
27
  function log(msg, color = RESET) {
28
28
  console.log(`${color}${msg}${RESET}`);
29
29
  }
@@ -38,7 +38,7 @@ export const DEFAULT_PROVIDER_CONFIGS = {
38
38
  enabled: false,
39
39
  apiKey: '',
40
40
  baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
41
- model: 'gemini-3.5-pro',
41
+ model: 'gemini-2.5-pro',
42
42
  temperature: 0.7,
43
43
  maxTokens: 4096,
44
44
  requiresApiKey: true
@@ -121,7 +121,12 @@ export const PROVIDER_INFO = {
121
121
  openai: { name: 'OpenAI', description: 'GPT-4, GPT-3.5 等模型', requiresApiKey: true, models: ['gpt-4.1', 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'] },
122
122
  anthropic: { name: 'Anthropic', description: 'Claude 3.5+ 系列模型', requiresApiKey: true, models: ['claude-sonnet-4-5-20250929', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'] },
123
123
  openrouter: { name: 'OpenRouter', description: '聚合多个 AI 供应商', requiresApiKey: true, models: ['anthropic/claude-sonnet-4.5', 'anthropic/claude-3.5-sonnet'] },
124
- gemini: { name: 'Google Gemini', description: 'Gemini 系列模型', requiresApiKey: true, models: ['gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'] },
124
+ gemini: { name: 'Google Gemini', description: 'Gemini 系列模型', requiresApiKey: true, models: [
125
+ 'gemini-3.5-flash', // 2026-06 官方推荐 stable 旗舰 (https://ai.google.dev/gemini-api/docs/models)
126
+ 'gemini-2.5-pro', // 高级推理, 仍为 GA
127
+ 'gemini-3.1-flash-lite', // 成本敏感场景
128
+ 'gemini-flash-latest', // 滚动 alias → 当前 stable Flash
129
+ ] },
125
130
  ollama: { name: 'Ollama', description: '本地 LLM 运行框架', requiresApiKey: false },
126
131
  minimax: {
127
132
  name: 'MiniMax',
@@ -209,6 +214,18 @@ function getDefaultConfig() {
209
214
  class LLMConfigStore {
210
215
  config = null;
211
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
+ }
212
229
  async initialize() {
213
230
  if (this.initialized)
214
231
  return;
@@ -270,19 +287,23 @@ class LLMConfigStore {
270
287
  if (providerConfig.requiresApiKey && !providerConfig.apiKey) {
271
288
  throw new Error(`${provider} requires an API key but none is configured`);
272
289
  }
273
- this.config.activeProvider = provider;
274
- await this.save();
290
+ await this.withWriteLock(async () => {
291
+ this.config.activeProvider = provider;
292
+ await this.save();
293
+ });
275
294
  }
276
295
  async updateProvider(provider, updates) {
277
296
  await this.initialize();
278
297
  if (!this.config?.providers[provider]) {
279
298
  throw new Error(`Unknown provider: ${provider}`);
280
299
  }
281
- this.config.providers[provider] = {
282
- ...this.config.providers[provider],
283
- ...updates
284
- };
285
- 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
+ });
286
307
  }
287
308
  async testProvider(provider) {
288
309
  await this.initialize();
@@ -295,10 +316,16 @@ class LLMConfigStore {
295
316
  }
296
317
  const startTime = Date.now();
297
318
  try {
298
- const response = await fetch(`${config.baseUrl}/models`, {
299
- method: 'GET',
300
- headers: this.buildHeaders(provider, config)
301
- });
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);
302
329
  const latency = Date.now() - startTime;
303
330
  if (response.ok) {
304
331
  return { success: true, latency };
@@ -307,9 +334,13 @@ class LLMConfigStore {
307
334
  const errorText = await response.text().catch(() => 'Unknown error');
308
335
  const hint = response.status === 401
309
336
  ? '(API Key 无效或不匹配该供应商 — 请检查是否复制完整、有无多余空格)'
310
- : response.status === 404
311
- ? '(端点不存在 — 请检查 baseUrl)'
312
- : '';
337
+ : response.status === 403
338
+ ? '(API Key 没有调用此端点的权限 — 请检查 key scope 或供应商 endpoint)'
339
+ : response.status === 404
340
+ ? '(端点不存在 — 请检查 baseUrl)'
341
+ : response.status === 429
342
+ ? '(供应商限流中 — 稍候再试)'
343
+ : '';
313
344
  return { success: false, error: `HTTP ${response.status}: ${errorText.substring(0, 500)}${hint ? ' ' + hint : ''}`, latency };
314
345
  }
315
346
  }
@@ -317,6 +348,59 @@ class LLMConfigStore {
317
348
  return { success: false, error: error.message || 'Connection failed', latency: Date.now() - startTime };
318
349
  }
319
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
+ }
320
404
  buildHeaders(provider, config) {
321
405
  const headers = { 'Content-Type': 'application/json' };
322
406
  switch (provider) {
package/dist/llm/pi-ai.js CHANGED
@@ -255,6 +255,8 @@ export class PiAIModel {
255
255
  anthropic: this.config.model || 'claude-sonnet-4-5-20250929',
256
256
  ollama: this.config.model || 'llama3.2',
257
257
  openrouter: this.config.model || 'anthropic/claude-sonnet-4.5',
258
+ // Pinned to 2.5-pro: the only `-pro` model that is GA per Google docs.
259
+ // The 3.x line ships as `-flash` only — there is no `gemini-3.x-pro`.
258
260
  gemini: this.config.model || 'gemini-2.5-pro',
259
261
  minimax: this.config.model || process.env.MINIMAX_MODEL || 'MiniMax-M3',
260
262
  deepseek: this.config.model || process.env.DEEPSEEK_MODEL || 'deepseek-chat',
@@ -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.13",
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.13",
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",