@bolloon/bolloon-agent 0.3.45 → 0.3.47

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.
@@ -17,6 +17,19 @@ import * as path from 'path';
17
17
  import * as os from 'os';
18
18
  import { KeyManager } from '@diap/sdk';
19
19
  const AGENT_KEYS_DIR = path.join(os.homedir(), '.bolloon', 'agent-keys');
20
+ /** 读取用户唯一 DID (与 server loadOrCreateUserIdentity 同源, 归属字段用) */
21
+ export function getUserOwnerDid() {
22
+ try {
23
+ const f = path.join(os.homedir(), '.bolloon', 'identity', 'user.json');
24
+ if (fs.existsSync(f)) {
25
+ const j = JSON.parse(fs.readFileSync(f, 'utf-8'));
26
+ if (j.did)
27
+ return j.did;
28
+ }
29
+ }
30
+ catch { /* 无 user.json → 无归属 */ }
31
+ return '';
32
+ }
20
33
  /**
21
34
  * 加载或创建 agent 的持久化 diap 身份
22
35
  * - 文件存在 → 从私钥重建 keyPair
@@ -44,6 +57,7 @@ export function loadOrCreateAgentIdentity(agentId) {
44
57
  did: kp.did,
45
58
  createdAt: j.createdAt || 'unknown',
46
59
  reused: true,
60
+ ownerDid: j.ownerDid || getUserOwnerDid(),
47
61
  };
48
62
  }
49
63
  }
@@ -70,6 +84,8 @@ export function loadOrCreateAgentIdentity(agentId) {
70
84
  lastUsedAt: createdAt,
71
85
  agentId,
72
86
  version: '1.0',
87
+ // 2026-08-09: 归属用户唯一 DID — DIAP 智能体身份全部归到用户身份下
88
+ ownerDid: getUserOwnerDid(),
73
89
  };
74
90
  fs.writeFileSync(fp, JSON.stringify(data, null, 2), { mode: 0o600 });
75
91
  try {
@@ -83,6 +99,7 @@ export function loadOrCreateAgentIdentity(agentId) {
83
99
  did: kp.did,
84
100
  createdAt,
85
101
  reused: false,
102
+ ownerDid: data.ownerDid,
86
103
  };
87
104
  }
88
105
  /**
@@ -46,11 +46,27 @@ export function decideAfterReview(state, maxReviews = DEFAULT_MAX_REVIEWS) {
46
46
  export function buildReviewHint(state, maxReviews = DEFAULT_MAX_REVIEWS) {
47
47
  const intent = (state.userIntent || '').trim() || '(无)';
48
48
  const tools = state.completedTools.length ? state.completedTools.join(', ') : '(尚未执行工具)';
49
- return (`[目标对齐 review ${state.reviewsDone + 1}/${maxReviews}]\n` +
50
- `用户需求: ${intent.slice(0, 300)}\n` +
51
- `本轮已完成工具: ${tools}\n` +
52
- `请对照需求自查: 是否还有未完成/可深挖的子目标? 若有 继续调用工具推进; ` +
53
- `若已满足用户原始需求 直接输出 <final gen> 结束. 不要再重复已完成的动作.`);
49
+ // 2026-08-09: 行动日志 逐条列出已完成动作 + 结果摘要, 让 LLM 对照目标核查
50
+ // (旧版只有工具名, LLM 容易潦草自查; 带结果才能判断"这一步到底做完了没")
51
+ let actionLines = '';
52
+ if (state.actionLog && state.actionLog.length > 0) {
53
+ actionLines = '\n本轮已执行动作 (逐条):\n' + state.actionLog
54
+ .map((a, i) => {
55
+ const args = a.argsPreview ? `(${a.argsPreview.slice(0, 80)})` : '';
56
+ const res = a.success
57
+ ? `✓ ${(a.resultPreview || 'ok').slice(0, 120)}`
58
+ : `✗ ${(a.resultPreview || 'failed').slice(0, 120)}`;
59
+ return ` ${i + 1}. ${a.tool}${args} → ${res}`;
60
+ })
61
+ .join('\n');
62
+ }
63
+ return (`[目标对齐 review ${state.reviewsDone + 1}/${maxReviews}]` +
64
+ `\n用户需求: ${intent.slice(0, 300)}` +
65
+ actionLines +
66
+ `\n已完成工具: ${tools}` +
67
+ `\n请对照需求逐条自查: 上面每一条动作是否真正完成了对应的子目标? 还有未完成/可深挖的子目标 → 继续调用工具推进 (注意: 同一动作已完成就不要重复执行, 直接基于已有结果推进下一步); ` +
68
+ `若已满足用户原始需求 → 直接输出 <final gen> 结束.` +
69
+ `\n[重要] 如果你要结束, 请先逐条对照「用户需求」确认每一项都已完成, 不要因为做了一部分就潦草收尾.`);
54
70
  }
55
71
  /** 布尔门: 是否该继续 review (测试/消融快速断言) */
