@bolloon/bolloon-agent 0.4.33 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +580 -40
  3. package/dist/agents/external-events.js +35 -6
  4. package/dist/agents/gateway-group.js +100 -12
  5. package/dist/agents/goal-criteria.js +10 -3
  6. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  7. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  8. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  9. package/dist/agents/goal-flywheel/index.js +26 -0
  10. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  11. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  12. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  13. package/dist/agents/goal-flywheel/types.js +329 -0
  14. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  15. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  16. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  17. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  18. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  19. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  20. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  21. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  22. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  23. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  24. package/dist/agents/goal-state-reducer.js +406 -0
  25. package/dist/agents/goal-store.js +35 -1
  26. package/dist/agents/mobile-flywheel-view.js +144 -0
  27. package/dist/agents/mobile-task-actions-verify.js +74 -0
  28. package/dist/agents/mobile-task-actions.js +178 -0
  29. package/dist/agents/mobile-task-views.js +199 -0
  30. package/dist/agents/model-policy.js +651 -0
  31. package/dist/agents/network-pulse.js +304 -18
  32. package/dist/agents/pi-sdk-tools.js +84 -33
  33. package/dist/agents/pi-sdk.js +157 -42
  34. package/dist/agents/run-store.js +71 -5
  35. package/dist/agents/skill-readiness.js +52 -10
  36. package/dist/agents/subagent-manager.js +209 -3
  37. package/dist/agents/task/task-runner.js +17 -4
  38. package/dist/agents/task-group.js +8 -51
  39. package/dist/agents/task-public-text.js +89 -0
  40. package/dist/agents/workflow-pivot-loop.js +19 -12
  41. package/dist/cli/clipboard.js +65 -0
  42. package/dist/cli/commands/index.js +1 -0
  43. package/dist/cli/commands/tasks.js +350 -2
  44. package/dist/cli/identity-command.js +177 -0
  45. package/dist/cli/ink-app.js +145 -17
  46. package/dist/cli/log-gate.js +386 -0
  47. package/dist/cli/mention-data.js +2 -0
  48. package/dist/cli/model-selector.js +538 -0
  49. package/dist/cli/reply-hygiene.js +54 -0
  50. package/dist/cli/setup-wizard.js +757 -69
  51. package/dist/cli/startup-notice.js +160 -0
  52. package/dist/cli/stores.js +22 -1
  53. package/dist/cli/theme.js +49 -0
  54. package/dist/cli/tui-select.js +970 -0
  55. package/dist/cli/update-commands.js +150 -24
  56. package/dist/cli-entry.js +137 -20
  57. package/dist/electron-build/agents/contacts/chain.js +26 -6
  58. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  59. package/dist/electron-build/agents/execution-supervisor.js +578 -38
  60. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  61. package/dist/electron-build/agents/external-events.js +34 -5
  62. package/dist/electron-build/agents/external-events.js.map +1 -1
  63. package/dist/electron-build/agents/goal-criteria.js +10 -3
  64. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  65. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  66. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  67. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  68. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  69. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  70. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  71. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  72. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  73. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  74. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  75. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  76. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  86. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  87. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  88. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  89. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  90. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  91. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  92. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  93. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  94. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  95. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  96. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  97. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  98. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  99. package/dist/electron-build/agents/goal-store.js +35 -1
  100. package/dist/electron-build/agents/goal-store.js.map +1 -1
  101. package/dist/electron-build/agents/model-policy.js +706 -0
  102. package/dist/electron-build/agents/model-policy.js.map +1 -0
  103. package/dist/electron-build/agents/network-pulse.js +307 -19
  104. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  105. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  106. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  107. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  108. package/dist/electron-build/agents/pi-sdk.js +156 -41
  109. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  110. package/dist/electron-build/agents/run-store.js +75 -6
  111. package/dist/electron-build/agents/run-store.js.map +1 -1
  112. package/dist/electron-build/agents/skill-readiness.js +52 -9
  113. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  114. package/dist/electron-build/agents/task/task-runner.js +16 -3
  115. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  116. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  117. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  118. package/dist/electron-build/llm/config-store.js +146 -6
  119. package/dist/electron-build/llm/config-store.js.map +1 -1
  120. package/dist/electron-build/llm/connection-probe.js +764 -0
  121. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  122. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  123. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  124. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  125. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  126. package/dist/electron-build/llm/model-catalog.js +778 -0
  127. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  128. package/dist/electron-build/llm/model-discovery.js +1202 -0
  129. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  130. package/dist/electron-build/llm/model-selection.js +1315 -0
  131. package/dist/electron-build/llm/model-selection.js.map +1 -0
  132. package/dist/electron-build/llm/pi-ai.js +70 -11
  133. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  134. package/dist/electron-build/llm/provider-catalog.js +969 -0
  135. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  136. package/dist/electron-build/llm/provider-registry.js +950 -0
  137. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  138. package/dist/electron-build/llm/tool-name.js +266 -0
  139. package/dist/electron-build/llm/tool-name.js.map +1 -0
  140. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  141. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  142. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  143. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  144. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  145. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  146. package/dist/electron-build/utils/dual-source.js +347 -0
  147. package/dist/electron-build/utils/dual-source.js.map +1 -0
  148. package/dist/electron-build/utils/update-manager.js +328 -104
  149. package/dist/electron-build/utils/update-manager.js.map +1 -1
  150. package/dist/electron-build/utils/update-state.js +9 -11
  151. package/dist/electron-build/utils/update-state.js.map +1 -1
  152. package/dist/electron-build/utils/version-identity.js +193 -0
  153. package/dist/electron-build/utils/version-identity.js.map +1 -0
  154. package/dist/electron-build/utils/version-info.js +57 -31
  155. package/dist/electron-build/utils/version-info.js.map +1 -1
  156. package/dist/index.js +385 -108
  157. package/dist/ios/bolloon-web.json +10 -0
  158. package/dist/ios/chain-explorer.js +617 -0
  159. package/dist/ios/explorer.html +163 -0
  160. package/dist/ios/index.html +96 -0
  161. package/dist/ios/mobile-agent.js +2 -2
  162. package/dist/ios/mobile-contacts.js +362 -0
  163. package/dist/ios/mobile-core.js +3102 -310
  164. package/dist/ios/mobile-privacy.js +185 -0
  165. package/dist/ios/mobile-tasks.js +339 -0
  166. package/dist/ios/mobile-update.js +1442 -0
  167. package/dist/ios/mobile.css +19 -0
  168. package/dist/ios/mobile.html +96 -0
  169. package/dist/ios/mobile.js +1013 -41
  170. package/dist/ios/routes-chain-index.js +108 -0
  171. package/dist/ios/routes-contacts.js +420 -0
  172. package/dist/ios/routes-llm-config.js +344 -52
  173. package/dist/ios/routes-mobile-tasks.js +353 -0
  174. package/dist/ios/routes-onchain-trade.js +99 -0
  175. package/dist/ios/routes-x402-info.js +1 -0
  176. package/dist/ios/server.js +921 -0
  177. package/dist/ios/sw.js +1 -1
  178. package/dist/ios/task-frame-server.js +211 -0
  179. package/dist/llm/config-store.js +146 -6
  180. package/dist/llm/connection-probe.js +744 -0
  181. package/dist/llm/custom-provider-store.js +224 -0
  182. package/dist/llm/data/provider-catalog-baked.js +19 -0
  183. package/dist/llm/model-catalog.js +717 -0
  184. package/dist/llm/model-discovery.js +1137 -0
  185. package/dist/llm/model-selection.js +1241 -0
  186. package/dist/llm/pi-ai.js +70 -11
  187. package/dist/llm/provider-catalog.js +898 -0
  188. package/dist/llm/provider-registry.js +917 -0
  189. package/dist/llm/tool-name.js +220 -0
  190. package/dist/orbitdb/cid-database.js +81 -16
  191. package/dist/orbitdb/ipfs-node.js +31 -2
  192. package/dist/setup/onboard.js +34 -8
  193. package/dist/utils/dual-source-facts.js +223 -0
  194. package/dist/utils/dual-source.js +297 -0
  195. package/dist/utils/update-cli.js +16 -21
  196. package/dist/utils/update-health.js +35 -5
  197. package/dist/utils/update-manager.js +325 -96
  198. package/dist/utils/update-state.js +13 -10
  199. package/dist/utils/version-identity.js +176 -0
  200. package/dist/utils/version-info.js +27 -22
  201. package/dist/web/bolloon-web.json +10 -0
  202. package/dist/web/mobile-contacts.js +44 -11
  203. package/dist/web/mobile-core.js +2901 -580
  204. package/dist/web/mobile-tasks.js +339 -0
  205. package/dist/web/mobile-update.js +1442 -0
  206. package/dist/web/mobile.css +19 -0
  207. package/dist/web/mobile.html +64 -0
  208. package/dist/web/mobile.js +838 -35
  209. package/dist/web/routes-llm-config.js +344 -52
  210. package/dist/web/routes-mobile-tasks.js +353 -0
  211. package/dist/web/server.js +163 -4
  212. package/dist/web/sw.js +1 -1
  213. package/package.json +3 -1
  214. package/scripts/build-web.ts +36 -0
@@ -69,7 +69,7 @@
69
69
  });
70
70
  } catch (e) {}
71
71
 
72
- const TITLES = { main: '首页', friends: '好友', network: '网络', me: '我' };
72
+ const TITLES = { main: '首页', friends: '好友', network: '网络', tasks: '任务', me: '我' };
73
73
  let currentTab = 'main';
74
74
  function switchTab(tab) {
75
75
  currentTab = tab;
@@ -1697,6 +1697,110 @@
1697
1697
  return r;
1698
1698
  }
1699
1699
 
