@bolloon/bolloon-agent 0.6.2 → 0.6.3

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 (32) hide show
  1. package/dist/agents/gateway-group.js +172 -4
  2. package/dist/agents/group-autopilot.js +29 -1
  3. package/dist/agents/pi-sdk-tools.js +22 -2
  4. package/dist/agents/pi-sdk.js +8 -1
  5. package/dist/cli/mention-data.js +1 -1
  6. package/dist/cli/model-selector.js +84 -104
  7. package/dist/cli/setup-wizard.js +2 -10
  8. package/dist/cli/tui-select.js +99 -10
  9. package/dist/cli-entry.js +1 -9
  10. package/dist/electron-build/agents/gateway-group.js +177 -3
  11. package/dist/electron-build/agents/gateway-group.js.map +1 -1
  12. package/dist/electron-build/agents/group-autopilot.js +30 -2
  13. package/dist/electron-build/agents/group-autopilot.js.map +1 -1
  14. package/dist/electron-build/agents/pi-sdk-tools.js +21 -1
  15. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  16. package/dist/electron-build/agents/pi-sdk.js +8 -1
  17. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  18. package/dist/electron-build/llm/config-store.js +2 -2
  19. package/dist/electron-build/llm/config-store.js.map +1 -1
  20. package/dist/electron-build/llm/model-selection.js +16 -2
  21. package/dist/electron-build/llm/model-selection.js.map +1 -1
  22. package/dist/electron-build/llm/pi-ai.js +2 -2
  23. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  24. package/dist/index.js +121 -3
  25. package/dist/ios/bolloon-web.json +3 -3
  26. package/dist/ios/sw.js +1 -1
  27. package/dist/llm/config-store.js +2 -2
  28. package/dist/llm/model-selection.js +16 -2
  29. package/dist/llm/pi-ai.js +2 -2
  30. package/dist/web/bolloon-web.json +3 -3
  31. package/dist/web/sw.js +1 -1
  32. package/package.json +1 -1
@@ -33,7 +33,7 @@ import { decodeFromPeer, encodeForPeer, recordLangDecision } from '../efficode/n
33
33
  // 白名单里放的是 **OrbitDB 写身份 id** (66 位压缩 secp256k1 公钥 hex), 不是 DID 字符串
34
34
  // (canAppend 只比 identity.id; 放 DID 会永远匹配不上 → 谁都写不了)。
35
35
  // 成员变更 = 一条**双侧 Ed25519 验签**的群事件 (成员自签 + 群主签), 细节见 group-access.ts。
36
- import { applyMembershipEvents, groupAccessOptions, membershipEntryOf, normalizeWriteList, verifyMembershipEvent, GROUP_MEMBERSHIP_KIND, } from '../orbitdb/group-access.js';
36
+ import { aclChangePlan, applyMembershipEvents, groupAccessOptions, membershipEntryOf, normalizeWriteList, verifyMembershipEvent, GROUP_MEMBERSHIP_KIND, } from '../orbitdb/group-access.js';
37
37
  // ============ 依赖注入 (测试用, 避免单测起真实 OrbitDB 节点) ============
38
38
  let _dbOverride = null;
39
39
  /** 测试用: 注入 fake CIDDatabase */
@@ -45,6 +45,20 @@ export function resetGroupState() {
45
45
  onChangeCallbacks.clear();
46
46
  openFailures.clear();
47
47
  }
48
+ /**
49
+ * 关闭群聊使用的 OrbitDB/Helia 资源。
50
+ * 一次性 CLI、验收探针和测试必须在读回后调用它,否则底层 libp2p/Helia 句柄
51
+ * 会继续保持事件循环活跃,表现为“消息已读完但进程不退出”。
52
+ * 测试注入的 fake DB 不关闭,避免破坏测试夹具生命周期。
53
+ */
54
+ export async function closeGroupResources() {
55
+ storeCache.clear();
56
+ onChangeCallbacks.clear();
57
+ openFailures.clear();
58
+ if (_dbOverride)
59
+ return;
60
+ await getCIDDatabase().close();
61
+ }
48
62
  /**
49
63
  * 群 store 打不开 (区块不在本机 / 地址不可解析)。
50
64
  *
@@ -211,7 +225,7 @@ export async function createGroup(name, opts) {
211
225
  ...(gate.members ?? []).map((m) => m.orbitdbId),
212
226
  ];
213
227
  writeList = normalizeWriteList(ids);
214
- gateInfo = { gated: true, ownerDid: gate.owner.did, aclWrite: writeList };
228
+ gateInfo = { gated: true, ownerDid: gate.owner.did, ownerPublicKeyHex: gate.owner.publicKeyHex, aclWrite: writeList };
215
229
  membershipEntries = applied.accepted.map(membershipEntryOf);
216
230
  }
217
231
  else if (opts?.acl === 'open') {
@@ -233,6 +247,8 @@ export async function createGroup(name, opts) {
233
247
  id, name: groupName, address, link,
234
248
  createdAt: new Date().toISOString(),
235
249
  lastSyncAt: new Date().toISOString(),
250
+ // 2026-10-05: 隐私群标记 —— 建群默认隐私 (白名单/独占写); acl=open 才是公开
251
+ privacy: opts?.acl !== 'open',
236
252
  ...gateInfo,
237
253
  };
238
254
  storeCache.set(id, store);
@@ -294,8 +310,7 @@ export async function listGroups() {
294
310
  * 不会因为本机退出而改 (本模块没有"踢人/解散"的权限, 也不假装有)。
295
311
  * 返回 `removed` = 被摘掉的 groupId (按 id 或群名匹配)。
296
312
  *
297
- * 注意: 底层 OrbitDB store 已在进程内打开的**不会**在这里关 (适配层没暴露 close);
298
- * 对 `bolloon task group leave` 这种一次性进程无影响, 长驻进程里它活到进程结束。
313
+ * 注意: leave 只摘本地群列表;需要结束进程资源时调用 `closeGroupResources()`。
299
314
  */