56
72
  export function shouldReviewAgain(state, maxReviews = DEFAULT_MAX_REVIEWS) {
@@ -1776,7 +1776,8 @@ export function registerBuiltinTools(ctx) {
1776
1776
  execute: async () => {
1777
1777
  try {
1778
1778
  const { readContextAssets, formatLayerListing } = await import('../bootstrap/context-os.js');
1779
- const listings = await readContextAssets();
1779
+ // 2026-08-09: agentId 分区读取 (每个智能体独立 Context OS)
1780
+ const listings = await readContextAssets(undefined, undefined, undefined, ctx.agentId || '');
1780
1781
  const total = listings.reduce((s, l) => s + l.fileCount, 0);
1781
1782
  if (total === 0)
1782
1783
  return { success: true, output: '📂 Context OS 资产层已就绪 (12+3 层), 当前暂无资产. 有价值的内容用 write_context_asset 写入对应层.' };
@@ -1816,7 +1817,7 @@ export function registerBuiltinTools(ctx) {
1816
1817
  tags = t.map(String);
1817
1818
  }
1818
1819
  catch { /* tags 解析失败 */ }
1819
- const r = await writeContextAsset({ layer, title, content, tags, domain: args.domain ? String(args.domain) : undefined });
1820
+ const r = await writeContextAsset({ layer, title, content, tags, domain: args.domain ? String(args.domain) : undefined }, undefined, ctx.agentId || '');
1820
1821
  if (!r.ok)
1821
1822
  return { success: false, error: r.error };
1822
1823
  if (r.skipped)
@@ -1840,7 +1841,8 @@ export function registerBuiltinTools(ctx) {
1840
1841
  const { readContextAssets, formatLayerListing } = await import('../bootstrap/context-os.js');
1841
1842
  const layer = args.layer ? String(args.layer) : undefined;
1842
1843
  const kw = args.keyword ? String(args.keyword) : undefined;
1843
- const listings = await readContextAssets(layer, kw);
1844
+ // 2026-08-09: agentId 分区读取 (每个智能体独立 Context OS)
1845
+ const listings = await readContextAssets(layer, kw, undefined, ctx.agentId || '');
1844
1846
  if (listings.every((l) => l.fileCount === 0)) {
1845
1847
  return { success: true, output: layer ? `📂 资产层 ${layer} 暂无资产` : '📂 资产层暂无资产' };
1846
1848
  }
@@ -1129,6 +1129,10 @@ ${this.getToolDefinitions()}
1129
1129
  // 上限=2 次 (用户要求"运行一两次"), 结束后按用户需求为准.
1130
1130
  let loopReviewCount = 0;
1131
1131
  const loopReviewCompletedTools = new Set();
1132
+ // 2026-08-09: 本轮行动日志 — 每轮工具执行都记录 (args + 结果摘要),
1133
+ // final 前 review 用逐条核查目标; 也注入 system prompt 让 LLM 看到连续进度
1134
+ // (防"每轮都像重启" — 之前 LLM 看不到自己已完成什么, 容易重复 react)
1135
+ const loopActionLog = [];
1132
1136
  // 发送循环开始的事件
1133
1137
  if (onStream) {
1134
1138
  onStream({ type: 'status', content: '🔄 开始 ReAct 循环...', tool: 'system' });
@@ -1252,11 +1256,26 @@ ${this.getToolDefinitions()}
1252
1256
  `;
1253
1257
  }
1254
1258
  const personaSection = this.cachedPersonaSection;
1259
+ // 2026-08-09: 循环进度段 — 让 LLM 看到本轮已完成的动作 (连续进度, 不重启)
1260
+ // Hermes 式 Agent Runtime: 循环是状态机, LLM 每次看到的是"第 N 步 + 已完成 X"
1261
+ // (之前每轮都是全新上下文, LLM 不知道做过什么 → 重复 react / 衔接差)
1262
+ let loopProgressSection = '';
1263
+ if (loopActionLog.length > 0) {
1264
+ const actionLines = loopActionLog
1265
+ .map((a, i) => {
1266
+ const args = a.argsPreview ? `(${a.argsPreview.slice(0, 60)})` : '';
1267
+ const res = a.success ? '✓' : '✗';
1268
+ return ` ${i + 1}. ${res} ${a.tool}${args}`;
1269
+ })
1270
+ .join('\n');
1271
+ loopProgressSection = `\n【本轮循环进度】你已完成以下 ${loopActionLog.length} 个动作, 这是连续执行的同一轮任务:\n${actionLines}\n请基于已有结果继续推进, 不要重复执行上面已成功的动作. 全部完成后用 <final gen> 结束.\n`;
1272
+ }
1255
1273
  const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${personaSection}
1256
1274
  当前工作目录: ${this.cwd}
1257
1275
  当前身份: ${this.identity.name} (${this.identity.did})
1258
1276
  ${refineContext}
1259
1277
  ${this.currentIntentHint}
1278
+ ${loopProgressSection}
1260
1279
 
1261
1280
  ${toolDefs}
1262
1281
 
@@ -1361,9 +1380,10 @@ ${toolDefs}
1361
1380
  }
1362
1381
  console.log(`[PiAgent] LLM 回复长度: ${reply.length}, 内容预览: "${reply.substring(0, 80)}..."`);
1363
1382
  console.log(`[PiAgent] LLM 完整回复:\n${reply}`);
1364
- // 通知前端:收到 LLM 回复
1383
+ // 通知前端:收到 LLM 回复 (2026-08-09: 不再截断 100 字符 — 前端流式渲染完整内容,
1384
+ // 配合 Hermes 式回复框: 加载中显示完整文本, 完成后封闭底框)
1365
1385
  if (onStream) {
1366
- onStream({ type: 'token', content: reply.substring(0, 100) });
1386
+ onStream({ type: 'token', content: reply });
1367
1387
  }
1368
1388
  // 2026-06-19 架构 fix: parseToolCall 优先于 isFinalResponse
1369
1389
  // 之前: 思考块里的 "<final gen>" 触发 isFinalResponse 提前 break, 工具从未真正执行
@@ -1415,9 +1435,10 @@ ${toolDefs}
1415
1435
  content: reply,
1416
1436
  toolCalls: toolCalls.length > 1 ? toolCalls : [toolCalls[0]],
1417
1437
  });
1418
- // 顺序执行每个工具
1419
- for (let ti = 0; ti < toolCalls.length; ti++) {
1420
- const toolCall = toolCalls[ti];
1438
+ // 2026-08-09: 并发执行本轮所有工具 (Hermes 式 Agent Runtime: 一轮内多工具并行,
1439
+ // 一轮没跑完之前不中断 工具执行不检查 abort, 全部完成才 continue 下一轮)
1440
+ // 旧实现顺序 for 循环, 一个工具等一个, 慢; 且多工具时 LLM 要等全部串完才能看到结果.
1441
+ await Promise.all(toolCalls.map(async (toolCall, ti) => {
1421
1442
  const isMulti = toolCalls.length > 1;
1422
1443
  // 通知前端
1423
1444
  if (onStream) {
@@ -1450,7 +1471,7 @@ ${toolDefs}
1450
1471
  const denyResultMsg = { success: false, error: `拒绝: [${denyResult.source}] ${denyResult.reason}` };
1451
1472
  this.messageHistory.push({ role: 'tool', content: JSON.stringify(denyResultMsg), toolResult: denyResultMsg });
1452
1473
  this.logToHarness(toolCall.name, toolCall.args, denyResultMsg);
1453
- continue;
1474
+ return;
1454
1475
  }
1455
1476
  if (denyResult.systemAddition) {
1456
1477
  this.contextHintAddition += '\n' + denyResult.systemAddition;
@@ -1469,7 +1490,7 @@ ${toolDefs}
1469
1490
  if (onStream)
1470
1491
  onStream({ type: 'status', content: `💡 Reflection: ${obs.summary}`, tool: 'system' });
1471
1492
  console.warn(`[PiAgent] 未知工具: ${toolCall.name} (累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}),跳过并继续`);
1472
- continue;
1493
+ return;
1473
1494
  }
1474
1495
  // Bootstrap PreToolUse hook: 调工具前校验 (危险命令拦截)
1475
1496
  // 失败静默 — hook 自身挂掉 = 放行
@@ -1513,7 +1534,7 @@ ${toolDefs}
1513
1534
  this.messageHistory.push({ role: 'system', content: `[注意] 连续 ${consecutiveErrors} 次工具调用被系统拒绝. 请换其他工具或直接回答用户, 末尾加 <final gen>.` });
1514
1535
  consecutiveErrors = 0;
1515
1536
  }
1516
- continue;
1537
+ return;
1517
1538
  }
1518
1539
  }
1519
1540
  catch (err) {
@@ -1550,7 +1571,7 @@ ${toolDefs}
1550
1571
  this.messageHistory.push({ role: 'system', content: `[注意] 连续 ${consecutiveErrors} 次工具调用被 Harness 拒绝. 请换其他工具或直接回答.` });
1551
1572
  consecutiveErrors = 0;
1552
1573
  }
1553
- continue;
1574
+ return;
1554
1575
  }
1555
1576
  }
1556
1577
  catch (err) {
@@ -1587,6 +1608,20 @@ ${toolDefs}
1587
1608
  }
1588
1609
  this.messageHistory.push({ role: 'tool', content: JSON.stringify(result), toolResult: result, toolCallId: toolCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` });
1589
1610
  this.logToHarness(toolCall.name, toolCall.args, result);
1611
+ // 2026-08-09: 记录到本轮行动日志 (循环进度 + final 前目标核查用)
1612
+ // 去重: 同一工具同 args 连续成功只记一次 (防 LLM 重复 react 刷屏)
1613
+ const argsPreview = JSON.stringify(toolCall.args || {}).slice(0, 120);
1614
+ const isDup = loopActionLog.some((a) => a.tool === toolCall.name && a.argsPreview === argsPreview && a.success === !!result.success);
1615
+ if (!isDup) {
1616
+ loopActionLog.push({
1617
+ tool: toolCall.name,
1618
+ argsPreview,
1619
+ resultPreview: result.success
1620
+ ? String(result.output || '(无输出)').slice(0, 200)
1621
+ : String(result.error || 'failed').slice(0, 200),
1622
+ success: !!result.success,
1623
+ });
1624
+ }
1590
1625
  if (onStream) {
1591
1626
  if (result.success) {
1592
1627
  onStream({ type: 'status', content: `✅ ${toolCall.name} 执行成功`, tool: toolCall.name });
@@ -1644,7 +1679,7 @@ ${toolDefs}
1644
1679
  lastFailedTool = '';
1645
1680
  lastFailedToolCount = 0;
1646
1681
  consecutiveErrors = 0;
1647
- continue;
1682
+ return;
1648
1683
  }
1649
1684
  if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
1650
1685
  this.messageHistory.push({ role: 'system', content: `[注意] 前面的工具调用连续失败。请尝试其他工具或换一种方式完成用户请求, 或用 <final gen> 给出最终回答.` });
@@ -1665,7 +1700,7 @@ ${toolDefs}
1665
1700
  onStream({ type: 'status', content: `💡 Reflection: ${obs.summary}`, tool: 'system' });
1666
1701
  console.error(`[PiAgent] 工具执行异常 (累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}): ${execError}`);
1667
1702
  }
1668
- } // end for (ti)
1703
+ })); // end Promise.all(toolCalls.map(async ...))
1669
1704
  // 所有工具执行完毕后, continue while 循环, 让 LLM 看到结果