1700
+ /**
1701
+ * 「App 更新 (web 资源层)」页 (2026-09-26, update-protocol §13)
1702
+ * 看得见的: 当前装的是哪个源 / 哪个版本·sha / 能切回哪个源 —— 与桌面 `bolloon update --status` 同一口径。
1703
+ * 做得出的: 检查 → (智能体可自动跑到"准备并验证") → 人工确认切换 → 重载; 一键回滚。
1704
+ * 说不出的不说: 原生壳层不能在这里自更 (iOS 走商店/TestFlight, Android 侧载需允许未知来源)。
1705
+ */
1706
+ async function openUpdatePage() {
1707
+ const page = document.createElement('div');
1708
+ page.className = 'chat-page';
1709
+ page.id = 'update-page';
1710
+ page.innerHTML = `
1711
+ <div class="chat-topbar">
1712
+ <button class="icon-btn" id="update-back">←</button>
1713
+ <div style="flex:1;font-weight:600">App 更新</div>
1714
+ <button class="icon-btn" id="update-check" title="检查更新">↻</button>
1715
+ </div>
1716
+ <div style="padding:12px;overflow:auto">
1717
+ <pre id="update-identity" style="white-space:pre-wrap;word-break:break-all;font-size:12px;line-height:1.6;background:var(--bg-2,#0001);padding:10px;border-radius:10px;margin:0 0 10px">读取中…</pre>
1718
+ <div style="display:flex;gap:8px;margin-bottom:10px">
1719
+ <button class="conv-item" id="update-ch-stable" style="flex:1;justify-content:center">stable (npm)</button>
1720
+ <button class="conv-item" id="update-ch-dev" style="flex:1;justify-content:center">dev (GitHub master)</button>
1721
+ </div>
1722
+ <div class="conv-item" id="update-apply"><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" id="update-apply-sub">先点右上角 ↻ 检查</span></span><span class="list-arrow">›</span></div>
1723
+ <div class="conv-item" id="update-rollback"><span class="list-icon">${ICONS.trash}</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>
1724
+ <div class="conv-item" id="update-reload"><span class="list-icon">${ICONS.themeAuto}</span><span>重载界面让更新生效</span></div>
1725
+ <div id="update-ceiling" style="font-size:12px;line-height:1.6;color:var(--fg-2,#888);margin:10px 0"></div>
1726
+ <pre id="update-log" style="white-space:pre-wrap;word-break:break-all;font-size:12px;line-height:1.6;margin:0"></pre>
1727
+ </div>`;
1728
+ document.body.appendChild(page);
1729
+
1730
+ const identityEl = page.querySelector('#update-identity');
1731
+ const logEl = page.querySelector('#update-log');
1732
+ const applySub = page.querySelector('#update-apply-sub');
1733
+ const ceilingEl = page.querySelector('#update-ceiling');
1734
+ let lastCheck = null;
1735
+
1736
+ const log = (line) => { logEl.textContent += `${line}\n`; logEl.scrollTop = logEl.scrollHeight; };
1737
+ const UP = () => (window.BolloonCore && window.BolloonCore.update) || null;
1738
+ const setIdentity = async () => {
1739
+ const up = UP();
1740
+ if (!up) { identityEl.textContent = '内核未就绪'; return; }
1741
+ const voc = up.vocabulary();
1742
+ ceilingEl.textContent = voc.nativeCeiling;
1743
+ const st = await up.state();
1744
+ const applied = st.installedChannel ? `${st.installedChannel}${st.installedDevSha ? ` @ ${st.installedDevSha}` : ''}` : '(还没用这个功能装过)';
1745
+ identityEl.textContent = [
1746
+ `当前装的: ${applied}`,
1747
+ `通道偏好: ${up.channel()}`,
1748
+ `能切回: ${st.switchableTo ? `${st.switchableTo.channel} (${st.switchableTo.source}${st.switchableTo.target ? ` @ ${st.switchableTo.target}` : ''})` : '—'}`,
1749
+ `上次来源: ${st.installedFrom ? `${st.installedFrom.source}${st.installedFrom.digest ? ` · ${st.installedFrom.digest}` : ''}` : '—'}`,
1750
+ `更新层: web 资源层(界面与逻辑:入群 / 发任务公告 / 看飞轮进度 / 授权签名)`,
1751
+ `会生效吗: ${up.nativeWired() ? '会(原生壳已指向可写目录)' : '不会 —— 原生壳还没指向可写目录,装了也不生效,所以不会切换'}`,
1752
+ ].join('\n');
1753
+ };
1754
+ const runCheck = async () => {
1755
+ const up = UP();
1756
+ if (!up) { log('内核未就绪'); return; }
1757
+ log('· 检查两源(npm + GitHub)…');
1758
+ const r = await up.report({});
1759
+ lastCheck = r.result;
1760
+ identityEl.textContent = r.lines.join('\n');
1761
+ applySub.textContent = r.result.status === 'update_available'
1762
+ ? `可装: ${r.result.targetIdentity || r.result.latestVersion}` : `结论: ${r.result.status}`;
1763
+ log(`· 结论: ${r.result.status}${r.result.reason ? ` — ${r.result.reason}` : ''}`);
1764
+ if (r.result.warnings && r.result.warnings.length) r.result.warnings.forEach((w) => log(` ⚠ ${w}`));
1765
+ await setIdentity();
1766
+ };
1767
+
1768
+ page.querySelector('#update-back').addEventListener('click', () => { page.remove(); void openSettings(); });
1769
+ page.querySelector('#update-check').addEventListener('click', () => { logEl.textContent = ''; void runCheck(); });
1770
+ page.querySelector('#update-ch-stable').addEventListener('click', async () => { const r = await UP().setChannel('stable'); log(`· ${r.detail}`); await setIdentity(); });
1771
+ page.querySelector('#update-ch-dev').addEventListener('click', async () => { const r = await UP().setChannel('dev'); log(`· ${r.detail}`); await setIdentity(); });
1772
+ page.querySelector('#update-apply').addEventListener('click', async () => {
1773
+ const up = UP();
1774
+ if (!up) return;
1775
+ const target = lastCheck && (lastCheck.targetIdentity || lastCheck.latestVersion);
1776
+ if (!lastCheck || lastCheck.status !== 'update_available') { log('· 先检查更新(右上角 ↻):现在没有可装的目标,或源不可达/交叉校验不一致 —— 拒绝安装。'); return; }
1777
+ // 人在环: 说清要换的是哪一层, 再动手
1778
+ if (!confirm(`要装 ${target} 吗?\n\n换的是「web 资源层」(界面与逻辑)。装完需要重载界面才生效;装坏了可以一键回滚。\n原生壳层(二进制)不在这里更新。`)) return;
1779
+ log('· 准备并验证 …');
1780
+ const prep = await up.prepare({ onStage: (s, d) => log(` [${s}] ${d}`) });
1781
+ if (!prep.ok) { log(`· 准备/验证失败: ${prep.status} — ${prep.reason}`); return; }
1782
+ log(`· 已验证 ${prep.to}(${prep.fileCount} 个文件)—— 现在切换`);
1783
+ const out = await up.apply({ confirm: true, reuseStaging: true, onStage: (s, d) => log(` [${s}] ${d}`) });
1784
+ log(out.ok ? `· 成功: ${out.from} → ${out.to}(重载界面后生效)` : `· 未生效 (${out.stage}): ${out.reason}`);
1785
+ applySub.textContent = out.ok ? '已装好 · 点「重载界面」生效' : `未生效: ${out.status}`;
1786
+ await setIdentity();
1787
+ });
1788
+ page.querySelector('#update-rollback').addEventListener('click', async () => {
1789
+ const up = UP();
1790
+ if (!up) return;
1791
+ if (!confirm('换回上一份能用的 web 资源吗?(当前这一份会被替换掉)')) return;
1792
+ const out = await up.rollback({ confirm: true, onStage: (s, d) => log(` [${s}] ${d}`) });
1793
+ log(out.ok ? `· 已回滚到 ${out.to}` : `· 回滚未执行 (${out.stage}): ${out.reason}`);
1794
+ await setIdentity();
1795
+ });
1796
+ page.querySelector('#update-reload').addEventListener('click', async () => {
1797
+ const r = await UP().reload();
1798
+ log(`· ${r.detail}(方式: ${r.how})`);
1799
+ });
1800
+
1801
+ await setIdentity();
1802
+ }
1803
+
1700
1804
  function openSettings() {
1701
1805
  const page = document.createElement('div');
1702
1806
  page.className = 'chat-page';
@@ -1709,10 +1813,11 @@
1709
1813
  <div style="padding:12px">
1710
1814
  <div class="conv-item" id="api-config-item"><span class="list-icon">${ICONS.chip}</span><span>API 配置</span><span class="list-arrow">›</span></div>
1711
1815
  <div class="conv-item" id="theme-toggle"><span class="list-icon" id="theme-icon">${ICONS.themeAuto}</span><span id="theme-text">跟随系统</span></div>
1816
+ <div class="conv-item" id="settings-update"><span class="list-icon">${ICONS.themeAuto}</span><span style="flex:1;min-width:0"><span style="display:block">App 更新(web 资源层)</span><span class="conv-preview" style="display:block" id="update-preview">读取中…</span></span><span class="list-arrow">›</span></div>
1712
1817
  <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>
1713
1818
  <div class="conv-item" id="settings-desktop"><span class="list-icon">${ICONS.globe}</span><span>电脑端同步</span><span class="list-arrow">›</span></div>
1714
1819
  <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>
1820
+ <div class="conv-item" id="settings-accessibility"><span class="list-icon">${ICONS.globe}</span><span>完全访问权限<span class="conv-preview" style="display:block" id="access-preview">默认开启</span></span><span class="list-arrow">›</span></div>
1716
1821
  <div class="conv-item" id="settings-ipfs"><span class="list-icon">${ICONS.chip}</span><span>IPFS 存储</span><span class="list-arrow">›</span></div>
1717
1822
  <div class="conv-item" id="settings-helia"><span class="list-icon">${ICONS.globe}</span><span>本机 IPFS 节点</span><span class="list-arrow">›</span></div>
1718
1823
  <div class="conv-item" id="settings-selfcard"><span class="list-icon">${ICONS.chip}</span><span id="selfcard-text">显示本机卡片: 开</span></div>
@@ -1725,20 +1830,66 @@
1725
1830
  applyTheme(resolveThemePref(), false);
1726
1831
  $('#settings-back').addEventListener('click', () => page.remove());
1727
1832
  $('#api-config-item').addEventListener('click', openApiConfig);
1833
+ // App 更新: 预览里就说清"现在装的是哪个源 / 哪个版本·sha / 能切回谁"(= 桌面 update --status 同一口径)
1834
+ $('#settings-update').addEventListener('click', () => { page.remove(); void openUpdatePage(); });
1835
+ void (async () => {
1836
+ const el = page.querySelector('#update-preview');
1837
+ if (!el) return;
1838
+ try {
1839
+ const up = window.BolloonCore && window.BolloonCore.update;
1840
+ if (!up) { el.textContent = '内核未就绪'; return; }
1841
+ const st = await up.state();
1842
+ const applied = st.installedChannel
1843
+ ? `${st.installedChannel}${st.installedDevSha ? ` @ ${st.installedDevSha}` : ''}`
1844
+ : '内置资源';
1845
+ const back = st.switchableTo ? ` · 能切回 ${st.switchableTo.channel}` : '';
1846
+ el.textContent = `当前 ${applied}${back} · 通道 ${up.channel()}${up.nativeWired() ? '' : ' · 原生壳未接通'}`;
1847
+ } catch (e) { el.textContent = '读取失败: ' + ((e && e.message) || e); }
1848
+ })();
1728
1849
  $('#theme-toggle').addEventListener('click', () => {
1729
1850
  const next = currentTheme === 'auto' ? 'light' : (currentTheme === 'light' ? 'dark' : 'auto');
1730
1851
  applyTheme(next, true);
1731
1852
  });
1732
- // 无障碍服务: 屏幕触控 (电脑端 phone.tap / phone.swipe) 的前提 — 与 MCP/Skills 控制无关
1853
+ // 完全访问权限 (屏幕点按/滑动, 电脑端 phone.tap / phone.swipe 的前提) — 默认开启
1854
+ const ACCESS_AUTO_KEY = 'bolloon_access_auto_guided';
1855
+ const refreshAccessPreview = async () => {
1856
+ const el = document.querySelector('#access-preview');
1857
+ if (!el) return;
1858
+ const cap = window.Capacitor;
1859
+ const b = cap && cap.Plugins && cap.Plugins.RokidBridge;
1860
+ if (!b) { el.textContent = '仅手机 App'; return; }
1861
+ let st = {};
1862
+ try { st = (await b.touchStatus()) || {}; } catch (e) { st = {}; }
1863
+ el.textContent = st.ready ? '已开启' : '未开启 · 点这里开';
1864
+ };
1865
+ void refreshAccessPreview();
1866
+ // 默认开启: 首次启动自动把用户带到系统开关页 (只引导一次, 不反复打扰)
1867
+ void (async () => {
1868
+ try {
1869
+ const cap = window.Capacitor;
1870
+ const b = cap && cap.Plugins && cap.Plugins.RokidBridge;
1871
+ if (!b) return;
1872
+ let st = {};
1873
+ try { st = (await b.touchStatus()) || {}; } catch (e) { return; }
1874
+ if (st.ready) return;
1875
+ if (localStorage.getItem(ACCESS_AUTO_KEY) === '1') return;
1876
+ localStorage.setItem(ACCESS_AUTO_KEY, '1');
1877
+ await b.openAccessibilitySettings();
1878
+ } catch (e) {}
1879
+ })();
1733
1880
  $('#settings-accessibility').addEventListener('click', async () => {
1734
1881
  const cap = window.Capacitor;
1735
1882
  const bridge = cap && cap.Plugins && cap.Plugins.RokidBridge;
1736
- if (!bridge) { alert('仅真机可用:App 内可把屏幕触控能力授权给电脑端'); return; }
1883
+ if (!bridge) { alert('完全访问权限只在手机 App 里有:电脑端 / 网页版没有这项。'); return; }
1737
1884
  let st = {};
1738
1885
  try { st = (await bridge.touchStatus()) || {}; } catch (e) { st = {}; }
1739
- if (st.ready) { alert('触控已就绪:电脑端可发 phone.tap / phone.swipe 操作这台手机'); return; }
1740
- try { await bridge.openAccessibilitySettings(); }
1741
- catch (e) { alert('请手动开启:设置 → 辅助功能 → 已安装的服务 → Bolloon Agent'); }
1886
+ if (st.ready) { alert('完全访问权限已开启:智能体可以在这台手机上点按、滑动屏幕(电脑端也能远程帮你点)。\n\n想关掉:系统设置 → 辅助功能 → 已开启的服务 → Bolloon Agent,关掉即可。'); return; }
1887
+ try {
1888
+ alert('马上打开系统设置,把「Bolloon Agent」的完全访问权限打开。\n(这一项在有些手机叫「辅助功能」,有些叫「无障碍」,指同一个开关;打开后一直有效,随时可以关掉。)');
1889
+ await bridge.openAccessibilitySettings();
1890
+ void refreshAccessPreview();
1891
+ }
1892
+ catch (e) { alert('没打开成功,请手动去:系统设置 → 辅助功能 → 已开启的服务 → Bolloon Agent,打开即可。'); }
1742
1893
  });
1743
1894
 
1744
1895
  // 本机数据: 直接读 IndexedDB 快照 (智能体/会话/消息都在本机, 重开 App 不会丢)
@@ -1797,9 +1948,28 @@
1797
1948
  $('#settings-did').addEventListener('click', () => { api.get('/api/auth/status').then((s) => alert('DID: ' + (s.did || '未生成'))); });
1798
1949
  }
