@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
@@ -12,12 +12,20 @@
12
12
  * - io 可注入 → 单测不碰真实 stdin/stdout
13
13
  */
14
14
  import * as readline from 'readline';
15
- import { evaluateSetup, providerUsable, readConfigFacts, resolveBolloonHome, } from '../setup/setup-store.js';
15
+ import { evaluateSetup, readConfigFacts, resolveBolloonHome, } from '../setup/setup-store.js';
16
16
  import { runOnboard } from '../setup/onboard.js';
17
+ // 2026-09-27: 启动期"续跑前言"闸门 (默认不上屏 → 面板一行 + 显式查询命令; verbose 逐字回流)
18
+ import { noticeLine as startupNotice, startupPreambleVisible, flushStartupNotices } from './startup-notice.js';
17
19
  import * as fs from 'fs/promises';
18
20
  import * as path from 'path';
19
21
  import * as os from 'os';
20
- import { llmConfigStore, PROVIDER_INFO } from '../llm/config-store.js';
22
+ import { llmConfigStore } from '../llm/config-store.js';
23
+ import { selectModel, resetModelSelection, probeSelection, effectiveModelConfig, formatEffectiveModel, normalizeBaseUrl, validateBaseUrlShape, currentSessionKey, } from '../llm/model-selection.js';
24
+ import { buildProviderSummaries, formatProviderMenuRow, providerGroupSummary, providerTierOf, providerTierCollapsedByDefault, orderProvidersForMenu, PROVIDER_GROUPS, } from '../llm/model-catalog.js';
25
+ import { runModelSelector } from './model-selector.js';
26
+ // 着色判据只有一处 (`theme.ts`) —— 主屏与全屏选择器共用
27
+ import { colorEnabled } from './theme.js';
28
+ import { refreshModelDiscovery, clearDiscoveryCache, listModelCatalog, admitManualModel, formatCatalogLine, formatListingSummary, } from '../llm/model-discovery.js';
21
29
  /** 向导推荐的供应商顺序 (第一个是最省事的国内直连) */
22
30
  export const RECOMMENDED_PROVIDERS = [
23
31
  'deepseek', 'minimax', 'openai', 'anthropic', 'openrouter', 'gemini',
@@ -47,10 +55,23 @@ export function defaultWizardIO() {
47
55
  }),
48
56
  };
49
57
  }
