@bolloon/bolloon-agent 0.4.23 → 0.4.24

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.
@@ -16,6 +16,9 @@
16
16
  },
17
17
  };
18
18
 
19
+ // 一键入网默认 prompt: 交给智能体去读网关入网说明并执行入网 (人类只点一下)
20
+ const DEFAULT_JOIN_PROMPT = 'read https://bolloon.cn/bolloon-gateway-join.md';
21
+
19
22
  const THEMES = {
20
23
  dark: { '--bg': '#1a1a18', '--bg-card': '#222220', '--bg-hover': '#2a2a26', '--text': '#d8d8c8', '--text-secondary': '#909088', '--accent': '#c4d640', '--border': '#3a3a36' },
21
24
  light: { '--bg': '#f5f5f0', '--bg-card': '#ffffff', '--bg-hover': '#eeeeea', '--text': '#1a1a18', '--text-secondary': '#606058', '--accent': '#8a9430', '--border': '#d0d0c8' },
@@ -66,7 +69,7 @@
66
69
  });
67
70
  } catch (e) {}
68
71
 
69
- const TITLES = { main: '首页', network: '网络', me: '我' };
72
+ const TITLES = { main: '首页', friends: '好友', network: '网络', me: '我' };
70
73
  let currentTab = 'main';
71
74
  function switchTab(tab) {
72
75
  currentTab = tab;
@@ -75,7 +78,8 @@
75
78
  $('#topbar-title').textContent = TITLES[tab] || '会话';
76
79
  const cs = $('#btn-create-session'); if (cs) cs.hidden = tab !== 'main';
77
80
  const ta = $('#topbar-actions'); if (ta) ta.hidden = tab === 'me'; // 我 页不显示 加号/刷新
78
- if (tab === 'network') { loadContacts(); loadMcpTools(); loadApprovals(); loadNetMembers(); loadP2PStatus(); loadAgentServices(); }
81
+ if (tab === 'friends') { loadContacts(); loadP2PStatus(); }
82
+ if (tab === 'network') { loadAgentControl(); loadApprovals(); loadNetMembers(); loadAgentServices(); loadX402Info(); }
79
83
  if (tab === 'main') { loadAgentCovers(); }
80
84
  window.__mobileTouch?.('tab', tab);
81
85
  }
@@ -131,30 +135,135 @@
131
135
  } catch (e) { /* 忽略 */ }
132
136
  }
133
137
 