1670
1705
  continue;
1671
1706
  }
@@ -1675,9 +1710,9 @@ ${toolDefs}
1675
1710
  role: 'assistant',
1676
1711
  content: reply
1677
1712
  });
1678
- // 通知前端收到非工具调用回复
1713
+ // 通知前端收到非工具调用回复 (2026-08-09: 完整内容, 不再截断 150)
1679
1714
  if (onStream) {
1680
- onStream({ type: 'token', content: reply.substring(0, 150) });
1715
+ onStream({ type: 'token', content: reply });
1681
1716
  }
1682
1717
  // 2026-06-19 架构 fix: 只有 strip <think> 后才检查 isFinalResponse
1683
1718
  // (parseToolCall 已先尝试, 既然没解析出 tool_call, 现在检查 final gen 是否真的在最终回答区)
@@ -1720,6 +1755,7 @@ ${toolDefs}
1720
1755
  reviewsDone: loopReviewCount,
1721
1756
  userIntent: this.currentIntentHint,
1722
1757
  completedTools: Array.from(loopReviewCompletedTools),
1758
+ actionLog: loopActionLog,
1723
1759
  }, DEFAULT_MAX_REVIEWS);
1724
1760
  if (reviewDecision.kind === 'continue-review') {
1725
1761
  loopReviewCount++;
@@ -260,7 +260,7 @@ export class WorkflowPivotLoop {
260
260
  const llmResponse = await llm.chat(context, headerForThisIter, signal, openAITools);
261
261
  const reply = (llmResponse.reply || '').trim();
262
262
  this.vlog(`[pivot] iter=${this.state.iteration} LLM took=${Date.now() - t0}ms reply=${reply.length} nativeToolCalls=${llmResponse.toolCalls?.length ?? 0} head=${reply.substring(0, 80).replace(/\n/g, ' ')}`);
263
- this.emit({ type: 'token', content: reply.substring(0, 100) });
263
+ this.emit({ type: 'token', content: reply });
264
264
  // 2026-07-06: 把完整 reply 推给前端 — 前端按需更新临时气泡
265
265
  // 之前只 emit token(100B 截断), 前端拿到 100B 看不清. 现在 emit preview 带完整 content.
266
266
  // 折叠 / 清洗交给前端的 message-renderer.addMessage (类型 ai 入口统一 strip).
@@ -48,7 +48,16 @@ const LAYER_KEYS = new Set(CONTEXT_OS_LAYERS.map((l) => l.key));
48
48
  export function getContextOsRoot(home = os.homedir()) {
49
49
  return path.join(home, '.bolloon', 'context-os');
50
50
  }
51
- export function getLayerDir(layer, home = os.homedir()) {
51
+ /**
52
+ * 2026-08-09: 层目录 — 支持按 agentId 分区 (每个智能体独立 Context OS).
53
+ * agentId 有值 → ~/.bolloon/context-os/<sanitizeAgentId>/<layer>
54
+ * agentId 空 → ~/.bolloon/context-os/<layer> (旧全局路径, 兼容)
55
+ */
56
+ export function getLayerDir(layer, home = os.homedir(), agentId) {
57
+ const safeId = String(agentId || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
58
+ if (safeId) {
59
+ return path.join(getContextOsRoot(home), safeId, layer);
60
+ }
52
61
  return path.join(getContextOsRoot(home), layer);
53
62
  }
54
63
  /** 校验 layer 合法; 非法返回 null */
@@ -86,11 +95,11 @@ ${l.usage}
86
95
  → 阶段2 固化 (本层唯一位置) → 阶段3 索引化 (高频引用) → 阶段4 归档/删除.
87
96
  `;
88
97
  }
89
- export async function ensureContextOsDirs(home) {
98
+ export async function ensureContextOsDirs(home, agentId) {
90
99
  const root = getContextOsRoot(home);
91
100
  await fs.mkdir(root, { recursive: true });
92
101
  for (const l of CONTEXT_OS_LAYERS) {
93
- const dir = getLayerDir(l.key, home);
102
+ const dir = getLayerDir(l.key, home, agentId);
94
103
  await fs.mkdir(dir, { recursive: true });
95
104
  const readmePath = path.join(dir, 'README.md');
96
105
  try {
@@ -106,7 +115,7 @@ export async function ensureContextOsDirs(home) {
106
115
  * 文件名: <ts>-<slug>.md; frontmatter v2 (stage0 = 临时价值点, 待验证).
107
116
  * 幂等: 同层同 slug 已存在 → 跳过 (不重复造文件, Context OS §6 Step3).
108
117
  */
109
- export async function writeContextAsset(input, home) {
118
+ export async function writeContextAsset(input, home, agentId) {
110
119
  const layer = resolveLayer(input.layer);
111
120
  if (!layer) {
112
121
  return { ok: false, error: `layer 非法: '${input.layer}'. 合法: ${CONTEXT_OS_LAYERS.map((l) => l.key).join(' / ')}` };
@@ -117,15 +126,15 @@ export async function writeContextAsset(input, home) {
117
126
  const content = String(input.content || '').trim();
118
127
  if (!content)
119
128
  return { ok: false, error: 'content 必填' };
120
- await ensureContextOsDirs(home);
129
+ await ensureContextOsDirs(home, agentId);
121
130
  const now = new Date().toISOString();
122
131
  const ts = Date.now();
123
132
  const slug = slugify(title);
124
133
  const fileName = `${ts}-${slug}.md`;
125
- const filePath = path.join(getLayerDir(layer.key, home), fileName);
134
+ const filePath = path.join(getLayerDir(layer.key, home, agentId), fileName);
126
135
  // 幂等: 同 slug 已存在 → 跳过
127
136
  try {
128
- const files = await fs.readdir(getLayerDir(layer.key, home));
137
+ const files = await fs.readdir(getLayerDir(layer.key, home, agentId));
129
138
  if (files.some((f) => f.endsWith(`-${slug}.md`))) {
130
139
  return { ok: true, skipped: true, error: `同标题资产已存在 (${slug}.md), 未重复写入` };
131
140
  }
@@ -157,7 +166,7 @@ export async function writeContextAsset(input, home) {
157
166
  }
158
167
  }
159
168
  /** 列出层资产; layer 为空 → 全层汇总 */
160
- export async function readContextAssets(layer, keyword, home) {
169
+ export async function readContextAssets(layer, keyword, home, agentId) {
161
170
  const root = getContextOsRoot(home);
162
171
  const kw = String(keyword || '').trim().toLowerCase();
163
172
  const wanted = layer ? [resolveLayer(layer)].filter(Boolean).map((l) => l.key) : CONTEXT_OS_LAYERS.map((l) => l.key);
@@ -165,11 +174,11 @@ export async function readContextAssets(layer, keyword, home) {
165
174
  for (const key of wanted) {
166
175
  const l = resolveLayer(key);
167
176
  try {
168
- const files = (await fs.readdir(getLayerDir(key, home))).filter((f) => f.endsWith('.md') && f !== 'README.md');
177
+ const files = (await fs.readdir(getLayerDir(key, home, agentId))).filter((f) => f.endsWith('.md') && f !== 'README.md');
169
178
  const entries = [];
170
179
  for (const f of files) {
171
180
  try {
172
- const raw = await fs.readFile(path.join(getLayerDir(key, home), f), 'utf-8');
181
+ const raw = await fs.readFile(path.join(getLayerDir(key, home, agentId), f), 'utf-8');
173
182
  const titleM = raw.match(/^title:\s*(.+)$/m);
174
183
  const createdM = raw.match(/^created:\s*(.+)$/m);
175
184
  const title = titleM ? titleM[1].trim() : f.replace(/\.md$/, '');
@@ -45,14 +45,20 @@ function getPackageVersion() {
45
45
  }
46
46
  }
47
47
  const BOLLOON_VERSION = getPackageVersion();
48
- // ── 品牌图标: 顶部带圆标注的 0 (气球) ──────────────
48
+ // ── 品牌图标: 笑脸机器人 (2026-08-09, bolloon 色系填充) ──────
49
+ // 头: 主色边框 + 亮绿填充 (C_ACCENT_BG); 眼睛 ◉ / 嘴 ◡ 用亮色填充;
50
+ // 末行 BOLLOON 主色艺术字 (仅 printBanner 用, brandArtLines 会裁掉避免双 logo).
51
+ const ROBOT_HEAD = [
52
+ `${C_ACCENT} ╭───────╮${RESET}`,
53
+ `${C_ACCENT} ╭─╯${C_ACCENT_BG} ${C_WHITE}◉${C_ACCENT_BG} ${C_WHITE}◉${C_ACCENT_BG} ${RESET}${C_ACCENT}╰─╮${RESET}`,
54
+ `${C_ACCENT} │${C_ACCENT_BG} ${C_WHITE}◡${C_ACCENT_BG} ${RESET}${C_ACCENT}│${RESET}`,
55
+ `${C_ACCENT} ╰─╮${C_ACCENT_BG} ${RESET}${C_ACCENT}╭─╯${RESET}`,
56
+ `${C_ACCENT} ╰───┬───╯${RESET}`,
57
+ `${C_ACCENT} │${RESET}`,
58
+ ];
49
59
  export const BOLLOON_ICON = [
50
- `${C_ACCENT} ✦${RESET}`,
51
- `${C_ACCENT} ╱ ╲${RESET}`,
52
- `${C_ACCENT} ════◆════${RESET}`,
53
- `${C_ACCENT} ╲ ╱${RESET}`,
54
- `${C_ACCENT} ╲${RESET}`,
55
- `${C_ACCENT} ✦ ╲${RESET}`,
60
+ ...ROBOT_HEAD,
61
+ `${C_ACCENT}${BOLD} BOLLOON${RESET}`,
56
62
  ].join('\n');
57
63
  // ── 艺术字: BOLLOON (box 字体) + Bolloon Agent 副标题 ──
58
64
  export const BOLLOON_BANNER = [
@@ -64,9 +70,10 @@ export const BOLLOON_BANNER = [
64
70
  `${C_TEXT}${BOLD}╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝${RESET}`,
65
71
  `${C_DIM}Bolloon Agent v${BOLLOON_VERSION}${RESET}`,
66
72
  ].join('\n');
67
- /** 艺术字全部行 (图标在左, BOLLOON 艺术字在右), 供框内渲染 */
73
+ /** 艺术字全部行 (机器人头在左, BOLLOON 艺术字在右), 供框内渲染 */
68
74
  export function brandArtLines() {
69
- const icon = BOLLOON_ICON.split('\n');
75
+ // 2026-08-09: icon 只取机器人头 (裁掉末行 BOLLOON 文字), 避免和右侧 banner 双 logo
76
+ const icon = ROBOT_HEAD;
70
77
  const banner = BOLLOON_BANNER.split('\n');
71
78
  const gap = 2;
72
79
  const iconW = Math.max(1, ...icon.map(l => dispWidth(l)));
@@ -81,8 +88,9 @@ export function brandArtLines() {
81
88
  return rows;
82
89
  }
83
90
  export function printBanner(version) {
91
+ // 2026-08-09: 新品牌 logo = 笑脸机器人 (BOLLOON_ICON 自带 BOLLOON 文字),
92
+ // 不再叠加旧 box 字体 banner (避免双 logo)
84
93
  console.log(BOLLOON_ICON);
85
- console.log(BOLLOON_BANNER);
86
94
  if (version)
87
95
  console.log(`${C_DIM} Bolloon Agent v${version}${RESET}`);
88
96
  console.log(`${C_DIM} P2P AI Agent · 文档智能体${RESET}`);
package/dist/cli-entry.js CHANGED
@@ -111,11 +111,13 @@ function getMainScript() {
111
111
  }
112
112
  function getElectronPath() {
113
113
  // ESM 兼容: use _require (createRequire) instead of raw require
114
+ // 2026-08-09: electron 在 devDependencies, 全局安装不装 → require 失败返回 null
115
+ // (上层 startElectron 收到 null 后降级为 Web 模式, 不再返回裸字符串 'electron' 导致 ENOENT)
114
116
  try {
115
117
  return _require('electron');
116
118
  }
117
119
  catch {
118
- return 'electron';
120
+ return null;
119
121
  }
120
122
  }
121
123
  // 解析命令行参数
@@ -466,6 +468,14 @@ async function runNodeScript(scriptPath, additionalArgs) {
466
468
  // 启动 Electron
467
469
  async function startElectron(additionalArgs) {
468
470
  const electronPath = getElectronPath();
471
+ // 2026-08-09: electron 是 devDependency, 全局安装不包含 → 降级为 Web 模式
472
+ // (浏览器打开 Web UI, 功能等价, 避免 spawn electron ENOENT 直接退出)
473
+ if (!electronPath) {
474
+ log('未检测到 Electron 桌面运行时 (全局安装不含 devDependencies)', YELLOW);
475
+ log('自动降级为 Web 模式 — 浏览器打开 Web UI...', CYAN);
476
+ await startWebServer(additionalArgs);
477
+ return;
478
+ }
469
479
  const distDir = getDistDir();
470
480
  // 确定主进程入口
471
481
  let mainPath = path.join(distDir, 'electron.js');
package/dist/index.js CHANGED
@@ -271,12 +271,44 @@ async function bootstrapIroh(keypair, name) {
271
271
  // Agent 懒加载
272
272
  // ---------------------------------------------------------------------------
273
273
  let agent = null;
274
+ /** 2026-08-09: agent 当前绑定的 channel id (null = 默认 harness 身份) — 切换时据此重建 */
275
+ let agentBoundChannelId = null;
274
276
  let harness = null;
275
277
  let hybridMessenger = null;
276
278
  let agentIdentity = null;
277
279
  async function getAgent() {
278
- if (!agent) {
279
- const identityDoc = agentIdentity ? {
280
+ // 2026-08-09: agent 身份绑定当前 active channel — 切换 / 新建 channel 后重建.
281
+ // 旧实现: agent 全局单例 + peerId:'harness' 固定, 切 channel 身份不变 (bug).
282
+ // 新实现: channel 有 agentId/did/publicKey/persona 时按 channel 建 session,
283
+ // agentIdentity 同步更新, loadSessionKey 回灌该 channel 的历史.
284
+ const targetChannelId = cliActiveChannelId || null;
285
+ if (agent && agentBoundChannelId === targetChannelId)
286
+ return agent;
287
+ // 读取当前 active channel 的持久身份
288
+ let chIdentity = null;
289
+ if (targetChannelId) {
290
+ try {
291
+ const { getIdentityStore } = await import('./agents/agent-identity-store.js');
292
+ const store = getIdentityStore();
293
+ await store.load();
294
+ const ch = store.rawChannels.find((c) => c.id === targetChannelId);
295
+ if (ch)
296
+ chIdentity = ch;
297
+ }
298
+ catch { /* 读不到就退默认 */ }
299
+ }
300
+ let identityDoc;
301
+ if (chIdentity?.did && chIdentity.publicKey) {
302
+ // channel 已有持久 DID → 用 channel 身份
303
+ identityDoc = {
304
+ did: chIdentity.did,
305
+ name: chIdentity.persona?.name || chIdentity.name || 'agent',
306
+ publicKey: chIdentity.publicKey,
307
+ createdAt: Date.now(),
308
+ };
309
+ }
310
+ else if (agentIdentity) {
311
+ identityDoc = {
280
312
  did: agentIdentity.did,
281
313
  name: agentIdentity.name,
282
314
  publicKey: agentIdentity.publicKey,
@@ -285,15 +317,39 @@ async function getAgent() {
285
317
  p2pChannel: agentIdentity.p2pChannel,
286
318
  cid: agentIdentity.cid,
287
319
  ipnsName: agentIdentity.ipnsName
288
- } : undefined;
289
- agent = await createAgentSession({
290
- cwd: process.cwd(),
291
- peerId: 'harness',
292
- identityDoc
293
- });
320
+ };
321
+ }
322
+ else {
323
+ identityDoc = undefined;
324
+ }
325
+ const loadSessionKey = targetChannelId
326
+ ? `${targetChannelId}:${chIdentity?.currentSessionId || 'default'}`
327
+ : undefined;
328
+ agent = await createAgentSession({
329
+ cwd: process.cwd(),
330
+ peerId: targetChannelId ?? 'harness',
331
+ identityDoc,
332
+ // 2026-08-09: 透传 channel.agentId → persona docs 按 agent 加载 (身份真正变化)
333
+ agentId: chIdentity?.agentId || (targetChannelId ? undefined : agentIdentity?.name),
334
+ loadSessionKey,
335
+ });
336
+ agentBoundChannelId = targetChannelId;
337
+ // 同步 agentIdentity (状态栏 / 身份引用)
338
+ if (chIdentity) {
339
+ agentIdentity = {
340
+ did: chIdentity.did || agentIdentity?.did || '',
341
+ name: chIdentity.persona?.name || chIdentity.name || 'agent',
342
+ publicKey: chIdentity.publicKey || agentIdentity?.publicKey || '',
343
+ peerId: targetChannelId ?? undefined,
344
+ };
294
345
  }
295
346
  return agent;
296
347
  }
348
+ /** 强制重建 agent (切 channel / 新建 agent 后调用) */
349
+ function invalidateAgent() {
350
+ agent = null;
351
+ agentBoundChannelId = null;
352
+ }
297
353
  // ---------------------------------------------------------------------------
298
354
  // Dispatch
299
355
  // ---------------------------------------------------------------------------
@@ -575,6 +631,13 @@ async function processInput(input, comm) {
575
631
  await store.setActive(r.channel.id);
576
632
  cliAgentName = r.identity.name;
577
633
  cliActiveChannelId = r.channel.id;
634
+ // 2026-08-09: 切 channel 必须重建 agent session — 否则身份/记忆停留在旧 channel (bug 修复)
635
+ invalidateAgent();
636
+ // 立即重建 (提前建好, 避免下次输入才卡顿; 失败不阻塞切换)
637
+ try {
638
+ await getAgent();
639
+ }
640
+ catch { /* 非致命, 下次输入时再试 */ }
578
641
  inkSetStatus(getStatus()); // 触发状态栏立即重绘 (无需等 1s 定时器)
579
642
  const extra = prev && prev.name !== r.identity.name ? ` (从 ${prev.name} 切换)` : '';
580
643
  appendLine(`${C_ACCENT}→ 当前智能体: ${r.identity.name}${RESET}${extra}`);
@@ -599,38 +662,47 @@ async function processInput(input, comm) {
599
662
  const { getIdentityStore } = await import('./agents/agent-identity-store.js');
600
663
  const store = getIdentityStore();
601
664
  await store.load();
602
- const { readFile, writeFile, mkdir } = await import('fs/promises');
603
- const { join } = await import('path');
604
- const home = process.env.HOME || '/tmp';
605
- const channelsPath = join(home, '.bolloon', 'sessions', 'channels.json');
606
- let channels = [];
607
- try {
608
- const parsed = JSON.parse(await readFile(channelsPath, 'utf-8'));
609
- channels = Array.isArray(parsed) ? parsed : parsed?.channels || [];
610
- }
611
- catch { /* 首次无文件 */ }
612
- const dupName = channels.find((c) => c.name === name.trim());
665
+ // 2026-08-09: 复用 server-storage updateChannels 原子写 (互斥锁) 旧实现裸 readFile→push→writeFile
666
+ // 与 Web server 并发写 channels.json 互相覆盖 → 创建的 agent 重启后丢失 (bug 修复)
667
+ const { updateChannels } = await import('./web/server-storage.js');
668
+ const dupName = store.rawChannels.find((c) => c.name === name.trim());
613
669
  if (dupName) {
614
670
  appendLine(`${C_ERROR}同名智能体已存在: '${dupName.name}' (id=${dupName.id})${RESET}`);
615
671
  return;
616
672
  }
617
673
  const id = `ch_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
674
+ const agentId = `agent-${name.trim().toLowerCase().replace(/\s+/g, '-')}`;
618
675
  const ch = {
619
676
  id,
620
677
  name: name.trim(),
621
- agentId: `agent-${name.trim().toLowerCase().replace(/\s+/g, '-')}`,
678
+ agentId,
622
679
  createdAt: new Date().toISOString(),
623
680
  updatedAt: new Date().toISOString(),
624
681
  currentSessionId: 'default',
625
682
  };
626
683
  if (personaHint)
627
684
  ch.persona = { name: name.trim(), description: personaHint };
628
- channels.push(ch);
629
- await mkdir(join(home, '.bolloon', 'sessions'), { recursive: true });
630
- await writeFile(channelsPath, JSON.stringify(channels, null, 2), 'utf-8');
685
+ // 2026-08-09: 立即生成该 agent 的持久 DID 身份 (agent-keys/<agentId>.json)
686
+ // 与 server fixOneChannelDID 对齐, 保证 CLI 新建的 agent 身份稳定且归属用户 DID
687
+ try {
688
+ const { loadOrCreateAgentIdentity } = await import('./agents/agent-identity.js');
689
+ const idt = loadOrCreateAgentIdentity(agentId);
690
+ ch.did = idt.did;
691
+ ch.publicKey = idt.publicKey;
692
+ }
693
+ catch { /* DID 生成失败不阻塞创建 */ }
694
+ const channels = await updateChannels((chs) => [...chs, ch]);
695
+ // 刷新 store 缓存 (updateChannels 走了 server-storage, store 内存还是旧的)
696
+ await store.load();
631
697
  await store.setActive(id);
632
698
  cliAgentName = name.trim();
633
699
  cliActiveChannelId = id;
700
+ // 2026-08-09: 新建 agent 后立即重建 session — 否则新 agent 身份不加载 (bug 修复)
701
+ invalidateAgent();
702
+ try {
703
+ await getAgent();
704
+ }
705
+ catch { /* 非致命 */ }
634
706
  inkSetStatus(getStatus());
635
707
  appendLine(`${C_OK}✓ 已创建智能体 channel: ${name.trim()}${RESET} (${C_DIM}${id}${RESET})${personaHint ? `\n ${C_DIM}persona: ${personaHint}${RESET}` : ''}`);
636
708
  }
@@ -3791,6 +3791,13 @@ ${data.error || "channel not found"}`, "error");
3791
3791
  if (nameEl) nameEl.textContent = identity.name || "\u533F\u540D";
3792
3792
  if (didEl) didEl.textContent = identity.didShort ? `did:key:${identity.didShort}` : "";
3793
3793
  if (letterEl) letterEl.textContent = letter;
3794
+ const avatarEl = document.getElementById("user-avatar");
3795
+ if (avatarEl) {
3796
+ avatarEl.style.cursor = "pointer";
3797
+ avatarEl.onclick = () => {
3798
+ openAuthModal();
3799
+ };
3800
+ }
3794
3801
  if (nameEl) {
3795
3802
  nameEl.onclick = () => {
3796
3803
  const current = nameEl.textContent || "";
@@ -3841,6 +3848,140 @@ ${data.error || "channel not found"}`, "error");
3841
3848
  } catch (e) {
3842
3849
  }
3843
3850
  }