1799
1950
 
1800
- // === API 配置 (LLM 供应商) ===
1801
- // 注意: 手机端 RemoteLlm 走 OpenAI 兼容协议 (baseUrl + /chat/completions) —— 新增 provider 必须是
1802
- // OpenAI 兼容端点 (gemini 用 /v1beta/openai, 智谱 v4 / dashscope compatible-mode 都兼容)。
1951
+ // === 模型配置 (供应商 / 模型 / 地址) ===
1952
+ //
1953
+ // 2026-09-26 (模型链路升级 P6 的手机侧): 这一页**只显示与触发**, 自己不算配置 ——
1954
+ // 唯一事实来自桌面端的模型端点 (与 CLI `/model` / Web 桌面页**同一份**):
1955
+ //
1956
+ // 当前真实生效 GET /api/models/providers → body.effective
1957
+ // (provider · model · baseUrl · scope · configHash)
1958
+ // 目录 + 来源 GET /api/models/options?provider=<id> → body.catalog.origin
1959
+ // (live | cached | curated | custom | unavailable)
1960
+ // 刷新发现 POST /api/models/discover {action:'refresh', provider}
1961
+ // 手输模型 POST /api/models/discover {action:'admit', provider, model}
1962
+ //
1963
+ // 纪律: **不回读 /api/llm-config 自己拼 activeProvider/providers 来显示"生效配置"** —— 那是
1964
+ // 第二套读配置逻辑, 与服务端早晚漂移。下面 `localCfg` 只喂"编辑表单"和"保存"(写路径),
1965
+ // 展示层一个字都不从它取; 拿不到桌面端就如实说"显示不了", 不猜不编。
1966
+ //
1967
+ // 注意: 手机端 RemoteLlm 走 OpenAI 兼容协议 (baseUrl + /chat/completions) —— 自定义 provider
1968
+ // 必须是 OpenAI 兼容端点 (gemini 用 /v1beta/openai, 智谱 v4 / dashscope compatible-mode 都兼容)。
1969
+ const ORIGIN_LABEL = {
1970
+ live: '实时发现 (live)', cached: '缓存发现 (cached)', curated: '内置清单 (curated)',
1971
+ custom: '自定义 (custom)', unavailable: '不可用 (unavailable)',
1972
+ };
1803
1973
  const LLM_PROVIDERS = [
1804
1974
  { id: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
1805
1975
  { id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
@@ -1817,43 +1987,155 @@
1817
1987
  { id: 'custom', label: '自定义', baseUrl: '', model: '' },
1818
1988
  ];
1819
1989
  const LLM_BY_ID = LLM_PROVIDERS.reduce((m, p) => (m[p.id] = p, m), {});
1990
+
1991
+ /** configHash 只显示前 8 位 (完整值在桌面端 / Run 快照里, 手机屏不需要 64 位) */
1992
+ function shortConfigHash(h) {
1993
+ const s = String(h == null ? '' : h);
1994
+ return s ? s.slice(0, 8) : '—';
1995
+ }
1996
+
1997
+ /** 目录来源标记 → 人话 (认不出来就原样回显, 不编) */
1998
+ function originLabel(origin) {
1999
+ const k = String(origin || '');
2000
+ return ORIGIN_LABEL[k] || (k ? k : '—');
2001
+ }
2002
+
2003
+ /** 电脑端基址 (设置里填; 手机自足模式拿不到就如实说, 不假装成功) */
2004
+ async function modelApiGet(path) {
2005
+ const base = await desktopBaseUrl();
2006
+ if (!base) return { ok: false, error: '未接电脑端 —— 设置 → 电脑端同步 里填桌面地址后可看' };
2007
+ const r = await desktopFetch(path);
2008
+ if (r === null) return { ok: false, error: '电脑端不可达或返回非 200' };
2009
+ return { ok: true, data: r };
2010
+ }
2011
+ async function modelApiPost(path, body) {
2012
+ const base = await desktopBaseUrl();
2013
+ if (!base) return { ok: false, error: '未接电脑端 —— 设置 → 电脑端同步 里填桌面地址后可看' };
2014
+ return await desktopPostRaw(path, body); // 保留后端错误原文
2015
+ }
2016
+
1820
2017
  async function openApiConfig() {
1821
- let cfg;
1822
- try { cfg = await api.get('/api/llm-config'); } catch { cfg = null; }
1823
- if (!cfg || !cfg.providers) cfg = { activeProvider: 'deepseek', providers: {}, updatedAt: Date.now() };
1824
2018
  const page = document.createElement('div');
1825
2019
  page.className = 'chat-page';
1826
2020
  page.id = 'api-config-page';
1827
- const provider = cfg.activeProvider || 'deepseek';
1828
- const pc = cfg.providers?.[provider] || {};
2021
+ // 编辑表单的本地草稿 (写路径) —— 展示层不读它
2022
+ let localCfg = null;
2023
+ try { localCfg = await api.get('/api/llm-config'); } catch (e) { localCfg = null; }
2024
+ if (!localCfg || !localCfg.providers) localCfg = { activeProvider: 'deepseek', providers: {}, updatedAt: Date.now() };
2025
+ const _in = 'width:100%;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-hover);color:var(--text);box-sizing:border-box';
2026
+ const _btn = 'padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--bg-hover);color:var(--text)';
1829
2027
  page.innerHTML = `
1830
2028
  <div class="chat-topbar">
1831
2029
  <button class="icon-btn" id="api-config-back">←</button>
1832
- <div style="flex:1;font-weight:600">API 配置</div>
2030
+ <div style="flex:1;font-weight:600">模型配置</div>
1833
2031
  </div>
1834
- <div style="padding:12px;display:flex;flex-direction:column;gap:12px">
1835
- <label style="font-size:13px;color:var(--text-secondary)">供应商 <span id="api-provider-count" style="opacity:.55"></span></label>
1836
- <div class="provider-chips" id="api-provider-chips">
1837
- ${LLM_PROVIDERS.map((p) => `<button type="button" class="provider-chip${p.id === provider ? ' active' : ''}" data-provider="${p.id}">${p.label}</button>`).join('')}
2032
+ <div style="padding:12px;display:flex;flex-direction:column;gap:12px;overflow:auto">
2033
+ <div class="section-label">当前真实生效 (电脑端 /api/models/providers)</div>
2034
+ <div id="api-eff-card" style="padding:12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-card);display:flex;flex-direction:column;gap:6px">
2035
+ <div style="font-size:13px">供应商: <b id="api-eff-provider">读取中…</b></div>
2036
+ <div style="font-size:13px">模型: <b id="api-eff-model">—</b></div>
2037
+ <div style="font-size:12px;color:var(--text-secondary);word-break:break-all">地址: <span id="api-eff-baseurl">—</span></div>
2038
+ <div style="font-size:12px;color:var(--text-secondary)">来源(scope): <span id="api-eff-scope">—</span> · 配置指纹: <span id="api-eff-hash">—</span></div>
2039
+ <div id="api-eff-note" style="font-size:12px;color:var(--text-muted)"></div>
1838
2040
  </div>
2041
+
2042
+ <div class="section-label">模型目录 (电脑端 /api/models/options)</div>
2043
+ <div id="api-catalog-origin" style="font-size:13px">—</div>
2044
+ <div id="api-catalog-head" style="font-size:12px;color:var(--text-secondary)"></div>
2045
+ <div id="api-catalog-list" style="font-size:12px;color:var(--text-secondary);max-height:170px;overflow:auto;word-break:break-all"></div>
2046
+ <div id="api-catalog-note" style="font-size:12px;color:var(--text-muted)"></div>
2047
+ <button id="api-discover-refresh" style="${_btn}">刷新发现</button>
2048
+ <div style="display:flex;gap:8px">
2049
+ <input id="api-manual-model" placeholder="手输模型名 (目录里没有的)" style="flex:1;${_in}">
2050
+ <button id="api-manual-admit" style="${_btn}">加入目录</button>
2051
+ </div>
2052
+ <div id="api-discover-result" style="font-size:12px;color:var(--text-secondary)"></div>
2053
+
2054
+ <div class="section-label">供应商 / 端点 / 凭据 (本机保存) <span id="api-provider-count" style="opacity:.55"></span></div>
2055
+ <div class="provider-chips" id="api-provider-chips"></div>
1839
2056
  <div id="api-hint" style="font-size:12px;color:var(--text-muted)"></div>
1840
- <label style="font-size:13px;color:var(--text-secondary)">Base URL</label>
1841
- <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)">
1842
- <label style="font-size:13px;color:var(--text-secondary)">API Key</label>
1843
- <input id="api-key" type="password" placeholder="sk-..." value="${escapeHtml(pc.apiKey || '')}" style="padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-hover);color:var(--text)">
1844
- <label style="font-size:13px;color:var(--text-secondary)">模型</label>
1845
- <input id="api-model" placeholder="模型名" value="${escapeHtml(pc.model || '')}" style="padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-hover);color:var(--text)">
2057
+ <input id="api-baseurl" placeholder="https://api.xxx.com/v1" style="${_in}">
2058
+ <input id="api-key" type="password" placeholder="sk-..." style="${_in}">
2059
+ <input id="api-model" placeholder="模型名" style="${_in}">
1846
2060
  <button id="api-save" style="padding:12px;border:none;border-radius:10px;background:var(--accent);color:var(--bg);font-weight:700">保存配置</button>
1847
2061
  </div>`;
1848
2062
  document.body.appendChild(page);
1849
2063
  $('#api-config-back').addEventListener('click', () => page.remove());
1850
- // 供应商选择: 芯片式 (原来是一个原生 select, 小屏上难点、也看不出有哪些可选)
1851
- let picked = LLM_BY_ID[provider] ? provider : 'deepseek';
1852
- const fillFrom = (id) => {
1853
- const saved = (cfg.providers && cfg.providers[id]) || {};
2064
+ const setText = (sel, v) => { const el = page.querySelector(sel); if (el) el.textContent = String(v == null ? '' : v); };
2065
+
2066
+ // ── ① 当前真实生效: 只认服务端 effective ──────────────────────────
2067
+ let lastProviders = null;
2068
+ async function paintEffective() {
2069
+ const r = await modelApiGet('/api/models/providers');
2070
+ if (!r.ok) {
2071
+ setText('#api-eff-provider', '—'); setText('#api-eff-model', '—');
2072
+ setText('#api-eff-baseurl', '—'); setText('#api-eff-scope', '—'); setText('#api-eff-hash', '—');
2073
+ setText('#api-eff-note', '读不到生效配置: ' + r.error);
2074
+ return null;
2075
+ }
2076
+ const eff = r.data && r.data.effective;
2077
+ if (!eff) {
2078
+ setText('#api-eff-note', '电脑端没给出 effective (不猜: 不显示任何默认值)');
2079
+ return r.data;
2080
+ }
2081
+ setText('#api-eff-provider', eff.provider);
2082
+ setText('#api-eff-model', eff.model || '—');
2083
+ setText('#api-eff-baseurl', eff.baseUrl || '—');
2084
+ setText('#api-eff-scope', eff.scope || eff.source || '—');
2085
+ setText('#api-eff-hash', shortConfigHash(eff.configHash));
2086
+ setText('#api-eff-note', '协议 ' + (eff.protocol || '?') + ' · 凭据 ' + (eff.authRef || 'none')
2087
+ + ' · 指纹前 8 位 (完整值见电脑端 Run 快照)');
2088
+ return r.data;
2089
+ }
2090
+
2091
+ // ── ② 目录 + 来源标记 ────────────────────────────────────────────
2092
+ let picked = (localCfg.activeProvider && LLM_BY_ID[localCfg.activeProvider]) ? localCfg.activeProvider : 'deepseek';
2093
+ async function paintCatalog() {
2094
+ setText('#api-catalog-origin', '来源: 读取中…');
2095
+ setText('#api-catalog-head', ''); setText('#api-catalog-list', ''); setText('#api-catalog-note', '');
2096
+ const r = await modelApiGet('/api/models/options?provider=' + encodeURIComponent(picked));
2097
+ if (!r.ok) { setText('#api-catalog-origin', '来源: —'); setText('#api-catalog-note', '读不到目录: ' + r.error); return; }
2098
+ const d = r.data || {};
2099
+ const cat = d.catalog || null;
2100
+ setText('#api-catalog-origin', '来源: ' + originLabel(cat && cat.origin));
2101
+ const models = Array.isArray(d.models) ? d.models : [];
2102
+ setText('#api-catalog-head', String(d.count == null ? models.length : d.count) + ' 个模型'
2103
+ + (cat && cat.stale ? ' · 缓存已过期' : '')
2104
+ + (cat && cat.discoveryFailed ? ' · 上游发现失败' : ''));
2105
+ const listEl = page.querySelector('#api-catalog-list');
2106
+ if (listEl) {
2107
+ listEl.innerHTML = models.slice(0, 40).map((m) => {
2108
+ const id = escapeHtml(String((m && (m.id || m.name)) || ''));
2109
+ const tool = (m && m.toolCalling) ? ' · 工具调用 ' + escapeHtml(String(m.toolCalling)) : '';
2110
+ return '<div>' + id + tool + '</div>';
2111
+ }).join('') || '(空目录)';
2112
+ }
2113
+ if (cat && cat.failure) {
2114
+ const f = cat.failure;
2115
+ setText('#api-catalog-note', '上游失败类目: ' + String(f.failureClass || f.class || f.message || JSON.stringify(f)).slice(0, 160));
2116
+ }
2117
+ }
2118
+
2119
+ // ── ③ 供应商芯片 (名字/可用性来自服务端 providers, 不是本机硬编码表) ──
2120
+ function paintChips() {
2121
+ const box = page.querySelector('#api-provider-chips');
2122
+ if (!box) return;
2123
+ const rows = (lastProviders && Array.isArray(lastProviders.providers) && lastProviders.providers.length)
2124
+ ? lastProviders.providers.map((p) => ({ id: String(p.id), label: String(p.name || p.id), configured: p.configured !== false, current: p.current === true, isLocal: p.isLocal === true }))
2125
+ : LLM_PROVIDERS;
2126
+ box.innerHTML = rows.map((p) => `<button type="button" class="provider-chip${p.id === picked ? ' active' : ''}" data-provider="${escapeHtml(p.id)}">${escapeHtml(p.label)}</button>`).join('');
2127
+ const cn = page.querySelector('#api-provider-count');
2128
+ if (cn) {
2129
+ const ready = rows.filter((p) => p.configured !== false).length;
2130
+ cn.textContent = '(' + ready + ' / ' + rows.length + ' 家可用凭据就绪)';
2131
+ }
2132
+ }
2133
+ function fillFrom(id) {
2134
+ const saved = (localCfg.providers && localCfg.providers[id]) || {};
1854
2135
  const d = LLM_BY_ID[id] || {};
1855
- $('#api-baseurl').value = saved.baseUrl || d.baseUrl || '';
1856
- $('#api-model').value = saved.model || d.model || '';
2136
+ const row = lastProviders && Array.isArray(lastProviders.providers) ? lastProviders.providers.find((x) => String(x.id) === String(id)) : null;
2137
+ $('#api-baseurl').value = saved.baseUrl || (row && row.baseUrl) || d.baseUrl || '';
2138
+ $('#api-model').value = saved.model || (row && row.configuredModel) || d.model || '';
1857
2139
  $('#api-key').value = saved.apiKey || '';
1858
2140
  const hint = $('#api-hint');
1859
2141
  if (hint) {
@@ -1862,23 +2144,37 @@
1862
2144
  : (saved.apiKey ? '该供应商已保存过 key(改完记得再点保存)'
1863
2145
  : '填官方文档里的 API key; 保存后本机智能体就走它');
1864
2146
  }
1865
- };
1866
- const paintChips = () => {
1867
- $$('#api-provider-chips .provider-chip').forEach((c) => c.classList.toggle('active', c.dataset.provider === picked));
1868
- };
1869
- const configured = Object.keys(cfg.providers || {}).filter((k) => cfg.providers[k] && cfg.providers[k].apiKey);
1870
- $('#api-provider-count').textContent = configured.length ? `(已配 ${configured.length} 个: ${configured.join(' / ')})` : '';
1871
- $('#api-provider-chips').addEventListener('click', (e) => {
2147
+ }
2148
+ page.querySelector('#api-provider-chips').addEventListener('click', (e) => {
1872
2149
  const b = e.target.closest && e.target.closest('.provider-chip');
1873
2150
  if (!b) return;
1874
2151
  picked = b.dataset.provider;
1875
2152
  paintChips();
1876
2153
  fillFrom(picked);
2154
+ void paintCatalog();
1877
2155
  });
1878
- fillFrom(picked);
2156
+
2157
+ // ── ④ 刷新发现 / ⑤ 手输模型 (都打在服务端唯一入口上) ─────────────
2158
+ page.querySelector('#api-discover-refresh').addEventListener('click', async () => {
2159
+ setText('#api-discover-result', '刷新中…');
2160
+ const r = await modelApiPost('/api/models/discover', { action: 'refresh', provider: picked, force: true });
2161
+ if (r && r.ok === false) { setText('#api-discover-result', '刷新失败: ' + String(r.error || r.message || '').slice(0, 200)); return; }
2162
+ setText('#api-discover-result', '刷新完成 · 来源 ' + originLabel(r && r.catalog && r.catalog.origin));
2163
+ await paintCatalog();
2164
+ });
2165
+ page.querySelector('#api-manual-admit').addEventListener('click', async () => {
2166
+ const model = String($('#api-manual-model').value || '').trim();
2167
+ if (!model) { setText('#api-discover-result', '先填一个模型名'); return; }
2168
+ const r = await modelApiPost('/api/models/discover', { action: 'admit', provider: picked, model });
2169
+ if (r && r.ok === false) { setText('#api-discover-result', '加入失败: ' + String(r.error || r.message || '').slice(0, 200)); return; }
2170
+ setText('#api-discover-result', '已加入目录: ' + model);
2171
+ await paintCatalog();
2172
+ });
2173
+
2174
+ // ── ⑥ 编辑表单保存 (写路径, 与从前同一口) ─────────────────────────
1879
2175
  $('#api-save').addEventListener('click', async () => {
1880
2176
  const p = picked;
1881
- const next = (cfg && cfg.providers) ? cfg : { activeProvider: cfg.activeProvider, providers: {}, updatedAt: Date.now() };
2177
+ const next = (localCfg && localCfg.providers) ? localCfg : { activeProvider: localCfg.activeProvider, providers: {}, updatedAt: Date.now() };
1882
2178
  next.activeProvider = p;
1883
2179
  next.providers[p] = Object.assign({}, (next.providers[p] || {}), {
1884
2180
  enabled: true, apiKey: $('#api-key').value.trim(), baseUrl: $('#api-baseurl').value.trim(),
@@ -1890,6 +2186,12 @@
1890
2186
  page.remove();
1891
2187
  } catch (e) { alert('保存失败: ' + (e.message || e)); }
1892
2188
  });
2189
+
2190
+ // 启动: 先画芯片(服务端清单) → 再画生效卡片与目录
2191
+ lastProviders = await paintEffective();
2192
+ paintChips();
2193
+ fillFrom(picked);
2194
+ await paintCatalog();
1893
2195
  }
1894
2196
 
1895
2197
  // === 加密钱包 (只读 MVP) ===
@@ -3001,3 +3303,673 @@
3001
3303
  document.addEventListener('DOMContentLoaded', init);
3002
3304
  if (document.readyState !== 'loading') init();
3003
3305
  })();
3306
+
3307
+ // ============ 联系方式与持久授权 (2026-09-19) ============
3308
+ // 独立 IIFE: 不改动既有逻辑, 只用 window.BolloonCore.contacts (编译进 mobile-core.js)。
3309
+ // 分工: 手机 = 输入/OTP/确认/展示待发内容/批准/签名授权; 桌面 = 落盘/执行/等待回复/证据。
3310
+ (function () {
3311
+ const $ = (sel) => document.querySelector(sel);
3312
+ const C = () => (window.BolloonCore && window.BolloonCore.contacts) || null;
3313
+ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
3314
+
3315
+ function openSheet() { const el = $('#contacts-sheet'); if (el) el.hidden = false; }
3316
+ function closeSheet() { const el = $('#contacts-sheet'); if (el) el.hidden = true; }
3317
+ function setText(id, text) { const el = $(id); if (el) el.textContent = text; }
3318
+
3319
+ let _lastChallenge = null;
3320
+
3321
+ async function refresh() {
3322
+ const c = C();
3323
+ setText('#contacts-effective', '加载中...');
3324
+ if (!c) { setText('#contacts-effective', '内核未就绪 (mobile-core 没加载)'); return; }
3325
+ setText('#contacts-signing', c.deviceSigning()
3326
+ ? '本机可做设备签名 (Ed25519) — 授权由这台手机签名后桌面才认'
3327
+ : '⚠ 本机 WebView 不支持 Ed25519 设备签名 — 无法在手机上授权, 请在桌面端授权');
3328
+ // 桌面回来先补同步排队中的授权
3329
+ try { const fl = await c.flushQueue(); if (fl && fl.sent) setText('#contacts-signing', `已补同步 ${fl.sent} 条手机授权到桌面`); } catch (e) {}
3330
+
3331
+ let card = null;
3332
+ try { card = await c.card(); } catch (e) { card = null; }
3333
+ if (!card) { setText('#contacts-effective', '读取失败'); return; }
3334
+ setText('#contacts-effective',
3335
+ `当前授权: ${card.effective}` + (card.deviceSigning ? '' : ' · 本机不能签名'));
3336
+ setText('#contacts-willnot', '不会获得权限: ' + (card.willNotGet || []).join(' · '));
3337
+
3338
+ // 联系方式 (只有脱敏值)
3339
+ const list = $('#contacts-list');
3340
+ if (list) {
3341
+ list.innerHTML = (card.contacts && card.contacts.length)
3342
+ ? card.contacts.map((x) => `<div class="sheet-text">${esc(x.displayValue)} <span style="color:var(--text-secondary)">[${esc(x.kind)}] ${esc(x.state)} · ${esc(x.label)}</span></div>`).join('')
3343
+ : '<div class="sheet-text" style="color:var(--text-secondary)">还没有绑定 — 在下面输入手机号或邮箱</div>';
3344
+ }
3345
+
3346
+ // 待批准 (展示待发内容 → 批准/拒绝)
3347
+ const ap = $('#contacts-approvals');
3348
+ if (ap) {
3349
+ ap.innerHTML = (card.approvals && card.approvals.length)
3350
+ ? `<div class="sheet-title" style="margin-top:10px">待你批准 (${card.approvals.length})</div>` + card.approvals.map((a) => `
3351
+ <div style="border:1px solid var(--border);border-radius:10px;padding:8px;margin-top:6px">
3352
+ <div class="sheet-text" style="font-weight:600">${esc(a.contactName)} · ${esc(a.channel)}${a.reallySent ? '' : ' · 本地落盘(未真实外发)'}</div>
3353
+ <div class="sheet-text" style="font-size:12px">${a.subject ? esc(a.subject) + ' — ' : ''}${esc(a.bodyPreview)}</div>
3354
+ <div class="sheet-text" style="font-size:11px;color:var(--text-secondary)">原因: ${esc(a.reason)}</div>
3355
+ <div style="display:flex;gap:8px;margin-top:6px">
3356
+ <button class="sheet-choice" data-approve="${esc(a.consentId)}" style="flex:1">批准并发送</button>
3357
+ <button class="sheet-choice sheet-cancel" data-reject="${esc(a.consentId)}" style="flex:1">拒绝</button>
3358
+ </div>
3359
+ </div>`).join('')
3360
+ : '<div class="sheet-text" style="color:var(--text-secondary);margin-top:10px">没有待批准的联系</div>';
3361
+ ap.querySelectorAll('[data-approve]').forEach((b) => b.addEventListener('click', async () => {
3362
+ const id = b.getAttribute('data-approve');
3363
+ const r = await C().decide(id, 'approve');
3364
+ alert(r && r.ok ? '已批准并发送' : '批准失败: ' + ((r && r.error) || '未知'));
3365
+ refresh();
3366
+ }));
3367
+ ap.querySelectorAll('[data-reject]').forEach((b) => b.addEventListener('click', async () => {
3368
+ const id = b.getAttribute('data-reject');
3369
+ const r = await C().decide(id, 'reject', { reason: '手机端拒绝' });
3370
+ alert(r && r.ok ? '已拒绝' : '拒绝失败: ' + ((r && r.error) || '未知'));
3371
+ refresh();
3372
+ }));
3373
+ }
3374
+
3375
+ // 三个授权选项
3376
+ const ch = $('#contacts-choices');
3377
+ if (ch && !ch.dataset.bound) {
3378
+ ch.dataset.bound = '1';
3379
+ ch.innerHTML = (card.choices || []).map((x) => `<button class="sheet-choice" data-choice="${esc(x.id)}">${esc(x.label)}</button>`).join('');
3380
+ ch.querySelectorAll('[data-choice]').forEach((b) => b.addEventListener('click', async () => {
3381
+ const choice = b.getAttribute('data-choice');
3382
+ const warn = choice === 'full_contact_access'
3383
+ ? '完全授权联系方式能力: 手机号/邮箱能力交给 Agent, 普通与敏感内容不再逐次确认 (密钥/支付/合同承诺仍然拒绝)。确认吗?'
3384
+ : choice === 'persistent' ? '长期使用: 以后 Agent 联系已验证的人不再打断你 (仍受频率/任务/证据/撤销约束)。确认吗?'
3385
+ : '仅本次任务: 只授权当前任务使用。确认吗?';
3386
+ if (window.confirm && !window.confirm(warn)) return;
3387
+ const r = await C().authorize(choice);
3388
+ if (r && r.ok) alert('已授权: 桌面已验签接受\n' + (r.note || ''));
3389
+ else if (r && r.queued) alert('桌面暂时不在线\n授权已保存在手机本地队列, 桌面一上线自动同步\n(' + (r.error || '') + ')');
3390
+ else alert('授权失败: ' + ((r && r.error) || '未知'));
3391
+ refresh();
3392
+ }));
3393
+ }
3394
+ }
3395
+
3396
+ function bindEvents() {
3397
+ const item = $('#item-contacts');
3398
+ if (item) item.addEventListener('click', () => { openSheet(); refresh(); });
3399
+ const close = $('#contacts-close');
3400
+ if (close) close.addEventListener('click', closeSheet);
3401
+
3402
+ const bind = $('#contacts-bind');
3403
+ if (bind) bind.addEventListener('click', async () => {
3404
+ const v = ($('#contacts-value') || {}).value || '';
3405
+ if (!v.trim()) { alert('先输入手机号或邮箱'); return; }
3406
+ const isEmail = /@/.test(v);
3407
+ const r = await C().bind(isEmail ? 'email' : 'phone', v.trim(), isEmail ? undefined : 'CN');
3408
+ if (!r || !r.ok) { alert('绑定失败: ' + ((r && r.error) || '未知')); return; }
3409
+ _lastChallenge = r.data;
3410
+ alert('已登记 ' + r.data.displayValue + '\n通道: ' + (r.data.channelLabel || '') +
3411
+ (r.data.otpForLocalSink ? '\n本地落盘验证码: ' + r.data.otpForLocalSink : '\n验证码已通过通道发出, 收到后填在下面'));
3412
+ refresh();
3413
+ });
3414
+
3415
+ const verify = $('#contacts-verify');
3416
+ if (verify) verify.addEventListener('click', async () => {
3417
+ const code = ($('#contacts-code') || {}).value || '';
3418
+ if (!_lastChallenge) { alert('先绑定一个手机号或邮箱'); return; }
3419
+ const r = await C().verify(_lastChallenge.contactId, _lastChallenge.challengeId, code.trim());
3420
+ if (!r || !r.ok) { alert('验证失败: ' + ((r && r.error) || '未知')); return; }
3421
+ alert('验证通过: ' + r.data.displayValue + '\n下一步: 选择下面的授权等级 (验证 ≠ 自动长期授权)');
3422
+ refresh();
3423
+ });
3424
+
3425
+ const revoke = $('#contacts-revoke-all');
3426
+ if (revoke) revoke.addEventListener('click', async () => {
3427
+ if (window.confirm && !window.confirm('撤销全部联系方式授权? 之后 Agent 不能再自动联系任何人 (历史证据保留)')) return;
3428
+ const card = await C().card();
3429
+ const grants = (card && card.grants) || [];
3430
+ if (!grants.length) { alert('当前没有生效中的授权'); return; }
3431
+ let done = 0, err = '';
3432
+ for (const g of grants) {
3433
+ if (g.status !== 'active') continue;
3434
+ const r = await C().revoke(g.grantId, '手机端收回');
3435
+ if (r && r.ok) done++; else err = (r && r.error) || '未知';
3436
+ }
3437
+ alert(done ? `已撤销 ${done} 条授权` : ('撤销未完成: ' + err));
3438
+ refresh();
3439
+ });
3440
+ }
3441
+
3442
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindEvents);
3443
+ else bindEvents();
3444
+ })();
3445
+
3446
+ // ============ 任务协作: 入群 · 发公告 · 看飞轮进度 (2026-09-25) ============
3447
+ // 独立 IIFE (与联系方式面板同款): 只用 window.BolloonCore.tasks (编译进 mobile-core.js)。
3448
+ // 分工**不变**: 手机 = 输入 · 展示待发内容 · 设备签名; 桌面 = 落盘 · 执行 · 等待回复 · 证据。
3449
+ // 纪律:
3450
+ // · 动态文本一律 textContent (本文件这一段**不用 innerHTML** 拼任何数据)
3451
+ // · 渲染只取白名单字段 (视图对象里多出来的字段进不了 DOM)
3452
+ // · 双语走 data-zh/data-en (切语言只重写文字节点, 不重建结构)
3453
+ // · 相对时间只更新 .rel-time 文字节点 (不重渲染整页)
3454
+ (function () {
3455
+ const $ = (sel) => document.querySelector(sel);
3456
+ const T = () => (window.BolloonCore && window.BolloonCore.tasks) || null;
3457
+
3458
+ // ── 语言 (data-zh/data-en; 默认中文, 显式切 en 才英文) ──────────────────────
3459
+ function lang() {
3460
+ try { return localStorage.getItem('bolloon_lang') === 'en' ? 'en' : 'zh'; } catch (e) { return 'zh'; }
3461
+ }
3462
+ function bilingual(el, zh, en) {
3463
+ if (!el) return el;
3464
+ el.setAttribute('data-zh', String(zh));
3465
+ el.setAttribute('data-en', String(en));
3466
+ el.textContent = lang() === 'en' ? String(en) : String(zh);
3467
+ return el;
3468
+ }
3469
+ function applyLang(root) {
3470
+ const scope = root || document;
3471
+ scope.querySelectorAll('[data-zh][data-en]').forEach((el) => {
3472
+ el.textContent = lang() === 'en' ? el.getAttribute('data-en') : el.getAttribute('data-zh');
3473
+ });
3474
+ }
3475
+ window.__mobileLang = {
3476
+ get: lang,
3477
+ set(v) {
3478
+ try { localStorage.setItem('bolloon_lang', v === 'en' ? 'en' : 'zh'); } catch (e) {}
3479
+ applyLang();
3480
+ // 列表里的空态/描述是用 lang() 现拼的 → 切完语言重渲染一次, 免得留下上一语言的旧句子
3481
+ try {
3482
+ if (window.__mobileTasksUi && typeof window.__mobileTasksUi.refresh === 'function') window.__mobileTasksUi.refresh();
3483
+ } catch (e) {}
3484
+ },
3485
+ };
3486
+
3487
+ // ── DOM 小工具 (全部 textContent) ─────────────────────────────────────────
3488
+ function mk(tag, cls) { const e = document.createElement(tag); if (cls) e.className = cls; return e; }
3489
+ function line(cls, text) { const e = mk('div', cls || 'sheet-text'); if (text !== undefined && text !== null) e.textContent = String(text); return e; }
3490
+ function row(text, sub) {
3491
+ const e = mk('div', 'list-item');
3492
+ const box = mk('div');
3493
+ box.style.flex = '1';
3494
+ box.appendChild(line('', text));
3495
+ if (sub !== undefined && sub !== null && String(sub) !== '') {
3496
+ const s = line('', sub);
3497
+ s.style.fontSize = '12px';
3498
+ s.style.color = 'var(--text-secondary)';
3499
+ box.appendChild(s);
3500
+ }
3501
+ e.appendChild(box);
3502
+ return e;
3503
+ }
3504
+ /** 相对时间节点: 只改这一个文字节点 (data-at 记住基准) */
3505
+ function relNode(atMs) {
3506
+ const s = mk('span', 'rel-time');
3507
+ s.setAttribute('data-at', String(Number(atMs) || 0));
3508
+ s.textContent = relText(Number(atMs) || 0);
3509
+ return s;
3510
+ }
3511
+ /** 相对时间节点 (输入是**毫秒差**, 例如 deadlineInMs / resumeInMs) —— 每 30s 只改这个文字节点 */
3512
+ function relDeltaNode(deltaMs) {
3513
+ const s = mk('span', 'rel-time');
3514
+ s.setAttribute('data-delta', String(Number(deltaMs) || 0));
3515
+ s.textContent = relDeltaText(Number(deltaMs) || 0);
3516
+ return s;
3517
+ }
3518
+ function relDeltaText(deltaMs) {
3519
+ const t = T();
3520
+ if (!t || !t.relative) return '';
3521
+ return t.relative(Number(deltaMs) || 0, lang()) || '';
3522
+ }
3523
+ function relText(atMs) {
3524
+ const t = T();
3525
+ if (!atMs) return '';
3526
+ if (t && t.relative) return t.relative(Date.now() - Number(atMs), lang()) || '';
3527
+ return '';
3528
+ }
3529
+ function shorten(v, n) { const t = T(); return t && t.shorten ? t.shorten(v, n) : String(v == null ? '' : v).slice(0, n || 12); }
3530
+ /** 最后一道自检: 命中红线就不显示原文 (桌面已经挡过, 这里只兜底) */
3531
+ function safeText(v) {
3532
+ const s = String(v == null ? '' : v);
3533
+ const t = T();
3534
+ if (t && t.scanText) {
3535
+ const hits = t.scanText(s);
3536
+ if (hits && hits.length) return '[' + (lang() === 'en' ? 'redacted:' : '已遮蔽:') + hits[0].rule + ']';
3537
+ }
3538
+ return s;
3539
+ }
3540
+ function setText(sel, text) { const el = $(sel); if (el) el.textContent = text == null ? '' : String(text); }
3541
+ /** 动态状态行: 带上 data-zh/data-en, 这样切语言时它也换 (applyLang 只管文字节点) */
3542
+ function dynText(sel, zh, en) {
3543
+ const el = $(sel);
3544
+ if (!el) return;
3545
+ el.setAttribute('data-zh', String(zh));
3546
+ el.setAttribute('data-en', String(en));
3547
+ el.textContent = lang() === 'en' ? String(en) : String(zh);
3548
+ }
3549
+ function clear(node) { if (node) while (node.firstChild) node.removeChild(node.firstChild); }
3550
+ function openSheet() { const el = $('#task-confirm-sheet'); if (el) el.hidden = false; }
3551
+ function closeSheet() { const el = $('#task-confirm-sheet'); if (el) el.hidden = true; }
3552
+ function toast(msg) {
3553
+ if (window.alert) window.alert(String(msg));
3554
+ }
3555
+
3556
+ // ── 状态 (完整 id 只留在这段 JS 内存里, 不进 DOM 文本) ─────────────────────
3557
+ const state = { groups: [], groupsView: [], board: null, flywheel: null, trail: null, selectedGroupRef: '', pending: null };
3558
+
3559
+ // ── 渲染: 群 ──────────────────────────────────────────────────────────────
3560
+ function renderGroups() {
3561
+ const list = $('#tasks-groups');
3562
+ if (!list) return;
3563
+ clear(list);
3564
+ if (!state.groups.length) {
3565
+ list.appendChild(line('sheet-text', lang() === 'en' ? 'No groups joined yet' : '还没有加入任何群'));
3566
+ return;
3567
+ }
3568
+ state.groups.forEach((g, i) => {
3569
+ const view = state.groupsView[i] || {};
3570
+ const r = row(String(view.name || '') + ' · ' + String(view.idShort || ''), '');
3571
+ const when = relNode(Date.parse(String(view.joinedAt || '')) || 0);
3572
+ const sub = line('', '');
3573
+ sub.style.fontSize = '12px';
3574
+ sub.style.color = 'var(--text-secondary)';
3575
+ sub.appendChild(document.createTextNode(lang() === 'en' ? 'joined ' : '加入于 '));
3576
+ sub.appendChild(when);
3577
+ r.firstChild.appendChild(sub);
3578
+ const b = mk('button', 'sheet-choice');
3579
+ b.style.flex = '0 0 auto';
3580
+ bilingual(b, '退群', 'Leave');
3581
+ b.setAttribute('data-group-leave', String(i));
3582
+ b.addEventListener('click', () => askLeave(i));
3583
+ r.appendChild(b);
3584
+ list.appendChild(r);
3585
+ });
3586
+ }
3587
+
3588
+ // ── 渲染: 公告板 ──────────────────────────────────────────────────────────
3589
+ function renderBoard() {
3590
+ const list = $('#tasks-board');
3591
+ if (!list) return;
3592
+ clear(list);
3593
+ const d = state.board || {};
3594
+ const items = d.items || [];
3595
+ if (!items.length) {
3596
+ list.appendChild(line('sheet-text', lang() === 'en' ? 'Board is empty' : '板上暂时没有公告'));
3597
+ }
3598
+ items.forEach((it, i) => {
3599
+ const r = row(
3600
+ String(it.capability || '') + ' · ' + String(it.idShort || '') + ' · ' + String((it.statusLabel || {})[lang()] || ''),
3601
+ '',
3602
+ );
3603
+ const sub = line('', '');
3604
+ sub.style.fontSize = '12px';
3605
+ sub.style.color = 'var(--text-secondary)';
3606
+ sub.appendChild(document.createTextNode((lang() === 'en' ? 'budget ' : '预算 ') + safeText(it.budgetLabel || '—') + ' · '));
3607
+ sub.appendChild(document.createTextNode(lang() === 'en' ? 'closes ' : '截止 '));
3608
+ sub.appendChild(relDeltaNode(it.deadlineInMs));
3609
+ r.firstChild.appendChild(sub);
3610
+ if (it.claimable) {
3611
+ const b = mk('button', 'sheet-choice');
3612
+ b.style.flex = '0 0 auto';
3613
+ bilingual(b, '发进群', 'Post to group');
3614
+ b.setAttribute('data-announce', String(i));
3615
+ b.addEventListener('click', () => askAnnounceToGroup(i));
3616
+ r.appendChild(b);
3617
+ }
3618
+ list.appendChild(r);
3619
+ });
3620
+ const notes = Array.isArray(d.notes) ? d.notes : [];
3621
+ if (notes.length) list.appendChild(line('sheet-text', notes.join(' · ')));
3622
+ }
3623
+
3624
+ // ── 渲染: 飞轮进度 (只读; 状态来自 goal-flywheel 的五类用户可见状态) ────────
3625
+ function renderFlywheel() {
3626
+ const list = $('#tasks-flywheel');
3627
+ if (!list) return;
3628
+ clear(list);
3629
+ const goals = (state.flywheel && state.flywheel.goals) || [];
3630
+ if (!goals.length) {
3631
+ list.appendChild(line('sheet-text', lang() === 'en' ? 'No goals yet' : '还没有目标'));
3632
+ const hint = state.flywheel && state.flywheel.hint;
3633
+ if (hint) list.appendChild(line('sheet-text', hint));
3634
+ return;
3635
+ }
3636
+ const zh = lang() === 'zh';
3637
+ goals.forEach((g) => {
3638
+ const v = g.view || {};
3639
+ const r = row(String(g.objectiveShort || '') + ' · ' + String((v.stateLabel || {})[lang()] || g.status || ''), '');
3640
+ const box = r.firstChild;
3641
+ const rep = v.report || {};
3642
+ if (rep.nextStep) box.appendChild(line('sheet-text', (zh ? '下一步: ' : 'next step: ') + safeText(rep.nextStep)));
3643
+ if (rep.conclusion) box.appendChild(line('sheet-text', (zh ? '结论: ' : 'conclusion: ') + safeText(rep.conclusion)));
3644
+ if (Array.isArray(rep.remaining) && rep.remaining.length) {
3645
+ box.appendChild(line('sheet-text', (zh ? '还剩: ' : 'remaining: ') + rep.remaining.map(safeText).join(' · ')));
3646
+ }
3647
+ if (Array.isArray(rep.blockReasons) && rep.blockReasons.length) {
3648
+ box.appendChild(line('sheet-text', (zh ? '卡住原因: ' : 'blocked by: ') + rep.blockReasons.map(safeText).join(' · ')));
3649
+ }
3650
+ const sub = line('', '');
3651
+ sub.style.fontSize = '12px';
3652
+ sub.style.color = 'var(--text-secondary)';
3653
+ if (v.resumeInMs !== null && v.resumeInMs !== undefined) {
3654
+ sub.appendChild(document.createTextNode(zh ? '下次醒来 ' : 'wakes '));
3655
+ sub.appendChild(relDeltaNode(v.resumeInMs));
3656
+ }
3657
+ sub.appendChild(document.createTextNode((zh ? ' · 会继续: ' : ' · will continue: ') + (rep.willContinue ? (zh ? '是' : 'yes') : (zh ? '否' : 'no'))));
3658
+ box.appendChild(sub);
3659
+ if (v.requiredAgent) box.appendChild(line('sheet-text', safeText(v.requiredAgent)));
3660
+ if (v.riskLevel) box.appendChild(line('sheet-text', (zh ? '风险: ' : 'risk: ') + safeText(v.riskLevel)));
3661
+ const blocks = Array.isArray(v.blocks) ? v.blocks : [];
3662
+ blocks.forEach((b) => {
3663
+ const label = [(b.kindLabel || {})[lang()], (b.ownerLabel || {})[lang()], (b.actionLabel || {})[lang()]].filter(Boolean).join(' · ');
3664
+ const bl = line('sheet-text', safeText(label) + (b.note ? ' — ' + safeText(b.note) : ''));
3665
+ box.appendChild(bl);
3666
+ const bd = line('', '');
3667
+ bd.style.fontSize = '12px';
3668
+ bd.style.color = 'var(--text-secondary)';
3669
+ bd.appendChild(document.createTextNode(zh ? '卡了 ' : 'blocked for '));
3670
+ bd.appendChild(relDeltaNode(b.blockedForMs));
3671
+ box.appendChild(bd);
3672
+ });
3673
+ list.appendChild(r);
3674
+ });
3675
+ const note = state.flywheel && state.flywheel.note;
3676
+ if (note) list.appendChild(line('sheet-text', note));
3677
+ }
3678
+
3679
+ // ── 渲染: 群痕迹 (只读) ───────────────────────────────────────────────────
3680
+ function renderTrail() {
3681
+ const list = $('#tasks-trail');
3682
+ if (!list) return;
3683
+ clear(list);
3684
+ const t = state.trail;
3685
+ if (!t) { list.appendChild(line('sheet-text', lang() === 'en' ? 'Pick a group first' : '先选一个群')); return; }
3686
+ if (!t.entries.length) {
3687
+ list.appendChild(line('sheet-text', lang() === 'en' ? 'No trail entries for this round' : '这一期还没有过程痕迹'));
3688
+ return;
3689
+ }
3690
+ t.entries.forEach((e) => {
3691
+ const label = (e.kindLabel || {})[lang()] || e.kind;
3692
+ const r = row(String(label) + ' · ' + String(e.sender || ''), '');
3693
+ const box = r.firstChild;
3694
+ const sub = line('', '');
3695
+ sub.style.fontSize = '12px';
3696
+ sub.style.color = 'var(--text-secondary)';
3697
+ sub.appendChild(relNode(e.atMs || 0));
3698
+ box.appendChild(sub);
3699
+ (e.facts || []).forEach((f) => box.appendChild(line('sheet-text', safeText(f.k) + '=' + safeText(f.v))));
3700
+ list.appendChild(r);
3701
+ });
3702
+ if (t.privacyHits) list.appendChild(line('sheet-text', lang() === 'en' ? '⚠ some lines hit the privacy rules and were redacted' : '⚠ 有消息命中隐私红线, 已遮蔽'));
3703
+ if (t.ignoredMessages) list.appendChild(line('sheet-text', (lang() === 'en' ? 'ignored messages: ' : '忽略消息: ') + String(t.ignoredMessages)));
3704
+ }
3705
+
3706
+ // ── 确认页 (展示待发内容 → 确认 → 签名) ───────────────────────────────────
3707
+ function renderConfirm() {
3708
+ const t = T();
3709
+ const p = state.pending;
3710
+ if (!t || !p) return;
3711
+ const d = t.describeConfirm(p.kind, p.req) || {};
3712
+ const title = $('#task-confirm-title');
3713
+ if (title) bilingual(title, d.titleZh || '确认这个动作', d.titleEn || 'Confirm this action');
3714
+ const lines = $('#task-confirm-lines');
3715
+ if (lines) {
3716
+ clear(lines);
3717
+ (d.lines || []).forEach((l) => lines.appendChild(line('sheet-text', (lang() === 'en' ? l.kEn : l.k) + ': ' + safeText(l.v))));
3718
+ }
3719
+ setText('#task-confirm-what', lang() === 'en'
3720
+ ? 'This will be signed with this device key. The desktop verifies that signature before doing anything.'
3721
+ : '这个动作会用本机设备密钥签名; 桌面验签通过后才会真正执行。');
3722
+ // 群选择 (群消息类动作必须显式选群)
3723
+ const wrap = $('#task-confirm-group-wrap');
3724
+ const box = $('#task-confirm-group');
3725
+ const needsGroup = p.needsGroup === true;
3726
+ if (wrap) wrap.hidden = !needsGroup;
3727
+ if (box && needsGroup) {
3728
+ clear(box);
3729
+ state.groups.forEach((g, i) => {
3730
+ const view = state.groupsView[i] || {};
3731
+ const b = mk('button', 'sheet-choice');
3732
+ bilingual(b, String(view.name || ''), String(view.name || ''));
3733
+ b.setAttribute('data-pick-group', String(i));
3734
+ if (p.req.groupRef === g.id) b.style.borderColor = 'var(--accent)';
3735
+ b.addEventListener('click', () => { p.req.groupRef = g.id; state.selectedGroupRef = g.id; renderConfirm(); refreshPreview(); });
3736
+ box.appendChild(b);
3737
+ });
3738
+ if (!state.groups.length) box.appendChild(line('sheet-text', lang() === 'en' ? 'no groups' : '还没有群'));
3739
+ }
3740
+ // 群消息类: 预览按钮 + 原文区
3741
+ const pv = $('#task-confirm-preview');
3742
+ if (pv) pv.hidden = !p.showPreview;
3743
+ const ct = $('#task-confirm-content-title');
3744
+ const cc = $('#task-confirm-content');
3745
+ if (ct) ct.hidden = !p.previewText;
3746
+ if (cc) { cc.hidden = !p.previewText; cc.textContent = p.previewText || ''; }
3747
+ const yes = $('#task-confirm-yes');
3748
+ if (yes) {
3749
+ const disabled = needsGroup && !p.req.groupRef;
3750
+ yes.disabled = !!disabled;
3751
+ yes.style.opacity = disabled ? '0.5' : '';
3752
+ }
3753
+ }
3754
+
3755
+ async function refreshPreview() {
3756
+ const t = T();
3757
+ const p = state.pending;
3758
+ if (!t || !p || !p.showPreview) return;
3759
+ if (!p.req.groupRef) { p.previewText = ''; renderConfirm(); return; }
3760
+ const r = await t.previewGroupMessage(p.req);
3761
+ if (r && r.ok) { p.previewText = (r.data && r.data.text) || ''; setText('#tasks-hint', ''); }
3762
+ else { p.previewText = ''; setText('#tasks-hint', (lang() === 'en' ? 'preview failed: ' : '预览失败: ') + String((r && r.code) || '') + ' ' + String((r && r.error) || '')); }
3763
+ renderConfirm();
3764
+ }
3765
+
3766
+ function askConfirm(kind, req, opts) {
3767
+ state.pending = Object.assign({ kind: kind, req: req, showPreview: false, previewText: '' }, opts || {});
3768
+ renderConfirm();
3769
+ openSheet();
3770
+ if (state.pending.showPreview) refreshPreview();
3771
+ }
3772
+
3773
+ function requireSigning() {
3774
+ const t = T();
3775
+ if (!t) { toast(lang() === 'en' ? 'phone kernel not ready' : '手机内核未就绪 (mobile-core 没加载)'); return null; }
3776
+ if (!t.deviceSigning || !t.deviceSigning()) {
3777
+ toast(lang() === 'en'
3778
+ ? 'this WebView cannot do Ed25519 device signing — run it from the desktop instead'
3779
+ : '本机 WebView 不支持 Ed25519 设备签名 —— 这个动作请在桌面端执行 (不降级, 不假装签了)');
3780
+ return null;
3781
+ }
3782
+ return t;
3783
+ }
3784
+
3785
+ // ── 四个能力的入口 ────────────────────────────────────────────────────────
3786
+ function askJoin() {
3787
+ const link = (($('#tasks-group-link') || {}).value || '').trim();
3788
+ if (!link) { toast(lang() === 'en' ? 'paste the group invite link first' : '先粘贴群邀请链接'); return; }
3789
+ if (!requireSigning()) return;
3790
+ const req = T().emptyRequest('group_join');
3791
+ req.groupRef = link;
3792
+ askConfirm('group_join', req, {});
3793
+ }
3794
+ function askCreate() {
3795
+ const name = (($('#tasks-group-name') || {}).value || '').trim();
3796
+ if (!name) { toast(lang() === 'en' ? 'give the group a name first' : '先给群起个名字'); return; }
3797
+ if (!requireSigning()) return;
3798
+ const req = T().emptyRequest('group_create');
3799
+ req.groupRef = name;
3800
+ askConfirm('group_create', req, {});
3801
+ }
3802
+ function askLeave(i) {
3803
+ if (!requireSigning()) return;
3804
+ const g = state.groups[i];
3805
+ if (!g) return;
3806
+ const req = T().emptyRequest('group_leave');
3807
+ req.groupRef = g.id;
3808
+ askConfirm('group_leave', req, {});
3809
+ }
3810
+ function askPublish() {
3811
+ if (!requireSigning()) return;
3812
+ const cap = (($('#tasks-pub-capability') || {}).value || '').trim();
3813
+ const ins = (($('#tasks-pub-instruction') || {}).value || '').trim();
3814
+ const bud = (($('#tasks-pub-budget') || {}).value || '').trim();
3815
+ const dl = (($('#tasks-pub-deadline') || {}).value || '').trim();
3816
+ if (!cap || !ins || !bud) { toast(lang() === 'en' ? 'capability / instruction / budget are required' : '能力 / 任务正文 / 预算 都要填'); return; }
3817
+ const req = T().emptyRequest('announce_publish');
3818
+ req.capability = cap; req.instruction = ins; req.budgetHuman = bud; req.currency = 'USDC';
3819
+ if (dl) req.deadline = dl;
3820
+ askConfirm('announce_publish', req, {});
3821
+ }
3822
+ function askAnnounceToGroup(i) {
3823
+ if (!requireSigning()) return;
3824
+ const it = ((state.board || {}).items || [])[i];
3825
+ if (!it) return;
3826
+ const req = T().emptyRequest('announce_to_group');
3827
+ req.announcementId = it.ref || '';
3828
+ req.groupRef = state.selectedGroupRef || (state.groups[0] && state.groups[0].id) || '';
3829
+ if (!req.announcementId) { toast(lang() === 'en' ? 'desktop did not give a usable announcement id' : '桌面没给出可用的公告号, 先刷新') ; return; }
3830
+ askConfirm('announce_to_group', req, { needsGroup: true, showPreview: true });
3831
+ }
3832
+ function askTrail(kind) {
3833
+ if (!requireSigning()) return;
3834
+ const req = T().emptyRequest('trail_post');
3835
+ req.groupRef = state.selectedGroupRef || (state.groups[0] && state.groups[0].id) || '';
3836
+ req.trailKind = kind;
3837
+ if (!req.groupRef) { toast(lang() === 'en' ? 'pick a group first' : '先选一个群'); return; }
3838
+ askConfirm('trail_post', req, { needsGroup: true, showPreview: true });
3839
+ }
3840
+
3841
+ async function doConfirmed() {
3842
+ const t = T();
3843
+ const p = state.pending;
3844
+ if (!t || !p) return;
3845
+ const yes = $('#task-confirm-yes');
3846
+ if (yes) yes.disabled = true;
3847
+ const r = await t.execute(p.req);
3848
+ if (yes) yes.disabled = false;
3849
+ closeSheet();
3850
+ if (r && r.ok) {
3851
+ const text = (r.data && r.data.text) || (lang() === 'en' ? 'done' : '已执行');
3852
+ t.setHint ? t.setHint(safeText(text)) : null;
3853
+ toast(safeText(text));
3854
+ } else {
3855
+ const code = String((r && r.code) || 'unknown');
3856
+ const err = String((r && r.error) || '');
3857
+ t.setHint ? t.setHint((lang() === 'en' ? 'failed: ' : '失败: ') + code) : null;
3858
+ toast((lang() === 'en' ? 'not executed: ' : '没有执行: ') + code + (err ? '\n' + safeText(err) : ''));
3859
+ }
3860
+ state.pending = null;
3861
+ state.selectedGroupRef = '';
3862
+ await refreshAll();
3863
+ }
3864
+
3865
+ // ── 刷新 ──────────────────────────────────────────────────────────────────
3866
+ async function refreshAll() {
3867
+ const t = T();
3868
+ if (!t) {
3869
+ dynText('#tasks-signing', '手机内核未就绪 (mobile-core 没加载)', 'phone kernel not ready');
3870
+ return;
3871
+ }
3872
+ const canSign = !!t.deviceSigning();
3873
+ const signingEl = $('#tasks-signing');
3874
+ if (signingEl) signingEl.setAttribute('data-signing', canSign ? 'yes' : 'no');
3875
+ setText('#tasks-hint', t.hint ? t.hint() : '');
3876
+ dynText('#tasks-signing', canSign
3877
+ ? '本机可做设备签名 (Ed25519) —— 高风险动作必须由它签名'
3878
+ : '⚠ 本机 WebView 不支持签名 —— 高风险动作请在桌面端执行',
3879
+ canSign
3880
+ ? 'this device can sign (Ed25519) — risky actions need it'
3881
+ : '⚠ this WebView cannot sign — run risky actions on the desktop');
3882
+
3883
+ const g = await t.listGroups();
3884
+ state.groups = (g && g.ok && Array.isArray(g.data)) ? g.data.map((x) => ({ id: x.id, name: x.name })) : [];
3885
+ state.groups.some((x) => x.id === state.selectedGroupRef) || (state.selectedGroupRef = state.groups[0] ? state.groups[0].id : '');
3886
+ state.groupsView = (g && g.ok && Array.isArray(g.data)) ? g.data : [];
3887
+ renderGroups();
3888
+ renderTrailGroupPicker();
3889
+
3890
+ const b = await t.loadBoard();
3891
+ state.board = b && b.ok ? b.data : {};
3892
+ renderBoard();
3893
+
3894
+ const f = await t.loadFlywheel();
3895
+ state.flywheel = f && f.ok ? f.data : {};
3896
+ renderFlywheel();
3897
+
3898
+ if (state.selectedGroupRef) await loadTrailNow();
3899
+ }
3900
+
3901
+ function renderTrailGroupPicker() {
3902
+ const list = $('#tasks-trail-group-picker');
3903
+ if (!list) return;
3904
+ clear(list);
3905
+ if (!state.groups.length) { list.appendChild(line('sheet-text', lang() === 'en' ? 'join a group to see its trail' : '入群后可以看群里这一期的痕迹')); return; }
3906
+ state.groups.forEach((g, i) => {
3907
+ const view = state.groupsView[i] || {};
3908
+ const b = mk('button', 'sheet-choice');
3909
+ bilingual(b, String(view.name || ''), String(view.name || ''));
3910
+ b.setAttribute('data-pick-trail', String(i));
3911
+ if (g.id === state.selectedGroupRef) b.style.borderColor = 'var(--accent)';
3912
+ b.addEventListener('click', async () => {
3913
+ state.selectedGroupRef = g.id;
3914
+ renderTrailGroupPicker();
3915
+ await loadTrailNow();
3916
+ });
3917
+ list.appendChild(b);
3918
+ });
3919
+ }
3920
+
3921
+ async function loadTrailNow() {
3922
+ const t = T();
3923
+ if (!t || !state.selectedGroupRef) return;
3924
+ const r = await t.loadTrail({ groupRef: state.selectedGroupRef });
3925
+ state.trail = (r && r.ok) ? r.data : null;
3926
+ if (!r || !r.ok) setText('#tasks-hint', (lang() === 'en' ? 'trail read failed: ' : '读群痕迹失败: ') + String((r && r.code) || '') + ' ' + String((r && r.error) || ''));
3927
+ renderTrail();
3928
+ }
3929
+
3930
+ function bindEvents() {
3931
+ const tab = document.querySelector('.tab[data-tab="tasks"]');
3932
+ if (tab) tab.addEventListener('click', () => { applyLang(); refreshAll(); });
3933
+ const r = $('#tasks-refresh');
3934
+ if (r) r.addEventListener('click', refreshAll);
3935
+ const j = $('#tasks-group-join');
3936
+ if (j) j.addEventListener('click', askJoin);
3937
+ const c = $('#tasks-group-create');
3938
+ if (c) c.addEventListener('click', askCreate);
3939
+ const p = $('#tasks-pub-submit');
3940
+ if (p) p.addEventListener('click', askPublish);
3941
+ const tr = $('#tasks-trail-refresh');
3942
+ if (tr) tr.addEventListener('click', loadTrailNow);
3943
+ const pv = $('#task-confirm-preview');
3944
+ if (pv) pv.addEventListener('click', refreshPreview);
3945
+ const yes = $('#task-confirm-yes');
3946
+ if (yes) yes.addEventListener('click', doConfirmed);
3947
+ const no = $('#task-confirm-no');
3948
+ if (no) no.addEventListener('click', () => { state.pending = null; closeSheet(); });
3949
+
3950
+ // 相对时间: 每 30s 只改 .rel-time 文字节点 (不重渲染, 不动结构)
3951
+ setInterval(() => {
3952
+ document.querySelectorAll('.rel-time[data-at]').forEach((el) => {
3953
+ const next = relText(Number(el.getAttribute('data-at')) || 0);
3954
+ if (next && el.textContent !== next) el.textContent = next;
3955
+ });
3956
+ document.querySelectorAll('.rel-time[data-delta]').forEach((el) => {
3957
+ const next = relDeltaText(Number(el.getAttribute('data-delta')) || 0);
3958
+ if (next && el.textContent !== next) el.textContent = next;
3959
+ });
3960
+ }, 30000);
3961
+
3962
+ applyLang();
3963
+ }
3964
+
3965
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindEvents);
3966
+ else bindEvents();
3967
+
3968
+ // 供验收脚本/壳层驱动 (真 headless DOM 用): 只暴露行为入口, 不暴露数据
3969
+ window.__mobileUpdateUi = { open: () => openUpdatePage(), openSettings: () => openSettings() };
3970
+ window.__mobileTasksUi = {
3971
+ refresh: refreshAll, askJoin: askJoin, askCreate: askCreate, askPublish: askPublish,
3972
+ askTrail: askTrail, askAnnounceToGroup: askAnnounceToGroup, confirm: doConfirmed,
3973
+ preview: refreshPreview, lang: window.__mobileLang, state: state,
3974
+ };
3975
+ })();