134
- async function loadMcpTools() {
135
- const box = $('#mcp-tools');
138
+ // 智能体控制 = MCP 控制 + Skills 控制 (不是屏幕触控!)
139
+ // - MCP 工具: 真正的工具调用 (gateway_status / join / register …)
140
+ // - Skills: 电脑端 ~/.bolloon/skills/ 同步下来的本机技能
141
+ // 屏幕触控(无障碍) 挪到 设置 → 无障碍服务 (屏幕触控)。
142
+ function mkOverlayPage(id, title, inner) {
143
+ const page = document.createElement('div');
144
+ page.className = 'chat-page';
145
+ page.id = id;
146
+ page.style.zIndex = '70';
147
+ page.innerHTML = `<div class="chat-topbar">
148
+ <button class="icon-btn" id="${id}-back">←</button>
149
+ <div style="flex:1;font-weight:600">${escapeHtml(title)}</div>
150
+ </div>
151
+ <div id="${id}-body" style="padding:12px;flex:1;min-height:0;overflow:auto">${inner}</div>`;
152
+ document.body.appendChild(page);
153
+ page.querySelector(`#${id}-back`).addEventListener('click', () => page.remove());
154
+ return page;
155
+ }
156
+
157
+ async function loadAgentControl() {
158
+ const box = $('#agent-control');
136
159
  if (!box) return;
137
- box.innerHTML = '<div style="padding:12px 16px;color:var(--text-muted)">加载 MCP 工具...</div>';
160
+ const iconMcp = '<svg class="ico" viewBox="0 0 24 24"><path d="M14.5 4.5a3.5 3.5 0 0 0-4.9 4.2L4 14.3V20h5.7l5.6-5.6a3.5 3.5 0 0 0 4.2-4.9l-2.4 2.4-2.1-2.1z"/></svg>';
161
+ const iconSkill = '<svg class="ico" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5z"/><path d="M9 12h6M9 16h4M9 8h4"/></svg>';
162
+ let tools = []; let skills = [];
163
+ try { tools = (await api.get('/api/mcp/tools')) || []; } catch (e) { tools = []; }
164
+ try { skills = (await api.get('/api/skills')) || []; } catch (e) { skills = []; }
165
+ const skillSub = skills.length
166
+ ? skills.slice(0, 3).map((s) => '/' + String(s.name || '')).join(' ')
167
+ : '尚无本机技能 —— 点开后从电脑端同步';
168
+ box.innerHTML = `
169
+ <div class="list-item" id="item-mcp"><span class="list-icon">${iconMcp}</span>
170
+ <span style="flex:1"><span style="display:block">MCP 工具 · ${tools.length} 个</span>
171
+ <span class="conv-preview" style="display:block">点开读工具说明并调用(gateway_status / join / register / call)</span></span>
172
+ <span class="list-arrow">›</span></div>
173
+ <div class="list-item" id="item-skills"><span class="list-icon">${iconSkill}</span>
174
+ <span style="flex:1"><span style="display:block">Skills · ${skills.length} 个</span>
175
+ <span class="conv-preview" style="display:block">${escapeHtml(skillSub)}</span></span>
176
+ <span class="list-arrow">›</span></div>`;
177
+ const mcpEl = $('#item-mcp'); if (mcpEl) mcpEl.addEventListener('click', () => void openMcpPage(tools));
178
+ const skEl = $('#item-skills'); if (skEl) skEl.addEventListener('click', () => void openSkillsPage());
179
+ }
180
+
181
+ async function openMcpPage(toolsIn) {
182
+ if ($('#mcp-page')) return;
183
+ const page = mkOverlayPage('mcp-page', 'MCP 工具', '<div style="font-size:12px;color:var(--text-muted)">读取中…</div>');
184
+ let tools = toolsIn;
185
+ if (!Array.isArray(tools) || !tools.length) {
186
+ try { tools = (await api.get('/api/mcp/tools')) || []; } catch (e) { tools = []; }
187
+ }
188
+ const body = page.querySelector('#mcp-page-body');
189
+ if (!tools.length) { body.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted)">没有可用的 MCP 工具</div>'; return; }
190
+ body.innerHTML = tools.map((t, i) => `<div class="conv-item" data-ti="${i}">
191
+ <div class="conv-avatar">${escapeHtml(String(t.name || 'M').charAt(0))}</div>
192
+ <div class="conv-body"><div class="conv-name">${escapeHtml(String(t.name || ''))}</div>
193
+ <div class="conv-preview">${escapeHtml(String(t.description || ''))}</div></div></div>`).join('')
194
+ + '<div style="font-size:11px;color:var(--text-muted);margin-top:12px;line-height:1.7">点一下 = 让本机智能体调用该工具(gateway_* 需要先加入 Agent 网络)</div>';
195
+ body.querySelectorAll('[data-ti]').forEach((el) => el.addEventListener('click', () => void runMcpTool(tools[Number(el.dataset.ti)])));
196
+ }
197
+
198
+ async function runMcpTool(t) {
199
+ const name = String((t && t.name) || '');
200
+ if (!name) return;
201
+ let args = {};
138
202
  try {
139
- const r = await api.get('/api/mcp/tools').catch(() => null);
140
- const tools = r?.tools || r || [];
141
- if (!Array.isArray(tools) || tools.length === 0) {
142
- box.innerHTML = '<div style="padding:12px 16px;color:var(--text-muted)">暂无可用 MCP 工具</div>';
203
+ if (name === 'gateway_join') {
204
+ const link = prompt('gateway_join 需要网络链接:\norbitdb://… ipns://… 或 https://…/registry', '');
205
+ if (!link) return;
206
+ args = { link };
207
+ } else if (name === 'gateway_register') {
208
+ const svc = prompt('注册为服务提供者,填一个服务名(例如 手机端助手):', '');
209
+ if (!svc) return;
210
+ let agentId = '';
211
+ try { const id2 = await api.get('/api/auth/status'); agentId = (id2 && (id2.agentId || id2.did)) || ''; } catch (e) { agentId = ''; }
212
+ if (!agentId) { alert('还没有身份(我 → 登录),无法注册为服务提供者'); return; }
213
+ args = { self: { agentId, name: svc, service: { name: svc } } };
214
+ } else if (name === 'gateway_call') {
215
+ const svc = prompt('要调用的服务(名称或 agentId):', '');
216
+ if (!svc) return;
217
+ args = { service: svc };
218
+ }
219
+ const r = await api.post('/api/mcp/call', { name, args });
220
+ const ok = r && r.ok !== false;
221
+ alert(`${name} ${ok ? '✔' : '✘'}\n\n${(r && r.output) || JSON.stringify(r)}`);
222
+ void loadAgentControl();
223
+ } catch (e) {
224
+ alert(`${name} 调用失败:${(e && e.message) || e}`);
225
+ }
226
+ }
227
+
228
+ async function openSkillsPage() {
229
+ if ($('#skills-page')) return;
230
+ const page = mkOverlayPage('skills-page', 'Skills', '<div style="font-size:12px;color:var(--text-muted)">读取中…</div>');
231
+ const bar = document.createElement('div');
232
+ bar.style.cssText = 'padding:0 12px 14px';
233
+ bar.innerHTML = '<button id="skills-sync" style="width:100%;padding:11px;border-radius:10px;border:1px solid var(--border);background:var(--bg-hover);color:var(--accent);font-size:14px">从电脑端同步</button>';
234
+ page.appendChild(bar);
235
+ const render = async () => {
236
+ let skills = []; let err = '';
237
+ try { skills = (await api.get('/api/skills')) || []; } catch (e) { skills = []; err = (e && e.message) || ''; }
238
+ const body = page.querySelector('#skills-page-body');
239
+ if (!body) return;
240
+ if (!skills.length) {
241
+ body.innerHTML = `<div style="padding:18px 8px;text-align:center;color:var(--text-muted);line-height:1.8">
242
+ 还没有技能列表<br><span style="font-size:12px">电脑端 ~/.bolloon/skills/ 下的 skills,会在「从电脑端同步」后出现在这里</span>
243
+ ${err ? `<div style="font-size:11px;margin-top:8px">(读取失败:${escapeHtml(err)})</div>` : ''}</div>`;
143
244
  return;
144
245
  }
145
- box.innerHTML = '';
146
- tools.forEach((t) => {
147
- const name = t.name || t.function?.name || '工具';
148
- const desc = t.description || t.function?.description || '';
149
- const el = document.createElement('div');
150
- el.className = 'conv-item';
151
- el.innerHTML = `<div class="conv-avatar"><svg class="ico" viewBox="0 0 24 24"><path d="M9 3.5v4.5M15 3.5v4.5"/><path d="M6.5 8h11v3.2a5.5 5.5 0 0 1-11 0z"/><path d="M12 16.7V20.5"/></svg></div>
152
- <div class="conv-body"><div class="conv-name">${escapeHtml(name)}</div>
153
- <div class="conv-preview">${escapeHtml(desc)}</div></div>`;
154
- el.addEventListener('click', () => { window.__mobileTouch?.('mcp', name); });
155
- box.appendChild(el);
156
- });
157
- } catch (e) { box.innerHTML = '<div style="padding:12px 16px;color:var(--text-muted)">MCP 工具加载失败</div>'; }
246
+ body.innerHTML = skills.map((s, i) => `<div class="conv-item" data-sk="${i}">
247
+ <div class="conv-avatar">${escapeHtml(String(s.name || 'S').charAt(0).toUpperCase())}</div>
248
+ <div class="conv-body"><div class="conv-name">/${escapeHtml(String(s.name || ''))}</div>
249
+ <div class="conv-preview">${escapeHtml(String(s.description || '(无说明)').slice(0, 60))}</div></div></div>`).join('')
250
+ + `<div style="font-size:11px;color:var(--text-muted);margin-top:12px">共 ${skills.length} 个 · 来自电脑端 ~/.bolloon/skills/</div>`;
251
+ body.querySelectorAll('[data-sk]').forEach((el) => el.addEventListener('click', () => {
252
+ const s = skills[Number(el.dataset.sk)] || {};
253
+ alert(`/${s.name || ''}\n\n${s.description || '(无说明)'}`);
254
+ }));
255
+ };
256
+ await render();
257
+ const btn = page.querySelector('#skills-sync');
258
+ if (btn) btn.addEventListener('click', async () => {
259
+ btn.disabled = true; const old = btn.textContent; btn.textContent = '同步中…';
260
+ try {
261
+ const r = await api.post('/api/desktop/sync');
262
+ if (r && r.ok === false) { alert('同步失败:' + (r.error || '未知错误')); }
263
+ else { alert('同步完成' + (r && r.counts ? ':' + JSON.stringify(r.counts) : '')); }
264
+ } catch (e) { alert('同步失败:' + ((e && e.message) || e)); }
265
+ finally { btn.disabled = false; btn.textContent = old; await render(); }
266
+ });
158
267
  }
159
268
 
160
269
  async function loadApprovals() {
@@ -681,6 +790,155 @@
681
790
  let chatEventSource = null;
682
791
  let chatStepCancel = null;
683
792
  let streamingBubble = null;
793
+ let chatLoadPromise = Promise.resolve(); // openChat 的首次历史加载 (供一键入网等自动发消息等它完成, 免被清屏抹掉)
794
+
795
+ function fmtAgo(ts) {
796
+ if (!ts) return '';
797
+ const d = Date.now() - ts;
798
+ if (d < 60e3) return '刚刚';
799
+ if (d < 3600e3) return Math.floor(d / 60e3) + ' 分钟前';
800
+ if (d < 86400e3) return Math.floor(d / 3600e3) + ' 小时前';
801
+ if (d < 7 * 86400e3) return Math.floor(d / 86400e3) + ' 天前';
802
+ return new Date(ts).toLocaleDateString();
803
+ }
804
+
805
+ // 左上角「索引」: 最近历史会话 (本机 IndexedDB 里的 sessions, 按更新时间倒序)
806
+ async function openIndexPanel() {
807
+ if ($('#index-page')) return;
808
+ const page = document.createElement('div');
809
+ page.className = 'chat-page';
810
+ page.id = 'index-page';
811
+ page.style.zIndex = '70';
812
+ page.innerHTML = `
813
+ <div class="chat-topbar">
814
+ <button class="icon-btn" id="index-back">←</button>
815
+ <div style="flex:1;font-weight:600">索引 · 最近会话</div>
816
+ <button class="icon-btn" id="index-settings" title="设置">⚙</button>
817
+ </div>
818
+ <div id="index-body" style="padding:12px;flex:1;min-height:0;overflow:auto">
819
+ <div style="font-size:12px;color:var(--text-muted)">读取最近会话…</div>
820
+ </div>`;
821
+ document.body.appendChild(page);
822
+ $('#index-back').addEventListener('click', () => page.remove());
823
+ $('#index-settings').addEventListener('click', () => { page.remove(); openSettings(); });
824
+
825
+ let snap = null;
826
+ try { snap = await api.get('/api/data/snapshot'); } catch (e) { snap = null; }
827
+ const channels = (snap && snap.channels) || [];
828
+ const sessions = (snap && snap.sessions) || [];
829
+ const byId = new Map();
830
+ channels.forEach((c) => byId.set(c.id, c));
831
+ const rows = sessions.map((s) => {
832
+ const msgs = s.messages || [];
833
+ const last = msgs[msgs.length - 1];
834
+ return {
835
+ ch: byId.get(s.channelId) || { id: s.channelId, name: '(会话已删除)' },
836
+ updatedAt: s.updatedAt || (last && last.ts) || 0,
837
+ count: msgs.length,
838
+ lastText: (last && last.content) || '(无消息)',
839
+ };
840
+ }).sort((a, b) => b.updatedAt - a.updatedAt);
841
+
842
+ const head = `<div style="font-size:12px;color:var(--text-muted);margin:2px 0 10px">本机智能体 ${channels.length} 个 · 有历史会话 ${rows.length} 个</div>`;
843
+ const list = rows.length
844
+ ? rows.map((r, i) => `<div class="conv-item" data-si="${i}">
845
+ <div class="conv-avatar">${escapeHtml(String(r.ch.name || 'A').charAt(0))}</div>
846
+ <div class="conv-body">
847
+ <div class="conv-name">${escapeHtml(String(r.ch.name || r.ch.id))}</div>
848
+ <div class="conv-preview">${escapeHtml(String(r.lastText).slice(0, 40))}</div>
849
+ </div>
850
+ <span style="font-size:11px;color:var(--text-muted);text-align:right;flex:0 0 auto">${escapeHtml(fmtAgo(r.updatedAt))}<br>${r.count} 条</span>
851
+ </div>`).join('')
852
+ : '<div style="padding:24px;text-align:center;color:var(--text-muted)">还没有历史会话<br><span style="font-size:12px">从下面的 + 新建一个智能体开始</span></div>';
853
+ const body = $('#index-body');
854
+ if (body) {
855
+ body.innerHTML = head + list;
856
+ body.querySelectorAll('[data-si]').forEach((el) => el.addEventListener('click', () => {
857
+ const r = rows[Number(el.dataset.si)];
858
+ page.remove();
859
+ openChat(r.ch);
860
+ }));
861
+ }
862
+ }
863
+
864
+ // 右上角「搜索」: 本机智能体 + 好友 + 全局智能体 (协议发现) 一起搜
865
+ async function openSearch() {
866
+ if ($('#search-page')) return;
867
+ const page = document.createElement('div');
868
+ page.className = 'chat-page';
869
+ page.id = 'search-page';
870
+ page.style.zIndex = '70';
871
+ page.innerHTML = `
872
+ <div class="chat-topbar">
873
+ <button class="icon-btn" id="search-back">←</button>
874
+ <div style="flex:1;font-weight:600">搜索</div>
875
+ </div>
876
+ <div style="padding:12px;display:flex;flex-direction:column;gap:10px;flex:1;min-height:0">
877
+ <input id="search-input" type="search" autocomplete="off" placeholder="搜智能体 / 好友:名称、DID、节点 ID"
878
+ style="padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-hover);color:var(--text);font-size:14px">
879
+ <div id="search-hint" style="font-size:12px;color:var(--text-muted)">正在建立索引…</div>
880
+ <div id="search-results" style="flex:1;overflow:auto;-webkit-overflow-scrolling:touch"></div>
881
+ </div>`;
882
+ document.body.appendChild(page);
883
+ $('#search-back').addEventListener('click', () => page.remove());
884
+
885
+ let channels = []; let peers = []; let services = [];
886
+ try { channels = (await api.get('/channels')) || []; } catch (e) { channels = []; }
887
+ try { const p = await api.get('/api/peers'); peers = Array.isArray(p) ? p : ((p && p.peers) || []); } catch (e) { peers = []; }
888
+ try { const d = await api.get('/api/social/discover'); services = (d && d.services) || []; } catch (e) { services = []; }
889
+
890
+ const rows = [
891
+ ...channels.map((c) => ({
892
+ tag: '本机智能体', name: c.name || c.agentId || c.id, sub: c.id || '',
893
+ hay: [c.name, c.id, c.agentId, c.did, c.description].filter(Boolean).join(' '),
894
+ act: () => { page.remove(); openChat(c); },
895
+ })),
896
+ ...peers.map((p) => ({
897
+ tag: '好友', name: p.name || String(p.publicKey || p.id || '好友').slice(0, 12),
898
+ sub: String(p.publicKey || p.id || p.address || '').slice(0, 28),
899
+ hay: [p.name, p.publicKey, p.id, p.address, p.did].filter(Boolean).join(' '),
900
+ act: () => alert(`好友信息\n名称: ${p.name || '(未命名)'}\n节点ID: ${p.publicKey || p.id || '—'}\n地址: ${p.address || '—'}`),
901
+ })),
902
+ ...services.map((s) => ({
903
+ tag: '全局智能体',
904
+ name: (s.service && s.service.name) || s.name || s.agentId || 'agent',
905
+ sub: (s.service && s.service.description) || s.description || '',
906
+ hay: [(s.service && s.service.name), s.serviceName, s.name, s.agentId, s.description, s.did].filter(Boolean).join(' '),
907
+ act: () => { page.remove(); openTradeCall(s); },
908
+ })),
909
+ ];
910
+ rows.forEach((r) => { r.hay = String(r.hay || '').toLowerCase(); });
911
+
912
+ const hint = $('#search-hint');
913
+ const box = $('#search-results');
914
+ const render = (q) => {
915
+ const k = String(q || '').trim().toLowerCase();
916
+ const hits = rows.filter((r) => !k || r.hay.includes(k) || r.name.toLowerCase().includes(k));
917
+ if (hint) hint.innerHTML = `本机智能体 ${channels.length} · 好友 ${peers.length} · 全局智能体 ${services.length}` + (k ? ` · 命中 ${hits.length}` : '');
918
+ if (!box) return;
919
+ if (!hits.length) {
920
+ box.innerHTML = `<div style="padding:24px;text-align:center;color:var(--text-muted);font-size:13px">没有匹配「${escapeHtml(String(q))}」的智能体或好友</div>`;
921
+ return;
922
+ }
923
+ let html = '';
924
+ let lastTag = '';
925
+ hits.forEach((r, i) => {
926
+ if (r.tag !== lastTag) { html += `<div class="section-label">${escapeHtml(r.tag)}</div>`; lastTag = r.tag; }
927
+ html += `<div class="conv-item" data-ri="${i}">
928
+ <div class="conv-avatar">${escapeHtml(String(r.name || 'A').charAt(0))}</div>
929
+ <div class="conv-body">
930
+ <div class="conv-name">${escapeHtml(String(r.name))}</div>
931
+ <div class="conv-preview">${escapeHtml(String(r.sub).slice(0, 48))}</div>
932
+ </div>
933
+ </div>`;
934
+ });
935
+ box.innerHTML = html;
936
+ box.querySelectorAll('[data-ri]').forEach((el) => el.addEventListener('click', () => hits[Number(el.dataset.ri)].act()));
937
+ };
938
+ render('');
939
+ const input = $('#search-input');
940
+ if (input) { input.addEventListener('input', () => render(input.value)); input.focus(); }
941
+ }
684
942
 
685
943
  function openChat(ch) {
686
944
  activeChannel = ch;
@@ -710,7 +968,7 @@
710
968
  $('#chat-send').addEventListener('click', sendChat);
711
969
  $('#chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChat(); });
712
970
  attachStepListener();
713
- loadMessages();
971
+ chatLoadPromise = loadMessages().catch(() => {});
714
972
  openChatSse();
715
973
  window.__mobileTouch?.('chat', ch.id);
716
974
  }
@@ -1261,10 +1519,19 @@
1261
1519
  try { const u = await api.get('/api/desktop/url'); url = (u && u.url) || ''; } catch (e) {}
1262
1520
  let d = { ok: false, addrs: [], error: '' };
1263
1521
  try { d = await api.get('/api/network/desktop-addrs'); } catch (e) {}
1522
+ // 2026-09-11: 可拨入地址 (/p2p-circuit) —— 手机在 WebView 里不能 listen,
1523
+ // 向中继预约是它唯一能被别人拨入的途径。两个节点 (P2P / Helia) 都看一眼。
1524
+ let helia = {};
1525
+ try { helia = await api.get('/api/helia/status'); } catch (e) {}
1264
1526
  const conn = !!(net && net.connected);
1265
1527
  const peers = ((net && net.peerIds) || []).length;
1266
1528
  const nodeId = (net && net.nodeId) || '';
1267
1529
  const addrs = (d && d.addrs) || [];
1530
+ const circuitAddrs = ((net && net.circuitAddrs) || []).length
1531
+ ? net.circuitAddrs
1532
+ : (((helia && helia.circuitAddrs) || []));
1533
+ const relays = ((net && net.relays) || []).length ? net.relays : (((helia && helia.relays) || []));
1534
+ const relayRes = ((net && net.relayReservations) || []).find((r) => r && !r.ok);
1268
1535
  const row = (k, v, small) => `<div class="list-item"><span style="flex:1">${k}</span><span style="font-size:${small ? 11 : 13}px;color:var(--text-secondary);text-align:right;word-break:break-all;max-width:60%">${escapeHtml(v)}</span></div>`;
1269
1536
  let html = '';
1270
1537
  html += row('状态', conn ? '已启动' : '未启动');
@@ -1272,7 +1539,27 @@
1272
1539
  html += row('已连对端', peers + ' 个');
1273
1540
  html += row('电脑端', url || '未配置', true);
1274
1541
  if (addrs.length) html += row('可拨地址', addrs[0], true);
1542
+ // 可拨入地址: 没有就是「暂时不能被别人拨入」, 用大白话说清原因, 不假装成功
1543
+ html += circuitAddrs.length
1544
+ ? row('可拨入地址', circuitAddrs[0], true)
1545
+ : row('可拨入地址', relayRes ? ('预约失败: ' + String(relayRes.error || '').slice(0, 60)) : '无 (没有可用中继 / 还没预约上)', true);
1546
+ if (relays.length) html += row('已预约中继', relays.length + ' 个', true);
1275
1547
  box.innerHTML = html;
1548
+ // 有可拨入地址 → 给一个复制入口 (别人要用它拨你)
1549
+ if (circuitAddrs.length) {
1550
+ const cp = document.createElement('div');
1551
+ cp.className = 'list-item';
1552
+ cp.id = 'p2p-copy-circuit';
1553
+ cp.innerHTML = `<span class="list-icon">${ICONS.globe}</span><span style="flex:1">复制可拨入地址</span>`;
1554
+ cp.addEventListener('click', async () => {
1555
+ try {
1556
+ if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(circuitAddrs[0]);
1557
+ else throw new Error('无剪贴板权限');
1558
+ showToast('已复制可拨入地址');
1559
+ } catch (e) { showToast('复制失败: ' + ((e && e.message) || e)); }
1560
+ });
1561
+ box.appendChild(cp);
1562
+ }
1276
1563
  // 连接/重连按钮 + 人话提示
1277
1564
  const btn = document.createElement('div');
1278
1565
  btn.className = 'list-item';
@@ -1299,6 +1586,9 @@
1299
1586
  hint.textContent = !url && !addrs.length
1300
1587
  ? '手机在 WebView 里不能自己监听端口 → 需要「拨入」至少一个节点才能进网。电脑端是**可选**的:点「添加节点地址」填任意可拨节点的 multiaddr (如 /ip4/1.2.3.4/tcp/4001/ws),手机就能独立入网并从该节点收发服务请求。'
1301
1588
  : (conn ? (peers ? '已连上 ' + peers + ' 个对端,可收发消息/服务请求(手机拨入的连接是双向的,所以别人也能调用你的服务)。' : '已连上节点,等待其他对端…') : ('未连接:' + ((d && d.error) || '点「连接电脑端」或「添加节点地址」')));
1589
+ if (conn && !circuitAddrs.length) {
1590
+ hint.textContent += '\n「可拨入地址」还是空的 —— 手机自己不能被别人拨入,必须成功预约到中继才有。确认电脑端已开启中继 (电脑端 /api/p2p/mobile-connect 里 isRelay=true),再点「重新连接电脑端」。';
1591
+ }
1302
1592
  box.appendChild(hint);
1303
1593
  }
1304
1594
 
@@ -1419,9 +1709,10 @@
1419
1709
  <div style="padding:12px">
1420
1710
  <div class="conv-item" id="api-config-item"><span class="list-icon">${ICONS.chip}</span><span>API 配置</span><span class="list-arrow">›</span></div>
1421
1711
  <div class="conv-item" id="theme-toggle"><span class="list-icon" id="theme-icon">${ICONS.themeAuto}</span><span id="theme-text">跟随系统</span></div>
1422
- <div class="conv-item" id="settings-network"><span class="list-icon">${ICONS.globe}</span><span>网络与同步</span><span class="list-arrow">›</span></div>
1712
+ <div class="conv-item" id="settings-data"><span class="list-icon">${ICONS.chip}</span><span style="flex:1;min-width:0"><span style="display:block">本机数据</span><span class="conv-preview" style="display:block">读取中…</span></span><span class="list-arrow">›</span></div>
1423
1713
  <div class="conv-item" id="settings-desktop"><span class="list-icon">${ICONS.globe}</span><span>电脑端同步</span><span class="list-arrow">›</span></div>
1424
1714
  <div class="conv-item" id="settings-chain"><span class="list-icon">${ICONS.chip}</span><span>链上配置 (RPC/网络)</span><span class="list-arrow">›</span></div>
1715
+ <div class="conv-item" id="settings-accessibility"><span class="list-icon">${ICONS.globe}</span><span>无障碍服务 (屏幕触控)</span><span class="list-arrow">›</span></div>
1425
1716
  <div class="conv-item" id="settings-ipfs"><span class="list-icon">${ICONS.chip}</span><span>IPFS 存储</span><span class="list-arrow">›</span></div>
1426
1717
  <div class="conv-item" id="settings-helia"><span class="list-icon">${ICONS.globe}</span><span>本机 IPFS 节点</span><span class="list-arrow">›</span></div>
1427
1718
  <div class="conv-item" id="settings-selfcard"><span class="list-icon">${ICONS.chip}</span><span id="selfcard-text">显示本机卡片: 开</span></div>
@@ -1435,7 +1726,36 @@
1435
1726
  const next = currentTheme === 'auto' ? 'light' : (currentTheme === 'light' ? 'dark' : 'auto');
1436
1727
  applyTheme(next, true);
1437
1728
  });