3851
+ function getEl(id) {
3852
+ return document.getElementById(id);
3853
+ }
3854
+ function showAuthMsg(text, isError = false) {
3855
+ const msg = getEl("auth-msg");
3856
+ if (!msg) return;
3857
+ msg.textContent = text;
3858
+ msg.style.display = "block";
3859
+ msg.style.color = isError ? "#ef4444" : "#22c55e";
3860
+ }
3861
+ async function refreshAuthAccounts() {
3862
+ try {
3863
+ const res = await fetch("/api/auth/status");
3864
+ if (!res.ok) return;
3865
+ const data = await res.json();
3866
+ const statusLine = getEl("auth-status-line");
3867
+ if (statusLine) {
3868
+ statusLine.innerHTML = `\u5F53\u524D\u7528\u6237 DID: <code style="font-size:11px;">${escapeHtml2(data.did || "")}</code><br><span style="color:var(--text-muted);">\u6240\u6709\u767B\u5F55\u8D26\u53F7\u4E0E DIAP \u667A\u80FD\u4F53\u8EAB\u4EFD\u5747\u5F52\u5C5E\u6B64 DID\u3002</span>`;
3869
+ }
3870
+ const listEl = getEl("auth-accounts-list");
3871
+ if (listEl) {
3872
+ const accs = data.accounts || [];
3873
+ if (accs.length === 0) {
3874
+ listEl.innerHTML = '<div class="form-info" style="margin-top:4px;color:var(--text-muted);">\u5C1A\u672A\u7ED1\u5B9A\u4EFB\u4F55\u8D26\u53F7</div>';
3875
+ } else {
3876
+ listEl.innerHTML = '<div class="form-info" style="margin-bottom:4px;"><b>\u5DF2\u7ED1\u5B9A\u8D26\u53F7</b></div>' + accs.map((a) => `<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 8px;border:1px solid var(--border);border-radius:6px;margin-bottom:4px;font-size:12px;">
3877
+ <span>${escapeHtml2(a.provider)}${a.identifier ? " \xB7 " + escapeHtml2(a.identifier) : ""}${a.skeleton ? ' <span style="color:#f59e0b;">(\u9AA8\u67B6)</span>' : ""}</span>
3878
+ <button class="btn-secondary btn-sm auth-unbind-btn" data-provider="${escapeHtml2(a.provider)}" style="padding:1px 8px;font-size:11px;">\u89E3\u7ED1</button>
3879
+ </div>`).join("");
3880
+ listEl.querySelectorAll(".auth-unbind-btn").forEach((btn) => {
3881
+ btn.addEventListener("click", async () => {
3882
+ const provider = btn.dataset.provider || "";
3883
+ try {
3884
+ const r = await fetch("/api/auth/logout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider }) });
3885
+ if (r.ok) {
3886
+ showAuthMsg(`\u2713 \u5DF2\u89E3\u7ED1 ${provider}`);
3887
+ refreshAuthAccounts();
3888
+ } else {
3889
+ showAuthMsg(`\u89E3\u7ED1\u5931\u8D25: ${(await r.json()).error || ""}`, true);
3890
+ }
3891
+ } catch {
3892
+ showAuthMsg("\u89E3\u7ED1\u5931\u8D25", true);
3893
+ }
3894
+ });
3895
+ });
3896
+ }
3897
+ }
3898
+ } catch {
3899
+ }
3900
+ }
3901
+ async function openAuthModal() {
3902
+ const modal = getEl("auth-modal");
3903
+ if (!modal) return;
3904
+ modal.style.display = "flex";
3905
+ const msg = getEl("auth-msg");
3906
+ if (msg) msg.style.display = "none";
3907
+ const emailEl = getEl("auth-email");
3908
+ const phoneEl = getEl("auth-phone");
3909
+ if (emailEl) emailEl.value = "";
3910
+ if (phoneEl) phoneEl.value = "";
3911
+ await refreshAuthAccounts();
3912
+ }
3913
+ function closeAuthModal() {
3914
+ const modal = getEl("auth-modal");
3915
+ if (modal) modal.style.display = "none";
3916
+ }
3917
+ async function authLogin(provider, identifier) {
3918
+ try {
3919
+ const res = await fetch("/api/auth/login", {
3920
+ method: "POST",
3921
+ headers: { "Content-Type": "application/json" },
3922
+ body: JSON.stringify({ provider, identifier })
3923
+ });
3924
+ const data = await res.json();
3925
+ if (!res.ok) {
3926
+ showAuthMsg(data.error || "\u767B\u5F55\u5931\u8D25", true);
3927
+ return;
3928
+ }
3929
+ showAuthMsg(`\u2713 ${data.message || "\u5DF2\u7ED1\u5B9A"}`);
3930
+ await refreshAuthAccounts();
3931
+ loadUserIdentity();
3932
+ } catch {
3933
+ showAuthMsg("\u767B\u5F55\u8BF7\u6C42\u5931\u8D25", true);
3934
+ }
3935
+ }
3936
+ function bindAuthModalEvents() {
3937
+ const closeBtn = getEl("auth-modal-close");
3938
+ if (closeBtn) closeBtn.onclick = closeAuthModal;
3939
+ document.querySelectorAll(".auth-oauth-btn").forEach((btn) => {
3940
+ btn.addEventListener("click", () => {
3941
+ const provider = btn.dataset.provider || "";
3942
+ authLogin(provider);
3943
+ });
3944
+ });
3945
+ const emailBtn = getEl("auth-email-btn");
3946
+ if (emailBtn) emailBtn.onclick = () => {
3947
+ const emailEl = getEl("auth-email");
3948
+ const val = emailEl?.value?.trim() || "";
3949
+ if (!val) {
3950
+ showAuthMsg("\u8BF7\u586B\u5199\u90AE\u7BB1", true);
3951
+ return;
3952
+ }
3953
+ authLogin("email", val);
3954
+ };
3955
+ const phoneBtn = getEl("auth-phone-btn");
3956
+ if (phoneBtn) phoneBtn.onclick = () => {
3957
+ const phoneEl = getEl("auth-phone");
3958
+ const val = phoneEl?.value?.trim() || "";
3959
+ if (!val) {
3960
+ showAuthMsg("\u8BF7\u586B\u5199\u624B\u673A\u53F7", true);
3961
+ return;
3962
+ }
3963
+ authLogin("phone", val);
3964
+ };
3965
+ const emailInput = getEl("auth-email");
3966
+ if (emailInput) emailInput.addEventListener("keydown", (e) => {
3967
+ if (e.key === "Enter") getEl("auth-email-btn")?.click();
3968
+ });
3969
+ const phoneInput = getEl("auth-phone");
3970
+ if (phoneInput) phoneInput.addEventListener("keydown", (e) => {
3971
+ if (e.key === "Enter") getEl("auth-phone-btn")?.click();
3972
+ });
3973
+ const modal = getEl("auth-modal");
3974
+ if (modal) modal.addEventListener("click", (e) => {
3975
+ if (e.target === modal) closeAuthModal();
3976
+ });
3977
+ }
3978
+ if (typeof document !== "undefined") {
3979
+ if (document.readyState === "loading") {
3980
+ document.addEventListener("DOMContentLoaded", bindAuthModalEvents);
3981
+ } else {
3982
+ bindAuthModalEvents();
3983
+ }
3984
+ }
3844
3985
  async function init() {
3845
3986
  const themeData = await loadTheme();
3846
3987
  currentAgentId = themeData.agentId || `agent_${generateId().substring(0, 8)}`;
@@ -414,6 +414,54 @@
414
414
  </main>
415
415
  </div>
416
416
 
417
+ <!-- ========== 登录 Modal (2026-08-09) — GitHub/Google/邮箱/手机号, 仅骨架 ========== -->
418
+ <div id="auth-modal" class="modal" style="display:none;">
419
+ <div class="modal-content">
420
+ <div class="modal-header">
421
+ <h2>登录 · 账号绑定</h2>
422
+ <button id="auth-modal-close" class="modal-close">&times;</button>
423
+ </div>
424
+ <div class="modal-body">
425
+ <div id="auth-status-line" class="form-info" style="margin-bottom:12px;font-size:12px;"></div>
426
+
427
+ <!-- OAuth 骨架按钮 -->
428
+ <div class="form-group">
429
+ <label>第三方账号</label>
430
+ <div style="display:flex;gap:8px;flex-wrap:wrap;">
431
+ <button type="button" class="btn-secondary auth-oauth-btn" data-provider="github" style="flex:1;min-width:120px;">GitHub</button>
432
+ <button type="button" class="btn-secondary auth-oauth-btn" data-provider="google" style="flex:1;min-width:120px;">Google</button>
433
+ </div>
434
+ <small class="form-hint" id="auth-oauth-hint">骨架: 点击后记录账号归属当前 DID, 真实 OAuth 授权页后续接入。</small>
435
+ </div>
436
+
437
+ <!-- 邮箱登录骨架 -->
438
+ <div class="form-group">
439
+ <label for="auth-email">邮箱登录</label>
440
+ <div style="display:flex;gap:8px;">
441
+ <input type="email" id="auth-email" placeholder="you@example.com" style="flex:1;">
442
+ <button type="button" class="btn-secondary" id="auth-email-btn">绑定</button>
443
+ </div>
444
+ <small class="form-hint">骨架: 记录邮箱归属当前 DID, 验证码发送后续接入。</small>
445
+ </div>
446
+
447
+ <!-- 手机号登录骨架 -->
448
+ <div class="form-group">
449
+ <label for="auth-phone">手机号登录</label>
450
+ <div style="display:flex;gap:8px;">
451
+ <input type="tel" id="auth-phone" placeholder="+86 138... " style="flex:1;">
452
+ <button type="button" class="btn-secondary" id="auth-phone-btn">绑定</button>
453
+ </div>
454
+ <small class="form-hint">骨架: 记录手机号归属当前 DID, 短信验证码后续接入。</small>
455
+ </div>
456
+
457
+ <!-- 已绑定列表 -->
458
+ <div id="auth-accounts-list" style="margin-top:8px;"></div>
459
+
460
+ <div id="auth-msg" class="form-info" style="display:none;margin-top:8px;"></div>
461
+ </div>
462
+ </div>
463
+ </div>
464
+
417
465
  <script type="module" src="./components/wallet-viem.mjs"></script>
418
466
  <script type="module" src="./components/p2p/index.js"></script>
419
467
  <script type="module" src="./ui/step-timeline.js"></script>
@@ -1873,6 +1873,13 @@ export async function createWebServer(port = 3000, options = {}) {
1873
1873
  // 尝试发布 DID 到 IPFS
1874
1874
  try {
1875
1875
  const auth = await AgentAuthManager.newWithRemoteIpfs('http://127.0.0.1:5001', 'http://127.0.0.1:8080');
1876
+ // 2026-08-09: 归属用户 DID — 智能体身份归属用户唯一身份 (DID 文档带 controller+alsoKnownAs)
1877
+ try {
1878
+ const owner = await loadOrCreateUserIdentity();
1879
+ if (owner?.did)
1880
+ auth.setOwnerDid(owner.did);
1881
+ }
1882
+ catch { /* 归属设置失败不阻塞 */ }
1876
1883
  await auth.registerAgent({ name, services: [] }, kp, '');
1877
1884
  console.log('P2P DID 已发布到 IPFS');
1878
1885
  }
@@ -2793,6 +2800,117 @@ ${goalDesc}
2793
2800
  res.status(500).json({ error: err.message });
2794
2801
  }
2795
2802
  });
