@bolloon/bolloon-agent 0.4.21 → 0.4.23

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(); loadX402Info(); }
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
  }
@@ -1451,9 +1709,10 @@
1451
1709
  <div style="padding:12px">
1452
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>
1453
1711
  <div class="conv-item" id="theme-toggle"><span class="list-icon" id="theme-icon">${ICONS.themeAuto}</span><span id="theme-text">跟随系统</span></div>
1454
- <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>
1455
1713
  <div class="conv-item" id="settings-desktop"><span class="list-icon">${ICONS.globe}</span><span>电脑端同步</span><span class="list-arrow">›</span></div>
1456
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>
1457
1716
  <div class="conv-item" id="settings-ipfs"><span class="list-icon">${ICONS.chip}</span><span>IPFS 存储</span><span class="list-arrow">›</span></div>
1458
1717
  <div class="conv-item" id="settings-helia"><span class="list-icon">${ICONS.globe}</span><span>本机 IPFS 节点</span><span class="list-arrow">›</span></div>
1459
1718
  <div class="conv-item" id="settings-selfcard"><span class="list-icon">${ICONS.chip}</span><span id="selfcard-text">显示本机卡片: 开</span></div>
@@ -1467,7 +1726,36 @@
1467
1726
  const next = currentTheme === 'auto' ? 'light' : (currentTheme === 'light' ? 'dark' : 'auto');
1468
1727
  applyTheme(next, true);
1469
1728
  });
1470
- $('#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
+ }
1471
1759
  $('#settings-desktop').addEventListener('click', openDesktopSync);
1472
1760
  // 本机卡片显示开关 (移除后从这里恢复)
1473
1761
  const drawSelfCardToggle = () => {
@@ -1493,14 +1781,25 @@
1493
1781
  }
1494
1782
 
1495
1783
  // === API 配置 (LLM 供应商) ===
1496
- const LLM_PROVIDERS = ['deepseek', 'openai', 'anthropic', 'minimax', 'openrouter', '自定义'];
1497
- const LLM_DEFAULTS = {
1498
- deepseek: { baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
1499
- openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
1500
- anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-3-5-sonnet-latest' },
1501
- minimax: { baseUrl: 'https://api.minimax.chat/v1', model: 'MiniMax-M2.7' },
1502
- openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/gpt-4o-mini' },
1503
- };
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), {});
1504
1803
  async function openApiConfig() {
1505
1804
  let cfg;
1506
1805
  try { cfg = await api.get('/api/llm-config'); } catch { cfg = null; }
@@ -1516,10 +1815,11 @@
1516
1815
  <div style="flex:1;font-weight:600">API 配置</div>
1517
1816
  </div>
1518
1817
  <div style="padding:12px;display:flex;flex-direction:column;gap:12px">
1519
- <label style="font-size:13px;color:var(--text-secondary)">供应商</label>
1520
- <select id="api-provider" style="padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-hover);color:var(--text)">
1521
- ${LLM_PROVIDERS.map((p) => `<option value="${p}" ${p === provider ? 'selected' : ''}>${p}</option>`).join('')}
1522
- </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>
1523
1823
  <label style="font-size:13px;color:var(--text-secondary)">Base URL</label>
1524
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)">
1525
1825
  <label style="font-size:13px;color:var(--text-secondary)">API Key</label>