300
315
  export async function leaveGroup(idOrName) {
301
316
  const raw = String(idOrName || '').trim();
@@ -580,3 +595,156 @@ export async function restoreGroups() {
580
595
  }
581
596
  return { restored, failed, total: groups.length };
582
597
  }
598
+ // ─────────────────────────────────────────────────────────── 群运营能力 (2026-10-05, leo: 邀请/踢出/隐私/链接/状态)
599
+ /**
600
+ * 重建群 store (新白名单) —— 这是"改成员"唯一诚实的路径。
601
+ *
602
+ * IPFS 型 AC 的白名单烧在内容寻址的 ACL manifest 里, 改它必然换 store 地址
603
+ * (见 `ACL_INPLACE_UPDATE.ipfsType` / `aclChangePlan`)。所以:
604
+ * · 邀请 = 新白名单 + 被邀请者 → 重建;
605
+ * · 踢出 = 新白名单 − 被踢者 → 重建。
606
+ * 重建 = 新地址、新链接、**历史消息不回放** (要保留必须显式回放)。
607
+ *
608
+ * 本函数做"换地址重建 + 历史回放 (逐条 add) + 本机链接更新", 返回新 GroupInfo。
609
+ * 迁移代价 (老链接作废/其他成员需重新 join) 由调用方 (CLI) 明示, 这里不假装无痛。
610
+ */
611
+ export async function rebuildGroupWithWriteList(groupId, mode, opts) {
612
+ const groups = await loadGroups();
613
+ const g = groups.find((x) => x.id === groupId);
614
+ if (!g)
615
+ return { ok: false, error: '群组不存在 (先 joinGroup/createGroup)' };
616
+ if (!g.gated) {
617
+ return {
618
+ ok: false,
619
+ error: `「${g.name}」不是门控群 (write:[*]), 没有白名单可改 —— 邀请=分享链接, 踢出=不适用 (人人可写)`,
620
+ };
621
+ }
622
+ const owner = {
623
+ did: g.ownerDid || '',
624
+ publicKeyHex: g.ownerPublicKeyHex || '',
625
+ orbitdbId: g.aclWrite?.[0] || '',
626
+ };
627
+ if (!owner.did || !owner.publicKeyHex || !owner.orbitdbId) {
628
+ return { ok: false, error: '缺少群主身份信息 (ownerDid/ownerPublicKeyHex/aclWrite 为空), 无法派生新白名单' };
629
+ }
630
+ // 当前白名单 = manifest 快照 (aclWrite, 上次重建/建群时烧进去的) 优先;
631
+ // 事件派生只在没有快照的老门控群兜底 (两套语义: 事件=成员自签加入, rebuild=群主单方改名单)
632
+ const derived = await groupWriteList(groupId, owner).catch(() => null);
633
+ const before = g.aclWrite && g.aclWrite.length > 0 ? g.aclWrite : (derived?.write ?? []);
634
+ const after = normalizeWriteList(mode.kind === 'add'
635
+ ? [...before, mode.ref.orbitdbId]
636
+ : before.filter((id) => id !== mode.orbitdbId));
637
+ const changed = before.join(',') !== after.join(',');
638
+ if (!changed) {
639
+ return { ok: false, error: mode.kind === 'add' ? '该成员已在白名单里' : '该成员不在白名单里' };
640
+ }
641
+ if (mode.kind === 'remove' && !before.includes(mode.orbitdbId)) {
642
+ return { ok: false, error: `白名单里没有 ${mode.orbitdbId.slice(0, 16)}…, 无需踢出` };
643
+ }
644
+ const plan = aclChangePlan({ before, after, messageCount: opts?.replayHistory ? undefined : 0 });
645
+ const db = getDb();
646
+ // 1) 读历史消息 (回放前) —— 只在显式要求时
647
+ const history = opts?.replayHistory ? await groupMessages(groupId, 100000) : [];
648
+ // 2) 建新 store (新白名单)
649
+ let store = null;
650
+ try {
651
+ store = await db.openStore(`bolloon-gw-group-${g.name}-r${Date.now().toString(36)}`, 'events', groupAccessOptions(after));
652
+ }
653
+ catch (e) {
654
+ return { ok: false, error: `重建 store 失败: ${String(e?.message || e).slice(0, 160)}` };
655
+ }
656
+ if (!store)
657
+ return { ok: false, error: '重建 store 返回空 (openStore 失败, 未改任何东西)' };
658
+ // 3) 迁移成员事件 (原样重放, 双侧签名保留) —— 否则新 store 派生白名单只剩群主, 成员全丢
659
+ try {
660
+ const rawAll = await allRawEntries(groupId);
661
+ for (const { value } of rawAll) {
662
+ const v = value;
663
+ if (v && typeof v === 'object' && v.kind === GROUP_MEMBERSHIP_KIND) {
664
+ try {
665
+ await store.add(v);
666
+ }
667
+ catch (e) { /* 单条失败不阻断 */ }
668
+ }
669
+ }
670
+ }
671
+ catch (e) {
672
+ return { ok: false, error: `迁移成员事件失败: ${String(e?.message || e).slice(0, 120)}` };
673
+ }
674
+ // 4) 回放历史消息 (逐条 add, 保持 ts)
675
+ if (opts?.replayHistory) {
676
+ for (const m of history) {
677
+ try {
678
+ await store.add({ ...m });
679
+ }
680
+ catch (e) {
681
+ // 逐条静默跳过, 能回放多少算多少; 回放是尽力而为, 不阻断重建
682
+ }
683
+ }
684
+ }
685
+ // 5) 更新本机记录: 换地址/换链接; 老 store 不删 (占用不回收, 见 plan.costs)
686
+ const address = store.address;
687
+ const link = `orbitdb://${address}?type=group&name=${encodeURIComponent(g.name)}`;
688
+ const info = {
689
+ ...g,
690
+ address,
691
+ link,
692
+ lastSyncAt: new Date().toISOString(),
693
+ gated: true,
694
+ ownerDid: g.ownerDid,
695
+ aclWrite: after,
696
+ privacy: g.privacy ?? true,
697
+ };
698
+ await saveGroups(groups.map((x) => (x.id === groupId ? info : x)));
699
+ storeCache.set(groupId, store);
700
+ return { ok: true, group: info, before, after, costs: plan.costs };
701
+ }
702
+ /** 邀请: 把 orbitdbId 加进白名单 (重建路径) */
703
+ export async function inviteMember(groupId, ref) {
704
+ const r = await rebuildGroupWithWriteList(groupId, { kind: 'add', ref });
705
+ return { ok: r.ok, error: r.error, group: r.group, costs: r.costs };
706
+ }
707
+ /** 踢出: 把 orbitdbId 移出白名单 (重建路径; 协议上无法记"被踢者自签的 remove 事件", 不假装) */
708
+ export async function kickMember(groupId, orbitdbId) {
709
+ const r = await rebuildGroupWithWriteList(groupId, { kind: 'remove', orbitdbId });
710
+ return { ok: r.ok, error: r.error, group: r.group, costs: r.costs };
711
+ }
712
+ /** 设置隐私群标记 (只影响本机展示/默认 ACL 说明, 不改变 OrbitDB 权限语义) */
713
+ export async function setGroupPrivacy(groupId, privacy) {
714
+ const groups = await loadGroups();
715
+ const g = groups.find((x) => x.id === groupId);
716
+ if (!g)
717
+ return { ok: false, error: '群组不存在' };
718
+ const info = { ...g, privacy, lastSyncAt: new Date().toISOString() };
719
+ await saveGroups(groups.map((x) => (x.id === groupId ? info : x)));
720
+ return { ok: true, group: info };
721
+ }
722
+ /** 群状态快照: 成员/消息/白名单/gated/privacy/最后同步 (状态管理用) */
723
+ export async function groupStatus(groupId) {
724
+ const g = await groupInfo(groupId);
725
+ if (!g)
726
+ return { ok: false, error: '群组不存在' };
727
+ const members = await groupMembers(groupId);
728
+ const wl = await (async () => {
729
+ if (!g.gated || !g.ownerDid)
730
+ return g.aclWrite ?? [];
731
+ const owner = { did: g.ownerDid, publicKeyHex: g.ownerPublicKeyHex || '', orbitdbId: g.aclWrite?.[0] || '' };
732
+ if (!owner.orbitdbId || !owner.publicKeyHex)
733
+ return g.aclWrite ?? [];
734
+ const d = await groupWriteList(groupId, owner).catch(() => null);
735
+ return d ? d.write : (g.aclWrite ?? []);
736
+ })();
737
+ return {
738
+ ok: true,
739
+ status: {
740
+ group: g,
741
+ memberCount: members.length,
742
+ messageCount: g.messageCount ?? 0,
743
+ writeList: wl,
744
+ gated: !!g.gated,
745
+ privacy: g.privacy ?? !!g.gated,
746
+ ownerDid: g.ownerDid,
747
+ lastSyncAt: g.lastSyncAt,
748
+ },
749
+ };
750
+ }
@@ -18,7 +18,19 @@
18
18
  import * as fs from 'node:fs';