2803
+ // ========== 登录框架 (2026-08-09) — GitHub/Google/邮箱/手机号, 仅骨架 ==========
2804
+ // 所有登录方式最终都归属到用户 DID (右下角唯一身份).
2805
+ // 真实 OAuth / 验证码后续接入, 这里先做: 记录账号 + 绑定用户 DID + 提供状态查询.
2806
+ const ACCOUNTS_FILE = `${process.env.HOME || '/tmp'}/.bolloon/accounts.json`;
2807
+ async function loadAccounts() {
2808
+ try {
2809
+ const { readFile } = await import('fs/promises');
2810
+ const parsed = JSON.parse(await readFile(ACCOUNTS_FILE, 'utf-8'));
2811
+ return Array.isArray(parsed) ? parsed : [];
2812
+ }
2813
+ catch {
2814
+ return [];
2815
+ }
2816
+ }
2817
+ async function saveAccounts(accs) {
2818
+ const { mkdir, writeFile } = await import('fs/promises');
2819
+ await mkdir(`${process.env.HOME || '/tmp'}/.bolloon`, { recursive: true });
2820
+ await writeFile(ACCOUNTS_FILE, JSON.stringify(accs, null, 2), { mode: 0o600 });
2821
+ }
2822
+ // GET /api/auth/status — 当前用户 DID + 已绑定账号列表
2823
+ app.get('/api/auth/status', async (_req, res) => {
2824
+ try {
2825
+ const identity = await loadOrCreateUserIdentity();
2826
+ const accs = await loadAccounts();
2827
+ res.json({
2828
+ did: identity.did,
2829
+ didShort: identity.didShort,
2830
+ name: identity.name,
2831
+ // 只返回脱敏视图 (不含 token)
2832
+ accounts: accs.map((a) => ({
2833
+ provider: a.provider,
2834
+ identifier: a.identifier || a.email || a.username || '',
2835
+ loggedAt: a.loggedAt,
2836
+ skeleton: !!a.skeleton,
2837
+ })),
2838
+ });
2839
+ }
2840
+ catch (err) {
2841
+ res.status(500).json({ error: err.message });
2842
+ }
2843
+ });
2844
+ // POST /api/auth/login — 登录骨架 (记录账号 + 绑定用户 DID)
2845
+ // body: { provider: 'github'|'google'|'email'|'phone', identifier?: string }
2846
+ app.post('/api/auth/login', async (req, res) => {
2847
+ try {
2848
+ const { provider, identifier } = req.body || {};
2849
+ const prov = String(provider || '').trim().toLowerCase();
2850
+ const VALID = ['github', 'google', 'email', 'phone'];
2851
+ if (!VALID.includes(prov)) {
2852
+ return res.status(400).json({ error: `provider 必须是 ${VALID.join('/')}` });
2853
+ }
2854
+ // 邮箱/手机号必填 identifier
2855
+ if ((prov === 'email' || prov === 'phone') && !String(identifier || '').trim()) {
2856
+ return res.status(400).json({ error: `${prov === 'email' ? '邮箱' : '手机号'}必填` });
2857
+ }
2858
+ const identity = await loadOrCreateUserIdentity();
2859
+ const accs = await loadAccounts();
2860
+ const idStr = String(identifier || '').trim();
2861
+ const existing = accs.find((a) => a.provider === prov && (!idStr || a.identifier === idStr || a.email === idStr));
2862
+ const now = new Date().toISOString();
2863
+ if (existing) {
2864
+ // 已绑定 → 更新归属 DID + 时间
2865
+ existing.ownerDid = identity.did;
2866
+ existing.loggedAt = now;
2867
+ existing.skeleton = true;
2868
+ }
2869
+ else {
2870
+ accs.push({
2871
+ provider: prov,
2872
+ identifier: idStr || '',
2873
+ email: prov === 'email' ? idStr : '',
2874
+ phone: prov === 'phone' ? idStr : '',
2875
+ username: idStr || '',
2876
+ token: '', // 真实 OAuth 后填
2877
+ ownerDid: identity.did, // 归属用户唯一 DID
2878
+ loggedAt: now,
2879
+ skeleton: true, // 骨架标记: 未做真实 OAuth/验证码
2880
+ });
2881
+ }
2882
+ await saveAccounts(accs);
2883
+ console.log(`[auth] 登录骨架: ${prov}${idStr ? ' ' + idStr : ''} → 归属 DID ${identity.did.substring(0, 20)}...`);
2884
+ res.json({
2885
+ ok: true,
2886
+ provider: prov,
2887
+ identifier: idStr,
2888
+ ownerDid: identity.did,
2889
+ skeleton: true,
2890
+ message: `${prov} 登录骨架已记录 (归属用户 DID), 真实 OAuth/验证码后续接入`,
2891
+ });
2892
+ }
2893
+ catch (err) {
2894
+ res.status(500).json({ error: err.message });
2895
+ }
2896
+ });
2897
+ // POST /api/auth/logout — 解除某个 provider 的绑定 (骨架)
2898
+ app.post('/api/auth/logout', async (req, res) => {
2899
+ try {
2900
+ const { provider } = req.body || {};
2901
+ const prov = String(provider || '').trim().toLowerCase();
2902
+ const accs = await loadAccounts();
2903
+ const before = accs.length;
2904
+ const remaining = accs.filter((a) => a.provider !== prov);
2905
+ if (remaining.length === before)
2906
+ return res.status(404).json({ error: `未绑定 ${prov} 账号` });
2907
+ await saveAccounts(remaining);
2908
+ res.json({ ok: true, provider: prov });
2909
+ }
2910
+ catch (err) {
2911
+ res.status(500).json({ error: err.message });
2912
+ }
2913
+ });
2796
2914
  // 2026-08-08: DID 目录 (Postgres 式) — 以用户 DID 为主键, 绑定 memory/persona/skills/on_policy/context_os 等表
