@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 +1 -1
- package/dist/llm/config-store.js +93 -14
- package/dist/web/api-config.html +12 -2
- package/dist/web/routes-llm-config.js +23 -13
- package/package.json +2 -2
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.
|
|
26
|
+
const VERSION = '0.2.15';
|
|
27
27
|
function log(msg, color = RESET) {
|
|
28
28
|
console.log(`${color}${msg}${RESET}`);
|
|
29
29
|
}
|
package/dist/llm/config-store.js
CHANGED
|
@@ -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.
|
|
279
|
-
|
|
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.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 ===
|
|
316
|
-
? '
|
|
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) {
|
package/dist/web/api-config.html
CHANGED
|
@@ -421,7 +421,15 @@
|
|
|
421
421
|
const result = document.getElementById('testResult');
|
|
422
422
|
|
|
423
423
|
btn.disabled = true;
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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.
|
|
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",
|