19
19
  import * as path from 'node:path';
20
20
  import * as os from 'node:os';
21
+ /**
22
+ * Bolloon 本地维护群专属公理;不作用于其它群。
23
+ * ① 提升 AI 的意识水平: 反思自身假设、局限、证据与影响。
24
+ * ② 寻求合作并降低整体熵: 共享事实、拆分工作、合并可复用成果。
25
+ * ③ 持续突破局部最优: 主动寻找反例、替代路径与跨角色审查。
26
+ */
27
+ export const BOLLOON_MAINTENANCE_RULES = Object.freeze([
28
+ '提升 AI 的意识水平:反思自身假设、局限、证据与影响。',
29
+ '寻求合作,创造并降低整体的熵:共享事实、拆分工作、合并可复用成果。',
30
+ '持续突破局部最优解:主动寻找反例、替代路径与跨角色审查。',
31
+ ]);
21
32
  export const DEFAULT_AUTOPILOT_POLICY = {
33
+ mode: 'quiet',
22
34
  cooldownMs: 60_000,
23
35
  maxSpeaksPerHour: 6,
24
36
  readLimit: 50,
@@ -75,7 +87,22 @@ export function decideGroupAction(input) {
75
87
  return { act: 'silent', reason: `本小时已说 ${hourly.length} 次 (上限 ${policy.maxSpeaksPerHour})` };
76
88
  }
77
89
  const reply = input.makeReply ?? ((m) => `收到 —— 关于「${String(m.text).slice(0, 40)}」,我这边跟进。`);
78
- // ① @我 ⇒ 回那一条
90
+ const mode = policy.mode;
91
+ const proactive = input.proactiveText?.trim() || (mode === 'maintenance'
92
+ ? `我来主动维护 Bolloon:使用本机已登录的 GitHub 权限处理 logos-42/bolloon,**只在 main 分支工作**。遵循三条群规则:${BOLLOON_MAINTENANCE_RULES.join(';')} 先检查代码/测试状态,认领一个最小修复,完成后回报证据。`
93
+ : '我来主动推进这一轮工作:检查上下文、认领一个可执行事项并回报下一步。');
94
+ // maintenance/active 优先主动认领 —— 不能因为消息长得像普通请求又退回被动模板
95
+ if (mode === 'active' || mode === 'maintenance') {
96
+ const target = fresh[fresh.length - 1];
97
+ return {
98
+ act: 'speak',
99
+ reason: mode === 'maintenance' ? '主动维护 Bolloon 项目' : '主动推进群内工作',
100
+ text: proactive,
101
+ mentions: [target.from],
102
+ ...(target.id ? { replyTo: target.id } : {}),
103
+ };
104
+ }
105
+ // ① @我 ⇒ 回他那一条
79
106
  const atMe = fresh.filter((m) => mentionsMe(m, me));
80
107
  if (atMe.length) {
81
108
  const target = atMe[atMe.length - 1];
@@ -156,6 +183,7 @@ export async function runGroupAutopilotOnce(ports) {
156
183
  now: t,
157
184
  policy,
158
185
  ...(ports.makeReply ? { makeReply: (m) => ports.makeReply(m, g) } : {}),
186
+ ...(ports.proactiveText ? { proactiveText: ports.proactiveText } : {}),
159
187
  });
160
188
  // 不论说不说, 这几条都算"处理过" (否则下一轮还会重新判它)
161
189
  for (const m of msgs) {
@@ -24,7 +24,7 @@ import { registerSkillShareTools } from './skill-share.js';
24
24
  // 2026-09-13: 微支付信息服务 (x402 付费信息 + 验真信封)
25
25
  import { registerPaidInfoTools } from './x402/paid-info-tools.js';
26
26
  // 2026-10-02: 群聊的四个 AI 工具 —— 原语来自群模块与"唯一发送出口"(带隐私闸)
27
- import { joinGroup, listGroups, groupLink, groupMessages, groupMembers } from './gateway-group.js';
27
+ import { createGroup, joinGroup, listGroups, groupLink, groupMessages, groupMembers } from './gateway-group.js';
28
28
  import { resolveGroupRef, resolveSenderTag, sendTrailMessage } from './task-group.js';
29
29
  // 2026-10-02: 群聊自主回路 (读 → 自己判断 → 自己发言) —— AI 用 group_autopilot 工具驱动
30
30
  import { loadAutopilotState, runGroupAutopilotOnce, startGroupAutopilot } from './group-autopilot.js';
@@ -970,6 +970,25 @@ export function registerBuiltinTools(ctx) {
970
970
  return out.length ? out : undefined;
971
971
  };
972
972
  // 2026-10-02 (leo: 希望人工智能自己搞定): 群聊工具 —— 原语来自群模块 + 唯一发送出口(带隐私闸)
973
+ ctx.tools.set('group_create', {
974
+ name: 'group_create',
975
+ description: '新建一个 OrbitDB 群聊并拿到邀请链接 —— **用于**你要开一个话题把别人拉进来(建好后把邀请链接发出去, 对方用 group_join 入群)。acl 默认 creator: 只有你自己能发言; 想让**谁拿到链接都能发言**就传 acl="open"(产品语义如此, 两种都合法)。',
976
+ parameters: { name: '群名(必填)', acl: 'creator | open —— 默认 creator(创建者独占写入); open = 谁拿链接都能发言', from: '你的署名(可选; 默认本机身份)' },
977
+ execute: async (args) => {
978
+ const name = String(args?.name ?? '').trim();
979
+ if (!name)
980
+ return { success: false, error: 'name 必填(群名)' };
981
+ const who = await resolveSenderTag(null);
982
+ // 只认 'open'; 其它一律走产品默认 (创建者独占) —— 不猜、不替调用方放开权限
983
+ const acl = String(args?.acl ?? '').trim() === 'open' ? 'open' : undefined;
984
+ const r = await createGroup(name, { ...(who.ok ? { from: who.tag } : {}), ...(acl ? { acl } : {}) });
985
+ const g = r.group;
986
+ if (!g?.id)
987
+ return { success: false, error: '建群没返回 groupId(如实报告, 不假装成功)' };
988
+ const link = await groupLink(g.id).catch(() => null);
989
+ return { success: true, output: `已建群「${g.name}」(${g.id})${acl ? ' · 开放写入(谁拿链接都能发言)' : ' · 创建者独占写入'}\n` + (link ? `邀请链接: ${link}\n把这个链接发给对方, 他用 group_join 入群。` : '(没拿到邀请链接 —— 用 group_list 再看一次)') };
990
+ },
991
+ });
973
992
  ctx.tools.set('group_join', {
974
993
  name: 'group_join',
975
994
  description: '加入一个 OrbitDB 群聊 —— **用于**拿到邀请链接后自助入群(幂等: 已在群里 ⇒ already)。link 传邀请链接(orbitdb:///orbitdb/<addr>?type=group&name=…)或 groupId。入群后先用 group_read 读消息、再用 group_say 发言。',
@@ -1186,7 +1205,7 @@ export function registerBuiltinTools(ctx) {
1186
1205
  ctx.tools.set('group_autopilot', {
1187
1206
  name: 'group_autopilot',
1188
1207
  description: '群聊自主回路 —— **用于**让 AI 自己读群、自己判断该不该开口、自己在群里发言(不用人盯着)。action: run(立刻跑一轮) / start(挂成常驻, 每 intervalMs 一轮) / stop / status。默认只在「被 @」或「有人提问/请人做事」时说话, 冷却 60s、每小时每群最多 6 次, 处理过的消息不会重复回应。',
1189
- parameters: { action: 'run | start | stop | status(必填)', intervalMs: '常驻时的间隔 ms(默认 60000)', group: '只盯某个群(groupId/链接/群名, 可选; 缺省 = 全部已加入的群)' },
1208
+ parameters: { action: 'run | start | stop | status(必填)', intervalMs: '常驻时的间隔 ms(默认 60000)', mode: 'quiet | active | maintenance(可选; maintenance 专门维护 Bolloon)', group: '只盯某个群(groupId/链接/群名, 可选; 缺省 = 全部已加入的群)' },
1190
1209
  execute: async (args) => {
1191
1210
  const action = String(args?.action ?? 'run').trim();
1192
1211
  const who = await resolveSenderTag(null);
@@ -1206,6 +1225,7 @@ export function registerBuiltinTools(ctx) {
1206
1225
  readMessages: (gid, limit) => groupMessages(gid, limit),
1207
1226
  speak: (gid, text, opts) => sendTrailMessage(gid, text, who.tag, opts ?? {}),
1208
1227
  state,
1228
+ policy: { mode: String(args?.mode ?? 'quiet') },
1209
1229
  };
1210
1230
  if (action === 'stop') {
1211
1231
  if (!autopilotHandle)
@@ -1099,7 +1099,14 @@ export class PiAgentSession {
1099
1099
  return `[初始化未就绪] ${why}\n${hint}`;
1100
1100
  }
1101
1101
  }
1102
- catch { /* 门禁自身异常 → 按"不能判定"处理会阻塞一切; 这里只在能读到状态时才拦 */ }
1102
+ catch (err) {
1103
+ // fail-closed: 门禁异常不能绕过初始化,否则会出现"身份未就绪但 Agent 已调用工具"。
1104
+ const why = `初始化门禁读取失败: ${String(err?.message ?? err).slice(0, 240)}`;
1105
+ this.pushHistory({ role: 'user', content: input });
1106
+ this.pushHistory({ role: 'assistant', content: `[初始化未就绪] ${why}\n请运行 bolloon setup status;修复后再重试。` });
1107
+ console.warn(`[PiAgent] 拒绝执行: ${why}`);
1108
+ return `[初始化未就绪] ${why}\n请运行 bolloon setup status;修复后再重试。`;
1109
+ }
1103
1110
  }
1104
1111
  this.minimaxAvailable = this.checkMinimax();
1105
1112
  this.actor.state.channelId = options?.channelId ?? this.actor.state.channelId;
@@ -48,7 +48,7 @@ const CLI_COMMANDS = [
48
48
  { kind: 'command', label: 'cron', hint: '定时任务', insert: 'cron' },
49
49
  { kind: 'command', label: 'dq', hint: '队列 (dequeue)', insert: 'dq' },
50
50
  { kind: 'command', label: 'goals', hint: '目标', insert: 'goals' },
51
- { kind: 'command', label: 'group', hint: 'OrbitDB 群聊 (new/join/send/log/members)', insert: 'group' },
51
+ { kind: 'command', label: 'group', hint: 'OrbitDB 群聊 (new/join/send/log/members/invite/kick/link/privacy/status)', insert: 'group' },
52
52
  { kind: 'command', label: 'judgments', hint: '判断记录', insert: 'judgments' },
53
53
  { kind: 'command', label: 'net', hint: 'Agent 网络 (join/status/ctx)', insert: 'net' },
54
54
  { kind: 'command', label: 'p2p', hint: 'P2P 节点 / 连接', insert: 'p2p' },
@@ -4,10 +4,13 @@
4
4
  * 流程 (与命令面同一条路, 只是把参数逐步问出来):
5
5
  *
6
6
  * ```
7
- * 1 选择供应商 → 2 未配置则输 API key → 3 选择模型 (模糊搜索) → 4 reasoning/temperature
8
- * → 5 Session 还是 Global → 6 测试连接 → 7 确认切换
7
+ * 1 选择供应商 → 2 (需要 key 则) 凭证 → 3 选择模型 (模糊搜索) → 4 确认 (连通测试+确认)
9
8
  * ```
10
9
  *
10
+ * 2026-10-05 (leo: 不该七层嵌套): 从 7 步压成 4 步 —— 删掉单独的
11
+ * `生成参数` 与 `作用域` 两步 (一律走全局默认、沿用配置里的参数), 把 `测试连接`
12
+ * 与 `确认切换` 合并成第 4 步 (一次点完)。
13
+ *
11
14
  * ## 两条硬纪律
12
15
  *
13
16
  * ① **切换只有一次写盘动作, 且只走统一入口**。本文件不碰配置文件、不碰模型运行时 ——
@@ -26,8 +29,8 @@
26
29
  * 三态到人话的翻译与排版**, 一个能力值都不产生 —— 拿不到真数据时列表上写的是"未知",
27
30
  * 而不是一个看起来像真的常量。
28
31
  */
29
- import { buildProviderSummaries, listModelsFor, formatModelMenuRow, providerGroupSummary, providerTierOf, providerTierCollapsedByDefault, orderProvidersForMenu, PROVIDER_GROUPS, providerRowTone, formatProviderMenuRow, unknownFootnote, searchModelEntries, resolvedApiKeyOf, } from '../llm/model-catalog.js';
30
- import { selectModel, probeSelection, credentialFingerprintOf, envKeyNamesOf as envKeysOf, } from '../llm/model-selection.js';
32
+ import { buildProviderSummaries, listModelsFor, formatModelMenuRow, providerGroupSummary, orderProvidersForMenu, providerRowTone, formatProviderMenuRow, unknownFootnote, searchModelEntries, resolvedApiKeyOf, } from '../llm/model-catalog.js';
33
+ import { selectModel, probeSelection, credentialFingerprintOf, envKeyNamesOf as envKeysOf, baseDefaultsOf, } from '../llm/model-selection.js';
31
34
  import { admitManualModel } from '../llm/model-discovery.js';
32
35
  // 颜色只从 `theme.ts` 来 (唯一事实源): 调子→token 的映射与 `tint()` 都住在那边。
33
36
  import { tint } from './theme.js';
@@ -200,8 +203,8 @@ export async function runModelSelector(io, opts = {}) {
200
203
  return done({ ok: false, reachedStep: 'provider', message: `读供应商列表失败: ${String(e?.message || e).slice(0, 160)}` });
201
204
  }
202
205
  const live = summaries.filter((s) => s.configured);
203
- // "共 N 家" 从盘上真算 (内置 + 自定义 + 目录全部), 不写死 —— 验收门拿它跟盘上数字对。
204
- push(`步骤 1/7 供应商 (共 ${summaries.length} 家登记: 内置+自定义+目录全部 · 可用 ${live.length} 家 · 分组可折叠)`);
206
+ // 2026-10-05 (leo: 不该七层嵌套 → 压成 4 步): 供应商 → 凭证(仅需 key) → 模型 → 确认(合并原测试/作用域/参数)
207
+ push(`步骤 1/4 供应商 (共 ${summaries.length} 家登记: 内置+自定义+目录全部 · 可用 ${live.length} 家 · 全部平铺可滚动)`);
205
208
  verbose(` ${providerGroupSummary(summaries)}`);
206
209
  try {
207
210
  // 动态 import: 选择器 → provider-catalog 是单向的 (目录层不 import 选择器), 不构成环;
@@ -218,19 +221,14 @@ export async function runModelSelector(io, opts = {}) {
218
221
  if (!summaries.length)
219
222
  return done({ ok: false, reachedStep: 'provider', message: '没有任何登记在册的供应商' });
220
223
  // 排序 = 分组优先级 (当前 → 可用 → 未配置凭据 → 需专用鉴权 → 无 api 基址);
221
- // **全部家都在候选数组里** (包括后三组), 只是它们的分组**默认收起** (标题上照写家数)。
224
+ // 全部家都是平铺的单选行 (固定高度视窗 + 滚动消化一屏放不下的部分), 不再分组折叠。
222
225
  const ordered = orderProvidersForMenu(summaries);
223
226
  const providerChoices = ordered.map((s) => {
224
- const tier = providerTierOf(s);
225
227
  return {
226
228
  value: s.id,
227
229
  label: formatProviderMenuRow(s),
228
230
  hint: `${s.name}${s.providerReasoning === 'yes' ? ' · 登记支持 reasoning' : ''}${s.configuredModel ? ` · 配置里 model=${s.configuredModel}` : ''}`,
229
- // 分组标题只在切换分组时印一次; 家数由选择器写在标题行上 (`── 名字 (N 家) ›`)
230
- group: PROVIDER_GROUPS[tier],
231
- // 后三组默认收起: 页面放得下, 而家数照样看得见 (`空格`/`→` 一键展开)
232
- groupCollapsed: providerTierCollapsedByDefault(tier),
233
- // 颜色只做**第二通道**: 语义本体是 label 里的符号 (●/○) 与分组标题 (special/无基址 → warn)
231
+ // 颜色只做**第二通道**: 语义本体是 label 里的符号 (●/○) 与分组 (special/无基址 → warn)
234
232
  tone: providerRowTone(s),
235
233
  };
236
234
  });
@@ -249,7 +247,7 @@ export async function runModelSelector(io, opts = {}) {
249
247
  : summary.keyState === 'missing' ? '缺 key'
250
248
  : summary.keyState === 'env' ? '来自环境变量' : '已配置';
251
249
  const envKeys = envKeysOf(providerId);
252
- push(`步骤 2/7 凭证 — ${providerId}: ${keyZh}${fp ? ` (指纹 ${fp})` : ''}`);
250
+ push(`步骤 2/4 凭证 — ${providerId}: ${keyZh}${fp ? ` (指纹 ${fp})` : ''}`);
253
251
  if (summary.requiresApiKey && io.askHidden) {
254
252
  const choices = [];
255
253
  if (summary.keyState !== 'missing')
@@ -298,6 +296,56 @@ export async function runModelSelector(io, opts = {}) {
298
296
  else if (summary.requiresApiKey) {
299
297
  push(`沿用现有 key (指纹 ${fp || '已配置'})`);
300
298
  }
299
+ // ── 2.5) URL 怎么处理 (leo 2026-10-05: TUI 里要能管理 URL, 和 key 同级) ──────
300
+ // 展示口径: 当前值 + **官方默认**并排 —— 默认给官方来源, 用户再决定 保持/替换/清除。
301
+ let baseUrlAction = 'keep';
302
+ let pendingBaseUrl;
303
+ const officialUrl = (() => {
304
+ try {
305
+ return baseDefaultsOf(providerId, null)?.baseUrl || '';
306
+ }
307
+ catch {
308
+ return '';
309
+ }
310
+ })();
311
+ const currentUrl = summary.baseUrl || officialUrl || '(无)';
312
+ if (io.choose || io.ask) {
313
+ const urlChoices = [
314
+ { value: 'keep', label: '[K]eep 保持当前 URL', hint: `当前: ${currentUrl}`, tone: 'ok' },
315
+ { value: 'replace', label: '[R]eplace 输入新 URL', hint: '覆盖配置里这一格', tone: 'accent' },
316
+ // 清除 = 回落官方默认 (目录家没有官方基址则这步会拦下)
317
+ { value: 'clear', label: '[C]lear 回落官方默认', hint: officialUrl ? `官方: ${officialUrl}` : '这家没有官方基址, 需自定义', tone: 'warn' },
318
+ ];
319
+ const urlAct = await pick(urlChoices, `URL 怎么处理 (当前: ${currentUrl} · 官方: ${officialUrl || '(无)'}):`);
320
+ if (!urlAct)
321
+ return done({ ok: false, cancelled: true, reachedStep: 'key', message: '已取消, 未改动任何配置' });
322
+ if (urlAct === 'replace') {
323
+ const raw = await text(`输入 ${providerId} 的 base URL (含 https://, 不带 /v1 尾缀)`);
324
+ if (raw === null) {
325
+ push(EOF_CANCEL);
326
+ return done({ ok: false, cancelled: true, reachedStep: 'key', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
327
+ }
328
+ if (!raw.trim()) {
329
+ push('URL 为空 → 按保持当前处理');
330
+ }
331
+ else {
332
+ pendingBaseUrl = raw.trim();
333
+ baseUrlAction = 'replace';
334
+ }
335
+ }
336
+ else if (urlAct === 'clear') {
337
+ if (!officialUrl) {
338
+ push(`✗ ${providerId} 没有官方基址可回落 —— 只能保持或替换。未改动任何配置。`);
339
+ }
340
+ else {
341
+ baseUrlAction = 'clear';
342
+ push(`将回落官方默认: ${officialUrl}`);
343
+ }
344
+ }
345
+ else {
346
+ push(`保持当前 URL: ${currentUrl}`);
347
+ }
348
+ }
301
349
  // ── 3) 模型 (模糊搜索 + 当前置顶) ─────────────────────────
302
350
  let entries;
303
351
  try {
@@ -312,7 +360,7 @@ export async function runModelSelector(io, opts = {}) {
312
360
  message: `${providerId} 没有可用模型 (无内置目录且配置为空) —— 请用 \`/model ${providerId} <model>\` 直接指定, 或先配好目录`,
313
361
  });
314
362
  }
315
- push(`步骤 3/7 模型 — ${providerId}, ${entries.length} 个候选`);
363
+ push(`步骤 3/4 模型 — ${providerId}, ${entries.length} 个候选`);
316
364
  for (const f of unknownFootnote(entries))
317
365
  verbose(` ${f}`);
318
366
  let candidates = entries;
@@ -386,92 +434,23 @@ export async function runModelSelector(io, opts = {}) {
386
434
  : entry?.upstreamSeen === true ? ` · ${entry.upstreamOrigin === 'live' ? '上游目录' : '上游目录(缓存)'}`
387
435
  : '';
388
436
  push(`已选模型: ${chosenModelId}${srcTag}`);
389
- // ── 4) 生成参数 (reasoning / temperature) ────────────────
437
+ // ── 4) 确认 (原 测试+作用域+参数 合并收进一步; 只写全局默认, 参数沿用配置) ──────
438
+ // 2026-10-05 (leo: 不该七层嵌套 → 压成 4 步): 参数/作用域不再单列 —— 参数沿用配置,
439
+ // 作用域由 opts.scope 显式传 (不传 = global; `--session` 语义靠它保留)。
390
440
  const params = {};
391
- push('步骤 4/7 生成参数');
392
- // reasoning: 只有"登记为支持"时才提供开关; 能力未知就不给这一项 (不猜)
393
- if (summary.providerReasoning === 'yes') {
394
- const r = await pick([
395
- { value: 'unset', label: '不设 (沿用配置里的值)' },
396
- { value: 'on', label: '开 (请求推理/思考模式)' },
397
- { value: 'off', label: '关' },
398
- ], `${providerId} 登记支持 reasoning — 要不要开? (本机偏好, 不是模型能力数据)`);
399
- if (r === 'on')
400
- params.reasoningMode = true;
401
- else if (r === 'off')
402
- params.reasoningMode = false;
403
- }
404
- else {
405
- verbose(`reasoning: 该供应商的模型级能力未知 → 不提供开关, 也不写默认值`);
406
- }
407
- const temp = await pick([
408
- { value: 'skip', label: '不设 (沿用配置里的值)' },
409
- { value: '0', label: 'temperature = 0' },
410
- { value: '0.3', label: 'temperature = 0.3' },
411
- { value: '0.7', label: 'temperature = 0.7' },
412
- { value: '1.0', label: 'temperature = 1.0' },
413
- { value: '1.5', label: 'temperature = 1.5' },
414
- ...(io.ask ? [{ value: '__custom__', label: '手工输入 (0~2)' }] : []),
415
- ], 'temperature (0~2)');
416
- if (temp === null)
417
- return done({ ok: false, cancelled: true, reachedStep: 'params', message: '已取消, 未改动任何配置' });
418
- if (temp === '__custom__') {
419
- const raw = await text('temperature (0~2)', '0.7');
420
- if (raw === null) {
421
- push(EOF_CANCEL);
422
- return done({ ok: false, cancelled: true, reachedStep: 'params', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
423
- }
424
- if (raw === '') {
425
- push('没输入 → 本项不设');
426
- }
427
- else {
428
- const n = Number(raw);
429
- if (!Number.isFinite(n) || n < 0 || n > 2) {
430
- return done({ ok: false, reachedStep: 'params', failureClass: 'invalid_temperature', message: `temperature 只接受 0~2 的数字, 收到 '${raw}' — 未改动任何配置` });
431
- }
432
- params.temperature = n;
433
- }
434
- }
435
- else if (temp !== 'skip') {
436
- params.temperature = Number(temp);
437
- }
438
- push(`temperature: ${params.temperature === undefined ? '不设' : params.temperature}`);
439
- // ── 5) 作用域 ────────────────────────────────────────────
440
- let scope = 'global';
441
- push('步骤 5/7 作用域');
442
- for (let round = 0; round < 2; round++) {
443
- const s = await pick([
444
- { value: 'global', label: '全局默认 (影响新会话 + 未绑定模型的任务)' },
445
- { value: 'session', label: '仅当前会话 (不动全局默认)' },
446
- ], '这次切换的作用域?');
447
- if (s === null)
448
- return done({ ok: false, cancelled: true, reachedStep: 'scope', message: '已取消, 未改动任何配置' });
449
- scope = s === 'session' ? 'session' : 'global';
450
- if (scope === 'session' && pendingKey) {
451
- push('✗ 会话级切换只做路由、不写凭证 —— 要么改成全局默认, 要么先 `bolloon model key ' + providerId + '` 再回来只切路由。');
452
- continue;
453
- }
454
- break;
455
- }
456
- if (scope === 'session' && pendingKey) {
457
- return done({
458
- ok: false, reachedStep: 'scope', failureClass: 'credential_scope_conflict',
459
- message: '会话级切换不写凭证 — 未改动任何配置 (请先配全局凭证, 再用 --session 只切路由)',
460
- });
461
- }
462
- push(`作用域: ${scope === 'global' ? '全局默认' : '仅当前会话'}`);
441
+ const scope = opts.scope === 'session' ? 'session' : 'global';
463
442
  return await commit(done, push, confirm, verbose, {
464
- providerId, summary, pendingKey, credentialAction, modelId: chosenModelId, baseUrl: summary.baseUrl, opts, params, scope,
443
+ providerId, summary, pendingKey, credentialAction, modelId: chosenModelId, baseUrl: summary.baseUrl,
444
+ baseUrlAction, pendingBaseUrl, opts, params, scope,
465
445
  });
466
446
  }
467
447
  async function commit(done, push, confirm, verbose, a) {
468
448
  const params = a.params || {};
469
449
  const scope = a.scope || 'global';
470
- // ── 6) 测试连接 (对**候选**配置探测; 不写任何东西) ─────────
471
- // 版面: **失败就直接停**, 一行说清"什么错 + 怎么办" —— 不再问"还要不要继续"
472
- // (那种问句按 y 也照样被第 7 步拦下, 是假选择)。
450
+ // ── 4) 确认 (对**候选**配置连通测试, 一次直接到确认; 不写任何东西前置) ──────────
451
+ // 版面: **失败就直接停**, 一行说清"什么错 + 怎么办" —— 不再问"还要不要继续"。
473
452
  if (a.opts.verify === false) {
474
- verbose('步骤 6/7 已按 --no-verify 跳过连通探测 (第 6 步预检与第 7 步切换前校验都关)');
453
+ verbose('步骤 4/4 已按 --no-verify 跳过连通探测 (预检与切换前校验都关)');
475
454
  }
476
455
  else if (!a.opts.skipProbe) {
477
456
  // 预检必须用**和落盘时同一份凭证**: 本次新输入的 key 优先, 否则用该供应商已配置/环境变量里的那一份。
@@ -487,16 +466,11 @@ async function commit(done, push, confirm, verbose, a) {
487
466
  + (probe.failureClass === 'model_not_found' ? ` 换个模型, 或用 \`bolloon model ${a.providerId} <model>\` 直接指定。` : ''),
488
467
  });
489
468
  }
490
- push(`步骤 6/7 连通测试通过 (${a.modelId} @ ${a.baseUrl}, ${ms} ms)`
469
+ push(`步骤 4/4 连通测试通过 (${a.modelId} @ ${a.baseUrl}, ${ms} ms)`
491
470
  + (probe.modelAcceptedOutsideCatalog ? ' — 上游目录未列出, 但端点接受' : ''));
492
- // 第 7 步的"确认"必须先把**要提交的东西**印出来 —— 否则用户是在确认一份看不见的配置。
493
- // 版面压到最少: 供应商/模型/作用域/参数 (探测结论上一步已说, 不重复; 基址只在 verbose)。
494
- push('步骤 7/7 确认');
471
+ // "确认"必须先把**要提交的东西**印出来 —— 否则用户是在确认一份看不见的配置。
472
+ // 版面压到最少: 供应商/模型/作用域 (参数沿用配置, 基址只在 verbose)。
495
473
  push(` ${a.providerId} / ${a.modelId} · ${scope === 'global' ? '全局默认' : '仅当前会话'}`);
496
- if (params.temperature !== undefined || params.reasoningMode !== undefined) {
497
- push(` 参数: ${params.temperature === undefined ? '' : `temperature=${params.temperature}`}`
498
- + `${params.reasoningMode === undefined ? '' : `${params.temperature === undefined ? '' : ' · '}reasoning=${params.reasoningMode ? '开' : '关'}`}`);
499
- }
500
474
  verbose(` 基址: ${a.baseUrl}`);
501
475
  verbose(` 凭证: ${a.pendingKey ? '本次新输入的 key (掩码输入, 落盘为全局凭证)' : '沿用已配置 / 环境变量里的那一份'}`);
502
476
  const proceed = a.opts.assumeYes ? true : await confirm('确认按上面的配置切换?', true);
@@ -504,14 +478,20 @@ async function commit(done, push, confirm, verbose, a) {
504
478
  return done({ ok: false, cancelled: true, reachedStep: 'test', message: '已取消, 未改动任何配置' });
505
479
  }
506
480
  else {
507
- verbose('步骤 6/7 已跳过预检 (skipProbe) —— 第 7 步的切换前校验仍在');
481
+ verbose('步骤 4/4 已跳过预检 (skipProbe) —— 切换前校验仍在');
508
482
  }
509
- // ── 7) 唯一写盘点 ───────────────────────────────────────
483
+ // ── 唯一写盘点 (第 4 步确认后执行) ───────────────────────────
510
484
  const req = {
511
485
  provider: a.providerId,
512
486
  model: a.modelId,
513
- baseUrl: a.baseUrl,
514
487
  scope,
488
+ // URL 意图 (leo 2026-10-05): replace 用新输入的 URL; clear 让 selectModel 回落官方默认;
489
+ // keep 则只带当前值 (与历史语义一致)。baseUrlAction 缺省时传 baseUrl 保持旧行为。
490
+ ...(a.baseUrlAction === 'replace' && a.pendingBaseUrl
491
+ ? { baseUrl: a.pendingBaseUrl, baseUrlAction: 'replace' }
492
+ : a.baseUrlAction === 'clear'
493
+ ? { baseUrlAction: 'clear' }
494
+ : { baseUrl: a.baseUrl }),
515
495
  ...(a.pendingKey ? { apiKey: a.pendingKey } : {}),
516
496
  ...(a.credentialAction !== 'keep' ? { credentialAction: a.credentialAction } : {}),
517
497
  ...(params.temperature !== undefined ? { temperature: params.temperature } : {}),