2797
2915
  app.get('/api/did-catalog/:table', async (req, res) => {
2798
2916
  try {
@@ -4176,6 +4294,13 @@ ${goalDesc}
4176
4294
  const pkBytes = Buffer.from(channel.publicKey, 'hex');
4177
4295
  const kp = { privateKey: new Uint8Array(32), publicKey: pkBytes, did: channel.did };
4178
4296
  const auth = await AgentAuthManager.newWithRemoteIpfs('http://127.0.0.1:5001', 'http://127.0.0.1:8080');
4297
+ // 2026-08-09: 归属用户 DID — 子智能体身份归属用户唯一身份
4298
+ try {
4299
+ const owner = await loadOrCreateUserIdentity();
4300
+ if (owner?.did)
4301
+ auth.setOwnerDid(owner.did);
4302
+ }
4303
+ catch { /* 归属设置失败不阻塞 */ }
4179
4304
  const result = await auth.registerAgent({ name: channel.name, services: [] }, kp, '');
4180
4305
  channel.cid = result.cid || channel.cid;
4181
4306
  // 关键: 不再保存整份 didDocument, 只留 cid/ipnsName 两个引用字段
@@ -1446,6 +1446,38 @@ body {
1446
1446
  gap: 8px;
1447
1447
  }
1448
1448
 
1449
+ /* 2026-08-09: Hermes 式流式回复框 — 加载中底部开放 (虚线底边 + 脉动),
1450
+ 回复完成后 finalizeTimelineAsMessage → addMessage 生成完整封闭气泡 (.bubble-ai).
1451
+ 加载框即"未封闭"状态: 顶部/左右边框实线, 底部虚线示意还在写. */
1452
+ .message-streaming {
1453
+ background: var(--ai-bg);
1454
+ color: var(--ai-text);
1455
+ border: 1px solid var(--border);
1456
+ border-top-left-radius: var(--radius);
1457
+ border-top-right-radius: var(--radius);
1458
+ border-bottom-left-radius: 4px;
1459
+ border-bottom-right-radius: 4px;
1460
+ padding: 14px 18px;
1461
+ line-height: 1.7;
1462
+ white-space: pre-line;
1463
+ word-break: break-word;
1464
+ font-size: 15px;
1465
+ /* 底部开放: 虚线 + 强调色, 视觉上"还没写完" */
1466
+ border-bottom: 2px dashed var(--accent);
1467
+ animation: streamBoxPulse 1.4s ease-in-out infinite;
1468
+ }
1469
+
1470
+ @keyframes streamBoxPulse {
1471
+ 0%, 100% { border-bottom-color: var(--accent); }
1472
+ 50% { border-bottom-color: var(--accent-dark); }
1473
+ }
1474
+
1475
+ /* 完成态: 底部封闭成实线 (由 addMessage 生成 .bubble-ai, 这里兜底旧 streaming 元素) */
1476
+ .message-streaming.closed {
1477
+ border-bottom: 1px solid var(--border);
1478
+ animation: none;
1479
+ }
1480
+
1449
1481
  .streaming-content::before {
1450
1482
  content: '';
1451
1483
  width: 6px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",
@@ -59,7 +59,7 @@
59
59
  "@capacitor/ios": "^8.4.2",
60
60
  "@chainsafe/libp2p-noise": "^17.0.0",
61
61
  "@chainsafe/libp2p-yamux": "^8.0.1",
62
- "@diap/sdk": "^0.2.2",
62
+ "@diap/sdk": "^0.2.4",
63
63
  "@libp2p/autonat": "^3.0.25",
64
64
  "@libp2p/circuit-relay-v2": "^4.2.9",
65
65
  "@libp2p/dcutr": "^3.0.20",