@bolloon/bolloon-agent 0.2.7 → 0.2.9

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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * peer-resource-bridge.ts — 4 类资源 (groups/function/exportment/science) 的
3
+ * 本地读 + 远端落盘的统一封装.
4
+ *
5
+ * 设计目的 (2026-07-05):
6
+ * peer-fs.ts 已经有完整路径 helpers (writeGroup/writeFunction/...) + reader (listPeerResources).
7
+ * server.ts 三处要调 (两个 manifest.exchange.reply 落盘 + 一个 manifest.exchange 发送),
8
+ * 每处手写 4 个 await 又啰嗦又容易漏. 这里抽出来.
9
+ *
10
+ * 本地读: ~/.bolloon/local-resources/<category>/<id>.md frontmatter
11
+ * 远端落: peerFs.writeGroup/Function/Exportment/Science (atomic, 人类可读 md)
12
+ *
13
+ * frontmatter 字段就是类型本身的字段 (id/name/description/...),
14
+ * description 从 frontmatter 后面的第一个段落拿.
15
+ */
16
+ import * as fs from 'fs/promises';
17
+ import * as path from 'path';
18
+ import * as os from 'os';
19
+ import * as peerFs from '../network/peer-fs.js';
20
+ const HOME = process.env.BOLLOON_HOME || path.join(os.homedir(), '.bolloon');
21
+ export const LOCAL_RESOURCES_ROOT = path.join(HOME, 'local-resources');
22
+ const EMPTY = { groups: [], functions: [], exportments: [], sciences: [] };
23
+ /**
24
+ * 简单 frontmatter 解析: 只支持 key: value / key: [..] (本文件自产自销, 不引第三方).
25
+ */
26
+ function parseFrontmatter(text) {
27
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
28
+ if (!m)
29
+ return { fields: {}, body: text };
30
+ const fields = {};
31
+ for (const line of m[1].split('\n')) {
32
+ const kv = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
33
+ if (!kv)
34
+ continue;
35
+ const key = kv[1];
36
+ let raw = kv[2].trim();
37
+ if (raw.startsWith('[') && raw.endsWith(']')) {
38
+ try {
39
+ fields[key] = JSON.parse(raw);
40
+ continue;
41
+ }
42
+ catch { }
43
+ }
44
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
45
+ try {
46
+ fields[key] = JSON.parse(raw);
47
+ continue;
48
+ }
49
+ catch { }
50
+ }
51
+ fields[key] = raw;
52
+ }
53
+ return { fields, body: m[2].trim() };
54
+ }
55
+ /**
56
+ * 从 ~/.bolloon/local-resources/<category>/<id>.md 读所有本地资源.
57
+ * 文件不存在 / 目录不存在 → 返回空数组 (不报错, 跟 peer-fs 风格一致).
58
+ */
59
+ async function readCategoryDir(category) {
60
+ try {
61
+ const dir = path.join(LOCAL_RESOURCES_ROOT, category);
62
+ const entries = await fs.readdir(dir);
63
+ return entries.filter((e) => e.endsWith('.md')).map((e) => path.join(dir, e));
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ async function readGroupFile(p) {
70
+ try {
71
+ const raw = await fs.readFile(p, 'utf-8');
72
+ const { fields, body } = parseFrontmatter(raw);
73
+ if (!fields.id)
74
+ return null;
75
+ return {
76
+ id: String(fields.id),
77
+ name: String(fields.name || fields.id),
78
+ description: body || undefined,
79
+ visibility: fields.visibility,
80
+ memberCount: typeof fields.memberCount === 'number' ? fields.memberCount : undefined,
81
+ };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ async function readFunctionFile(p) {
88
+ try {
89
+ const raw = await fs.readFile(p, 'utf-8');
90
+ const { fields, body } = parseFrontmatter(raw);
91
+ if (!fields.capability)
92
+ return null;
93
+ return {
94
+ capability: String(fields.capability),
95
+ description: body || undefined,
96
+ mediaType: fields.mediaType,
97
+ endpoint: fields.endpoint ? String(fields.endpoint) : undefined,
98
+ };
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ async function readExportmentFile(p) {
105
+ try {
106
+ const raw = await fs.readFile(p, 'utf-8');
107
+ const { fields, body } = parseFrontmatter(raw);
108
+ if (!fields.name)
109
+ return null;
110
+ return {
111
+ name: String(fields.name),
112
+ description: body || undefined,
113
+ genre: fields.genre ? String(fields.genre) : undefined,
114
+ minPlayers: typeof fields.minPlayers === 'number' ? fields.minPlayers : undefined,
115
+ maxPlayers: typeof fields.maxPlayers === 'number' ? fields.maxPlayers : undefined,
116
+ };
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ async function readScienceFile(p) {
123
+ try {
124
+ const raw = await fs.readFile(p, 'utf-8');
125
+ const { fields, body } = parseFrontmatter(raw);
126
+ if (!fields.id)
127
+ return null;
128
+ return {
129
+ id: String(fields.id),
130
+ title: String(fields.title || fields.id),
131
+ description: body || undefined,
132
+ status: fields.status,
133
+ tags: Array.isArray(fields.tags) ? fields.tags.map(String) : undefined,
134
+ };
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ /**
141
+ * 读本机所有 4 类资源. 任何失败 → 该类别返回空 (不阻塞其他类别).
142
+ */
143
+ export async function loadLocalResources() {
144
+ const out = { ...EMPTY };
145
+ const [gFiles, fFiles, eFiles, sFiles] = await Promise.all([
146
+ readCategoryDir('groups'),
147
+ readCategoryDir('functions'),
148
+ readCategoryDir('exportments'),
149
+ readCategoryDir('sciences'),
150
+ ]);
151
+ for (const f of gFiles) {
152
+ const v = await readGroupFile(f);
153
+ if (v)
154
+ out.groups.push(v);
155
+ }
156
+ for (const f of fFiles) {
157
+ const v = await readFunctionFile(f);
158
+ if (v)
159
+ out.functions.push(v);
160
+ }
161
+ for (const f of eFiles) {
162
+ const v = await readExportmentFile(f);
163
+ if (v)
164
+ out.exportments.push(v);
165
+ }
166
+ for (const f of sFiles) {
167
+ const v = await readScienceFile(f);
168
+ if (v)
169
+ out.sciences.push(v);
170
+ }
171
+ return out;
172
+ }
173
+ /**
174
+ * 把 manifest 里的 4 类资源落盘到 peerFs 对应目录.
175
+ * 静默容错: 单条失败不影响其他.
176
+ */
177
+ export async function writeRemoteResources(publicKey, m) {
178
+ let groups = 0, functions = 0, exportments = 0, sciences = 0;
179
+ if (Array.isArray(m.groups)) {
180
+ for (const g of m.groups) {
181
+ try {
182
+ await peerFs.writeGroup(publicKey, g);
183
+ groups++;
184
+ }
185
+ catch { }
186
+ }
187
+ }
188
+ if (Array.isArray(m.functions)) {
189
+ for (const f of m.functions) {
190
+ try {
191
+ await peerFs.writeFunction(publicKey, f);
192
+ functions++;
193
+ }
194
+ catch { }
195
+ }
196
+ }
197
+ if (Array.isArray(m.exportments)) {
198
+ for (const e of m.exportments) {
199
+ try {
200
+ await peerFs.writeExportment(publicKey, e);
201
+ exportments++;
202
+ }
203
+ catch { }
204
+ }
205
+ }
206
+ if (Array.isArray(m.sciences)) {
207
+ for (const s of m.sciences) {
208
+ try {
209
+ await peerFs.writeScience(publicKey, s);
210
+ sciences++;
211
+ }
212
+ catch { }
213
+ }
214
+ }
215
+ return { groups, functions, exportments, sciences };
216
+ }
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  // @ts-nocheck
3
2
  // marked 库可能从 CDN 加载失败, 这里做安全降级 (避免 ReferenceError 让 addMessage 整体崩溃)
4
3
  if (typeof marked === 'undefined') {
@@ -618,7 +617,7 @@ async function deleteChannel(channelId, e) {
618
617
  if (currentChannelId) {
619
618
  const ch = channels.find(c => c.id === currentChannelId);
620
619
  if (channelNameEl)
621
- channelNameEl.textContent = ch?.name || 'Bolloon Agent';
620
+ channelNameEl.textContent = safeChannelName(ch?.name, 'Bolloon Agent');
622
621
  await selectChannel(currentChannelId);
623
622
  }
624
623
  else {
@@ -970,7 +969,7 @@ function renderChannels() {
970
969
  <polyline points="9 18 15 12 9 6"></polyline>
971
970
  </svg>
972
971
  <div class="channel-icon">💬</div>
973
- <span class="channel-name" title="${escapeHtml(ch.name)}">${escapeHtml(ch.name)}</span>
972
+ <span class="channel-name" title="${escapeHtml(safeChannelName(ch.name, ''))}">${escapeHtml(safeChannelName(ch.name))}</span>
974
973
  <span class="agent-row-meta">
975
974
  ${walletBadge}
976
975
  ${toolsBadge}
@@ -1098,6 +1097,9 @@ function formatSessionName(sess) {
1098
1097
  const escapeHtml = MR_escapeHtml || ((s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
1099
1098
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
1100
1099
  }[c])));
1100
+ // 2026-07-06: 通用 channel name 兜底 — 防止 name=undefined/null/'undefined' 字串
1101
+ // 时 UI 出现 "undefined" 字面量. 委托 util/safe-name.ts (有单测覆盖).
1102
+ import { safeChannelName } from './util/safe-name.js';
1101
1103
  function ensureMessageContainer(channelId) {
1102
1104
  if (!messagesContainers.has(channelId)) {
1103
1105
  const container = document.createElement('div');
@@ -1134,7 +1136,7 @@ async function selectChannel(channelId, targetSessionId = null) {
1134
1136
  const channel = channels.find(c => c.id === channelId);
1135
1137
  if (channel) {
1136
1138
  if (channelNameEl)
1137
- channelNameEl.textContent = channel.name;
1139
+ channelNameEl.textContent = safeChannelName(channel.name);
1138
1140
  currentSessionId = targetSessionId || channel.currentSessionId || 'default';
1139
1141
  if (targetSessionId) {
1140
1142
  channel.currentSessionId = targetSessionId;
@@ -1532,7 +1534,7 @@ function connect(channelId) {
1532
1534
  channel.name = data.newName;
1533
1535
  renderChannels();
1534
1536
  if (currentChannelId === data.channelId && channelNameEl) {
1535
- channelNameEl.textContent = data.newName;
1537
+ channelNameEl.textContent = safeChannelName(data.newName);
1536
1538
  }
1537
1539
  }
1538
1540
  }
@@ -1698,7 +1700,7 @@ async function refreshMentionChannels() {
1698
1700
  const remote = [];
1699
1701
  for (const p of (remoteData.peers || [])) {
1700
1702
  for (const c of (p.channels || [])) {
1701
- remote.push({ id: c.id, name: c.name, source: 'remote', ownerPublicKey: p.peerId });
1703
+ remote.push({ id: c.id, name: safeChannelName(c.name, '(远端未命名)'), source: 'remote', ownerPublicKey: p.peerId });
1702
1704
  }
1703
1705
  }
1704
1706
  mentionChannels = [
@@ -1751,9 +1753,9 @@ function renderMentionDropdown(items) {
1751
1753
  // 浅蓝 = 键盘高亮, 白 = 普通
1752
1754
  const bg = i === mentionHighlightIdx ? '#eff6ff' : '#fff';
1753
1755
  const borderLeft = i === mentionHighlightIdx ? '3px solid #93c5fd' : '3px solid transparent';
1754
- return `<div class="mention-item" data-idx="${i}" data-channel-id="${escapeHtml(c.id)}" data-channel-name="${escapeHtml(c.name)}" style="padding:8px 12px;cursor:pointer;background:${bg};border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:8px;border-left:${borderLeft};">
1756
+ return `<div class="mention-item" data-idx="${i}" data-channel-id="${escapeHtml(c.id)}" data-channel-name="${escapeHtml(safeChannelName(c.name, ''))}" style="padding:8px 12px;cursor:pointer;background:${bg};border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:8px;border-left:${borderLeft};">
1755
1757
  <span style="font-size:10px;color:${isLocal ? '#059669' : '#2563eb'};background:${isLocal ? '#d1fae5' : '#dbeafe'};padding:1px 6px;border-radius:3px;white-space:nowrap;">${tag}</span>
1756
- <span style="flex:1;">${escapeHtml(c.name)}</span>${owner}
1758
+ <span style="flex:1;">${escapeHtml(safeChannelName(c.name))}</span>${owner}
1757
1759
  </div>`;
1758
1760
  }).join('');
1759
1761
  mentionDropdownEl.innerHTML = headerHtml + rows;
@@ -1794,7 +1796,7 @@ function applyMention(channel) {
1794
1796
  }
1795
1797
  const before = input.value.slice(0, anchor); // 含 @
1796
1798
  const after = input.value.slice(blockEnd); // query 之后 (可能用户已输入正文)
1797
- const insert = `@${channel.name} `;
1799
+ const insert = `@${safeChannelName(channel.name)} `;
1798
1800
  input.value = before + insert + after;
1799
1801
  const newPos = before.length + insert.length;
1800
1802
  input.focus();
@@ -1826,7 +1828,7 @@ function updateMentionDropdown() {
1826
1828
  }
1827
1829
  mentionQuery = m.query;
1828
1830
  const q = m.query.toLowerCase();
1829
- const items = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
1831
+ const items = mentionChannels.filter(c => safeChannelName(c.name).toLowerCase().includes(q)).slice(0, 8);
1830
1832
  mentionHighlightIdx = items.length > 0 ? 0 : -1;
1831
1833
  renderMentionDropdown(items);
1832
1834
  }
@@ -1843,7 +1845,7 @@ input.addEventListener('keydown', (e) => {
1843
1845
  return;
1844
1846
  mentionHighlightIdx = (mentionHighlightIdx + 1) % items.length;
1845
1847
  const q = (mentionQuery || '').toLowerCase();
1846
- const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
1848
+ const filtered = mentionChannels.filter(c => safeChannelName(c.name).toLowerCase().includes(q)).slice(0, 8);
1847
1849
  renderMentionDropdown(filtered);
1848
1850
  }
1849
1851
  else if (e.key === 'ArrowUp') {
@@ -1852,7 +1854,7 @@ input.addEventListener('keydown', (e) => {
1852
1854
  return;
1853
1855
  mentionHighlightIdx = (mentionHighlightIdx - 1 + items.length) % items.length;
1854
1856
  const q = (mentionQuery || '').toLowerCase();
1855
- const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
1857
+ const filtered = mentionChannels.filter(c => safeChannelName(c.name).toLowerCase().includes(q)).slice(0, 8);
1856
1858
  renderMentionDropdown(filtered);
1857
1859
  }
1858
1860
  else if (e.key === 'Enter' || e.key === 'Tab') {
@@ -1861,7 +1863,7 @@ input.addEventListener('keydown', (e) => {
1861
1863
  e.preventDefault();
1862
1864
  e.stopPropagation();
1863
1865
  const q = (mentionQuery || '').toLowerCase();
1864
- const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
1866
+ const filtered = mentionChannels.filter(c => safeChannelName(c.name).toLowerCase().includes(q)).slice(0, 8);
1865
1867
  const cur = filtered[mentionHighlightIdx];
1866
1868
  if (cur)
1867
1869
  applyMention(cur);
@@ -1914,7 +1916,7 @@ function setupMentionAutocomplete(inputEl) {
1914
1916
  }
1915
1917
  const before = inputEl.value.slice(0, anchor); // 含 @
1916
1918
  const after = inputEl.value.slice(blockEnd);
1917
- const insert = `@${channel.name} `;
1919
+ const insert = `@${safeChannelName(channel.name)} `;
1918
1920
  inputEl.value = before + insert + after;
1919
1921
  const newPos = before.length + insert.length;
1920
1922
  inputEl.focus();
@@ -1941,10 +1943,10 @@ function setupMentionAutocomplete(inputEl) {
1941
1943
  const owner = !isLocal && c.ownerPublicKey ? ` <span style="color:#9ca3af;font-size:11px;">(${c.ownerPublicKey.substring(0, 8)}…)</span>` : '';
1942
1944
  const bg = i === localHighlight ? '#eff6ff' : '#fff';
1943
1945
  const borderLeft = i === localHighlight ? '3px solid #93c5fd' : '3px solid transparent';
1944
- return `<div class="mention-item" data-idx="${i}" data-channel-id="${escapeHtml(c.id)}" data-channel-name="${escapeHtml(c.name)}" style="padding:8px 12px;cursor:pointer;background:${bg};border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:8px;border-left:${borderLeft};">
1945
- <span style="font-size:10px;color:${isLocal ? '#059669' : '#2563eb'};background:${isLocal ? '#d1fae5' : '#dbeafe'};padding:1px 6px;border-radius:3px;white-space:nowrap;">${tag}</span>
1946
- <span style="flex:1;">${escapeHtml(c.name)}</span>${owner}
1947
- </div>`;
1946
+ return `<div class="mention-item" data-idx="${i}" data-channel-id="${escapeHtml(c.id)}" data-channel-name="${escapeHtml(safeChannelName(c.name, ''))}" style="padding:8px 12px;cursor:pointer;background:${bg};border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:8px;border-left:${borderLeft};">
1947
+ <span style="font-size:10px;color:${isLocal ? '#059669' : '#2563eb'};background:${isLocal ? '#d1fae5' : '#dbeafe'};padding:1px 6px;border-radius:3px;white-space:nowrap;">${tag}</span>
1948
+ <span style="flex:1;">${escapeHtml(safeChannelName(c.name))}</span>${owner}
1949
+ </div>`;
1948
1950
  }).join('');
1949
1951
  inputEl.__mentionDD.querySelectorAll('.mention-item').forEach((el) => {
1950
1952
  const idx = parseInt(el.getAttribute('data-idx'));
@@ -1990,7 +1992,7 @@ function setupMentionAutocomplete(inputEl) {
1990
1992
  }
1991
1993
  localQuery = m.query;
1992
1994
  const q = m.query.toLowerCase();
1993
- const items = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
1995
+ const items = mentionChannels.filter(c => safeChannelName(c.name).toLowerCase().includes(q)).slice(0, 8);
1994
1996
  localHighlight = items.length > 0 ? 0 : -1;
1995
1997
  renderLocal(items);
1996
1998
  }
@@ -2005,7 +2007,7 @@ function setupMentionAutocomplete(inputEl) {
2005
2007
  return;
2006
2008
  localHighlight = (localHighlight + 1) % items.length;
2007
2009
  const q = (localQuery || '').toLowerCase();
2008
- renderLocal(mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8));
2010
+ renderLocal(mentionChannels.filter(c => safeChannelName(c.name).toLowerCase().includes(q)).slice(0, 8));
2009
2011
  }
2010
2012
  else if (e.key === 'ArrowUp') {
2011
2013
  e.preventDefault();
@@ -2013,14 +2015,14 @@ function setupMentionAutocomplete(inputEl) {
2013
2015
  return;
2014
2016
  localHighlight = (localHighlight - 1 + items.length) % items.length;
2015
2017
  const q = (localQuery || '').toLowerCase();
2016
- renderLocal(mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8));
2018
+ renderLocal(mentionChannels.filter(c => safeChannelName(c.name).toLowerCase().includes(q)).slice(0, 8));
2017
2019
  }
2018
2020
  else if (e.key === 'Enter' || e.key === 'Tab') {
2019
2021
  if (items.length > 0) {
2020
2022
  e.preventDefault();
2021
2023
  e.stopPropagation();
2022
2024
  const q = (localQuery || '').toLowerCase();
2023
- const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
2025
+ const filtered = mentionChannels.filter(c => safeChannelName(c.name).toLowerCase().includes(q)).slice(0, 8);
2024
2026
  const cur = filtered[localHighlight];
2025
2027
  if (cur)
2026
2028
  applyLocal(cur);
@@ -2256,7 +2258,7 @@ function renderJudgments(items) {
2256
2258
  ? channels.find(c => c.id === currentChannelId)
2257
2259
  : null;
2258
2260
  if (chNameEl) {
2259
- chNameEl.textContent = currentCh ? `(${currentCh.name})` : '(未选)';
2261
+ chNameEl.textContent = currentCh ? `(${safeChannelName(currentCh.name)})` : '(未选)';
2260
2262
  }
2261
2263
  if (all.length === 0) {
2262
2264
  judgmentsList.innerHTML = '<div class="task-empty">还没有判断, 在上面记录第一条吧</div>';
@@ -2286,7 +2288,7 @@ function renderJudgments(items) {
2286
2288
  const bound = all.filter(j => boundIds.has(j.id));
2287
2289
  const unbound = all.filter(j => !boundIds.has(j.id));
2288
2290
  if (titleEl)
2289
- titleEl.textContent = `${currentCh.name} 的判断力 (已绑 ${bound.length} / 共 ${all.length})`;
2291
+ titleEl.textContent = `${safeChannelName(currentCh.name)} 的判断力 (已绑 ${bound.length} / 共 ${all.length})`;
2290
2292
  let html = '';
2291
2293
  if (bound.length > 0) {
2292
2294
  html += `<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;padding:8px 4px 4px;">已绑定 (${bound.length})</div>`;
@@ -3259,7 +3261,7 @@ function renderRemoteChannels() {
3259
3261
  <div class="remote-channel-row" data-peer-id="${escapeHtml(peer.publicKey)}" data-channel-id="${escapeHtml(c.id)}"
3260
3262
  style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;">
3261
3263
  <span>🤖</span>
3262
- <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
3264
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(safeChannelName(c.name, ''))}">${escapeHtml(safeChannelName(c.name))}</span>
3263
3265
  </div>
3264
3266
  `).join('')}
3265
3267
  </div>
@@ -3408,7 +3410,7 @@ async function openShareToPeerModal(peerName, peerPublicKey) {
3408
3410
  <label class="share-modal-row">
3409
3411
  <input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''} class="share-modal-cb">
3410
3412
  <div class="share-modal-row-info">
3411
- <div class="share-modal-row-name">${escapeHtml(ch.name)}</div>
3413
+ <div class="share-modal-row-name">${escapeHtml(ch.name || '(未命名)')}</div>
3412
3414
  <div class="share-modal-row-meta">
3413
3415
  ${isShared ? '✓ 已分享' : '未分享'} · ${escapeHtml(ch.id.slice(0, 24))}…
3414
3416
  </div>
@@ -4190,7 +4192,7 @@ function renderWalletList() {
4190
4192
  row.innerHTML = `
4191
4193
  <span class="wallet-chain">${escapeHtml(chain)}</span>
4192
4194
  <div class="wallet-info">
4193
- <span class="wallet-agent" title="${escapeHtml(ch.name)}">${escapeHtml(ch.name)}</span>
4195
+ <span class="wallet-agent" title="${escapeHtml(ch.name || '')}">${escapeHtml(ch.name || '(未命名)')}</span>
4194
4196
  <span class="wallet-address" title="${escapeHtml(ch.walletAddress)}">${escapeHtml(ch.walletAddress)}</span>
4195
4197
  </div>
4196
4198
  <div class="wallet-actions">
@@ -1,7 +1,7 @@
1
- "use strict";
2
1
  /**
3
2
  * P2P Modal - 纯 TypeScript 版本
4
3
  */
4
+ import { safeName as safeAnyName } from '../../util/safe-name.js';
5
5
  class P2PModalUI {
6
6
  modal = null;
7
7
  overlay = null;
@@ -58,8 +58,14 @@ class P2PModalUI {
58
58
  const irohResp = await fetch('/api/iroh/init', { method: 'POST' });
59
59
  const irohData = await irohResp.json();
60
60
  if (irohData.cid) {
61
+ // 2026-07-06: 防御 irohData.name 是 'undefined'/'null'/空白 的情况,
62
+ // 否则 UI 会渲染字面量 "undefined". (multi-confirmed fix: 避免再现 v0.2.7 的 bug)
63
+ const rawName = (typeof irohData.name === 'string' && irohData.name.trim()
64
+ && irohData.name.trim() !== 'undefined'
65
+ && irohData.name.trim() !== 'null') ? irohData.name.trim() : '';
66
+ const tailName = rawName.split('-').slice(-1)[0] || '';
61
67
  identityData = {
62
- name: irohData.name || irohData.name?.split('-').slice(-1)[0] || 'Bolloon',
68
+ name: rawName || tailName || 'Bolloon',
63
69
  did: irohData.did || '未知',
64
70
  cid: irohData.cid || '未知',
65
71
  nodeId: irohData.irohNodeId ? irohData.irohNodeId.substring(0, 20) + '...' : '未知'
@@ -77,7 +83,7 @@ class P2PModalUI {
77
83
  <h3>身份信息</h3>
78
84
  <div class="p2p-info-row">
79
85
  <span class="label">名称:</span>
80
- <span class="value">${this.escapeHtml(identityData.name || '未知')}</span>
86
+ <span class="value">${this.escapeHtml(this.safeName(identityData.name, '未知'))}</span>
81
87
  </div>
82
88
  <div class="p2p-info-row">
83
89
  <span class="label">CID:</span>
@@ -237,7 +243,7 @@ class P2PModalUI {
237
243
  });
238
244
  const data = await resp.json();
239
245
  if (data.ok) {
240
- result.innerHTML = `<div class="p2p-success">连接成功!节点: ${this.escapeHtml(data.nodeName || cid.substring(0, 16))}...</div>`;
246
+ result.innerHTML = `<div class="p2p-success">连接成功!节点: ${this.escapeHtml(this.safeName(data.nodeName, cid.substring(0, 16)))}...</div>`;
241
247
  result.className = 'p2p-result success';
242
248
  // 清空输入框
243
249
  input.value = '';
@@ -301,7 +307,7 @@ class P2PModalUI {
301
307
  const time = new Date(info.time).toLocaleTimeString();
302
308
  html += `
303
309
  <div class="p2p-peer-item">
304
- <span class="p2p-peer-name">${this.escapeHtml(info.name || shortId)}</span>
310
+ <span class="p2p-peer-name">${this.escapeHtml(this.safeName(info.name, shortId))}</span>
305
311
  <span class="p2p-peer-id">${shortId}</span>
306
312
  <span class="p2p-peer-time">${time}</span>
307
313
  </div>
@@ -314,6 +320,12 @@ class P2PModalUI {
314
320
  div.textContent = str;
315
321
  return div.innerHTML;
316
322
  }
323
+ // 2026-07-06: 通用 name 兜底 — 防止 name=undefined/null/'undefined' 字串
324
+ // 时 UI 渲染字面量 "undefined".
325
+ safeName(input, fallback) {
326
+ // 2026-07-06: 委托共享 safe-name.ts (有单测覆盖); 保持方法签名兼容旧调用.
327
+ return safeAnyName(input, fallback || '未知');
328
+ }
317
329
  showToast(message) {
318
330
  const toast = document.createElement('div');
319
331
  toast.className = 'p2p-toast';
@@ -2,6 +2,7 @@
2
2
  * P2P Modal Web Component
3
3
  */
4
4
  import { p2pManager } from './p2p-manager.js';
5
+ import { safeName as safeAnyName } from '../../util/safe-name.js';
5
6
  export class P2PModal extends HTMLElement {
6
7
  shadow = this.attachShadow({ mode: 'open' });
7
8
  activeTab = 'identity';
@@ -107,7 +108,7 @@ export class P2PModal extends HTMLElement {
107
108
  <div class="history-item-icon">💬</div>
108
109
  <div class="history-item-info">
109
110
  <div class="history-item-name">
110
- ${this.escapeHtml(item.name || 'Unknown')}
111
+ ${this.escapeHtml(this.safeName(item.name, 'Unknown'))}
111
112
  ${item.isPinned ? '<span class="pin-icon">📌</span>' : ''}
112
113
  </div>
113
114
  <div class="history-item-meta">
@@ -217,7 +218,7 @@ export class P2PModal extends HTMLElement {
217
218
  div.innerHTML = `
218
219
  <div class="peer-status"><span class="dot online"></span></div>
219
220
  <div class="peer-info">
220
- <div class="peer-name">${this.escapeHtml(peer.info?.name || 'Unknown')}</div>
221
+ <div class="peer-name">${this.escapeHtml(this.safeName(peer.info?.name, 'Unknown'))}</div>
221
222
  <div class="peer-meta">${(peer.nodeId || '').substring(0, 16)}...</div>
222
223
  </div>
223
224
  `;
@@ -275,6 +276,12 @@ export class P2PModal extends HTMLElement {
275
276
  div.textContent = text;
276
277
  return div.innerHTML;
277
278
  }
279
+ // 2026-07-06: 通用 name 兜底 — 防止 name=undefined/null/'undefined' 字串
280
+ // 时 UI 渲染字面量 "undefined".
281
+ safeName(input, fallback) {
282
+ // 2026-07-06: 委托共享 safe-name.ts (有单测覆盖); 保持方法签名兼容旧调用.
283
+ return safeAnyName(input, fallback || 'Unknown');
284
+ }
278
285
  showToast(message) {
279
286
  const toast = document.createElement('div');
280
287
  toast.className = 'toast';