@bolloon/bolloon-agent 0.4.22 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/gateway-join.js +271 -0
- package/dist/agents/pi-sdk-tools.js +75 -2
- package/dist/agents/pi-sdk.js +6 -3
- package/dist/ios/index.html +74 -17
- package/dist/ios/mobile-agent.js +49 -4
- package/dist/ios/mobile-chain.js +481 -0
- package/dist/ios/mobile-core.js +373 -13
- package/dist/ios/mobile-helia.js +683 -0
- package/dist/ios/mobile-ipfs.js +680 -0
- package/dist/ios/mobile-orbit.js +268 -0
- package/dist/ios/mobile-p2p.js +130 -1
- package/dist/ios/mobile-social.js +624 -0
- package/dist/ios/mobile-sync.js +193 -0
- package/dist/ios/mobile-trade.js +492 -0
- package/dist/ios/mobile-wallet.js +11 -0
- package/dist/ios/mobile.css +24 -0
- package/dist/ios/mobile.html +74 -17
- package/dist/ios/mobile.js +911 -55
- package/dist/ios/routes-x402-info.js +194 -0
- package/dist/ios/server.js +303 -9
- package/dist/ios/sw.js +26 -2
- package/dist/llm/pi-ai.js +35 -2
- package/dist/network/agent-network.js +78 -6
- package/dist/web/agent-delegate-server.js +58 -12
- package/dist/web/mobile-agent.js +195 -1
- package/dist/web/mobile-core.js +187 -1
- package/dist/web/mobile.css +24 -0
- package/dist/web/mobile.html +23 -14
- package/dist/web/mobile.js +439 -45
- package/dist/web/server.js +114 -9
- package/dist/web/sw.js +26 -2
- package/package.json +1 -1
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mobile-social.ts — 手机端自动社交 (2026-09-11)
|
|
3
|
+
*
|
|
4
|
+
* 目标: 让手机节点自动完成 Agent Economic Loop 的 DISCOVERY 段
|
|
5
|
+
* (IDENTITY → **DISCOVERY** → NEGOTIATION → …), 即
|
|
6
|
+
* "自己注册服务 + 发现别人的服务 + 连接后自动打招呼 + 周期性心跳".
|
|
7
|
+
*
|
|
8
|
+
* 协议依据 (本文件所有命名均对齐下列规范, 无规范处已注明推导):
|
|
9
|
+
* - docs/wiki/agent-economic-protocol.md
|
|
10
|
+
* §二 7 协议: IDENTITY→DISCOVERY→NEGOTIATION→EXECUTION→PROOF→PAYMENT→REPUTATION
|
|
11
|
+
* §四 E1 Agent 服务 Registry 声明结构:
|
|
12
|
+
* { agent_id, wallet, service{name,description,price{amount,currency,per},endpoint},
|
|
13
|
+
* capabilities[], reputation{tasks,success,score} }
|
|
14
|
+
* §七 里程碑 M1: 桌面端注册/发现工具名 registry_register / registry_discover
|
|
15
|
+
* → 本层 P2P 消息类型沿用 'registry.register' / 'registry.discover' 命名; 回复加 '.reply' 后缀
|
|
16
|
+
* (与仓库既有 mobile-agent.ts 的 'agent.info' → 'agent.info.reply' 约定一致)
|
|
17
|
+
* - docs/agent-communication.md
|
|
18
|
+
* 签名消息 { type, from, … } + 地址广播"每 5 分钟" → agent.hello 握手 + 5min 心跳节流
|
|
19
|
+
* - @diap/sdk (DIAP 身份层) 的 DID (did:blln:* / did:diap:* / did:key:*) 即 agent_id
|
|
20
|
+
* 本模块不直接依赖 @diap/sdk: DID 由调用方 (mobile-agent.ensureIdentity) 传入.
|
|
21
|
+
*
|
|
22
|
+
* 设计约束:
|
|
23
|
+
* - 纯函数 + 依赖注入 (fetch / P2P send / 存储), 不 import 任何其它 web 模块
|
|
24
|
+
* → 浏览器可用 + vitest 可单测 (注入 mem store / fake fetch / spy send).
|
|
25
|
+
* - 网络/传输失败一律"返回 { ok:false, error }", 绝不抛出.
|
|
26
|
+
* - 不修改任何既有文件; 接线由 mobile-core.ts / mobile.js 侧完成 (见文件尾 §接线).
|
|
27
|
+
*/
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// 协议常量
|
|
30
|
+
// ============================================================================
|
|
31
|
+
/** P2P 消息类型 (依据 docs/wiki/agent-economic-protocol.md §四 E1 + §七 M1 推导, 见文件头). */
|
|
32
|
+
export const SOCIAL_MESSAGE_TYPES = {
|
|
33
|
+
/** 注册: 我方 → 电脑端 registry (携带服务声明) */
|
|
34
|
+
REGISTER: 'registry.register',
|
|
35
|
+
/** 注册回执: 电脑端 → 我方 */
|
|
36
|
+
REGISTER_REPLY: 'registry.register.reply',
|
|
37
|
+
/** 发现: 请求对端可用服务 */
|
|
38
|
+
DISCOVER: 'registry.discover',
|
|
39
|
+
/** 发现回执: 对端 → 我方 (携带服务列表) */
|
|
40
|
+
DISCOVER_REPLY: 'registry.discover.reply',
|
|
41
|
+
/** 握手: 连上 peer 后的自动问候 (绑定 docs/agent-communication.md 的签名消息) */
|
|
42
|
+
HELLO: 'agent.hello',
|
|
43
|
+
};
|
|
44
|
+
/** 心跳间隔: docs/agent-communication.md 「地址广播…每 5 分钟」 */
|
|
45
|
+
export const DEFAULT_HEARTBEAT_MS = 5 * 60 * 1000;
|
|
46
|
+
/** 默认本地存储 key (接线的 localStorage store 用) */
|
|
47
|
+
export const SOCIAL_STORE_KEY = 'bolloon_mobile_social';
|
|
48
|
+
export function emptySocialState() {
|
|
49
|
+
return { welcomedPeers: {}, lastHeartbeatTs: 0, announced: {}, discovered: [] };
|
|
50
|
+
}
|
|
51
|
+
/** 内存 store (测试 + 无持久化环境默认) */
|
|
52
|
+
export function createMemoryStore(initial = {}) {
|
|
53
|
+
let state = { ...emptySocialState(), ...initial };
|
|
54
|
+
return {
|
|
55
|
+
get: () => state,
|
|
56
|
+
set: (s) => { state = s; },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** localStorage store (浏览器/WebView 持久化; 无 localStorage 时自动退化为内存) */
|
|
60
|
+
export function createLocalStorageStore(key = SOCIAL_STORE_KEY, storage) {
|
|
61
|
+
const ls = storage ?? safeLocalStorage();
|
|
62
|
+
if (!ls)
|
|
63
|
+
return createMemoryStore();
|
|
64
|
+
const read = () => {
|
|
65
|
+
try {
|
|
66
|
+
const raw = ls.getItem(key);
|
|
67
|
+
const parsed = raw ? JSON.parse(raw) : null;
|
|
68
|
+
return { ...emptySocialState(), ...(parsed && typeof parsed === 'object' ? parsed : {}) };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return emptySocialState();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
get: read,
|
|
76
|
+
set: (s) => { try {
|
|
77
|
+
ls.setItem(key, JSON.stringify(s));
|
|
78
|
+
}
|
|
79
|
+
catch { /* 存储满/不可用 → 忽略 */ } },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function safeLocalStorage() {
|
|
83
|
+
try {
|
|
84
|
+
return typeof localStorage !== 'undefined' ? localStorage : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** 模块级默认 store (未注入 store 时使用; 生产接线建议注入 createLocalStorageStore()) */
|
|
91
|
+
let _defaultStore = createMemoryStore();
|
|
92
|
+
export function getDefaultSocialStore() { return _defaultStore; }
|
|
93
|
+
/** 测试用: 重置默认 store */
|
|
94
|
+
export function resetDefaultSocialStore() { _defaultStore = createMemoryStore(); }
|
|
95
|
+
function nowOf(deps) {
|
|
96
|
+
return deps.now ? deps.now() : Date.now();
|
|
97
|
+
}
|
|
98
|
+
function storeOf(deps) {
|
|
99
|
+
return deps.store ?? _defaultStore;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 生成 Agent 服务声明 (docs/wiki/agent-economic-protocol.md §四 E1).
|
|
103
|
+
* 字段: agent_id / wallet / service{name,description,price{amount,currency,per},endpoint}
|
|
104
|
+
* / capabilities / reputation{tasks,success,score}
|
|
105
|
+
*/
|
|
106
|
+
export function buildServiceDeclaration(opts) {
|
|
107
|
+
const did = String(opts.did || '').trim();
|
|
108
|
+
if (!did)
|
|
109
|
+
throw new Error('buildServiceDeclaration: did 必填 (agent_id)');
|
|
110
|
+
const serviceName = String(opts.serviceName || 'local-agent').trim() || 'local-agent';
|
|
111
|
+
const caps = Array.isArray(opts.capabilities) && opts.capabilities.length
|
|
112
|
+
? opts.capabilities.map((c) => String(c))
|
|
113
|
+
: ['chat', 'local-agent'];
|
|
114
|
+
const rep = opts.reputation || {};
|
|
115
|
+
return {
|
|
116
|
+
agent_id: did,
|
|
117
|
+
name: String(opts.name || '').trim() || undefined,
|
|
118
|
+
wallet: String(opts.wallet || '').trim(),
|
|
119
|
+
service: {
|
|
120
|
+
name: serviceName,
|
|
121
|
+
description: String(opts.description || '手机端本地 Agent 执行 (离线可用)'),
|
|
122
|
+
price: {
|
|
123
|
+
amount: String(opts.price?.amount ?? '0'),
|
|
124
|
+
currency: String(opts.price?.currency ?? 'USDC'),
|
|
125
|
+
per: String(opts.price?.per ?? 'query'),
|
|
126
|
+
},
|
|
127
|
+
endpoint: String(opts.endpoint || `agent://${serviceName}/query`),
|
|
128
|
+
},
|
|
129
|
+
capabilities: caps,
|
|
130
|
+
reputation: {
|
|
131
|
+
tasks: Number(rep.tasks ?? 0),
|
|
132
|
+
success: Number(rep.success ?? 0),
|
|
133
|
+
score: Number(rep.score ?? 0),
|
|
134
|
+
},
|
|
135
|
+
updatedAt: new Date(nowOf(opts)).toISOString(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** 声明 → 桌面端 registry 条目 (src/agents/agent-registry.ts AgentService, 里程碑 M1) */
|
|
139
|
+
export function toRegistryEntry(decl) {
|
|
140
|
+
const ts = decl.updatedAt || new Date().toISOString();
|
|
141
|
+
const rep = decl.reputation || { tasks: 0, success: 0, score: 0 };
|
|
142
|
+
return {
|
|
143
|
+
agentId: decl.agent_id,
|
|
144
|
+
name: decl.name || decl.agent_id,
|
|
145
|
+
wallet: decl.wallet || '',
|
|
146
|
+
service: {
|
|
147
|
+
name: decl.service?.name || '',
|
|
148
|
+
description: decl.service?.description || '',
|
|
149
|
+
price: {
|
|
150
|
+
amount: String(decl.service?.price?.amount ?? '0'),
|
|
151
|
+
currency: String(decl.service?.price?.currency ?? 'USDC'),
|
|
152
|
+
per: String(decl.service?.price?.per ?? 'query'),
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
capabilities: Array.isArray(decl.capabilities) ? [...decl.capabilities] : [],
|
|
156
|
+
endpoint: decl.service?.endpoint || '',
|
|
157
|
+
reputation: {
|
|
158
|
+
tasks: Number(rep.tasks || 0),
|
|
159
|
+
success: Number(rep.success || 0),
|
|
160
|
+
failed: 0,
|
|
161
|
+
disputed: 0,
|
|
162
|
+
score: Number(rep.score || 0),
|
|
163
|
+
},
|
|
164
|
+
registeredAt: ts,
|
|
165
|
+
updatedAt: ts,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** deps → 声明 (已有 declaration 则直接用) */
|
|
169
|
+
function declarationFor(deps) {
|
|
170
|
+
if (deps.declaration)
|
|
171
|
+
return deps.declaration;
|
|
172
|
+
return buildServiceDeclaration({
|
|
173
|
+
did: deps.ownDid,
|
|
174
|
+
name: deps.ownName,
|
|
175
|
+
wallet: deps.wallet,
|
|
176
|
+
serviceName: deps.serviceName,
|
|
177
|
+
description: deps.description,
|
|
178
|
+
price: deps.price,
|
|
179
|
+
endpoint: deps.endpoint,
|
|
180
|
+
capabilities: deps.capabilities,
|
|
181
|
+
now: deps.now,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// 合并 / 去重 (发现结果)
|
|
186
|
+
// ============================================================================
|
|
187
|
+
/** 服务的唯一键: 优先 agent_id/agentId, 兼容仅有 service.name 的情形 */
|
|
188
|
+
export function serviceKey(s) {
|
|
189
|
+
if (!s || typeof s !== 'object')
|
|
190
|
+
return '';
|
|
191
|
+
return String(s.agent_id || s.agentId || s.service?.name || s.name || '').trim();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* 合并两组服务列表并去重 (键 = serviceKey, 后出现者覆盖前者 = 更新优先).
|
|
195
|
+
* "发现结果合并去重" 的纯逻辑, 供 discoverAgents / 入站 registry.*.reply 复用.
|
|
196
|
+
*/
|
|
197
|
+
export function mergeServiceLists(base, incoming) {
|
|
198
|
+
const map = new Map();
|
|
199
|
+
const order = [];
|
|
200
|
+
for (const s of [...(base || []), ...(incoming || [])]) {
|
|
201
|
+
if (!s || typeof s !== 'object')
|
|
202
|
+
continue;
|
|
203
|
+
const k = serviceKey(s);
|
|
204
|
+
if (!k)
|
|
205
|
+
continue;
|
|
206
|
+
if (!map.has(k))
|
|
207
|
+
order.push(k);
|
|
208
|
+
map.set(k, s);
|
|
209
|
+
}
|
|
210
|
+
return order.map((k) => map.get(k));
|
|
211
|
+
}
|
|
212
|
+
function normalizeBase(url) {
|
|
213
|
+
return String(url || '').trim().replace(/\/+$/, '');
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 向电脑端 registry 注册自己 (Agent Economic Loop 的 DISCOVERY 写侧).
|
|
217
|
+
* 通道 1: P2P 'registry.register' (send 注入时)
|
|
218
|
+
* 通道 2: HTTP POST `${desktopUrl}/api/registry/register` (桌面端 server.ts 已有该端点)
|
|
219
|
+
* 同一目标重复调用幂等 (store.announced); 失败或传 force=true 时重试.
|
|
220
|
+
* 网络失败 → 返回 { ok:false, error }, 不抛出.
|
|
221
|
+
*/
|
|
222
|
+
export async function announceSelf(deps) {
|
|
223
|
+
let decl;
|
|
224
|
+
try {
|
|
225
|
+
decl = declarationFor(deps);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
return { ok: false, declaration: buildSafeDecl(deps), via: { p2p: false, http: false }, error: String(e?.message || e) };
|
|
229
|
+
}
|
|
230
|
+
const target = String(deps.peerId || normalizeBase(deps.desktopUrl || '') || '');
|
|
231
|
+
const annKey = `${decl.agent_id}|${target}`;
|
|
232
|
+
const store = storeOf(deps);
|
|
233
|
+
const st = store.get();
|
|
234
|
+
const canP2P = typeof deps.send === 'function';
|
|
235
|
+
const canHttp = typeof deps.fetchImpl === 'function' && !!normalizeBase(deps.desktopUrl || '');
|
|
236
|
+
if (!canP2P && !canHttp) {
|
|
237
|
+
return { ok: false, declaration: decl, via: { p2p: false, http: false }, error: '无可用的注册通道 (需注入 send 或 desktopUrl+fetchImpl)' };
|
|
238
|
+
}
|
|
239
|
+
if (!deps.force && st.announced?.[annKey]) {
|
|
240
|
+
return { ok: true, declaration: decl, via: { p2p: false, http: false }, skipped: true, reason: 'already-announced' };
|
|
241
|
+
}
|
|
242
|
+
const via = { p2p: false, http: false };
|
|
243
|
+
const errors = [];
|
|
244
|
+
// 通道 1: P2P (发送 E1 声明, 由对端 handler 落库)
|
|
245
|
+
if (canP2P) {
|
|
246
|
+
try {
|
|
247
|
+
const payload = JSON.stringify({ declaration: decl, fromPublicKey: deps.ownDid, ts: nowOf(deps) });
|
|
248
|
+
via.p2p = !!(await deps.send(SOCIAL_MESSAGE_TYPES.REGISTER, payload, deps.peerId || '*'));
|
|
249
|
+
if (!via.p2p)
|
|
250
|
+
errors.push('P2P 发送返回 false');
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
errors.push('P2P: ' + String(e?.message || e).slice(0, 80));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// 通道 2: HTTP (转换到桌面端 AgentService 结构)
|
|
257
|
+
if (canHttp) {
|
|
258
|
+
const base = normalizeBase(deps.desktopUrl || '');
|
|
259
|
+
try {
|
|
260
|
+
const r = await deps.fetchImpl(`${base}/api/registry/register`, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'content-type': 'application/json' },
|
|
263
|
+
body: JSON.stringify(toRegistryEntry(decl)),
|
|
264
|
+
});
|
|
265
|
+
via.http = !!r.ok;
|
|
266
|
+
if (!r.ok)
|
|
267
|
+
errors.push(`电脑端返回 ${r.status}`);
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
errors.push('HTTP: ' + String(e?.message || e).slice(0, 80));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const ok = via.p2p || via.http;
|
|
274
|
+
if (ok) {
|
|
275
|
+
store.set({ ...store.get(), announced: { ...(store.get().announced || {}), [annKey]: nowOf(deps) } });
|
|
276
|
+
return { ok: true, declaration: decl, via };
|
|
277
|
+
}
|
|
278
|
+
return { ok: false, declaration: decl, via, error: errors.join('; ') || '注册失败' };
|
|
279
|
+
}
|
|
280
|
+
function buildSafeDecl(deps) {
|
|
281
|
+
return {
|
|
282
|
+
agent_id: String(deps.ownDid || ''),
|
|
283
|
+
wallet: String(deps.wallet || ''),
|
|
284
|
+
service: { name: 'local-agent', description: '', price: { amount: '0', currency: 'USDC', per: 'query' }, endpoint: '' },
|
|
285
|
+
capabilities: [],
|
|
286
|
+
reputation: { tasks: 0, success: 0, score: 0 },
|
|
287
|
+
updatedAt: new Date(0).toISOString(),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* 发现智能体 (DISCOVERY 读侧):
|
|
292
|
+
* 源 1: HTTP GET `${desktopUrl}/api/registry[?q=]` (桌面端已有)
|
|
293
|
+
* 源 2: 本地缓存 (store.discovered, 含 P2P registry.discover.reply 累积)
|
|
294
|
+
* 源 3: 我方声明 (deps.declaration, 便于 UI 展示"我提供的服务")
|
|
295
|
+
* 另: 若注入 send && peerId && p2pDiscover !== false → 发 'registry.discover' (异步, 回执经 handleSocialMessage 累积)
|
|
296
|
+
* 结果按 serviceKey 合并去重后写回 store.
|
|
297
|
+
* 网络失败 → 返回 { ok:false, error }, 不抛出.
|
|
298
|
+
*/
|
|
299
|
+
export async function discoverAgents(deps) {
|
|
300
|
+
const store = storeOf(deps);
|
|
301
|
+
const before = store.get().discovered || [];
|
|
302
|
+
const sources = [];
|
|
303
|
+
const errors = [];
|
|
304
|
+
const incoming = [];
|
|
305
|
+
// 源 1: HTTP registry
|
|
306
|
+
const base = normalizeBase(deps.desktopUrl || '');
|
|
307
|
+
if (typeof deps.fetchImpl === 'function' && base) {
|
|
308
|
+
try {
|
|
309
|
+
const q = String(deps.query || '').trim();
|
|
310
|
+
const url = `${base}/api/registry${q ? `?q=${encodeURIComponent(q)}` : ''}`;
|
|
311
|
+
const r = await deps.fetchImpl(url, { method: 'GET' });
|
|
312
|
+
if (r.ok) {
|
|
313
|
+
const j = await r.json();
|
|
314
|
+
const list = Array.isArray(j?.services) ? j.services : [];
|
|
315
|
+
incoming.push(...list);
|
|
316
|
+
sources.push('http');
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
errors.push(`电脑端返回 ${r.status}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
errors.push('HTTP: ' + String(e?.message || e).slice(0, 80));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// 源 2: 调用方显式传入的已知列表 (例如 P2P 回执 / 外部缓存)
|
|
327
|
+
if (Array.isArray(deps.known) && deps.known.length) {
|
|
328
|
+
incoming.push(...deps.known);
|
|
329
|
+
sources.push('known');
|
|
330
|
+
}
|
|
331
|
+
// 源 3: 我方声明 (deps.declaration; 只读展示, 不算 added)
|
|
332
|
+
let own = null;
|
|
333
|
+
if (deps.declaration) {
|
|
334
|
+
own = deps.declaration;
|
|
335
|
+
}
|
|
336
|
+
else if (deps.ownDid) {
|
|
337
|
+
try {
|
|
338
|
+
own = declarationFor(deps);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
own = null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const merged = mergeServiceLists(mergeServiceLists(before, incoming), own ? [own] : []);
|
|
345
|
+
store.set({ ...store.get(), discovered: merged });
|
|
346
|
+
// 异步 P2P 发现 (回执稍后到达 → handleSocialMessage 累积)
|
|
347
|
+
if (typeof deps.send === 'function' && deps.peerId && deps.p2pDiscover !== false) {
|
|
348
|
+
try {
|
|
349
|
+
const payload = JSON.stringify({ query: deps.query || '', fromPublicKey: deps.ownDid, ts: nowOf(deps) });
|
|
350
|
+
const ok = await deps.send(SOCIAL_MESSAGE_TYPES.DISCOVER, payload, deps.peerId);
|
|
351
|
+
if (ok)
|
|
352
|
+
sources.push('p2p');
|
|
353
|
+
else
|
|
354
|
+
errors.push('P2P 发送返回 false');
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
errors.push('P2P: ' + String(e?.message || e).slice(0, 80));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const ok = sources.length > 0 || merged.length > 0;
|
|
361
|
+
return {
|
|
362
|
+
ok,
|
|
363
|
+
services: merged,
|
|
364
|
+
added: Math.max(0, merged.length - before.length),
|
|
365
|
+
total: merged.length,
|
|
366
|
+
sources,
|
|
367
|
+
error: ok ? undefined : (errors.join('; ') || '所有发现通道均失败'),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
// ============================================================================
|
|
371
|
+
// 心跳 (节流 + 状态存取)
|
|
372
|
+
// ============================================================================
|
|
373
|
+
/** 是否到了该心跳的时间 (纯函数). lastTs<=0 → 立即; 间隔<=0 → 永不. */
|
|
374
|
+
export function shouldHeartbeat(lastTs, nowMs, intervalMs) {
|
|
375
|
+
const last = Number(lastTs) || 0;
|
|
376
|
+
const now = Number(nowMs) || 0;
|
|
377
|
+
const iv = Number(intervalMs) || 0;
|
|
378
|
+
if (iv <= 0)
|
|
379
|
+
return false;
|
|
380
|
+
if (last <= 0)
|
|
381
|
+
return true;
|
|
382
|
+
return now - last >= iv;
|
|
383
|
+
}
|
|
384
|
+
/** 读心跳状态 (默认 store 或注入 store) */
|
|
385
|
+
export function getHeartbeatState(store) {
|
|
386
|
+
return { lastTs: storeOf({ store }).get().lastHeartbeatTs || 0 };
|
|
387
|
+
}
|
|
388
|
+
/** 写心跳状态 — 记录上次发送时间戳 */
|
|
389
|
+
export function setHeartbeatState(ts, store) {
|
|
390
|
+
const s = storeOf({ store });
|
|
391
|
+
s.set({ ...s.get(), lastHeartbeatTs: Number(ts) || 0 });
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* 周期性心跳: 到点就重发 registry.register (维持 registry 里的在线声明).
|
|
395
|
+
* 未到间隔 → { sent:false, reason:'throttled' }, 不调用传输.
|
|
396
|
+
* 到点 → 发送并把 lastTs 更新为 now (无论成功与否都不重复轰炸).
|
|
397
|
+
* 网络失败 → 仍返回结果对象 (含 error), 不抛出.
|
|
398
|
+
*/
|
|
399
|
+
export async function heartbeat(deps) {
|
|
400
|
+
const interval = deps.intervalMs ?? DEFAULT_HEARTBEAT_MS;
|
|
401
|
+
const now = nowOf(deps);
|
|
402
|
+
const store = storeOf(deps);
|
|
403
|
+
const last = store.get().lastHeartbeatTs || 0;
|
|
404
|
+
if (!deps.force && !shouldHeartbeat(last, now, interval)) {
|
|
405
|
+
return { sent: false, reason: 'throttled', ts: last };
|
|
406
|
+
}
|
|
407
|
+
const canP2P = typeof deps.send === 'function';
|
|
408
|
+
const canHttp = typeof deps.fetchImpl === 'function' && !!normalizeBase(deps.desktopUrl || '');
|
|
409
|
+
if (!canP2P && !canHttp) {
|
|
410
|
+
return { sent: false, reason: 'no-channel', ts: last, error: '无可用的心跳通道 (需注入 send 或 desktopUrl+fetchImpl)' };
|
|
411
|
+
}
|
|
412
|
+
let decl;
|
|
413
|
+
try {
|
|
414
|
+
decl = declarationFor(deps);
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
return { sent: false, reason: 'no-channel', ts: last, error: String(e?.message || e) };
|
|
418
|
+
}
|
|
419
|
+
const via = { p2p: false, http: false };
|
|
420
|
+
const errors = [];
|
|
421
|
+
if (canP2P) {
|
|
422
|
+
try {
|
|
423
|
+
const payload = JSON.stringify({ declaration: decl, heartbeat: true, fromPublicKey: deps.ownDid, ts: now });
|
|
424
|
+
via.p2p = !!(await deps.send(SOCIAL_MESSAGE_TYPES.REGISTER, payload, deps.peerId || '*'));
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
errors.push('P2P: ' + String(e?.message || e).slice(0, 80));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (canHttp) {
|
|
431
|
+
const base = normalizeBase(deps.desktopUrl || '');
|
|
432
|
+
try {
|
|
433
|
+
const r = await deps.fetchImpl(`${base}/api/registry/register`, {
|
|
434
|
+
method: 'POST',
|
|
435
|
+
headers: { 'content-type': 'application/json' },
|
|
436
|
+
body: JSON.stringify(toRegistryEntry(decl)),
|
|
437
|
+
});
|
|
438
|
+
via.http = !!r.ok;
|
|
439
|
+
if (!r.ok)
|
|
440
|
+
errors.push(`电脑端返回 ${r.status}`);
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
errors.push('HTTP: ' + String(e?.message || e).slice(0, 80));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// 记录本次心跳 (节流), 即使发送失败也不立刻重试
|
|
447
|
+
setHeartbeatState(now, store);
|
|
448
|
+
return { sent: true, reason: 'interval-elapsed', ts: now, via, error: errors.length ? errors.join('; ') : undefined };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* 新 peer 连上时的钩子 (接线: core.network.start 成功后对每个 peerId 调用).
|
|
452
|
+
* 幂等: store.welcomedPeers 记录已欢迎 peer → 同一 peer 第二次直接返回 duplicate.
|
|
453
|
+
* 发送失败 → 回滚记录 (允许后续重试), 返回 { welcomed:false, error }, 不抛出.
|
|
454
|
+
*/
|
|
455
|
+
export async function onPeerConnected(peerId, deps) {
|
|
456
|
+
const key = String(peerId || '').trim();
|
|
457
|
+
if (!key)
|
|
458
|
+
return { welcomed: false, error: 'peerId 为空' };
|
|
459
|
+
const store = storeOf(deps);
|
|
460
|
+
const welcomed = { ...(store.get().welcomedPeers || {}) };
|
|
461
|
+
if (welcomed[key])
|
|
462
|
+
return { welcomed: false, duplicate: true, type: SOCIAL_MESSAGE_TYPES.HELLO };
|
|
463
|
+
// 先落记录 (避免并发重复欢迎), 失败再回滚
|
|
464
|
+
welcomed[key] = nowOf(deps);
|
|
465
|
+
store.set({ ...store.get(), welcomedPeers: welcomed });
|
|
466
|
+
if (typeof deps.send !== 'function') {
|
|
467
|
+
return { welcomed: true, sent: false, type: SOCIAL_MESSAGE_TYPES.HELLO };
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const payload = JSON.stringify({
|
|
471
|
+
type: SOCIAL_MESSAGE_TYPES.HELLO,
|
|
472
|
+
did: deps.ownDid,
|
|
473
|
+
name: deps.ownName || '',
|
|
474
|
+
capabilities: Array.isArray(deps.capabilities) ? deps.capabilities : [],
|
|
475
|
+
fromPublicKey: deps.ownDid,
|
|
476
|
+
ts: nowOf(deps),
|
|
477
|
+
});
|
|
478
|
+
const sent = !!(await deps.send(SOCIAL_MESSAGE_TYPES.HELLO, payload, key));
|
|
479
|
+
if (!sent) {
|
|
480
|
+
const rollback = { ...(store.get().welcomedPeers || {}) };
|
|
481
|
+
delete rollback[key];
|
|
482
|
+
store.set({ ...store.get(), welcomedPeers: rollback });
|
|
483
|
+
return { welcomed: false, error: 'P2P 发送返回 false' };
|
|
484
|
+
}
|
|
485
|
+
return { welcomed: true, sent: true, type: SOCIAL_MESSAGE_TYPES.HELLO };
|
|
486
|
+
}
|
|
487
|
+
catch (e) {
|
|
488
|
+
const rollback = { ...(store.get().welcomedPeers || {}) };
|
|
489
|
+
delete rollback[key];
|
|
490
|
+
store.set({ ...store.get(), welcomedPeers: rollback });
|
|
491
|
+
return { welcomed: false, error: String(e?.message || e).slice(0, 120) };
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function parseJson(s) {
|
|
495
|
+
try {
|
|
496
|
+
return JSON.parse(s);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* 处理入站社交消息 (mobile-core 的 P2P 路由分发).
|
|
504
|
+
* registry.register → 合并对方声明 + 回 registry.register.reply
|
|
505
|
+
* registry.register.reply→ 记录注册回执 (合并声明)
|
|
506
|
+
* registry.discover → 回 registry.discover.reply (我方 + 缓存服务, 支持 query 过滤)
|
|
507
|
+
* registry.discover.reply→ 合并服务列表到 store.discovered (发现结果去重)
|
|
508
|
+
* agent.hello → 标记该 peer 已知 (避免回环问候)
|
|
509
|
+
* 全程不抛出 (返回 { handled:false }).
|
|
510
|
+
*/
|
|
511
|
+
export async function handleSocialMessage(type, payload, fromPeer, deps) {
|
|
512
|
+
const store = storeOf(deps);
|
|
513
|
+
const msg = parseJson(payload) || {};
|
|
514
|
+
try {
|
|
515
|
+
switch (type) {
|
|
516
|
+
case SOCIAL_MESSAGE_TYPES.REGISTER: {
|
|
517
|
+
if (msg.declaration) {
|
|
518
|
+
store.set({ ...store.get(), discovered: mergeServiceLists(store.get().discovered || [], [msg.declaration]) });
|
|
519
|
+
}
|
|
520
|
+
let replied;
|
|
521
|
+
if (typeof deps.send === 'function') {
|
|
522
|
+
const ok = await deps.send(SOCIAL_MESSAGE_TYPES.REGISTER_REPLY, JSON.stringify({ ok: true, registered: msg?.declaration?.agent_id || '', fromPublicKey: deps.ownDid, ts: nowOf(deps) }), fromPeer);
|
|
523
|
+
if (ok)
|
|
524
|
+
replied = SOCIAL_MESSAGE_TYPES.REGISTER_REPLY;
|
|
525
|
+
}
|
|
526
|
+
return { handled: true, replied };
|
|
527
|
+
}
|
|
528
|
+
case SOCIAL_MESSAGE_TYPES.REGISTER_REPLY: {
|
|
529
|
+
// 桌面端确认注册 → 记录 (announced 已在 announceSelf 落, 这里仅合并声明)
|
|
530
|
+
if (msg.declaration) {
|
|
531
|
+
store.set({ ...store.get(), discovered: mergeServiceLists(store.get().discovered || [], [msg.declaration]) });
|
|
532
|
+
}
|
|
533
|
+
return { handled: true };
|
|
534
|
+
}
|
|
535
|
+
case SOCIAL_MESSAGE_TYPES.DISCOVER: {
|
|
536
|
+
const q = String(msg.query || '').trim().toLowerCase();
|
|
537
|
+
const own = [];
|
|
538
|
+
if (deps.declaration)
|
|
539
|
+
own.push(deps.declaration);
|
|
540
|
+
else if (deps.ownDid) {
|
|
541
|
+
try {
|
|
542
|
+
own.push(declarationFor(deps));
|
|
543
|
+
}
|
|
544
|
+
catch { /* 无声明 */ }
|
|
545
|
+
}
|
|
546
|
+
let services = mergeServiceLists(store.get().discovered || [], own);
|
|
547
|
+
if (q) {
|
|
548
|
+
services = services.filter((s) => {
|
|
549
|
+
const hay = [s?.service?.name, s?.service?.description, s?.name, ...(Array.isArray(s?.capabilities) ? s.capabilities : [])]
|
|
550
|
+
.map((x) => String(x || '').toLowerCase()).join(' ');
|
|
551
|
+
return hay.includes(q);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
let replied;
|
|
555
|
+
if (typeof deps.send === 'function') {
|
|
556
|
+
const ok = await deps.send(SOCIAL_MESSAGE_TYPES.DISCOVER_REPLY, JSON.stringify({ services, fromPublicKey: deps.ownDid, ts: nowOf(deps) }), fromPeer);
|
|
557
|
+
if (ok)
|
|
558
|
+
replied = SOCIAL_MESSAGE_TYPES.DISCOVER_REPLY;
|
|
559
|
+
}
|
|
560
|
+
return { handled: true, replied };
|
|
561
|
+
}
|
|
562
|
+
case SOCIAL_MESSAGE_TYPES.DISCOVER_REPLY: {
|
|
563
|
+
const list = Array.isArray(msg.services) ? msg.services : [];
|
|
564
|
+
store.set({ ...store.get(), discovered: mergeServiceLists(store.get().discovered || [], list) });
|
|
565
|
+
return { handled: true };
|
|
566
|
+
}
|
|
567
|
+
case SOCIAL_MESSAGE_TYPES.HELLO: {
|
|
568
|
+
const key = String(fromPeer || '').trim();
|
|
569
|
+
if (key) {
|
|
570
|
+
const welcomed = { ...(store.get().welcomedPeers || {}) };
|
|
571
|
+
if (!welcomed[key])
|
|
572
|
+
welcomed[key] = nowOf(deps);
|
|
573
|
+
store.set({ ...store.get(), welcomedPeers: welcomed });
|
|
574
|
+
}
|
|
575
|
+
return { handled: true };
|
|
576
|
+
}
|
|
577
|
+
default:
|
|
578
|
+
return { handled: false };
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return { handled: false };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// ============================================================================
|
|
586
|
+
// §接线 (由 mobile-core.ts / mobile.js 完成, 本模块不改任何既有文件)
|
|
587
|
+
// ============================================================================
|
|
588
|
+
// 1) 身份/传输就绪后注册自己:
|
|
589
|
+
// const social = await import('./mobile-social.js');
|
|
590
|
+
// const store = social.createLocalStorageStore();
|
|
591
|
+
// await social.announceSelf({
|
|
592
|
+
// ownDid: id.did, ownName: id.name,
|
|
593
|
+
// send, // mobile-core 的 sendViaP2P
|
|
594
|
+
// peerId: desktopPeer || '*',
|
|
595
|
+
// desktopUrl: sync.getDesktopUrl(), // mobile-sync
|
|
596
|
+
// fetchImpl: fetch,
|
|
597
|
+
// store,
|
|
598
|
+
// });
|
|
599
|
+
// 2) 连上 peer 后欢迎 (幂等): social.onPeerConnected(peerId, { ownDid, send, store })
|
|
600
|
+
// 3) 心跳: setInterval(() => social.heartbeat({ ownDid, send, peerId, desktopUrl, fetchImpl: fetch, store }), 60000)
|
|
601
|
+
// 4) 入站路由: routeIncomingMessage 里把 type 命中 registry.* / agent.hello 时
|
|
602
|
+
// 委派 social.handleSocialMessage(type, payload, fromPeer, { ownDid, send, store })
|
|
603
|
+
// 5) 网络页渲染: const r = await social.discoverAgents({ ownDid, desktopUrl, fetchImpl: fetch, store, peerId, send });
|
|
604
|
+
// 用 r.services (含我方声明) 渲染服务/智能体列表
|
|
605
|
+
export default {
|
|
606
|
+
SOCIAL_MESSAGE_TYPES,
|
|
607
|
+
DEFAULT_HEARTBEAT_MS,
|
|
608
|
+
buildServiceDeclaration,
|
|
609
|
+
toRegistryEntry,
|
|
610
|
+
mergeServiceLists,
|
|
611
|
+
serviceKey,
|
|
612
|
+
announceSelf,
|
|
613
|
+
discoverAgents,
|
|
614
|
+
shouldHeartbeat,
|
|
615
|
+
getHeartbeatState,
|
|
616
|
+
setHeartbeatState,
|
|
617
|
+
heartbeat,
|
|
618
|
+
onPeerConnected,
|
|
619
|
+
handleSocialMessage,
|
|
620
|
+
createMemoryStore,
|
|
621
|
+
createLocalStorageStore,
|
|
622
|
+
getDefaultSocialStore,
|
|
623
|
+
emptySocialState,
|
|
624
|
+
};
|