1438
- $('#settings-network').addEventListener('click', () => switchTab('network'));
1729
+ // 无障碍服务: 屏幕触控 (电脑端 phone.tap / phone.swipe) 的前提 — 与 MCP/Skills 控制无关
1730
+ $('#settings-accessibility').addEventListener('click', async () => {
1731
+ const cap = window.Capacitor;
1732
+ const bridge = cap && cap.Plugins && cap.Plugins.RokidBridge;
1733
+ if (!bridge) { alert('仅真机可用:App 内可把屏幕触控能力授权给电脑端'); return; }
1734
+ let st = {};
1735
+ try { st = (await bridge.touchStatus()) || {}; } catch (e) { st = {}; }
1736
+ if (st.ready) { alert('触控已就绪:电脑端可发 phone.tap / phone.swipe 操作这台手机'); return; }
1737
+ try { await bridge.openAccessibilitySettings(); }
1738
+ catch (e) { alert('请手动开启:设置 → 辅助功能 → 已安装的服务 → Bolloon Agent'); }
1739
+ });
1740
+
1741
+ // 本机数据: 直接读 IndexedDB 快照 (智能体/会话/消息都在本机, 重开 App 不会丢)
1742
+ const dataEl = $('#settings-data');
1743
+ if (dataEl) {
1744
+ const subEl = dataEl.querySelector('.conv-preview');
1745
+ void (async () => {
1746
+ try {
1747
+ const snap = await api.get('/api/data/snapshot');
1748
+ const chs = (snap && snap.channels) || [];
1749
+ const ses = (snap && snap.sessions) || [];
1750
+ const msgs = ses.reduce((n, s) => n + ((s.messages || []).length), 0);
1751
+ if (subEl) subEl.textContent = `已保存 ${chs.length} 个智能体 · ${ses.length} 个会话 · ${msgs} 条消息(本机 IndexedDB)`;
1752
+ } catch (e) { if (subEl) subEl.textContent = '读取失败: ' + ((e && e.message) || e); }
1753
+ })();
1754
+ dataEl.addEventListener('click', () => {
1755
+ const sp = $('#settings-page'); if (sp) sp.remove();
1756
+ void openIndexPanel();
1757
+ });
1758
+ }
1439
1759
  $('#settings-desktop').addEventListener('click', openDesktopSync);