@@ -1530,15 +1830,37 @@
1530
1830
  </div>`;
1531
1831
  document.body.appendChild(page);
1532
1832
  $('#api-config-back').addEventListener('click', () => page.remove());
1533
- const provSel = $('#api-provider');
1534
- provSel.addEventListener('change', () => {
1535
- const p = provSel.value;
1536
- const d = LLM_DEFAULTS[p];
1537
- if (d) { $('#api-baseurl').value = d.baseUrl; $('#api-model').value = d.model; }
1538
- 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);
1539
1860
  });
1861
+ fillFrom(picked);
1540
1862
  $('#api-save').addEventListener('click', async () => {
1541
- const p = provSel.value === '自定义' ? 'custom' : provSel.value;
1863
+ const p = picked;
1542
1864
  const next = (cfg && cfg.providers) ? cfg : { activeProvider: cfg.activeProvider, providers: {}, updatedAt: Date.now() };
1543
1865
  next.activeProvider = p;
1544
1866
  next.providers[p] = Object.assign({}, (next.providers[p] || {}), {
@@ -1734,6 +2056,38 @@
1734
2056
  function showSheet(id) { const s = $(id); if (s) s.hidden = false; }
1735
2057
  function hideSheet(id) { const s = $(id); if (s) s.hidden = true; }
1736
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
+
1737
2091
  // 创建智能体: 无输入框, 底部滑入加载 sheet, 完成后滑出
1738
2092
  async function createSession() {
1739
2093
  showSheet('#create-sheet');
@@ -1840,11 +2194,13 @@
1840
2194
  try { await api.post('/api/auth/logout', {}); await loadMe(); } catch (e) { alert('注销失败: ' + (e.message || e)); }
1841
2195
  });
1842
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());
1843
2199
  const cs = $('#btn-create-session'); if (cs) cs.addEventListener('click', createSession);
1844
2200
  const csScan = $('#choice-scan'); if (csScan) csScan.addEventListener('click', addFriendScan);
1845
2201
  const csMan = $('#choice-manual'); if (csMan) csMan.addEventListener('click', addFriendManual);
1846
2202
  const csCan = $('#choice-cancel'); if (csCan) csCan.addEventListener('click', () => hideSheet('#addfriend-sheet'));
1847
- $('#item-p2p').addEventListener('click', () => { switchTab('network'); });
2203
+ $('#item-p2p').addEventListener('click', () => { switchTab('friends'); void loadContacts(); });
1848
2204
  const itTrade = $('#item-trade');
1849
2205
  if (itTrade) itTrade.addEventListener('click', async () => {
1850
2206
  await loadAgentServices();
@@ -1855,6 +2211,10 @@
1855
2211
  try { const net = await api.get('/api/network/status'); const p2p = net && net.nodeId; alert('P2P ID (通信ID, ≠ DID):\n' + (p2p || '未连接')); }
1856
2212
  catch (e) { alert('P2P ID: 获取失败'); }
1857
2213
  });
2214
+ // #1 一键入网 (全球智能体网络): 点一下 → 默认 prompt 交给智能体执行
2215
+ const joinGlobalBtn = $('#item-join-global');
2216
+ if (joinGlobalBtn) joinGlobalBtn.addEventListener('click', () => { void joinGlobalNetwork(); });
2217
+
1858
2218
  // #2 加入网络 (点按式): sheet → [附近的电脑/设备] [扫电脑上的二维码] [粘贴链接兜底]
1859
2219
  const joinNetBtn = $('#item-join-net');
1860
2220
  if (joinNetBtn) joinNetBtn.addEventListener('click', () => showSheet('#network-sheet'));
@@ -2323,7 +2683,7 @@
2323
2683
  if (!msg || msg.type !== 'ui' || !msg.action) return;
2324
2684
  const d = msg.data || {};
2325
2685
  switch (msg.action) {
2326
- 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;
2327
2687
  case 'openSettings': openSettings(); break;
2328
2688
  case 'showToast': alert(d.message || ''); break;
2329
2689
  case 'goBack': closeCardDetail(); closeChat(); break;
@@ -2409,11 +2769,85 @@
2409
2769
  } catch (e) {}
2410
2770
  }
2411
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
+
2412
2845
  function init() {
2413
2846
  bindMenu();
2414
2847
  applyTheme(resolveThemePref(), false);
2415
2848
  switchTab('main');
2416
2849
  setupUiControl();
2850
+ setupGestures();
2417
2851
  installDeepLinkListeners();
2418
2852
  loadAgentCovers();
2419
2853
  loadMe();