@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
package/dist/llm/pi-ai.js
CHANGED
|
@@ -303,6 +303,28 @@ export class PiAIModel {
|
|
|
303
303
|
};
|
|
304
304
|
return modelMap[this.provider];
|
|
305
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* 2026-09-15: 出网前把 messages 规整成 wire 形状.
|
|
308
|
+
*
|
|
309
|
+
* 背景 (真跑复现 + 逐项对照, 见 wiki log 2026-09-15): DeepSeek 思考模式 (deepseek-v4-*)
|
|
310
|
+
* 在**请求带 tools** 时, 任何 assistant 消息缺 `reasoning_content` 字段 →
|
|
311
|
+
* HTTP 400 "The `reasoning_content` in the thinking mode must be passed back to the API".
|
|
312
|
+
* 复现: 同一个 17 条消息的真实请求体, 不带 tools → 200; 带上 tools → 400;
|
|
313
|
+
* 给每条 assistant 补 `reasoning_content:""` → 带 tools 也 200。
|
|
314
|
+
* 表现: 多轮工具循环 (第 2 轮起) 直接断, 用户看到 "AI 服务调用失败"。
|
|
315
|
+
*
|
|
316
|
+
* 处置: 只对 deepseek 生效 (唯一实测过的 provider); 有原文用原文, 没有就补空串 —
|
|
317
|
+
* 空串已被官方接受, 且不改变语义 (思维链不是给模型看的历史内容)。
|
|
318
|
+
* 其他 provider 原样透传 (OpenAI 系对未知字段更敏感, 不做无证据的改动)。
|
|
319
|
+
*/
|
|
320
|
+
prepareWireMessages(messages) {
|
|
321
|
+
const echoReasoning = this.provider === 'deepseek';
|
|
322
|
+
if (!echoReasoning)
|
|
323
|
+
return messages;
|
|
324
|
+
return messages.map((m) => m.role === 'assistant'
|
|
325
|
+
? { role: 'assistant', content: m.content ?? '', reasoning_content: m.reasoningContent ?? '' }
|
|
326
|
+
: m);
|
|
327
|
+
}
|
|
306
328
|
async callOpenAI(messages, temperature, maxTokens, signal, tools) {
|
|
307
329
|
const apiKey = this.getApiKey();
|
|
308
330
|
if (!apiKey) {
|
|
@@ -310,7 +332,7 @@ export class PiAIModel {
|
|
|
310
332
|
}
|
|
311
333
|
const requestBody = {
|
|
312
334
|
model: this.mapModel(),
|
|
313
|
-
messages,
|
|
335
|
+
messages: this.prepareWireMessages(messages),
|
|
314
336
|
temperature,
|
|
315
337
|
max_tokens: maxTokens
|
|
316
338
|
};
|
|
@@ -370,6 +392,15 @@ export class PiAIModel {
|
|
|
370
392
|
const errBody = await body.text().catch(() => '(no body)');
|
|
371
393
|
console.log(`[pi-ai DEBUG] OpenAI 错误 ${statusCode}: ${String(errBody).slice(0, 500)}`);
|
|
372
394
|
console.log(`[pi-ai DEBUG] 请求体: model=${requestBody.model}, messages=${requestBody.messages?.length}, max_tokens=${requestBody.max_tokens}, baseUrl=${this.getBaseUrl()}`);
|
|
395
|
+
if (process.env.BOLLOON_DUMP_BODY === '1') {
|
|
396
|
+
try {
|
|
397
|
+
const fsx = await import('fs');
|
|
398
|
+
const p = `/tmp/bolloon-req-${Date.now()}.json`;
|
|
399
|
+
fsx.writeFileSync(p, JSON.stringify(requestBody, null, 2));
|
|
400
|
+
console.log(`[pi-ai DEBUG] 失败请求体已落盘: ${p}`);
|
|
401
|
+
}
|
|
402
|
+
catch { /* 调试用, 失败忽略 */ }
|
|
403
|
+
}
|
|
373
404
|
retryAgent?.destroy().catch(() => { });
|
|
374
405
|
throw new Error(`OpenAI API error: ${statusCode} ${String(errBody).slice(0, 300)}`);
|
|
375
406
|
}
|
|
@@ -378,6 +409,8 @@ export class PiAIModel {
|
|
|
378
409
|
const choice = data.choices?.[0];
|
|
379
410
|
const content = choice?.message?.content || '';
|
|
380
411
|
const toolCalls = choice?.message?.tool_calls;
|
|
412
|
+
// 2026-09-15: 思考模式思维链 — 原样存回 history, 下一轮必须回带 (见 prepareWireMessages)
|
|
413
|
+
const reasoningContent = choice?.message?.reasoning_content || undefined;
|
|
381
414
|
lastFinishReason = choice?.finish_reason || '';
|
|
382
415
|
// Bug 7: tool_calls 存在时不走重试 — LLM 选工具时 content 空是合法的
|
|
383
416
|
if (content || (toolCalls && toolCalls.length > 0)) {
|
|
@@ -388,7 +421,7 @@ export class PiAIModel {
|
|
|
388
421
|
const promptBytes = JSON.stringify(messages).length;
|
|
389
422
|
console.log(`[pi-ai timing] total=${_tAfter - _t0}ms attempt=${attempt + 1} fetch=${_tResp - _tFetch}ms parse=${_tParse - _tResp}ms reply=${content.length}B toolCalls=${toolCalls?.length ?? 0} model=${this.mapModel()} prompt=${promptBytes}B`);
|
|
390
423
|
retryAgent?.destroy().catch(() => { });
|
|
391
|
-
return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined };
|
|
424
|
+
return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined, reasoningContent };
|
|
392
425
|
}
|
|
393
426
|
console.warn(`[pi-ai] attempt ${attempt + 1}/3: 空 content (finish_reason=${lastFinishReason}), 退避 1.5s 重试`);
|
|
394
427
|
const _tSleep = Date.now();
|
|
@@ -10,6 +10,33 @@ const RELAY_RETRY_INTERVAL = 30000;
|
|
|
10
10
|
const MAX_RELAY_HOPS = 3;
|
|
11
11
|
const MESSAGE_TIMESTAMP_TOLERANCE = 24 * 60 * 60 * 1000;
|
|
12
12
|
const SIGNED_MESSAGE_TYPES = ['task', 'response', 'discovery', 'address_broadcast'];
|
|
13
|
+
/**
|
|
14
|
+
* 2026-09-15: did:key:z6Mk… 与 Ed25519 公钥字节是否一致。
|
|
15
|
+
*
|
|
16
|
+
* DID 由公钥派生 (did:key 规范: multibase base58btc of 0xed01 || rawKey), 所以首次接触时
|
|
17
|
+
* 自携公钥**无法伪造**: 冒充者换了公钥就解码不出同一个 DID。
|
|
18
|
+
* 返回: true 一致 / false 不一致 (疑似冒充) / null 非 did:key 形态 (无法判定, 不阻断, 交给签名验证)。
|
|
19
|
+
*/
|
|
20
|
+
export async function didKeyMatchesPublicKey(did, publicKeyHex) {
|
|
21
|
+
if (!/^did:key:z/.test(did))
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const { base58btc } = await import('multiformats/bases/base58');
|
|
25
|
+
const decoded = base58btc.decode(did.slice('did:key:'.length));
|
|
26
|
+
// 34 字节 = 2 字节 multicodec 前缀 (0xed01 = ed25519-pub) + 32 字节公钥
|
|
27
|
+
if (decoded.length !== 34)
|
|
28
|
+
return false;
|
|
29
|
+
if (decoded[0] !== 0xed || decoded[1] !== 0x01)
|
|
30
|
+
return false;
|
|
31
|
+
const claimed = Buffer.from(publicKeyHex, 'hex');
|
|
32
|
+
if (claimed.length !== 32)
|
|
33
|
+
return false;
|
|
34
|
+
return Buffer.compare(Buffer.from(decoded.slice(2)), claimed) === 0;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null; // 解码失败 → 不判定 (不当成冒充, 也不放行: 签名仍要过)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
13
40
|
export class AgentRegistry {
|
|
14
41
|
agents = new Map();
|
|
15
42
|
registryPath;
|
|
@@ -192,6 +219,7 @@ export class AgentRegistry {
|
|
|
192
219
|
return null;
|
|
193
220
|
}
|
|
194
221
|
const relayAddr = p2pNetwork.getRelayAddress?.() || null;
|
|
222
|
+
const ownPublicKeyHex = Buffer.from(this.keyPair.publicKey).toString('hex');
|
|
195
223
|
const broadcastData = JSON.stringify({
|
|
196
224
|
type: 'address_broadcast',
|
|
197
225
|
from: this.keyPair.did,
|
|
@@ -200,6 +228,7 @@ export class AgentRegistry {
|
|
|
200
228
|
multiaddrs: this.ownEndpoint.multiaddrs,
|
|
201
229
|
relayAddr: relayAddr || undefined,
|
|
202
230
|
canRelay: relayAddr ? true : false,
|
|
231
|
+
publicKey: ownPublicKeyHex,
|
|
203
232
|
timestamp: now
|
|
204
233
|
});
|
|
205
234
|
const signature = await this.signMessage(broadcastData);
|
|
@@ -213,6 +242,7 @@ export class AgentRegistry {
|
|
|
213
242
|
multiaddrs: this.ownEndpoint.multiaddrs,
|
|
214
243
|
relayAddr: relayAddr || undefined,
|
|
215
244
|
canRelay: relayAddr ? true : false,
|
|
245
|
+
publicKey: ownPublicKeyHex,
|
|
216
246
|
timestamp: now,
|
|
217
247
|
signature: Buffer.from(signature).toString('hex')
|
|
218
248
|
};
|
|
@@ -237,10 +267,35 @@ export class AgentRegistry {
|
|
|
237
267
|
multiaddrs: broadcast.multiaddrs,
|
|
238
268
|
relayAddr: broadcast.relayAddr,
|
|
239
269
|
canRelay: broadcast.canRelay,
|
|
270
|
+
publicKey: broadcast.publicKey,
|
|
240
271
|
timestamp: broadcast.timestamp
|
|
241
272
|
});
|
|
242
273
|
const signature = Buffer.from(broadcast.signature, 'hex');
|
|
243
|
-
const
|
|
274
|
+
const known = this.agents.get(broadcast.from);
|
|
275
|
+
// 2026-09-15: 首次接触 (全球化去中心网络里全是陌生人) —— 原来只认「registry 里已有的公钥」,
|
|
276
|
+
// 陌生人第一次广播必然验签失败 → 广播被丢弃 → 陌生人永远发现不了彼此 (入网文档 §7 的链路实际不通).
|
|
277
|
+
// 现在: 未知 DID 时用广播自带 publicKey 自证 (TOFU: 首次接触信任自携公钥);
|
|
278
|
+
// did:key 额外做 DID↔公钥 派生一致性检查 (DID 本身就是公钥, 自携公钥无法伪造).
|
|
279
|
+
let verifyKeyHex = known?.publicKey || undefined;
|
|
280
|
+
if (!verifyKeyHex) {
|
|
281
|
+
const claimed = String(broadcast.publicKey || '');
|
|
282
|
+
if (!claimed) {
|
|
283
|
+
console.warn(`[Registry] Unknown agent ${broadcast.from.substring(0, 20)}… 且广播未携带 publicKey → 无法验证, 拒收`);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
const binding = await didKeyMatchesPublicKey(broadcast.from, claimed);
|
|
287
|
+
if (binding === false) {
|
|
288
|
+
console.warn(`[Registry] did:key 与自带公钥不匹配 (疑似冒充) from=${broadcast.from.substring(0, 24)}… → 拒收`);
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
verifyKeyHex = claimed;
|
|
292
|
+
}
|
|
293
|
+
else if (broadcast.publicKey && broadcast.publicKey !== verifyKeyHex) {
|
|
294
|
+
// 已认识这个 DID, 却报出另一把公钥 → 身份接管尝试, 拒收 (不覆盖已存公钥)
|
|
295
|
+
console.warn(`[Registry] ${broadcast.from.substring(0, 20)}… 广播的公钥与已知公钥不一致 → 拒收 (不覆盖)`);
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
const isValid = await this.verifySignatureWithKey(broadcast.from, verifyKeyHex, broadcastData, signature);
|
|
244
299
|
if (!isValid) {
|
|
245
300
|
console.warn(`[Registry] Invalid broadcast signature from ${broadcast.from.substring(0, 20)}`);
|
|
246
301
|
return false;
|
|
@@ -253,7 +308,9 @@ export class AgentRegistry {
|
|
|
253
308
|
registeredAt: Date.now(),
|
|
254
309
|
lastSeen: broadcast.timestamp,
|
|
255
310
|
lastBroadcast: 0,
|
|
256
|
-
|
|
311
|
+
// 2026-09-15 修复: 原来写的是**自己**的公钥 (this.keyPair.publicKey) →
|
|
312
|
+
// 对端后续任何签名消息都验不过 (拿我方公钥去验对方签名), 属于把对端身份写坏.
|
|
313
|
+
publicKey: verifyKeyHex,
|
|
257
314
|
relayAddr: broadcast.relayAddr,
|
|
258
315
|
canRelay: broadcast.canRelay
|
|
259
316
|
};
|
|
@@ -261,15 +318,30 @@ export class AgentRegistry {
|
|
|
261
318
|
if (existing) {
|
|
262
319
|
entry.registeredAt = existing.registeredAt;
|
|
263
320
|
entry.lastBroadcast = existing.lastBroadcast;
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
}
|
|
321
|
+
if (existing.publicKey)
|
|
322
|
+
entry.publicKey = existing.publicKey; // 已有公钥永不覆盖
|
|
267
323
|
}
|
|
268
324
|
this.agents.set(broadcast.from, entry);
|
|
269
325
|
this.saveRegistry();
|
|
270
|
-
console.log(`[Registry] Verified broadcast from: ${broadcast.name} (${broadcast.from.substring(0, 20)}...) ${broadcast.canRelay ? '[Relay Capable]' : ''}`);
|
|
326
|
+
console.log(`[Registry] Verified broadcast from: ${broadcast.name} (${broadcast.from.substring(0, 20)}...) ${broadcast.canRelay ? '[Relay Capable]' : ''}${known ? '' : ' [首次接触 TOFU]'}`);
|
|
271
327
|
return true;
|
|
272
328
|
}
|
|
329
|
+
/** 用给定公钥 (hex) 验签 — 首次接触路径用 (未知 DID 时 registry 里没公钥) */
|
|
330
|
+
async verifySignatureWithKey(did, publicKeyHex, data, signature) {
|
|
331
|
+
try {
|
|
332
|
+
const publicKey = Buffer.from(publicKeyHex, 'hex');
|
|
333
|
+
if (publicKey.length !== 32) {
|
|
334
|
+
console.warn(`[Registry] publicKey 长度非法 (${publicKey.length} 字节, 期望 32): ${did.substring(0, 20)}…`);
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
const keyPair = { privateKey: new Uint8Array(32), publicKey, did };
|
|
338
|
+
return await KeyManager.verify(keyPair, new TextEncoder().encode(data), signature);
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
console.warn(`[Registry] Verification with provided key failed:`, e);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
273
345
|
async createSignedMessage(type, payload) {
|
|
274
346
|
if (!this.keyPair || !this.ownEndpoint)
|
|
275
347
|
return null;
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import express from 'express';
|
|
19
19
|
import { buildAgentDelegateRequest, buildAgentResponse, buildManifestPayload, parseFrame, setLocalManifest, getLocalManifest, getRemoteManifests, cacheRemoteManifest, pickAgent, } from '../agents/agent-manifest-protocol.js';
|
|
20
|
-
export function createAgentDelegateApp(transport) {
|
|
20
|
+
export function createAgentDelegateApp(transport, options = {}) {
|
|
21
21
|
const app = express();
|
|
22
22
|
app.use(express.json({ limit: '2mb' }));
|
|
23
|
+
const executeTimeoutMs = options.executeTimeoutMs ?? 60_000;
|
|
23
24
|
// ---- 本地 manifest ----
|
|
24
25
|
app.get('/api/agent/local-manifest', (_req, res) => {
|
|
25
26
|
res.json(getLocalManifest());
|
|
@@ -81,7 +82,10 @@ export function createAgentDelegateApp(transport) {
|
|
|
81
82
|
}
|
|
82
83
|
res.json({
|
|
83
84
|
ok: true,
|
|
84
|
-
|
|
85
|
+
// 2026-09-15: 没缓存到对端 manifest 就如实给 null —— 旧版会编一个
|
|
86
|
+
// 「capabilities:[capability], name:<id>」的假目标, 让人以为已经知道对端是谁。
|
|
87
|
+
targetAgent: targetAgent || null,
|
|
88
|
+
targetAgentKnown: !!targetAgent,
|
|
85
89
|
response: f.payload,
|
|
86
90
|
});
|
|
87
91
|
}
|
|
@@ -104,18 +108,60 @@ export function createAgentDelegateApp(transport) {
|
|
|
104
108
|
return null; // 不需要回包
|
|
105
109
|
}
|
|
106
110
|
if (f.type === 'agent_delegate') {
|
|
107
|
-
// 路由到本地匹配 agent
|
|
108
111
|
const req = f.payload;
|
|
112
|
+
const capability = String(req?.capability || '');
|
|
113
|
+
// 2026-09-15: 严格按文档 §6 / §9 —— 只认 capabilities 含该能力且 active 的 agent。
|
|
114
|
+
// 旧实现 `|| local.agents[0]` 会把不匹配的指令塞给任意一个本地 agent,
|
|
115
|
+
// 与「pick 404 = 没有匹配能力」的语义自相矛盾。
|
|
109
116
|
const local = getLocalManifest();
|
|
110
|
-
const target = local.agents.find((a) => a.capabilities.includes(
|
|
111
|
-
if (!target)
|
|
112
|
-
return buildAgentResponse({
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
117
|
+
const target = local.agents.find((a) => a.capabilities.includes(capability) && a.status === 'active');
|
|
118
|
+
if (!target) {
|
|
119
|
+
return buildAgentResponse({
|
|
120
|
+
ok: false,
|
|
121
|
+
delegatedTo: 'none',
|
|
122
|
+
summary: `no local agent available for capability '${capability}'`,
|
|
123
|
+
error: 'no-capability-match',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// 匹配到了, 但本节点没接执行器 → 如实说"干不了", 不假签收
|
|
127
|
+
if (!options.execute) {
|
|
128
|
+
return buildAgentResponse({
|
|
129
|
+
ok: false,
|
|
130
|
+
delegatedTo: target.id,
|
|
131
|
+
summary: `matched agent '${target.name}' but this node has no executor wired`,
|
|
132
|
+
error: 'no-executor',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const raced = await Promise.race([
|
|
137
|
+
options.execute({
|
|
138
|
+
capability,
|
|
139
|
+
instruction: String(req?.instruction || ''),
|
|
140
|
+
docPath: req?.docPath ? String(req.docPath) : undefined,
|
|
141
|
+
docContent: req?.docContent ? String(req.docContent) : undefined,
|
|
142
|
+
fromAgentId: req?.fromAgentId ? String(req.fromAgentId) : undefined,
|
|
143
|
+
fromPublicKey,
|
|
144
|
+
targetAgentId: target.id,
|
|
145
|
+
targetAgentName: target.name,
|
|
146
|
+
}),
|
|
147
|
+
new Promise((resolve) => setTimeout(() => resolve({ ok: false, summary: `executor timed out after ${executeTimeoutMs}ms`, error: 'executor-timeout' }), executeTimeoutMs)),
|
|
148
|
+
]);
|
|
149
|
+
return buildAgentResponse({
|
|
150
|
+
ok: !!raced.ok,
|
|
151
|
+
delegatedTo: target.id,
|
|
152
|
+
resultCid: raced.resultCid,
|
|
153
|
+
summary: String(raced.summary || '').slice(0, 4000),
|
|
154
|
+
error: raced.error,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
return buildAgentResponse({
|
|
159
|
+
ok: false,
|
|
160
|
+
delegatedTo: target.id,
|
|
161
|
+
summary: `executor threw: ${String(e?.message || e).slice(0, 300)}`,
|
|
162
|
+
error: 'executor-error',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
119
165
|
}
|
|
120
166
|
return null;
|
|
121
167
|
});
|
package/dist/web/mobile-agent.js
CHANGED
|
@@ -160,11 +160,205 @@ async function applyLlmConfigToBridge() {
|
|
|
160
160
|
}
|
|
161
161
|
catch { /* 注入失败不阻塞本地执行 */ }
|
|
162
162
|
}
|
|
163
|
-
// ============
|
|
163
|
+
// ============ 手机端「读入网说明 → 入网」(2026-09-15) ============
|
|
164
|
+
//
|
|
165
|
+
// 背景: 手机端「一键入网」发出的口令是 `read https://bolloon.cn/bolloon-gateway-join.md`
|
|
166
|
+
// (mobile.js DEFAULT_JOIN_PROMPT), 但手机侧此前只会走到「已收到: "…"」的兜底回复 ——
|
|
167
|
+
// 也就是说这句话在手机上是**空转**的: 既不读文档, 也不入网。
|
|
168
|
+
// 现在手机自己就能走完: 读说明(校验 frontmatter) → 本机 DID → 服务登记
|
|
169
|
+
// (桌面可达则登记进桌面的网络 registry, 否则本机登记并如实说明) → P2P 公告(尽力) → 落盘入网态。
|
|
170
|
+
// 桌面不可达不是失败: 手机是自治节点; 但每一步都如实报 ok/note, 不假装入网。
|
|
171
|
+
/** 入网口令识别 (与 mobile.js 的默认 prompt 同源) */
|
|
172
|
+
export const MOBILE_JOIN_DOC_RE = /read\s+(https?:\/\/\S*bolloon-gateway-join\.md)/i;
|
|
173
|
+
const MOBILE_JOIN_STATE_KEY = 'bolloon_gateway_join';
|
|
174
|
+
export function detectJoinDocUrl(text) {
|
|
175
|
+
const m = MOBILE_JOIN_DOC_RE.exec(String(text || ''));
|
|
176
|
+
return m ? m[1] : null;
|
|
177
|
+
}
|
|
178
|
+
/** 极简 SKILL.md frontmatter 解析 (只认 name/version) */
|
|
179
|
+
function parseFm(text) {
|
|
180
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
|
|
181
|
+
if (!m)
|
|
182
|
+
return {};
|
|
183
|
+
const out = {};
|
|
184
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
185
|
+
const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
|
|
186
|
+
if (!kv)
|
|
187
|
+
continue;
|
|
188
|
+
const k = kv[1].toLowerCase();
|
|
189
|
+
if (k === 'name')
|
|
190
|
+
out.name = kv[2].trim().replace(/^["']|["']$/g, '');
|
|
191
|
+
if (k === 'version')
|
|
192
|
+
out.version = kv[2].trim().replace(/^["']|["']$/g, '');
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
export async function getMobileJoinState() {
|
|
197
|
+
try {
|
|
198
|
+
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(MOBILE_JOIN_STATE_KEY) : null;
|
|
199
|
+
return raw ? JSON.parse(raw) : null;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function saveMobileJoinState(s) {
|
|
206
|
+
try {
|
|
207
|
+
if (typeof localStorage !== 'undefined')
|
|
208
|
+
localStorage.setItem(MOBILE_JOIN_STATE_KEY, JSON.stringify(s));
|
|
209
|
+
}
|
|
210
|
+
catch { /* 忽略 */ }
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 按入网说明文档入网 (手机端自足执行)。
|
|
214
|
+
* opts.desktopBaseUrl 可注入 (测试用); 默认读 mobile-gateway 持久化的桌面基址。
|
|
215
|
+
*/
|
|
216
|
+
export async function joinGatewayFromDoc(docUrl, opts = {}) {
|
|
217
|
+
const f = opts.fetchImpl || fetch;
|
|
218
|
+
const steps = [];
|
|
219
|
+
const url = String(docUrl || '').trim();
|
|
220
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
221
|
+
return { ok: false, docUrl: url, steps, error: `入网说明地址必须是 http(s) URL (收到: ${url.slice(0, 60)})` };
|
|
222
|
+
}
|
|
223
|
+
// ① 读入网说明 + 校验
|
|
224
|
+
let docVersion;
|
|
225
|
+
try {
|
|
226
|
+
const r = await f(url, { signal: AbortSignal.timeout(opts.timeoutMs ?? 15000) });
|
|
227
|
+
if (!r.ok) {
|
|
228
|
+
steps.push({ step: '读入网说明', ok: false, note: `文档不可达 (HTTP ${r.status})` });
|
|
229
|
+
return { ok: false, docUrl: url, steps, error: `入网说明不可达 (HTTP ${r.status})` };
|
|
230
|
+
}
|
|
231
|
+
const text = await r.text();
|
|
232
|
+
const fm = parseFm(text);
|
|
233
|
+
if (fm.name !== 'bolloon-gateway-join' && !/加入网关|bolloon-gateway-join/.test(text)) {
|
|
234
|
+
steps.push({ step: '读入网说明', ok: false, note: `不是 Bolloon 入网说明 (name=${fm.name || '无'})` });
|
|
235
|
+
return { ok: false, docUrl: url, steps, error: '该文档不是 Bolloon 网关入网说明, 拒绝据此入网' };
|
|
236
|
+
}
|
|
237
|
+
docVersion = fm.version;
|
|
238
|
+
steps.push({ step: '读入网说明', ok: true, note: `${fm.name || 'bolloon-gateway-join'} v${fm.version || '?'} (${text.length} 字符)` });
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
steps.push({ step: '读入网说明', ok: false, note: `读取失败: ${String(e?.message || e).slice(0, 120)}` });
|
|
242
|
+
return { ok: false, docUrl: url, steps, error: `入网说明读取失败: ${String(e?.message || e).slice(0, 120)}` };
|
|
243
|
+
}
|
|
244
|
+
// ② 本机 DID (手机端身份层; 可注入, 便于测试/无 IndexedDB 环境)
|
|
245
|
+
let did = String(opts.did || '');
|
|
246
|
+
if (did) {
|
|
247
|
+
steps.push({ step: 'DID 身份', ok: true, note: `${did} (注入身份)` });
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
try {
|
|
251
|
+
const id = await ensureIdentity();
|
|
252
|
+
did = id.did;
|
|
253
|
+
steps.push({ step: 'DID 身份', ok: true, note: `${did} (手机端本机生成)` });
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
steps.push({ step: 'DID 身份', ok: false, note: String(e?.message || e).slice(0, 120) });
|
|
257
|
+
return { ok: false, docUrl: url, docVersion, steps, error: '手机端 DID 生成失败' };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const name = String(opts.name || 'phone-agent');
|
|
261
|
+
const capabilities = ['chat', 'gateway-join'];
|
|
262
|
+
// ③ 服务登记: 桌面可达 → 登记进桌面(网络) registry; 否则本机登记并如实说明
|
|
263
|
+
let desktopBase = String(opts.desktopBaseUrl ?? '');
|
|
264
|
+
if (opts.desktopBaseUrl === undefined) {
|
|
265
|
+
try {
|
|
266
|
+
const g = await import('./mobile-gateway.js');
|
|
267
|
+
desktopBase = String(g.getDesktopBaseUrl() || '');
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
desktopBase = '';
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
desktopBase = desktopBase.replace(/\/+$/, '');
|
|
274
|
+
let registeredOn = 'local';
|
|
275
|
+
if (desktopBase) {
|
|
276
|
+
try {
|
|
277
|
+
const r = await f(`${desktopBase}/api/registry/register`, {
|
|
278
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
agentId: did, name, wallet: '',
|
|
281
|
+
service: { name: 'chat', description: '手机端智能体 (自足节点)', price: { amount: '0', currency: 'USDC', per: 'task' }, endpoint: '' },
|
|
282
|
+
capabilities,
|
|
283
|
+
}),
|
|
284
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 15000),
|
|
285
|
+
});
|
|
286
|
+
if (r.ok) {
|
|
287
|
+
registeredOn = 'desktop';
|
|
288
|
+
steps.push({ step: '服务登记', ok: true, note: `已登记进电脑端网络 registry (${desktopBase}) —— 网络内其他智能体可按能力发现我` });
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
steps.push({ step: '服务登记', ok: false, note: `电脑端 registry 拒绝 (HTTP ${r.status}); 已改为本机登记` });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
steps.push({ step: '服务登记', ok: false, note: `电脑端不可达 (${String(e?.message || e).slice(0, 80)}); 已改为本机登记` });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
steps.push({ step: '服务登记', ok: true, note: '未配置电脑端基址 → 只在本机登记 (手机是自治节点; 设置里填电脑端地址可登记进网络 registry)' });
|
|
300
|
+
}
|
|
301
|
+
// ④ P2P 公告 (尽力): 让已连接的对端知道本机服务; 没连上不是入网失败
|
|
302
|
+
try {
|
|
303
|
+
const p2p = await import('./mobile-p2p.js');
|
|
304
|
+
let peers = 0;
|
|
305
|
+
try {
|
|
306
|
+
peers = (p2p.getConnectedPeers?.() || []).length;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
peers = 0;
|
|
310
|
+
}
|
|
311
|
+
if (peers > 0 && typeof p2p.sendMobileP2PMessage === 'function') {
|
|
312
|
+
const okAnnounce = await p2p.sendMobileP2PMessage('*', 'registry.register', JSON.stringify({ agent_id: did, name, capabilities }), did);
|
|
313
|
+
steps.push({ step: 'P2P 公告', ok: !!okAnnounce, note: okAnnounce ? `已向 ${peers} 个对端广播本机声明` : `广播失败 (对端 ${peers} 个)` });
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
steps.push({ step: 'P2P 公告', ok: false, note: '当前无已连接对端 (浏览器/未连电脑端时正常) — 本机声明已就绪, 连上即生效' });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
steps.push({ step: 'P2P 公告', ok: false, note: `P2P 层不可用: ${String(e?.message || e).slice(0, 80)}` });
|
|
321
|
+
}
|
|
322
|
+
// ⑤ 落盘入网态 (幂等: 同 url 再次入网覆盖时间戳)
|
|
323
|
+
saveMobileJoinState({ url, did, name, capabilities, docVersion, registeredOn, desktopBaseUrl: desktopBase || undefined, joinedAt: new Date().toISOString() });
|
|
324
|
+
const state = await getMobileJoinState();
|
|
325
|
+
steps.push({ step: '落盘入网态', ok: !!state?.did, note: `localStorage:${MOBILE_JOIN_STATE_KEY}` });
|
|
326
|
+
return { ok: true, docUrl: url, docVersion, did, steps };
|
|
327
|
+
}
|
|
328
|
+
/** 把入网结果渲染成给用户看的回复 (与桌面工具的 steps 汇报风格一致) */
|
|
329
|
+
export function formatMobileJoinResult(r) {
|
|
330
|
+
if (!r.ok) {
|
|
331
|
+
return `❌ 入网失败: ${r.error || '未知原因'}\n\n${r.steps.map((s) => `${s.ok ? '✓' : '✗'} ${s.step}: ${s.note}`).join('\n')}`;
|
|
332
|
+
}
|
|
333
|
+
const s = r.steps.find((x) => x.step === '服务登记');
|
|
334
|
+
return [
|
|
335
|
+
'✅ 已加入全球智能体网络 (手机端自足执行)',
|
|
336
|
+
'',
|
|
337
|
+
`DID: ${r.did}`,
|
|
338
|
+
`入网说明: v${r.docVersion || '?'} (${r.docUrl})`,
|
|
339
|
+
s ? `登记: ${s.note}` : '',
|
|
340
|
+
'',
|
|
341
|
+
...r.steps.map((x) => `${x.ok ? '✓' : '✗'} ${x.step}: ${x.note}`),
|
|
342
|
+
'',
|
|
343
|
+
'用「网络 → Agent 网络」可查看成员; 设置里填电脑端地址可把本机登记进网络 registry。',
|
|
344
|
+
].filter((l) => l !== '').join('\n');
|
|
345
|
+
}
|
|
346
|
+
// ============ 本地执行 (Kotlin AgentRuntime / 离线兜底) ============
|
|
164
347
|
/** 手机端本地 agent 执行 (优先 Kotlin, 离线内置规则) */
|
|
165
348
|
export async function runLocalAgent(goal) {
|
|
166
349
|
const win = typeof window !== 'undefined' ? window : null;
|
|
167
350
|
const cap = win?.Capacitor;
|
|
351
|
+
// 2026-09-15: 「读入网说明 → 入网」在手机端本地自足执行 (先于 Kotlin 桥: 原生工具集里没有入网能力,
|
|
352
|
+
// 交给它只会得到空转回复)。这样浏览器 / WebView / 真机三种环境行为一致。
|
|
353
|
+
const joinDocUrl = detectJoinDocUrl(goal);
|
|
354
|
+
if (joinDocUrl) {
|
|
355
|
+
_lastWorklog = [`🧩 识别为入网口令: ${joinDocUrl}`];
|
|
356
|
+
const r = await joinGatewayFromDoc(joinDocUrl).catch((e) => ({
|
|
357
|
+
ok: false, docUrl: joinDocUrl, steps: [{ step: '入网', ok: false, note: String(e?.message || e).slice(0, 120) }], error: String(e?.message || e),
|
|
358
|
+
}));
|
|
359
|
+
_lastWorklog = [..._lastWorklog, ...r.steps.map((s) => `${s.ok ? '✓' : '✗'} ${s.step}: ${s.note}`)];
|
|
360
|
+
return formatMobileJoinResult(r);
|
|
361
|
+
}
|
|
168
362
|
const bridge = cap && cap.Plugins && cap.Plugins.RokidBridge;
|
|
169
363
|
if (bridge && cap.isNativePlatform?.()) {
|
|
170
364
|
try {
|