1440
1760
  // 本机卡片显示开关 (移除后从这里恢复)
1441
1761
  const drawSelfCardToggle = () => {
@@ -1461,14 +1781,25 @@
1461
1781
  }
1462
1782
 
1463
1783
  // === API 配置 (LLM 供应商) ===
1464
- const LLM_PROVIDERS = ['deepseek', 'openai', 'anthropic', 'minimax', 'openrouter', '自定义'];
1465
- const LLM_DEFAULTS = {
1466
- deepseek: { baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
1467
- openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
1468
- anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-3-5-sonnet-latest' },
1469
- minimax: { baseUrl: 'https://api.minimax.chat/v1', model: 'MiniMax-M2.7' },
1470
- openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/gpt-4o-mini' },
1471
- };
1784
+ // 注意: 手机端 RemoteLlm OpenAI 兼容协议 (baseUrl + /chat/completions) —— 新增 provider 必须是
1785
+ // OpenAI 兼容端点 (gemini 用 /v1beta/openai, 智谱 v4 / dashscope compatible-mode 都兼容)。
1786
+ const LLM_PROVIDERS = [
1787
+ { id: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
1788
+ { id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
1789
+ { id: 'anthropic', label: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1', model: 'claude-3-5-sonnet-latest' },
1790
+ { id: 'gemini', label: 'Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-3.5-flash' },
1791
+ { id: 'xai', label: 'Grok', baseUrl: 'https://api.x.ai/v1', model: 'grok-2-latest' },
1792
+ { id: 'qwen', label: '通义千问', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus' },
1793
+ { id: 'zhipu', label: '智谱 GLM', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-plus' },
1794
+ { id: 'moonshot', label: 'Kimi', baseUrl: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k' },
1795
+ { id: 'minimax', label: 'MiniMax', baseUrl: 'https://api.minimax.chat/v1', model: 'MiniMax-M2.7' },
1796
+ { id: 'siliconflow', label: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1', model: 'deepseek-ai/DeepSeek-V3' },
1797
+ { id: 'groq', label: 'Groq', baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile' },
1798
+ { id: 'openrouter', label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/gpt-4o-mini' },
1799
+ { id: 'ollama', label: '本地 Ollama', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b' },
1800
+ { id: 'custom', label: '自定义', baseUrl: '', model: '' },
1801
+ ];
1802
+ const LLM_BY_ID = LLM_PROVIDERS.reduce((m, p) => (m[p.id] = p, m), {});
1472
1803
  async function openApiConfig() {
1473
1804
  let cfg;
1474
1805
  try { cfg = await api.get('/api/llm-config'); } catch { cfg = null; }
@@ -1484,10 +1815,11 @@
1484
1815
  <div style="flex:1;font-weight:600">API 配置</div>
1485
1816
  </div>
1486
1817
  <div style="padding:12px;display:flex;flex-direction:column;gap:12px">
1487
- <label style="font-size:13px;color:var(--text-secondary)">供应商</label>
1488
- <select id="api-provider" style="padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-hover);color:var(--text)">
1489
- ${LLM_PROVIDERS.map((p) => `<option value="${p}" ${p === provider ? 'selected' : ''}>${p}</option>`).join('')}
1490
- </select>
1818
+ <label style="font-size:13px;color:var(--text-secondary)">供应商 <span id="api-provider-count" style="opacity:.55"></span></label>
1819
+ <div class="provider-chips" id="api-provider-chips">
1820
+ ${LLM_PROVIDERS.map((p) => `<button type="button" class="provider-chip${p.id === provider ? ' active' : ''}" data-provider="${p.id}">${p.label}</button>`).join('')}
1821
+ </div>
1822
+ <div id="api-hint" style="font-size:12px;color:var(--text-muted)"></div>
1491
1823
  <label style="font-size:13px;color:var(--text-secondary)">Base URL</label>
1492
1824
  <input id="api-baseurl" placeholder="https://api.xxx.com/v1" value="${escapeHtml(pc.baseUrl || '')}" style="padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-hover);color:var(--text)">
1493
1825
  <label style="font-size:13px;color:var(--text-secondary)">API Key</label>
@@ -1498,15 +1830,37 @@
1498
1830
  </div>`;
1499
1831
  document.body.appendChild(page);
1500
1832
  $('#api-config-back').addEventListener('click', () => page.remove());
1501
- const provSel = $('#api-provider');
1502
- provSel.addEventListener('change', () => {
1503
- const p = provSel.value;
1504
- const d = LLM_DEFAULTS[p];
1505
- if (d) { $('#api-baseurl').value = d.baseUrl; $('#api-model').value = d.model; }
1506
- if (p === '自定义') { $('#api-baseurl').value = ''; $('#api-model').value = ''; $('#api-key').value = ''; }
1833
+ // 供应商选择: 芯片式 (原来是一个原生 select, 小屏上难点、也看不出有哪些可选)
1834
+ let picked = LLM_BY_ID[provider] ? provider : 'deepseek';
1835
+ const fillFrom = (id) => {
1836
+ const saved = (cfg.providers && cfg.providers[id]) || {};
1837
+ const d = LLM_BY_ID[id] || {};
1838
+ $('#api-baseurl').value = saved.baseUrl || d.baseUrl || '';
1839
+ $('#api-model').value = saved.model || d.model || '';
1840
+ $('#api-key').value = saved.apiKey || '';
1841
+ const hint = $('#api-hint');
1842
+ if (hint) {
1843
+ hint.textContent = id === 'custom'
1844
+ ? '自定义: 填任意 OpenAI 兼容的 baseUrl (/chat/completions)'
1845
+ : (saved.apiKey ? '该供应商已保存过 key(改完记得再点保存)'
1846
+ : '填官方文档里的 API key; 保存后本机智能体就走它');
1847
+ }
1848
+ };
1849
+ const paintChips = () => {
1850
+ $$('#api-provider-chips .provider-chip').forEach((c) => c.classList.toggle('active', c.dataset.provider === picked));
1851
+ };
1852
+ const configured = Object.keys(cfg.providers || {}).filter((k) => cfg.providers[k] && cfg.providers[k].apiKey);
1853
+ $('#api-provider-count').textContent = configured.length ? `(已配 ${configured.length} 个: ${configured.join(' / ')})` : '';
1854
+ $('#api-provider-chips').addEventListener('click', (e) => {
1855
+ const b = e.target.closest && e.target.closest('.provider-chip');
1856
+ if (!b) return;
1857
+ picked = b.dataset.provider;
1858
+ paintChips();
1859
+ fillFrom(picked);
1507
1860
  });
1861
+ fillFrom(picked);
1508
1862
  $('#api-save').addEventListener('click', async () => {
1509
- const p = provSel.value === '自定义' ? 'custom' : provSel.value;
1863
+ const p = picked;
1510
1864
  const next = (cfg && cfg.providers) ? cfg : { activeProvider: cfg.activeProvider, providers: {}, updatedAt: Date.now() };
1511
1865
  next.activeProvider = p;
1512
1866
  next.providers[p] = Object.assign({}, (next.providers[p] || {}), {
@@ -1702,6 +2056,38 @@
1702
2056
  function showSheet(id) { const s = $(id); if (s) s.hidden = false; }
1703
2057
  function hideSheet(id) { const s = $(id); if (s) s.hidden = true; }
1704
2058
 
2059
+ /**
2060
+ * 一键入网 (全球智能体网络): 把默认 prompt 发给智能体, 由它读网关入网说明并执行入网。
2061
+ * 人类只点一下 — 没有可用会话时先建一个 (与"新建会话"同一条路径)。
2062
+ */
2063
+ async function joinGlobalNetwork() {
2064
+ showToast('正在加入全球智能体网络…');
2065
+ try {
2066
+ let ch = activeChannel;
2067
+ if (!ch) {
2068
+ let channels = [];
2069
+ try { channels = await api.get('/channels'); } catch { channels = []; }
2070
+ ch = (Array.isArray(channels) && channels[0]) || null;
2071
+ if (!ch) {
2072
+ await api.post('/api/channels/create', {});
2073
+ await new Promise((r) => setTimeout(r, 700));
2074
+ channels = await api.get('/channels').catch(() => []);
2075
+ ch = (Array.isArray(channels) && channels[0]) || null;
2076
+ }
2077
+ }
2078
+ if (!ch) { showToast('没有可用会话: 先在电脑端连上你的智能体'); return; }
2079
+ openChat(ch);
2080
+ await chatLoadPromise; // 等首次历史加载完成再发, 免得用户气泡被清屏抹掉
2081
+ const input = $('#chat-input');
2082
+ if (!input) return;
2083
+ input.value = DEFAULT_JOIN_PROMPT;
2084
+ await sendChat();
2085
+ showToast('入网指令已交给智能体');
2086
+ } catch (e) {
2087
+ showToast('入网失败: ' + ((e && e.message) || e));
2088
+ }
2089
+ }
2090
+
1705
2091
  // 创建智能体: 无输入框, 底部滑入加载 sheet, 完成后滑出
1706
2092
  async function createSession() {
1707
2093
  showSheet('#create-sheet');
@@ -1808,11 +2194,13 @@
1808
2194
  try { await api.post('/api/auth/logout', {}); await loadMe(); } catch (e) { alert('注销失败: ' + (e.message || e)); }
1809
2195
  });
1810
2196
  $('#btn-add').addEventListener('click', addFriend);
2197
+ const bIdx = $('#btn-index'); if (bIdx) bIdx.addEventListener('click', () => void openIndexPanel());
2198
+ const bSearch = $('#btn-search'); if (bSearch) bSearch.addEventListener('click', () => void openSearch());
1811
2199
  const cs = $('#btn-create-session'); if (cs) cs.addEventListener('click', createSession);
1812
2200
  const csScan = $('#choice-scan'); if (csScan) csScan.addEventListener('click', addFriendScan);
1813
2201
  const csMan = $('#choice-manual'); if (csMan) csMan.addEventListener('click', addFriendManual);
1814
2202
  const csCan = $('#choice-cancel'); if (csCan) csCan.addEventListener('click', () => hideSheet('#addfriend-sheet'));
1815
- $('#item-p2p').addEventListener('click', () => { switchTab('network'); });
2203
+ $('#item-p2p').addEventListener('click', () => { switchTab('friends'); void loadContacts(); });
1816
2204
  const itTrade = $('#item-trade');
1817
2205
  if (itTrade) itTrade.addEventListener('click', async () => {
1818
2206
  await loadAgentServices();
@@ -1823,17 +2211,47 @@
1823
2211
  try { const net = await api.get('/api/network/status'); const p2p = net && net.nodeId; alert('P2P ID (通信ID, ≠ DID):\n' + (p2p || '未连接')); }
1824
2212
  catch (e) { alert('P2P ID: 获取失败'); }
1825
2213
  });
1826
- // #2 极简入网按钮: 粘贴/输入链接BolloonCore.gateway.join (懒加载 mobile-gateway)
2214
+ // #1 一键入网 (全球智能体网络): 点一下 默认 prompt 交给智能体执行
2215
+ const joinGlobalBtn = $('#item-join-global');
2216
+ if (joinGlobalBtn) joinGlobalBtn.addEventListener('click', () => { void joinGlobalNetwork(); });
2217
+
2218
+ // #2 加入网络 (点按式): sheet → [附近的电脑/设备] [扫电脑上的二维码] [粘贴链接兜底]
1827
2219
  const joinNetBtn = $('#item-join-net');
1828
- if (joinNetBtn) joinNetBtn.addEventListener('click', async () => {
1829
- const link = (window.prompt && window.prompt('粘贴网络链接\n(orbitdb:// ipns:// https://.../registry)') || '').trim();
1830
- if (!link) return;
1831
- try {
1832
- const r = await (window.BolloonCore && window.BolloonCore.gateway && window.BolloonCore.gateway.join(link));
1833
- alert(r ? (r.output || '已处理') : 'BolloonCore.gateway 不可用');
1834
- } catch (e) { alert('加入失败: ' + String((e && e.message) || e).slice(0, 120)); }
1835
- loadNetMembers();
2220
+ if (joinNetBtn) joinNetBtn.addEventListener('click', () => showSheet('#network-sheet'));
2221
+ const cJoinNearby = $('#choice-join-nearby');
2222
+ if (cJoinNearby) cJoinNearby.addEventListener('click', () => { hideSheet('#network-sheet'); openNearbySheet('join'); });
2223
+ const cJoinScan = $('#choice-join-scan');
2224
+ if (cJoinScan) cJoinScan.addEventListener('click', () => {
2225
+ hideSheet('#network-sheet'); _qrMode = 'join';
2226
+ const inp = $('#qr-scan-input'); if (inp) inp.click();
1836
2227
  });
2228
+ const cJoinManual = $('#choice-join-manual');
2229
+ if (cJoinManual) cJoinManual.addEventListener('click', () => { hideSheet('#network-sheet'); joinNetworkManual(); });
2230
+ const cJoinCancel = $('#choice-join-cancel');
2231
+ if (cJoinCancel) cJoinCancel.addEventListener('click', () => hideSheet('#network-sheet'));
2232
+
2233
+ // #2b 连接好友 (点按式) + 附近设备 + 待处理申请
2234
+ const addFriendBtn = $('#item-add-friend');
2235
+ if (addFriendBtn) addFriendBtn.addEventListener('click', () => showSheet('#addfriend-sheet'));
2236
+ const cNearby = $('#choice-nearby');
2237
+ if (cNearby) cNearby.addEventListener('click', () => { hideSheet('#addfriend-sheet'); openNearbySheet('friend'); });
2238
+ const cRequests = $('#choice-requests');
2239
+ if (cRequests) cRequests.addEventListener('click', () => { hideSheet('#addfriend-sheet'); openRequestsSheet(); });
2240
+ const nearbyBtn = $('#item-nearby');
2241
+ if (nearbyBtn) nearbyBtn.addEventListener('click', () => openNearbySheet('friend'));
2242
+ const nearbyRefresh = $('#nearby-refresh');
2243
+ if (nearbyRefresh) nearbyRefresh.addEventListener('click', () => loadNearbyList(_nearbyMode));
2244
+ const nearbyClose = $('#nearby-close');
2245
+ if (nearbyClose) nearbyClose.addEventListener('click', () => hideSheet('#nearby-sheet'));
2246
+ // 微信息 (x402 付费信息): 详情 → 购买并验真 / 只看元数据
2247
+ const x402Buy = $('#x402-buy');
2248
+ if (x402Buy) x402Buy.addEventListener('click', () => buyX402Info());
2249
+ const x402VerifyBtn = $('#x402-verify');
2250
+ if (x402VerifyBtn) x402VerifyBtn.addEventListener('click', () => verifyX402Info());
2251
+ const x402Close = $('#x402-close');
2252
+ if (x402Close) x402Close.addEventListener('click', () => hideSheet('#x402-sheet'));
2253
+ const x402ResultClose = $('#x402-result-close');
2254
+ if (x402ResultClose) x402ResultClose.addEventListener('click', () => hideSheet('#x402-result-sheet'));
1837
2255
  // #3 扫码入网: 拍照/选图 → BolloonCore.qr.decode(jsQR) → gateway.join (免原生插件)
1838
2256
  const scanBtn = $('#item-scan-net');
1839
2257
  const scanInput = $('#qr-scan-input');
@@ -1878,6 +2296,370 @@
1878
2296
  });
1879
2297
  }
1880
2298
 
2299
+ // ==================== 点按式联网 / 加好友 (2026-09-13) ====================
2300
+ // 人类手机习惯: 点按钮选一条, 而不是粘贴网址。粘贴/手动输入只作兜底放在最下面。
2301
+ let _nearbyMode = 'friend'; // 'join' = 加入网络 | 'friend' = 连接好友
2302
+
2303
+ /** 电脑端基地址 (未配置 → 空字符串, 走本机能力) */
2304
+ async function desktopBaseUrl() {
2305
+ try {
2306
+ const u = await (window.BolloonCore && window.BolloonCore.desktop && window.BolloonCore.desktop.url && window.BolloonCore.desktop.url());
2307
+ // BolloonCore.desktop.url() 返回 { url }, 但也兼容直接返回字符串的实现 —
2308
+ // 直接 String(对象) 会得到 "[object Object]",转发全部静默失败 (2026-09-13 修)
2309
+ const raw = (u && typeof u === 'object') ? (u.url || u.baseUrl || '') : u;
2310
+ return String(raw || '').replace(/\/+$/, '');
2311
+ } catch (e) { return ''; }
2312
+ }
2313
+
2314
+ /** 转发到电脑端 HTTP (手机自足模式下拿不到就返回 null, 不假装成功) */
2315
+ async function desktopFetch(path, body) {
2316
+ const base = await desktopBaseUrl();
2317
+ if (!base) return null;
2318
+ try {
2319
+ const res = await fetch(base + path, body
2320
+ ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
2321
+ : undefined);
2322
+ if (!res.ok) return null;
2323
+ return await res.json();
2324
+ } catch (e) { return null; }
2325
+ }
2326
+
2327
+ /** 加入网络 · 手动粘贴 (兜底入口, 从 sheet 底部小字进来) */
2328
+ async function joinNetworkManual() {
2329
+ const link = (window.prompt && window.prompt('粘贴网络链接\n(orbitdb:// ipns:// https://.../registry)') || '').trim();
2330
+ if (!link) return;
2331
+ try {
2332
+ const r = await (window.BolloonCore && window.BolloonCore.gateway && window.BolloonCore.gateway.join(link));
2333
+ alert(r ? (r.output || '已处理') : 'BolloonCore.gateway 不可用');
2334
+ } catch (e) { alert('加入失败: ' + String((e && e.message) || e).slice(0, 120)); }
2335
+ loadNetMembers();
2336
+ }
2337
+
2338
+ function openNearbySheet(mode) {
2339
+ _nearbyMode = mode;
2340
+ const t = $('#nearby-title');
2341
+ if (t) t.textContent = mode === 'join' ? '附近的电脑 / 设备' : '附近的设备';
2342
+ showSheet('#nearby-sheet');
2343
+ loadNearbyList(mode);
2344
+ }
2345
+
2346
+ /** 扫一遍"附近": 电脑端 (同一 Wi-Fi) + 已连通的 P2P 设备 (+ 待处理申请) */
2347
+ async function loadNearbyList(mode) {
2348
+ const list = $('#nearby-list');
2349
+ const hint = $('#nearby-hint');
2350
+ if (!list) return;
2351
+ list.innerHTML = '';
2352
+ if (hint) hint.textContent = '正在查找...';
2353
+ const rows = [];
2354
+ try {
2355
+ const addrs = await (window.BolloonCore && window.BolloonCore.network && window.BolloonCore.network.desktopAddrs());
2356
+ const base = await desktopBaseUrl();
2357
+ const firstAddr = (addrs && Array.isArray(addrs.addrs) && addrs.addrs[0]) || '';
2358
+ if (base || firstAddr) {
2359
+ rows.push({
2360
+ icon: '🖥️',
2361
+ title: '电脑端' + (addrs && addrs.peerId ? ' · ' + String(addrs.peerId).slice(0, 8) : ''),
2362
+ sub: base || firstAddr,
2363
+ kind: 'desktop',
2364
+ });
2365
+ }
2366
+ } catch (e) { /* 电脑端不可达 */ }
2367
+ try {
2368
+ const peers = await api.get('/api/peers');
2369
+ for (const p of (peers || [])) {
2370
+ const key = p.publicKey || p.id || '';
2371
+ rows.push({
2372
+ icon: '📱',
2373
+ title: p.name || String(key).slice(0, 12) || '设备',
2374
+ sub: '已连通 · ' + String(key).slice(0, 16),
2375
+ kind: 'peer',
2376
+ key,
2377
+ });
2378
+ }
2379
+ } catch (e) { /* 无对端 */ }
2380
+ if (mode === 'friend') {
2381
+ const fr = await desktopFetch('/api/friend-requests');
2382
+ if (fr && fr.count > 0) {
2383
+ rows.push({ icon: '📋', title: `待处理好友申请 ${fr.count} 个`, sub: '点一下处理', kind: 'requests' });
2384
+ }
2385
+ }
2386
+ if (hint) hint.textContent = rows.length ? '点一条即操作' : '没找到设备 — 让电脑端开着 (同一 Wi-Fi), 或扫电脑上的二维码';
2387
+ if (rows.length === 0) { list.innerHTML = '<div class="list-item">(暂无)</div>'; return; }
2388
+ for (const r of rows) {
2389
+ const el = document.createElement('div');
2390
+ el.className = 'list-item';
2391
+ el.innerHTML = `<span class="list-icon">${r.icon}</span><span><div>${escapeHtml(r.title)}</div><div style="font-size:12px;color:var(--text-muted)">${escapeHtml(r.sub || '')}</div></span><span class="list-arrow">›</span>`;
2392
+ el.addEventListener('click', () => onNearbyTap(r, mode));
2393
+ list.appendChild(el);
2394
+ }
2395
+ }
2396
+
2397
+ async function onNearbyTap(row, mode) {
2398
+ if (row.kind === 'requests') { hideSheet('#nearby-sheet'); return openRequestsSheet(); }
2399
+ if (row.kind === 'desktop') {
2400
+ try {
2401
+ const r = await (window.BolloonCore && window.BolloonCore.network && window.BolloonCore.network.connect());
2402
+ const bad = r && r.ok === false;
2403
+ alert(bad ? ('连接电脑端失败: ' + String(r.error || '').slice(0, 100)) : '已连接电脑端 (数据/网络开始同步)');
2404
+ if (!bad && mode === 'join') {
2405
+ try { await (window.BolloonCore.desktop && window.BolloonCore.desktop.sync && window.BolloonCore.desktop.sync()); } catch (e) {}
2406
+ }
2407
+ loadNetMembers(); loadP2PStatus();
2408
+ } catch (e) { alert('连接失败: ' + String((e && e.message) || e).slice(0, 120)); }
2409
+ return;
2410
+ }
2411
+ if (row.kind === 'peer') {
2412
+ if (mode !== 'friend') { alert('这个设备已经和本机连通了。'); return; }
2413
+ let me = '手机端用户';
2414
+ try { const s = await api.get('/api/auth/status'); if (s && s.name) me = s.name; } catch (e) {}
2415
+ const sent = await desktopFetch('/api/friend-request', { targetPublicKey: row.key, name: me, message: '手机端请求加好友' });
2416
+ alert(sent ? '已发出好友申请' : '本机已记录该设备; 需要电脑端在线才能把申请发出去');
2417
+ }
2418
+ }
2419
+
2420
+ /** 待处理好友申请: 点一条 → 通过 (取消 = 忽略) */
2421
+ async function openRequestsSheet() {
2422
+ const t = $('#nearby-title'); if (t) t.textContent = '待处理好友申请';
2423
+ showSheet('#nearby-sheet');
2424
+ const list = $('#nearby-list'); const hint = $('#nearby-hint');
2425
+ if (!list) return;
2426
+ list.innerHTML = '';
2427
+ if (hint) hint.textContent = '正在读取...';
2428
+ const fr = await desktopFetch('/api/friend-requests');
2429
+ if (!fr || !fr.requests || fr.requests.length === 0) {
2430
+ if (hint) hint.textContent = fr ? '没有待处理申请' : '需要电脑端在线 (设置里填桌面地址) 才能读取';
2431
+ list.innerHTML = '<div class="list-item">(空)</div>';
2432
+ return;
2433
+ }
2434
+ if (hint) hint.textContent = '点一条 = 通过 · 取消 = 忽略';
2435
+ for (const r of fr.requests) {
2436
+ const el = document.createElement('div');
2437
+ el.className = 'list-item';
2438
+ el.innerHTML = `<span class="list-icon">👤</span><span><div>${escapeHtml(r.fromName || '陌生人')}</div><div style="font-size:12px;color:var(--text-muted)">${escapeHtml(r.note || r.message || '(无备注)')}</div></span>`;
2439
+ el.addEventListener('click', async () => {
2440
+ const ok = window.confirm(`通过 ${r.fromName || '对方'} 的好友申请?\n\n确定 = 通过并加为好友\n取消 = 忽略这条申请`);
2441
+ if (ok) {
2442
+ const acc = await desktopFetch('/api/friend-accept', { fromPublicKey: r.fromPublicKey, name: r.fromName, requestId: r.requestId });
2443
+ alert(acc ? '已加为好友' : '通过失败 — 检查电脑端是否在线');
2444
+ } else {
2445
+ const ig = await desktopFetch('/api/friend-requests/ignore', { requestId: r.requestId });
2446
+ alert(ig ? '已忽略' : '忽略失败 — 检查电脑端是否在线');
2447
+ }
2448
+ hideSheet('#nearby-sheet');
2449
+ if (typeof loadContacts === 'function') loadContacts();
2450
+ });
2451
+ list.appendChild(el);
2452
+ }
2453
+ }
2454
+
2455
+ // ==================== 微信息 (x402 付费信息, 2026-09-13) ====================
2456
+ // 人类手机操作: 浏览 → 点一条看详情 (价格/类别/哈希/来源) → 「购买并验真」→ 内容 + 验真分档。
2457
+ // 安全边界 (**不能破**): 手机端不持 EVM 私钥, 付款一律经电脑端 /api/x402/info/buy 代付;
2458
+ // 没配桌面地址 / 电脑端不可达 → 说人话提示, 绝不假装成功、绝不自己造数据。
2459
+ let _x402Cache = []; // 最近一次列表 (行点击按 id 找回)
2460
+ let _x402Current = null; // 详情 sheet 当前对应的 item
2461
+
2462
+ /** 售卖端点 (付费取内容): <桌面基址>/api/x402/info/<id> */
2463
+ async function x402SellUrl(id) {
2464
+ const base = await desktopBaseUrl();
2465
+ return base ? base + '/api/x402/info/' + encodeURIComponent(String(id || '')) : '';
2466
+ }
2467
+
2468
+ /**
2469
+ * 转发到电脑端并**保留后端错误原文** (购买失败要如实显示 "需要钱包私钥"/"facilitator 不可达",
2470
+ * 而 desktopFetch 失败时只回 null 会丢掉原因)。基地址仍走 desktopBaseUrl(), 不另起一套。
2471
+ */
2472
+ async function desktopPostRaw(path, body) {
2473
+ const base = await desktopBaseUrl();
2474
+ if (!base) return { ok: false, error: '需要电脑端在线 (设置里填桌面地址)' };
2475
+ try {
2476
+ const res = await fetch(base + path, {
2477
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}),
2478
+ });
2479
+ let data = null;
2480
+ try { data = await res.json(); } catch (e) { data = null; }
2481
+ if (!res.ok) {
2482
+ return { ok: false, status: res.status, error: (data && (data.error || data.message)) || ('电脑端返回 ' + res.status), data };
2483
+ }
2484
+ return data || { ok: false, error: '电脑端返回空响应' };
2485
+ } catch (e) {
2486
+ return { ok: false, error: '电脑端不可达: ' + String((e && e.message) || e).slice(0, 120) };
2487
+ }
2488
+ }
2489
+
2490
+ /** 离线能核对的部分 (全部来自免费元数据, 不含内容本体) */
2491
+ function x402MetaLines(item) {
2492
+ const it = item || {};
2493
+ const p = it.price || {};
2494
+ const src = it.source || {};
2495
+ const refs = Array.isArray(src.refs) ? src.refs : [];
2496
+ const lines = [
2497
+ `价格: ${p.amount || '0'} ${p.currency || ''} (网络 ${p.network || '未声明'})`,
2498
+ `类别: ${it.category || 'other'}`,
2499
+ `提供方: ${(it.provider && (it.provider.name || '未命名')) || '未知'} · ${String((it.provider && it.provider.did) || '').slice(0, 28)}${(it.provider && it.provider.did && String(it.provider.did).length > 28) ? '…' : ''}`,
2500
+ `内容哈希: ${it.contentHash || '(无)'}`,
2501
+ `来源声明: ${src.kind || '未声明'}${refs.length ? ' · 引用 ' + refs.length + ' 条' : ' · 无引用'}`,
2502
+ ];
2503
+ for (const r of refs) lines.push(' · ' + String(r));
2504
+ if (src.note) lines.push(`来源备注: ${src.note}`);
2505
+ if (it.description) lines.push(`说明: ${it.description}`);
2506
+ lines.push('', '付款由电脑端代付 (手机端不拿私钥)');
2507
+ return lines;
2508
+ }
2509
+
2510
+ /** 拉列表: 有数据 / 桌面不可达 / 空列表 三种情况都给人话 */
2511
+ async function loadX402Info() {
2512
+ const list = $('#x402-info-list');
2513
+ if (!list) return;
2514
+ list.innerHTML = '<div class="list-item">正在读取微信息...</div>';
2515
+ const base = await desktopBaseUrl();
2516
+ const r = base ? await desktopFetch('/api/x402/info') : null;
2517
+ if (!r || r.note === 'desktop-unreachable') {
2518
+ // (b) 桌面不可达: 手机端不持私钥, 浏览/代付都只能靠电脑端 — 直接说, 不假装有数据
2519
+ _x402Cache = [];
2520
+ list.innerHTML = '<div class="list-item">需要电脑端在线 (设置里填桌面地址)</div>';
2521
+ return;
2522
+ }
2523
+ const items = Array.isArray(r.items) ? r.items : [];
2524
+ _x402Cache = items;
2525
+ if (items.length === 0) {
2526
+ // (c) 电脑端在线但没发布过
2527
+ list.innerHTML = '<div class="list-item">电脑端还没发布任何付费信息</div>';
2528
+ return;
2529
+ }
2530
+ // (a) 有数据 → 每条一行: 标题 + 价格 + 类别 + 提供方
2531
+ list.innerHTML = '';
2532
+ for (const it of items) {
2533
+ const p = it.price || {};
2534
+ const amount = p.amount ? `${p.amount} ${p.currency || ''}`.trim() : '免费';
2535
+ const provName = (it.provider && (it.provider.name || it.provider.did)) || '未知提供方';
2536
+ const el = document.createElement('div');
2537
+ el.className = 'list-item';
2538
+ el.innerHTML = `<span class="list-icon">💰</span><span><div>${escapeHtml(it.title || '(无标题)')}</div>` +
2539
+ `<div style="font-size:12px;color:var(--text-muted)">${escapeHtml(amount)} · ${escapeHtml(it.category || 'other')} · ${escapeHtml(String(provName).slice(0, 20))}</div></span>` +
2540
+ `<span class="list-arrow">›</span>`;
2541
+ el.addEventListener('click', () => openX402Sheet(it));
2542
+ list.appendChild(el);
2543
+ }
2544
+ }
2545
+
2546
+ /** 点一条 → 详情 sheet (价格/网络/类别/提供方 DID 前缀/内容哈希/来源) */
2547
+ function openX402Sheet(item) {
2548
+ if (!item) return;
2549
+ _x402Current = item;
2550
+ const t = $('#x402-title');
2551
+ if (t) t.textContent = item.title || '微信息';
2552
+ const body = $('#x402-body');
2553
+ if (body) body.textContent = x402MetaLines(item).join('\n');
2554
+ const buy = $('#x402-buy');
2555
+ if (buy) { buy.disabled = false; buy.textContent = '购买并验真'; }
2556
+ showSheet('#x402-sheet');
2557
+ }
2558
+
2559
+ /** 结果 sheet: 内容 + 验真结论 (纯文本渲染, 不用 innerHTML) */
2560
+ function showX402Result(title, text) {
2561
+ const t = $('#x402-result-title');
2562
+ if (t) t.textContent = title || '验真结果';
2563
+ const b = $('#x402-result-body');
2564
+ if (b) b.textContent = String(text || '');
2565
+ showSheet('#x402-result-sheet');
2566
+ }
2567
+
2568
+ /** 购买并验真: 一律转发电脑端代付 (手机端不签名, 不持 EVM 私钥) */
2569
+ async function buyX402Info() {
2570
+ const item = _x402Current;
2571
+ if (!item) return;
2572
+ const btn = $('#x402-buy');
2573
+ const base = await desktopBaseUrl();
2574
+ if (!base) {
2575
+ alert('需要电脑端在线 (设置里填桌面地址) — 付款只能由电脑端代付, 手机端不保存私钥');
2576
+ return;
2577
+ }
2578
+ const sellUrl = base + '/api/x402/info/' + encodeURIComponent(String(item.id || ''));
2579
+ // 上限 = 标价上浮 20% (防挂单涨价); 标价读不到就不传上限
2580
+ const amt = Number((item.price || {}).amount);
2581
+ const maxPayment = Number.isFinite(amt) && amt > 0 ? String(Number((amt * 1.2).toFixed(8))) : undefined;
2582
+ if (btn) { btn.disabled = true; btn.textContent = '付款中...'; }
2583
+ try {
2584
+ const r = await desktopPostRaw('/api/x402/info/buy', {
2585
+ url: sellUrl,
2586
+ ...(maxPayment ? { maxPayment } : {}),
2587
+ allowLocalDev: true,
2588
+ });
2589
+ if (!r || r.ok !== true) {
2590
+ // 如实转述后端原因 (缺私钥 / facilitator 不可达 / 电脑端不可达), 不吞错
2591
+ alert('购买失败: ' + ((r && r.error) || '电脑端不可达 (检查设置里的桌面地址)'));
2592
+ return;
2593
+ }
2594
+ const content = String(r.content || '');
2595
+ const lines = [
2596
+ r.verifySummary || ((r.verify && r.verify.trust) ? '验真档: ' + r.verify.trust : '后端未给验真结论'),
2597
+ r.payment ? `支付模式: ${r.payment.mode || '?'}${r.payment.txHash ? ' · tx ' + String(r.payment.txHash).slice(0, 24) : ''}` : '',
2598
+ `内容哈希: ${(r.item && r.item.contentHash) || item.contentHash || '?'}`,
2599
+ `内容长度: ${content.length} 字`,
2600
+ '',
2601
+ '—— 内容 (前 2000 字) ——',
2602
+ content.slice(0, 2000) + (content.length > 2000 ? '\n…(已截断)' : ''),
2603
+ ].filter((x) => x !== '');
2604
+ showX402Result('已购买并验真', lines.join('\n'));
2605
+ } catch (e) {
2606
+ alert('购买失败: ' + String((e && e.message) || e).slice(0, 160));
2607
+ } finally {
2608
+ if (btn) { btn.disabled = false; btn.textContent = '购买并验真'; }
2609
+ }
2610
+ }
2611
+
2612
+ /** 只看元数据 / 离线验真: 没付款就没有信封 → 明说还差什么, 不谎称"验真通过" */
2613
+ async function verifyX402Info() {
2614
+ const item = _x402Current;
2615
+ if (!item) return;
2616
+ const btn = $('#x402-verify');
2617
+ const meta = x402MetaLines(item);
2618
+ const sellUrl = await x402SellUrl(item.id);
2619
+ if (!sellUrl) {
2620
+ showX402Result('离线查看元数据', meta.join('\n') + '\n\n没配桌面地址 → 拿不到签名信封, 无法验真。');
2621
+ return;
2622
+ }
2623
+ if (btn) { btn.disabled = true; btn.textContent = '核对中...'; }
2624
+ try {
2625
+ const r = await desktopPostRaw('/api/x402/info/verify', { envelope: null, url: sellUrl });
2626
+ // 注意: 后端 verify 的 ok = "信封验真是否通过", 不是 "HTTP 是否成功" —
2627
+ // 只要拿到 report 就是一次成功的请求, 不能把 report.ok=false 当成"电脑端不可达"
2628
+ const report = r && r.report ? r.report : null;
2629
+ if (!report) {
2630
+ showX402Result('还不能验真', ((r && r.error) || '电脑端不可达') + '\n\n离线可核对的:\n' + meta.join('\n'));
2631
+ return;
2632
+ }
2633
+ const checks = Array.isArray(report.checks) ? report.checks : [];
2634
+ // 没付款时后端拿到的是 402 的付款要求 (不是信封) → 报告必然是 unverified; 如实说明原因
2635
+ const noEnvelope = report.trust === 'unverified' && !checks.some((c) => c.name === 'provider-signature' && c.ok);
2636
+ if (noEnvelope) {
2637
+ const p = item.price || {};
2638
+ showX402Result('只看元数据 (还没付款)', [
2639
+ '电脑端返回 402 付款要求 → 还没付款就拿不到签名信封, 此时无法验真。',
2640
+ `付款要求 (来自元数据): ${p.amount || '0'} ${p.currency || ''} → ${String(p.payTo || '').slice(0, 20)}… (${p.network || '未声明'})`,
2641
+ '',
2642
+ '离线可核对的 (免费元数据):',
2643
+ ...meta,
2644
+ '',
2645
+ '点「购买并验真」由电脑端代付后, 才能拿到信封做分档验真。',
2646
+ ].join('\n'));
2647
+ return;
2648
+ }
2649
+ showX402Result('验真结果', [
2650
+ r.summary || ('验真档: ' + (report.trust || '?')),
2651
+ report.warnings && report.warnings.length ? '提示: ' + report.warnings.join('; ') : '',
2652
+ '',
2653
+ '逐项检查:',
2654
+ ...checks.map((c) => `${c.ok ? '✅' : '❌'} ${c.name}: ${c.detail}`),
2655
+ ].filter((x) => x !== '').join('\n'));
2656
+ } catch (e) {
2657
+ showX402Result('核对失败', String((e && e.message) || e).slice(0, 200));
2658
+ } finally {
2659
+ if (btn) { btn.disabled = false; btn.textContent = '只看元数据 (离线验真)'; }
2660
+ }
2661
+ }
2662
+
1881
2663
  async function loadNetMembers() {
1882
2664
  const el = $('#net-members');
1883
2665
  if (!el) return;
@@ -1901,7 +2683,7 @@
1901
2683
  if (!msg || msg.type !== 'ui' || !msg.action) return;
1902
2684
  const d = msg.data || {};
1903
2685
  switch (msg.action) {
1904
- case 'switchTab': if (d.tab && ['main', 'network', 'me'].includes(d.tab)) switchTab(d.tab); break;
2686
+ case 'switchTab': if (d.tab && ['main', 'friends', 'network', 'me'].includes(d.tab)) switchTab(d.tab); break;
1905
2687
  case 'openSettings': openSettings(); break;
1906
2688
  case 'showToast': alert(d.message || ''); break;
1907
2689
  case 'goBack': closeCardDetail(); closeChat(); break;
@@ -1987,11 +2769,85 @@
1987
2769
  } catch (e) {}
1988
2770
  }
1989
2771
 
2772
+ // === 手势 (2026-09-14): 滑动切 tab / 点空白关弹窗 / 右滑返回上一层 ===
2773
+ // 优先级: 有浮层 → 右滑 = 返回上一层 (关浮层); 无浮层 → 左右滑 = 依次切 tab
2774
+ function topOverlay() {
2775
+ const all = $$('.crop-modal:not([hidden]), .sheet:not([hidden]), .chat-page:not([hidden]), .card-detail:not([hidden])');
2776
+ if (!all.length) return null;
2777
+ return all
2778
+ .map((el) => ({ el, z: parseFloat(getComputedStyle(el).zIndex) || 0 }))
2779
+ .sort((a, b) => a.z - b.z)
2780
+ .pop().el;
2781
+ }
2782
+ function closeOverlay(el) {
2783
+ if (!el) return false;
2784
+ if (el.classList.contains('sheet')) { el.hidden = true; return true; }
2785
+ if (el.classList.contains('card-detail')) {
2786
+ const b = el.querySelector('#detail-back'); if (b) { b.click(); return true; }
2787
+ el.hidden = true; return true;
2788
+ }
2789
+ if (el.classList.contains('crop-modal')) {
2790
+ const b = el.querySelector('[id$="-cancel"], [id$="-close"], .icon-btn');
2791
+ if (b) { b.click(); return true; }
2792
+ el.hidden = true; return true;
2793
+ }
2794
+ if (el.classList.contains('chat-page')) {
2795
+ const b = el.querySelector('.chat-topbar .icon-btn'); // 各页左上角 ← : 走它自带的清理
2796
+ if (b) { b.click(); return true; }
2797
+ el.remove(); return true;
2798
+ }
2799
+ return false;
2800
+ }
2801
+ function goBack() { return closeOverlay(topOverlay()); }
2802
+ function inHorizontalScroller(node) {
2803
+ for (let n = node; n && n !== document.body && n.nodeType === 1; n = n.parentElement) {
2804
+ if (!n.scrollWidth || n.scrollWidth <= n.clientWidth + 4) continue;
2805
+ const ox = getComputedStyle(n).overflowX;
2806
+ if (ox === 'auto' || ox === 'scroll') return true;
2807
+ }
2808
+ return false;
2809
+ }
2810
+ function setupGestures() {
2811
+ const TAB_ORDER = ['main', 'friends', 'network', 'me'];
2812
+ let sx = 0, sy = 0, st = 0, active = false, startTarget = null;
2813
+ document.addEventListener('touchstart', (e) => {
2814
+ if (e.touches.length !== 1) { active = false; return; }
2815
+ sx = e.touches[0].clientX; sy = e.touches[0].clientY;
2816
+ st = Date.now(); active = true; startTarget = e.target;
2817
+ }, { passive: true });
2818
+ document.addEventListener('touchend', (e) => {
2819
+ if (!active) return;
2820
+ active = false;
2821
+ const t = e.changedTouches[0];
2822
+ const dx = t.clientX - sx, dy = t.clientY - sy;
2823
+ if (Date.now() - st > 900) return; // 慢拖不算滑
2824
+ if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.4) return; // 不够横向
2825
+ if (inHorizontalScroller(startTarget)) return; // 横向列表/卡片轨道里不抢手势
2826
+ const ov = topOverlay();
2827
+ if (ov) {
2828
+ if (dx > 0) closeOverlay(ov); // 浮层内右滑 = 返回上一层 (输入框选中文本时不触发: dx 门槛已过滤)
2829
+ return;
2830
+ }
2831
+ const i = TAB_ORDER.indexOf(currentTab);
2832
+ if (dx < 0) { if (i + 1 < TAB_ORDER.length) switchTab(TAB_ORDER[i + 1]); }
2833
+ else { if (i > 0) switchTab(TAB_ORDER[i - 1]); }
2834
+ }, { passive: true });
2835
+
2836
+ // 点空白关弹窗: 点 sheet 的暗背景 (非 .sheet-inner 内容) 即关
2837
+ document.addEventListener('click', (e) => {
2838
+ const t = e.target;
2839
+ if (!t || !t.closest) return;
2840
+ const sheet = t.closest('.sheet');
2841
+ if (sheet && !sheet.hasAttribute('hidden') && !t.closest('.sheet-inner')) sheet.hidden = true;
2842
+ });
2843
+ }
2844
+
1990
2845
  function init() {
1991
2846
  bindMenu();
1992
2847
  applyTheme(resolveThemePref(), false);
1993
2848
  switchTab('main');
1994
2849
  setupUiControl();
2850
+ setupGestures();
1995
2851
  installDeepLinkListeners();
1996
2852
  loadAgentCovers();
1997
2853
  loadMe();