50
- /** 一次性隐藏输入 (bolloon model key <provider> 用; 单独开 readline, 用完即关, 不回显) */
51
- export async function askHiddenLine(question) {
58
+ /**
59
+ * 一次性隐藏输入 —— **带回 EOF 判定**。
60
+ *
61
+ * 为什么要把"EOF/Ctrl-D"与"用户输入了空串"分开: 空串在分步选择器里是**回车 = 取默认**,
62
+ * 而输入流结束 (管道读完 / Ctrl-D) 只可能取消 —— 混成一件事会让 `printf '\x04' | bolloon model`
63
+ * 把"没有输入了"读成"用户选了第 1 项"并继续往下走 (真实踩过)。
64
+ * readline 在输入流结束时不一定回调 `question`, 所以另挂 `close` 兜底。
65
+ */
66
+ export async function askHiddenLineEof(question) {
52
67
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
53
68
  return new Promise((resolve) => {
69
+ let settled = false;
70
+ const finish = (value, eof) => { if (!settled) {
71
+ settled = true;
72
+ resolve({ value, eof });
73
+ } };
74
+ rl.once('close', () => finish('', true));
54
75
  const anyRl = rl;
55
76
  const orig = anyRl._writeToOutput?.bind(anyRl);
56
77
  anyRl._writeToOutput = function (s) { if (String(s).includes(question))
@@ -59,11 +80,40 @@ export async function askHiddenLine(question) {
59
80
  if (orig)
60
81
  anyRl._writeToOutput = orig;
61
82
  process.stdout.write('\n');
83
+ finish(String(ans ?? '').trim(), false);
62
84
  rl.close();
63
- resolve(String(ans ?? '').trim());
64
85
  });
65
86
  });
66
87
  }
88
+ /** 一次性隐藏输入 (`bolloon model key <provider>` 用; 单独开 readline, 用完即关, 不回显) */
89
+ export async function askHiddenLine(question) {
90
+ return (await askHiddenLineEof(question)).value;
91
+ }
92
+ /**
93
+ * 一次性普通输入 —— **带回 EOF 判定** (理由见 `askHiddenLineEof`)。
94
+ * 有默认值时回车即取默认; EOF 时 `eof=true` (调用方必须当成取消, 不许当成回车)。
95
+ */
96
+ export async function askLineEof(question, opts = {}) {
97
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
98
+ return new Promise((resolve) => {
99
+ let settled = false;
100
+ const finish = (value, eof) => { if (!settled) {
101
+ settled = true;
102
+ resolve({ value, eof });
103
+ } };
104
+ rl.once('close', () => finish('', true));
105
+ const prompt = `${question}${opts.default !== undefined ? ` [${opts.default}]` : ''} `;
106
+ rl.question(prompt, (ans) => {
107
+ const v = String(ans ?? '').trim();
108
+ finish(v === '' && opts.default !== undefined ? String(opts.default) : v, false);
109
+ rl.close();
110
+ });
111
+ });
112
+ }
113
+ /** 一次性普通输入 (分步选择器的搜索/参数输入用; 有默认值时回车即取默认) */
114
+ export async function askLine(question, opts = {}) {
115
+ return (await askLineEof(question, opts)).value;
116
+ }
67
117
  // ---------------------------------------------------------------- 用户身份
68
118
  export function getUserIdentityFile(home = os.homedir()) {
69
119
  return path.join(home, '.bolloon', 'identity', 'user.json');
@@ -242,8 +292,23 @@ export async function runSetupWizard(opts = {}) {
242
292
  const io = opts.io ?? defaultWizardIO();
243
293
  const home = opts.home ?? os.homedir();
244
294
  const bolloonHome = resolveBolloonHome(process.env, home);
245
- const P = (s) => io.print(s);
246
295
  const mode = opts.mode || 'setup';
296
+ // ── 启动期 vs 显式运行 (2026-09-27) ──────────────────────────────────────
297
+ // 启动期 (`bolloon --cli` 的续跑引导): 续跑前言默认**不上屏** —— 收进缓冲 + 落启动日志,
298
+ // 面板那边会给一行就绪度; 失败/要人回答时下面的 `flushQuiet()` 会先把缓冲打出来。
299
+ // 判据只有一处 (`startupPreambleVisible()`): verbose / BOLLOON_VERBOSE / BOLLOON_STARTUP_PREAMBLE=1
300
+ // 任一为真 → 与修前逐字一致地全量输出 (也是验收门的"修前对照")。
301
+ const quiet = !!opts.quietStartup && !startupPreambleVisible();
302
+ const quietBuf = [];
303
+ const P = (s) => { if (quiet)
304
+ quietBuf.push(s);
305
+ else
306
+ io.print(s); };
307
+ // 要人当场回答 → 先把**所有**攒着的前言落屏: 向导自己的续跑框 + `startup-notice` 那边
308
+ // 攒的初始门禁报告 (缺什么/下一步)。少了后一半就等于"把失败原因吞了" —— 人正要就着它做决定。
309
+ // (两次调用幂等: 两边缓冲都是取走即清。)
310
+ const flushQuiet = () => { flushStartupNotices(); for (const l of quietBuf.splice(0))
311
+ startupNotice(l, { visible: true }); };
247
312
  P('');
248
313
  P('╭─ Bolloon 初始化 (可中断, 下次从失败阶段继续) ─────╮');
249
314
  P('│ 身份 → 供应商 → 凭证 → 模型 → 连通性 → 运行时 │');
@@ -254,29 +319,47 @@ export async function runSetupWizard(opts = {}) {
254
319
  await writeUserIdentity(opts.name, home);
255
320
  const prov = opts.provider;
256
321
  if (prov) {
257
- const store = llmConfigStore;
258
- await store.initialize();
259
- await store.updateProvider(prov, { enabled: true });
260
- if (opts.apiKey)
261
- await store.updateProvider(prov, { apiKey: opts.apiKey });
262
- if (opts.model)
263
- await store.updateProvider(prov, { model: opts.model });
264
- if (mode !== 'reconfigure')
265
- await store.setActiveProvider(prov);
322
+ // 2026-09-26 (P6): 参数式输入也是一次**切换**, 走统一入口 (校验 / 落盘 / 重建运行时都在它里面)。
323
+ // 此前这里自己 updateProvider + setActiveProvider = 第二条切换实现 (而且不重建运行时)。
324
+ // `verify:false`: 脚本/CI 场景不在这里打上游 —— 紧随其后的 onboard 连通性阶段才是实测处
325
+ // (只有 skipTest 才真跳过)。
326
+ const { selectModel: selectFromParams } = await import('../llm/model-selection.js');
327
+ const r = await selectFromParams({
328
+ provider: prov,
329
+ model: opts.model,
330
+ apiKey: opts.apiKey,
331
+ scope: 'global',
332
+ verify: false,
333
+ });
334
+ if (!r.ok)
335
+ return { ok: false, error: `参数落盘失败 [${r.failureClass}]: ${r.message}` };
266
336
  }
267
337
  }
268
338
  catch (e) {
269
339
  return { ok: false, error: `参数落盘失败: ${String(e?.message || e).slice(0, 160)}` };
270
340
  }
341
+ // 启动期静默: onboard 的 io 全部收进缓冲; 一旦**要人当场回答** → 先把缓冲打出来再问
342
+ const baseOnboardIO = onboardIO(io);
343
+ const wrappedOnboardIO = quiet ? {
344
+ ...baseOnboardIO,
345
+ print: (m) => P(m),
346
+ ask: async (q, o) => { flushQuiet(); return baseOnboardIO.ask(q, o); },
347
+ askHidden: async (q) => { flushQuiet(); return baseOnboardIO.askHidden(q); },
348
+ confirm: async (q, d) => { flushQuiet(); return baseOnboardIO.confirm(q, d); },
349
+ select: async (q, c) => { flushQuiet(); return baseOnboardIO.select(q, c); },
350
+ } : baseOnboardIO;
271
351
  const res = await runOnboard({
272
352
  mode,
273
- io: onboardIO(io),
353
+ io: wrappedOnboardIO,
274
354
  home,
275
355
  bolloonHome,
276
356
  targets: opts.targets,
277
357
  skipSteps: opts.skipTest ? ['connectivity'] : undefined,
278
358
  oneShot: opts.interactive === false,
279
359
  });
360
+ // 真干了活 (有步骤不是 skipped) 或没走通 → 前言必须先落屏: 那是"发生了什么/哪儿卡住"的唯一上下文
361
+ if (quiet && (!res.ok || res.steps.some((s) => s.status !== 'skipped')))
362
+ flushQuiet();
280
363
  const cfg = await readConfigFacts(bolloonHome).catch(() => null);
281
364
  return {
282
365
  ok: res.ok,
@@ -291,44 +374,563 @@ export async function runSetupWizard(opts = {}) {
291
374
  ...{ gate: res.gate, stage: res.state.stage, summary: res.summary },
292
375
  };
293
376
  }
294
- export async function formatProviderStatus() {
295
- await llmConfigStore.initialize();
296
- const cfg = await llmConfigStore.getConfig();
297
- const providers = cfg.providers;
298
- const lines = [`模型供应商 (当前: ${cfg.activeProvider})`];
299
- for (const p of RECOMMENDED_PROVIDERS) {
300
- const st = providers[p];
301
- if (!st)
377
+ /** 切换失败的机器可读分类 → 人话 (不许只回"切换成功"/"失败了") */
378
+ const FAILURE_ZH = {
379
+ invalid_provider: '供应商不存在',
380
+ invalid_model: '模型名为空',
381
+ invalid_url: 'API 地址非法',
382
+ missing_api_key: '缺少 API key',
383
+ credential_scope_conflict: '会话级切换不允许写凭证',
384
+ auth_failed: '凭证被拒 (401/403)',
385
+ provider_unreachable: '服务不可达',
386
+ model_not_found: '服务不认识这个模型',
387
+ protocol_mismatch: '协议不匹配',
388
+ invalid_temperature: 'temperature 越界 (只接受 0~2)',
389
+ timeout: '连接超时',
390
+ // 2026-09-26 (接线收口): 探测原语第 ⑥ 步确认工具调用能力时可能报这一类 —— 入口必须认它,
391
+ // 否则这类失败会在映射时被丢进别的类 (用户就看不到"是工具调用声明被端点拒了")
392
+ tool_call_unsupported: '模型/端点不接受工具调用声明 (这个模型不能用于 Agent 执行)',
393
+ // 写盘阶段的两类 (写盘失败 / 写成功了但重建运行时失败), 分开报, 不再合成一句"切换失败"
394
+ persist_failed: '写配置失败 (已回滚, 盘上仍是旧配置)',
395
+ runtime_rebuild_failed: '配置写成功但重建模型运行时失败 (已回滚)',
396
+ // 理论上不可达: 探测报了一个映射表还没覆盖的新类目 (宁可露出原文类名, 也不糊成一句"失败")
397
+ probe_failure_unmapped: '探测类目未被映射表覆盖 (按原文类名报出)',
398
+ // 2026-09-27 (目录驱动): 目录里有些家要专用鉴权 (云签名/OAuth) —— 本运行时发不出它们的请求。
399
+ // 必须与"凭证被拒"分开说: 前者要换路, 后者换 key 就行。
400
+ provider_auth_unsupported: '这家需要专用鉴权 (本版本未支持)',
401
+ };
402
+ function flagValue(parts, i, name) {
403
+ const p = parts[i];
404
+ if (p === name)
405
+ return parts[i + 1];
406
+ if (p.startsWith(`${name}=`))
407
+ return p.slice(name.length + 1);
408
+ return undefined;
409
+ }
410
+ export function parseModelCommand(arg) {
411
+ const parts = String(arg || '').trim().split(/\s+/).filter(Boolean);
412
+ const out = { action: 'status', scope: 'global', verify: true, json: false, errors: [] };
413
+ const positional = [];
414
+ for (let i = 0; i < parts.length; i++) {
415
+ const p = parts[i];
416
+ if (p === '--json') {
417
+ out.json = true;
418
+ continue;
419
+ }
420
+ if (p === '--no-verify') {
421
+ out.verify = false;
422
+ continue;
423
+ }
424
+ if (p === '--clear') {
425
+ out.clear = true;
302
426
  continue;
303
- const info = PROVIDER_INFO[p] || {};
304
- const active = p === cfg.activeProvider ? '●' : '○';
305
- const keyState = st.apiKey ? '🔑' : (st.requiresApiKey === false ? '免key' : '—');
306
- const flag = providerUsable(st) ? '' : ' (未启用)';
307
- lines.push(` ${active} ${p.padEnd(11)} ${String(info.name || '').padEnd(16)} ${keyState.padEnd(6)} model: ${st.model || '(默认)'}${flag}`);
427
+ }
428
+ // `/model catalog list --all | --usable | --family <族>` (目录驱动的筛选; 不认识的族名留给展示层如实报)
429
+ if (p === '--all') {
430
+ out.all = true;
431
+ continue;
432
+ }
433
+ if (p === '--usable' || p === '--only-usable') {
434
+ out.usableOnly = true;
435
+ continue;
436
+ }
437
+ const fam = flagValue(parts, i, '--family');
438
+ if (fam !== undefined) {
439
+ if (!p.includes('='))
440
+ i++;
441
+ out.family = String(fam).toLowerCase();
442
+ continue;
443
+ }
444
+ if (p === '--session' || p === '--scope=session') {
445
+ out.scope = 'session';
446
+ continue;
447
+ }
448
+ if (p === '--global' || p === '--scope=global') {
449
+ out.scope = 'global';
450
+ continue;
451
+ }
452
+ if (p === '--scope') {
453
+ const v = parts[i + 1];
454
+ if (v === 'session' || v === 'global') {
455
+ out.scope = v;
456
+ i++;
457
+ continue;
458
+ }
459
+ out.errors.push(`--scope 只接受 session|global, 收到 '${v ?? ''}'`);
460
+ i++;
461
+ continue;
462
+ }
463
+ const bu = flagValue(parts, i, '--base-url') ?? flagValue(parts, i, '--url');
464
+ if (bu !== undefined) {
465
+ const consumedEq = p.includes('=');
466
+ if (!consumedEq)
467
+ i++;
468
+ const shape = validateBaseUrlShape(bu);
469
+ if (!shape.ok)
470
+ out.errors.push(`--base-url 非法: ${shape.reason}`);
471
+ else
472
+ out.baseUrl = shape.url;
473
+ continue;
474
+ }
475
+ if (p.startsWith('--')) {
476
+ out.errors.push(`未知选项 ${p}`);
477
+ continue;
478
+ }
479
+ positional.push(p);
480
+ }
481
+ const sub = (positional[0] || '').toLowerCase();
482
+ if (!sub || sub === 'status') {
483
+ out.action = 'status';
484
+ return out;
485
+ }
486
+ // `/model catalog [status|list|refresh]` — 供应商目录层 (目录驱动): 看它是几号的 / 逐家列 / 真拉一次最新。
487
+ if (sub === 'catalog') {
488
+ out.action = 'catalog';
489
+ const csub = (positional[1] || '').toLowerCase();
490
+ out.catalogSub = csub === 'list' ? 'list' : csub === 'refresh' ? 'refresh' : 'status';
491
+ if (out.catalogSub === 'list') {
492
+ // 筛选词可以是第 3 段; 但要先跳过 `--family <值>` 这种"值也算一段"的写法
493
+ const rest = positional.slice(2).filter((s, i, arr) => arr[i - 1] !== '--family');
494
+ out.filter = rest[0]?.toLowerCase();
495
+ }
496
+ return out;
497
+ }
498
+ // `/model list [provider]` — 看 P5 的发现目录 (不写配置)。action 仍是 status (见 list 字段注释)。
499
+ if (sub === 'list') {
500
+ out.action = 'status';
501
+ out.list = true;
502
+ out.provider = positional[1]?.toLowerCase();
503
+ return out;
504
+ }
505
+ // `/model refresh [provider]` — 真去上游重取一次目录 (P5 的能力); `--clear` 改成清缓存。
506
+ if (sub === 'refresh' || sub === 'discover') {
507
+ out.action = 'refresh';
508
+ out.provider = positional[1]?.toLowerCase();
509
+ return out;
510
+ }
511
+ // 手输模型: 把一个上游目录里没有的模型名记进这家供应商的发现缓存 (不是"切换")。
512
+ if (sub === 'admit') {
513
+ out.action = 'admit';
514
+ out.provider = positional[1]?.toLowerCase();
515
+ out.model = positional[2];
516
+ if (!out.provider || !out.model)
517
+ out.errors.push('用法: /model admit <provider> <model>');
518
+ return out;
519
+ }
520
+ if (sub === 'pick' || sub === 'wizard') {
521
+ out.action = 'pick';
522
+ return out;
523
+ }
524
+ if (sub === 'test') {
525
+ out.action = 'test';
526
+ out.provider = positional[1]?.toLowerCase();
527
+ return out;
528
+ }
529
+ if (sub === 'reset') {
530
+ out.action = 'reset';
531
+ return out;
532
+ }
533
+ if (sub === 'key' || sub === 'auth') {
534
+ out.action = 'key';
535
+ out.provider = positional[1]?.toLowerCase();
536
+ out.apiKey = positional[2];
537
+ if (!out.provider)
538
+ out.errors.push('用法: /model key <provider>');
539
+ return out;
540
+ }
541
+ out.action = 'select';
542
+ out.provider = sub;
543
+ out.model = positional[1];
544
+ return out;
545
+ }
546
+ /**
547
+ * 供应商列表 —— 每一行就是选择器第一步看到的那种行 (`● 供应商 · N models`)。
548
+ * 数字与状态全部来自 `model-catalog`: 有真来源的给真值, 拿不到目录的写"无内置目录"
549
+ * (而不是 0 个模型), 未配置凭证的写清要哪个环境变量。
550
+ */
551
+ async function providerLines(sessionKey) {
552
+ const summaries = await buildProviderSummaries({ sessionKey });
553
+ // 2026-09-27 (目录驱动, 二改): 这里**列全量** (内置 13 + 自定义 + 目录全部), 并按第 1 步同一套
554
+ // 分组优先级排出分组标题 (带家数)。纯文本这条路没有视窗/折叠 (那是全屏选择器的事), 所以逐行列全 ——
555
+ // 门禁钉着"不许只给计数": 任何一家都要能在这份输出里被看见。
556
+ const lines = [];
557
+ const ordered = orderProvidersForMenu(summaries);
558
+ let lastGroup = '';
559
+ for (const s of ordered) {
560
+ const tier = providerTierOf(s);
561
+ const g = PROVIDER_GROUPS[tier];
562
+ if (g !== lastGroup) {
563
+ lastGroup = g;
564
+ lines.push(` ── ${g} (${ordered.filter((x) => providerTierOf(x) === tier).length} 家)`
565
+ + `${providerTierCollapsedByDefault(tier) ? ' · 全屏选择器里默认收起 (空格/→ 展开)' : ''}`);
566
+ }
567
+ lines.push(` ${formatProviderMenuRow(s)}`);
308
568
  }
309
- lines.push('用法: /model <provider> [model] 切换 · /model test [provider] 测连通 · /model status 状态');
569
+ lines.push(` ${providerGroupSummary(summaries)}`);
570
+ lines.push(...await providerCatalogLines());
571
+ return lines;
572
+ }
573
+ /**
574
+ * `/model catalog [status|list|refresh]` — 供应商目录层 (目录驱动, 2026-09-27)。
575
+ *
576
+ * 三件事, 全部输出都由 `provider-catalog.ts` 生成 (不在这里另造一套说法):
577
+ * · 默认/`status` —— 这份目录**是几号的、是不是刚刷的** (陈旧必如实标), 各族多少家;
578
+ * · `list` —— 逐家列 (**默认全量**: 连"需专用鉴权/无 api 基址"的也列并标清原因; `--usable` 才只看能用的);
579
+ * · `refresh` —— 运行期真拉一次公开源, 落盘 `${BOLLOON_HOME}/provider-catalog.json` (0600),
580
+ * 打印来源 URL / 字节数 / sha256 / 拉到时间 / 家数。
581
+ */
582
+ export async function formatProviderCatalog(parsed) {
583
+ const pc = await import('../llm/provider-catalog.js');
584
+ await pc.initializeProviderCatalog();
585
+ const sub = parsed.catalogSub || 'status';
586
+ // ── 真拉一次最新 (运行期刷新: 不只在构建期烤死那份) ──────────────
587
+ if (sub === 'refresh') {
588
+ const rep = await pc.refreshProviderCatalog(parsed.baseUrl ? { url: parsed.baseUrl } : {});
589
+ if (parsed.json)
590
+ return JSON.stringify(rep, null, 2);
591
+ const lines = [`目录刷新: ${rep.ok ? '✅ 成功' : '✗ 失败'}`];
592
+ lines.push(` 源 URL: ${rep.sourceUrl}`);
593
+ lines.push(` 字节数: ${rep.sourceBytes} · sha256: ${String(rep.sourceSha256 || '').slice(0, 16)}`);
594
+ lines.push(` 拉到时间: ${rep.fetchedAt} · 家数: ${rep.providerCount} (有 api 基址 ${rep.stats?.withApi ?? 0}) · 模型 ${rep.modelCount}`);
595
+ lines.push(` 落盘: ${rep.path || '(未落盘)'} (权限 ${rep.fileMode || '未写'})`);
596
+ if (rep.error)
597
+ lines.push(` ⚠ ${rep.error}`);
598
+ for (const n of rep.notes || [])
599
+ lines.push(` · ${n}`);
600
+ lines.push('');
601
+ lines.push(pc.catalogStatusLine());
602
+ return lines.join('\n');
603
+ }
604
+ // ── 逐家列 (**默认全部** —— 折/藏家数是这条线要修的病) ─────────────
605
+ if (sub === 'list') {
606
+ const fams = pc.CATALOG_FAMILIES;
607
+ if (parsed.family && !fams.includes(parsed.family)) {
608
+ return `未知协议族 '${parsed.family}' —— 目录只分这几族: ${fams.join(' / ')} (没编别的)`;
609
+ }
610
+ const all = pc.catalogProviders();
611
+ const envOn = (v) => !!(v.auth?.envVar && String(process.env[String(v.auth.envVar)] || '').trim());
612
+ let rows = all.filter((v) => (parsed.family ? v.family === parsed.family : true));
613
+ if (parsed.filter) {
614
+ const q = parsed.filter;
615
+ rows = rows.filter((v) => v.id.includes(q) || String(v.name || '').toLowerCase().includes(q));
616
+ }
617
+ const usable = rows.filter((v) => v.speakable);
618
+ // 默认 = 全部匹配 (含不能用但如实标了原因的); `--usable` 才是"只看能用的"
619
+ const shown = parsed.usableOnly ? usable : rows;
620
+ const lines = [
621
+ `${pc.catalogStatusLine()}`,
622
+ ` 匹配 ${rows.length} 家 (列出全部 · 目录里共 ${all.length} 家` +
623
+ ` · 真能打 ${usable.length} 家${parsed.usableOnly ? ' (--usable: 只列这些)' : ''})`
624
+ + `${parsed.filter ? ` · 筛选 '${parsed.filter}'` : ''}${parsed.family ? ` · 族 ${parsed.family}` : ''}`,
625
+ ];
626
+ if (!shown.length) {
627
+ lines.push(' (没有匹配的家 —— 换个筛选词, 或去掉 --family / --usable)');
628
+ }
629
+ // 已配置凭证的排前面 (用户最可能想用这些)
630
+ for (const v of [...shown].sort((a, b) => Number(envOn(b)) - Number(envOn(a)) || String(a.id).localeCompare(String(b.id)))) {
631
+ lines.push(` ${pc.formatCatalogProviderLine(v, { envConfigured: envOn(v) })}`);
632
+ }
633
+ lines.push('');
634
+ lines.push(` 用起来: /model <家> <模型> [--base-url <地址>] · 看模型: /model list <家> · 刷新目录: /model catalog refresh`);
635
+ lines.push(` 注意: 只有"有 api 基址 + 鉴权形状受支持 + 你配了该家声明的环境变量"的家能真发请求 —— 其余如实标未支持, 不假装可用`);
636
+ return lines.join('\n');
637
+ }
638
+ // ── 默认: 这份目录是几号的 / 新鲜度 / 分布 ──────────────────────
639
+ const load = pc.catalogLoad();
640
+ const stats = pc.catalogStats();
641
+ if (parsed.json)
642
+ return JSON.stringify({ load, stats }, null, 2);
643
+ const lines = [
644
+ pc.catalogStatusLine(),
645
+ ` ${pc.catalogGroupLine()}`,
646
+ ` 本运行时真能打的家 (有 api 基址 + 鉴权受支持): ${stats.speakable} / ${stats.providers}`
647
+ + ` · 需专用鉴权 (未支持): ${stats.specialAuth} · 无 api 基址: ${stats.noBaseUrl}`,
648
+ ` 源: ${load.provenance?.sourceUrl || pc.CATALOG_SOURCE_URL} · 来源: ${load.source === 'runtime' ? `运行期文件 ${load.path}` : '构建期烘焙数据 (离线可用)'}`,
649
+ '',
650
+ ' 用法: /model catalog list [筛选词] [--usable] [--family <族>] (默认**全量**列出, 不藏家数)',
651
+ ' /model catalog refresh [--url <地址>] (真拉最新, 落盘 0600, 记来源与时间)',
652
+ '',
653
+ ` 目录里的家直接用: /model <家> <模型> —— 只要你有该家声明的环境变量, 不用改代码就能用`,
654
+ ];
655
+ for (const w of load.warnings || [])
656
+ lines.push(` ⚠ ${w}`);
657
+ return lines.join('\n');
658
+ }
659
+ /** 供应商列表末尾的目录状态行 (status / 选择器第一步共用; 陈旧必如实标) */
660
+ export async function providerCatalogLines() {
661
+ const pc = await import('../llm/provider-catalog.js');
662
+ await pc.initializeProviderCatalog();
663
+ const load = pc.catalogLoad();
664
+ return [
665
+ '',
666
+ `${pc.catalogStatusLine()}`,
667
+ ` ${pc.catalogGroupLine()}`,
668
+ ` 看目录: /model catalog · 筛: /model catalog list <筛选词> · 拉最新: /model catalog refresh`,
669
+ ...(load.warnings || []).map((w) => ` ⚠ ${w}`),
670
+ ];
671
+ }
672
+ /**
673
+ * `/model list [provider]` — 把 P5 的发现目录打成人看的清单。
674
+ * 明细/汇总/来源中文全部复用 model-discovery 自己的格式化函数 (不在这里再写一套说法)。
675
+ */
676
+ export async function formatCatalogListing(provider, json = false) {
677
+ if (provider) {
678
+ const one = await listModelCatalog(provider);
679
+ const entry = one.entries[0];
680
+ const unavailable = one.unavailable.find((u) => u.provider === provider);
681
+ if (json)
682
+ return JSON.stringify({ ok: !unavailable, listing: one, entry: entry || null }, null, 2);
683
+ const lines = [`${provider} 的模型目录:`];
684
+ if (entry) {
685
+ lines.push(` ${formatCatalogLine(entry)}`);
686
+ for (const m of entry.models)
687
+ lines.push(` · ${m}${entry.modelOrigins?.[m] ? ` (${entry.modelOrigins[m]})` : ''}`);
688
+ if (!entry.models.length)
689
+ lines.push(' (目录里一个模型都没有 — 用 /model admit 手输, 或 /model refresh 重取)');
690
+ }
691
+ else {
692
+ lines.push(` ⚠ 这家这一轮拿不到目录${unavailable?.failureClass ? ` (${unavailable.failureClass})` : ''}: ${String(unavailable?.reason ?? '未发现').slice(0, 160)}`);
693
+ }
694
+ for (const n of one.notes || [])
695
+ lines.push(` · ${n}`);
696
+ return lines.join('\n');
697
+ }
698
+ const listing = await listModelCatalog();
699
+ if (json)
700
+ return JSON.stringify({ ok: listing.unavailable.length === 0, listing }, null, 2);
701
+ const lines = [...formatListingSummary(listing)];
702
+ for (const c of listing.entries)
703
+ lines.push(` ${formatCatalogLine(c)}`);
704
+ lines.push(' 细节: /model list <provider> · 重取: /model refresh [provider] · 清缓存: /model refresh --clear');
705
+ return lines.join('\n');
706
+ }
707
+ /**
708
+ * `/model status` — 一律先给**当前真实生效**的那一份 (provider/model/base URL/协议/凭证来源/作用域),
709
+ * 再给候选列表。用户问"现在到底在用哪个"必须能一眼答上。
710
+ */
711
+ export async function formatProviderStatus(sessionKey) {
712
+ const eff = await effectiveModelConfig({ sessionKey });
713
+ const key = sessionKey || currentSessionKey();
714
+ const lines = [
715
+ `当前生效: ${eff.provider}/${eff.model}`,
716
+ ` ${formatEffectiveModel(eff)}`,
717
+ ` 会话键: ${key}`,
718
+ '',
719
+ '供应商 (**全部**列出 · 分组标题带家数 · ● 可用 / ○ 未配置; 一屏放得下的交互版走 `/model pick`):',
720
+ ];
721
+ lines.push(...await providerLines(sessionKey));
722
+ lines.push('');
723
+ lines.push('用法: /model pick 分步选择 (供应商→凭证→模型→参数→作用域→测试→确认)');
724
+ lines.push(' /model <provider> [model] [--base-url <url>] [--session] · /model test [provider] · /model status · /model reset · /model key <provider>');
725
+ lines.push(' /model list [provider] 看模型发现目录 · /model refresh [provider] 重取 · /model refresh --clear 清缓存 · /model admit <provider> <model> 手输模型');
726
+ lines.push(' /model catalog 看供应商目录 (是几号的/新鲜度) · /model catalog list [筛选词] [--usable] · /model catalog refresh 拉最新');
727
+ lines.push(' --session 只影响当前会话 (不动全局默认) · --no-verify 跳过切换前连通探测 (不推荐)');
310
728
  return lines.join('\n');
311
729
  }
730
+ /**
731
+ * 分步选择器的薄包装: 把界面给的选择/输入能力交给它, 结果整理成可打印文本。
732
+ * 这里**不看配置、不写配置** —— 全部动作都在选择器内部走到 `selectModel()` 那一个点。
733
+ */
734
+ export async function runModelPicker(io = {}, opts = {}) {
735
+ const collected = [];
736
+ // 交互式 (live): 每行**当场**打到终端, 同时仍收进 `collected` 作为完整回执。
737
+ // 非交互: 只收进 `collected`, 由调用方决定怎么呈现 (会话内 Ink / 验收脚本读整段)。
738
+ const liveSink = io.live ? (io.print ?? ((l) => process.stdout.write(l + '\n'))) : null;
739
+ const res = await runModelSelector({
740
+ print: (l) => { collected.push(l); liveSink?.(l); },
741
+ ...(io.ask ? { ask: io.ask } : {}),
742
+ ...(io.askHidden ? { askHidden: io.askHidden } : {}),
743
+ ...(io.choose ? { choose: io.choose } : {}),
744
+ ...(io.filterable ? { filterable: true } : {}),
745
+ // 七步主屏也走 bolloon 色系 (真终端才上; `lines` 里仍是原文)
746
+ ...(io.color ? { color: true } : {}),
747
+ }, { ...opts, ...(io.verbose ? { verbose: true } : {}) });
748
+ const tailLines = res.ok
749
+ ? [`✅ 当前生效: ${formatEffectiveModel(res.effective)}`]
750
+ : [res.cancelled ? `· ${res.message}` : `✗ 切换未完成${res.failureClass ? ` [${FAILURE_ZH[res.failureClass]}]` : ''}: ${res.message}`];
751
+ // 已经当场印过的部分不再回放 (live 时只回执尾行); 非 live 时整段文本原样返回。
752
+ return {
753
+ text: [...collected, ...tailLines].join('\n'),
754
+ tail: tailLines.join('\n'),
755
+ live: !!liveSink,
756
+ result: res,
757
+ };
758
+ }
759
+ /**
760
+ * 真终端下的模型选择器 IO: **全屏光标选择器 + 掩码隐藏输入** (2026-09-27)。
761
+ *
762
+ * 为什么单列一个工厂: 交互能力必须**同一份**递给所有步骤 (供应商/凭证/模型/参数/作用域/确认都用
763
+ * 同一个 `tuiSelect`), 否则就会出现"某一步退化成文本问答"。
764
+ *
765
+ * 能力/降级说得明明白白:
766
+ * · `tuiCapable()` 为假 (非 TTY / 终端太矮 / `BOLLOON_NO_TUI=1`) → 调用方**不要**用这个工厂,
767
+ * 退回逐行问答 (管道输出仍可读, 一个字节不变);
768
+ * · 拿不到 raw mode 时掩码输入**如实降级**成 readline 隐藏输入 (同样不回显明文), 绝不静默明文回显;
769
+ * · 颜色按 `NO_COLOR` / `TERM=dumb` / isTTY 三判据关掉 —— 但符号 (`●`/`○`/`← 当前`/`special`) 永远在,
770
+ * 无色终端一样分得清。
771
+ */
772
+ export function modelTtyIO() {
773
+ return {
774
+ live: true,
775
+ // ★ 七步主屏也走 bolloon 色系 (leo 2026-09-27: "切换时的颜色变成 bolloon 色系, 不是灰白") ——
776
+ // 判据只有一处 (`theme.ts`: 真终端 + 没设 NO_COLOR + TERM != dumb)。
777
+ color: colorEnabled(!!process.stdout.isTTY),
778
+ print: (l) => { try {
779
+ process.stdout.write(l + '\n');
780
+ }
781
+ catch { /* 管道关了就安静 */ } },
782
+ choose: async (items, title) => {
783
+ const tui = await import('./tui-select.js');
784
+ const choices = items.map((c) => ({
785
+ value: c.value,
786
+ label: c.label,
787
+ ...(c.hint ? { hint: c.hint } : {}),
788
+ ...(c.group ? { group: c.group } : {}),
789
+ // 分组"默认收起"的标记要原样带过去 (候选集不受影响, 只是排版: 标题照写家数)
790
+ ...(c.groupCollapsed ? { groupCollapsed: true } : {}),
791
+ ...(c.tone ? { tone: c.tone } : {}),
792
+ // 末行"取消 / 手输"与其它步骤同一形态: 把它当取消行, `Esc` 与它同义
793
+ ...(/^取消/.test(c.label) ? { cancel: true } : {}),
794
+ }));
795
+ // 供应商那一步的单位是"家", 其余是"项" (计数行读起来才像话)
796
+ const unit = /供应商/.test(title) ? '家' : '项';
797
+ return await tui.tuiSelect(choices, title, { unit });
798
+ },
799
+ filterable: true,
800
+ askHidden: async (q) => {
801
+ const tui = await import('./tui-select.js');
802
+ if (tui.tuiCapable()) {
803
+ const r = await tui.tuiAskMasked({ prompt: q.replace(/\(.*?\)\s*$/, '').trim() || q });
804
+ return r.eof ? null : r.value;
805
+ }
806
+ // 拿不到 raw mode: 如实降级到 readline 隐藏输入 (仍然不回显明文, 且明确告知)
807
+ process.stdout.write('· 本终端不支持全屏掩码输入 → 降级为隐藏输入 (同样不回显明文)\n');
808
+ const r = await askHiddenLineEof(q);
809
+ return r.eof ? null : r.value;
810
+ },
811
+ ask: async (q, opts) => { const r = await askLineEof(q, opts); return r.eof ? null : r.value; },
812
+ };
813
+ }
312
814
  /**
313
815
  * `/model` / `bolloon model` 命令实现。
314
- * 参数为空 = 状态; `<provider> [model]` = 切换; `test [provider]` = 测试; `key <provider>` = 设 key。
816
+ *
817
+ * 切换类动作**全部**走统一入口 `selectModel()` —— 这里只做参数解析与结果展示,
818
+ * 不自己写配置、不自己重建运行时 (否则又会分叉出第二条路)。
819
+ *
820
+ * 返回**要打印的文本**。⚠️ `io.live === true` (真终端交互) 时返回的**只有尾行** ——
821
+ * 分步选择器的每一步已经当场打给用户了, 调用方别再回放整段 (会刷两遍)。
822
+ * `io.live !== true` 时返回完整文本 (会话内 Ink / 验收脚本 / 管道都要这一份)。
315
823
  */
316
824
  export async function runModelCommand(arg, io = {}) {
317
- const parts = String(arg || '').trim().split(/\s+/).filter(Boolean);
318
- await llmConfigStore.initialize();
319
- if (parts.length === 0 || parts[0] === 'status' || parts[0] === 'list') {
825
+ const parsed = parseModelCommand(arg);
826
+ if (parsed.errors.length) {
827
+ return [`参数有问题, 未改动任何配置:`, ...parsed.errors.map((e) => ` · ${e}`)].join('\n');
828
+ }
829
+ // ── 分步选择器 (显式 `pick`, 或空参 + 界面能给结构化选择) ───
830
+ // 两种入口都只是"把界面能力递给选择器"; 写配置/重建运行时仍只在 selectModel 一处。
831
+ if (parsed.action === 'pick' || (parsed.action === 'status' && !String(arg || '').trim() && !!io.choose)) {
832
+ if (!io.choose && !io.ask) {
833
+ return [
834
+ '分步选择需要交互能力 (当前环境既没有选择器也没有文本输入)。',
835
+ ' 在系统终端跑: bolloon model pick',
836
+ ` 或直接一条命令切: /model <provider> [model] [--base-url <url>]`,
837
+ ].join('\n');
838
+ }
839
+ const picked = await runModelPicker(io, { skipProbe: !parsed.verify, verify: parsed.verify });
840
+ // live: 正文已当场印过 → 只回执尾行; 非 live: 整段文本原样交出
841
+ return picked.live ? picked.tail : picked.text;
842
+ }
843
+ // ── 供应商目录 (目录驱动: 不写配置; refresh 是显式拉源) ──────
844
+ if (parsed.action === 'catalog')
845
+ return formatProviderCatalog(parsed);
846
+ // ── 状态 ─────────────────────────────────────────────────
847
+ if (parsed.action === 'status') {
848
+ // `/model list [provider]` — P5 的发现目录 (只读, 不写配置)
849
+ if (parsed.list)
850
+ return formatCatalogListing(parsed.provider, parsed.json);
851
+ const eff = await effectiveModelConfig({});
852
+ if (parsed.json)
853
+ return JSON.stringify({ ok: true, effective: eff }, null, 2);
320
854
  return formatProviderStatus();
321
855
  }
322
- const sub = parts[0].toLowerCase();
323
- // key <provider> [key]
324
- if (sub === 'key' || sub === 'auth') {
325
- const provider = (parts[1] || '').toLowerCase();
326
- if (!provider)
327
- return '用法: /model key <provider> (随后会提示粘贴 API key, 输入不回显)';
856
+ // ── 发现目录: 重取 / 清缓存 (P5 的能力挂到命令面) ───────────
857
+ if (parsed.action === 'refresh') {
858
+ if (parsed.clear) {
859
+ const cleared = await clearDiscoveryCache(parsed.provider || undefined);
860
+ if (parsed.json)
861
+ return JSON.stringify({ ok: true, action: 'clear', provider: parsed.provider || null, cleared }, null, 2);
862
+ return `✅ 已清掉发现缓存${parsed.provider ? ` (${parsed.provider})` : ' (全部)'} — 清了 ${cleared} 条; 下次需要时再真取。`;
863
+ }
864
+ const r = await refreshModelDiscovery(parsed.provider || undefined, { force: true });
865
+ if (parsed.json)
866
+ return JSON.stringify({ ok: r.failures.length === 0, ...r }, null, 2);
867
+ const lines = [`模型目录已刷新 (${r.results.length} 家, 失败 ${r.failures.length} 家 · ${r.refreshedAt})`];
868
+ for (const c of r.results)
869
+ lines.push(` ${formatCatalogLine(c)}`);
870
+ for (const f of r.failures)
871
+ lines.push(` ⚠ ${f.provider} · ${f.failureClass}: ${String(f.reason).slice(0, 160)}`);
872
+ for (const n of r.notes || [])
873
+ lines.push(` · ${n}`);
874
+ lines.push(' 用 /model list [provider] 看明细');
875
+ return lines.join('\n');
876
+ }
877
+ // ── 手输模型: 记进这一家的发现缓存 (不是"切换"; 切换仍只走 selectModel) ──
878
+ if (parsed.action === 'admit') {
879
+ const provider = parsed.provider;
880
+ const r = await admitManualModel(provider, parsed.model);
881
+ if (parsed.json)
882
+ return JSON.stringify(r, null, 2);
883
+ if (!r.ok)
884
+ return `✗ ${provider} 手工模型没记上: ${r.reason}`;
885
+ return [
886
+ `✅ 已把 ${provider}/${parsed.model} 记进发现缓存 (手输)`,
887
+ ` ${formatCatalogLine(r.catalog)}`,
888
+ ` 切成当前模型: /model ${provider} ${parsed.model}`,
889
+ ].join('\n');
890
+ }
891
+ // ── 重置 (清会话级绑定, 回到全局那一份) ────────────────────
892
+ if (parsed.action === 'reset') {
893
+ const r = await resetModelSelection();
894
+ if (parsed.json)
895
+ return JSON.stringify(r, null, 2);
896
+ if (!r.ok)
897
+ return `⚠ 重置失败: ${r.message}`;
898
+ return [
899
+ r.previous?.source === 'session' ? '✅ 已清掉当前会话的模型绑定, 回到全局默认' : '当前会话本来就没有绑定 (全局默认不变)',
900
+ `当前生效: ${formatEffectiveModel(r.effective)}`,
901
+ ].join('\n');
902
+ }
903
+ // ── 连通测试 ─────────────────────────────────────────────
904
+ if (parsed.action === 'test') {
905
+ const eff = await effectiveModelConfig({});
906
+ const provider = (parsed.provider || eff.provider).toLowerCase();
907
+ const target = provider === eff.provider
908
+ ? { provider, model: eff.model, baseUrl: eff.baseUrl, apiKey: undefined }
909
+ : await (async () => {
910
+ const p = await llmConfigStore.getProvider(provider);
911
+ if (!p)
912
+ return null;
913
+ return { provider, model: p.model, baseUrl: normalizeBaseUrl(p.baseUrl), apiKey: p.apiKey };
914
+ })();
915
+ if (!target)
916
+ return `未知供应商 '${provider}'`;
917
+ const started = Date.now();
918
+ const probe = await probeSelection(target);
919
+ const ms = Date.now() - started;
920
+ if (parsed.json)
921
+ return JSON.stringify({ ok: probe.ok, provider, model: target.model, baseUrl: target.baseUrl, failureClass: probe.failureClass, detail: probe.detail, ms }, null, 2);
922
+ return probe.ok
923
+ ? `✅ ${provider}/${target.model} 连通 (${ms} ms) — ${probe.detail}`
924
+ : `⚠ ${provider}/${target.model} 测试失败 [${probe.failureClass ? FAILURE_ZH[probe.failureClass] : '未知'}]: ${probe.detail}`;
925
+ }
926
+ // ── 设 key (然后立刻切到该 provider) ────────────────────────
927
+ if (parsed.action === 'key') {
928
+ const provider = parsed.provider;
328
929
  const cfg = await llmConfigStore.getConfig();
329
- if (!cfg.providers[provider])
930
+ if (!cfg.providers[provider]) {
330
931
  return `未知供应商 '${provider}'. 可用: ${Object.keys(cfg.providers).join(', ')}`;
331
- let key = parts[2] || '';
932
+ }
933
+ let key = parsed.apiKey || '';
332
934
  if (!key) {
333
935
  if (!io.askHidden) {
334
936
  return [
@@ -337,36 +939,51 @@ export async function runModelCommand(arg, io = {}) {
337
939
  `或打开 Web 配置页 (bolloon --web) 填 key。`,
338
940
  ].join('\n');
339
941
  }
340
- key = await io.askHidden(`粘贴 ${provider} API key`);
942
+ const got = await io.askHidden(`粘贴 ${provider} API key`);
943
+ if (got === null)
944
+ return '输入已结束 (EOF/Ctrl-D) —— 未输入 key, 未改动配置';
945
+ key = got;
341
946
  }
342
947
  if (!key)
343
948
  return '未输入 key, 未改动配置';
344
- await llmConfigStore.updateProvider(provider, { enabled: true, apiKey: key });
345
- await llmConfigStore.setActiveProvider(provider);
949
+ const r = await selectModel({ provider, apiKey: key, scope: 'global' });
346
950
  const tail = key.slice(-4);
347
- return `✅ 已配置并启用 ${provider} (key 尾号 ****${tail}, 已写入 ~/.bolloon/bolloon-config.json, 不会进 git)`;
951
+ if (!r.ok) {
952
+ // 实情: 统一入口先校验探测再写盘, 所以失败(=探测没过/校验没过)时 **key 与切换都没落盘**,
953
+ // 旧配置与运行时原样。这里不许写成"key 已保存但没切过去" —— 那是另一种状态, 而且不是真的。
954
+ return [
955
+ `⚠ ${provider} 没有配置成功 [${r.failureClass ? FAILURE_ZH[r.failureClass] : '未知'}]: ${r.message}`,
956
+ ` key (尾号 ****${tail}) 与这次切换**都没有落盘**, 旧配置与运行时保持原样。`,
957
+ r.previous ? `当前仍在用: ${formatEffectiveModel(r.previous)}` : '',
958
+ ].filter(Boolean).join('\n');
959
+ }
960
+ return [
961
+ `✅ 已配置并启用 ${provider} (key 尾号 ****${tail}, 写入 ~/.bolloon/bolloon-config.json, 不会进 git)`,
962
+ `当前生效: ${formatEffectiveModel(r.effective)}`,
963
+ ].join('\n');
348
964
  }
349
- // test [provider]
350
- if (sub === 'test') {
351
- const provider = (parts[1] || (await llmConfigStore.getActiveProvider())).toLowerCase();
352
- const r = await llmConfigStore.testProvider(provider);
353
- return r.success
354
- ? `✅ ${provider} 连通 (${r.latency ?? '?'} ms)`
355
- : `⚠ ${provider} 测试失败: ${String(r.error || '').slice(0, 300)}`;
356
- }
357
- // <provider> [model]
358
- const provider = sub;
359
- const cfg = await llmConfigStore.getConfig();
360
- const st = cfg.providers[provider];
361
- if (!st)
362
- return `未知供应商 '${provider}'. 可用: ${Object.keys(cfg.providers).join(', ')}`;
363
- if (st.requiresApiKey !== false && !st.apiKey) {
364
- return `${provider} 还没有 API key — 用 /model key ${provider} 配置 (或 bolloon setup 走向导)`;
365
- }
366
- const patch = { enabled: true };
367
- if (parts[1])
368
- patch.model = parts[1];
369
- await llmConfigStore.updateProvider(provider, patch);
370
- await llmConfigStore.setActiveProvider(provider);
371
- return `✅ 已切换到 ${provider}${parts[1] ? ` (model=${parts[1]})` : ''}`;
965
+ // ── 切换供应商 / 模型 / URL ────────────────────────────────
966
+ const provider = parsed.provider;
967
+ const r = await selectModel({
968
+ provider,
969
+ model: parsed.model,
970
+ baseUrl: parsed.baseUrl,
971
+ scope: parsed.scope,
972
+ verify: parsed.verify,
973
+ });
974
+ if (parsed.json)
975
+ return JSON.stringify(r, null, 2);
976
+ if (!r.ok) {
977
+ return [
978
+ `✗ 切换失败 [${r.failureClass ? FAILURE_ZH[r.failureClass] : '未知'}]: ${r.message}`,
979
+ ...(r.checks || []).map((c) => ` ${c}`),
980
+ r.previous ? `配置与运行时均保持原样, 仍在用: ${formatEffectiveModel(r.previous)}` : '',
981
+ ].filter(Boolean).join('\n');
982
+ }
983
+ const scopeZh = parsed.scope === 'session' ? '仅当前会话' : '全局默认 (新会话生效)';
984
+ return [
985
+ `✅ 已切换到 ${provider}${parsed.model ? ` (model=${parsed.model})` : ''} — ${scopeZh}`,
986
+ ...(r.checks || []).map((c) => ` ${c}`),
987
+ `当前生效: ${formatEffectiveModel(r.effective)}`,
988
+ ].join('\n');
372
989
  }