@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.
@@ -0,0 +1,680 @@
1
+ /**
2
+ * mobile-ipfs.ts — 手机端 (WebView / Capacitor) 独立 IPFS 模块 (2026-09-11)
3
+ *
4
+ * 目标: 手机端不依赖电脑端就能用 IPFS ——
5
+ * 1) 本地确定性算 CID / 验 CID → 协议 PROOF 阶段 (结果哈希进交易记录 / agent registry)
6
+ * 2) 读写远端 IPFS → 协议 资源存储阶段 (agent 结果 / 附件 / 产物外存)
7
+ * 3) 公共网关回退 (多网关逐个尝试) → 资源读取阶段的可用性兜底
8
+ * 4) 本地缓存 (网关首次拉取后落本地) → 二次读取零网络, 离线可读
9
+ *
10
+ * ─────────────────── IPFS 客户端来源: @diap/sdk/browser (2026-09-11 重接) ───────────────────
11
+ * 任务要求用用户自己的协议 SDK。上次直接 import `@diap/sdk` barrel 在 WebView 挂了并回滚
12
+ * (barrel 链里带 fs / path / node:crypto)。SDK 0.2.7 起有了**浏览器安全子路径**
13
+ * `@diap/sdk/browser`, 所以本模块现在直接用它:
14
+ * - `BolloonIpfsClient` **继承** SDK 的 `IpfsClient` —— newPublicOnly / newWithRemoteNode /
15
+ * newWithPinata / upload / get / getApiUrl / getGatewayUrl / pin / publishIpns 全部来自 SDK。
16
+ * - 唯一差异: 调用方**显式注入** fetchImpl (测试 / 代理) 时, upload/get 走本模块的可注入
17
+ * 网络实现 (语义与 SDK 一致, 但不碰全局 fetch); 未注入时一律委托 super ——
18
+ * 即手机端真实运行的就是 SDK 的 IpfsClient 代码。
19
+ * - 实测 esbuild --platform=browser 打包本模块 0 个 node 内置引用 (browser 子路径无 fs/path)。
20
+ * - CID 计算**不变**: 仍走 multiformats + @ipld/dag-cbor + sha2-256 (与桌面端 contentToCid()
21
+ * 逐字节一致); 实测 SDK 的 computeDagCborCid 得出同一 CID。
22
+ *
23
+ * 浏览器安全: 不 import 任何 node 内置模块 (fs/path/os/crypto/stream 全无)。
24
+ * 网络走可注入 fetchImpl (默认 globalThis.fetch);
25
+ * 存储走可注入 storage (默认 localStorage, 取不到时内存兜底);
26
+ * CID 计算用 multiformats + @ipld/dag-cbor (纯 JS, 与桌面端 src/orbitdb/cid-database.ts
27
+ * contentToCid() 语义完全一致: dag-cbor encode + sha2-256 + CID v1 codec 0x71)。
28
+ * 全部导出函数失败一律返回 `{ ok:false, error }`, 永不抛。
29
+ */
30
+ import { IpfsClient as SdkIpfsClient } from '@diap/sdk/browser';
31
+ import { CID } from 'multiformats/cid';
32
+ import * as dagCbor from '@ipld/dag-cbor';
33
+ import { sha256 } from 'multiformats/hashes/sha2';
34
+ const errMsg = (err) => {
35
+ if (err instanceof Error)
36
+ return err.message;
37
+ if (typeof err === 'string')
38
+ return err;
39
+ try {
40
+ return JSON.stringify(err);
41
+ }
42
+ catch {
43
+ return String(err);
44
+ }
45
+ };
46
+ /** CID 规范化: 去掉 ipfs:// / /ipfs/ 前缀、尾斜杠与首尾空白 (容错链上 / 网关 / URL 回传的写法) */
47
+ export function normalizeCid(cid) {
48
+ if (typeof cid !== 'string')
49
+ return '';
50
+ let s = cid.trim();
51
+ if (s.startsWith('ipfs://'))
52
+ s = s.slice('ipfs://'.length);
53
+ s = s.replace(/^\/?ipfs\//, '');
54
+ return s.replace(/\/+$/, '').trim();
55
+ }
56
+ /** 内存存储 (测试 / 无 localStorage 环境兜底) */
57
+ export function createMemoryStorage() {
58
+ const m = new Map();
59
+ return {
60
+ getItem: (k) => (m.has(k) ? m.get(k) : null),
61
+ setItem: (k, v) => {
62
+ m.set(k, String(v));
63
+ },
64
+ removeItem: (k) => {
65
+ m.delete(k);
66
+ },
67
+ };
68
+ }
69
+ let _memStorage = null;
70
+ /** 默认存储: localStorage (可用时) → 内存兜底 (Safari 隐私模式 / SSR / 测试) */
71
+ export function defaultStorage() {
72
+ try {
73
+ if (typeof localStorage !== 'undefined' && localStorage) {
74
+ const probe = '__bolloon_ipfs_probe__';
75
+ localStorage.setItem(probe, '1');
76
+ localStorage.removeItem(probe);
77
+ return localStorage;
78
+ }
79
+ }
80
+ catch {
81
+ /* localStorage 被禁用 (抛异常) → 内存兜底 */
82
+ }
83
+ if (!_memStorage)
84
+ _memStorage = createMemoryStorage();
85
+ return _memStorage;
86
+ }
87
+ /** 按 UTF-8 计字节数 (缓存上限用) */
88
+ function byteLength(s) {
89
+ try {
90
+ return new TextEncoder().encode(s).length;
91
+ }
92
+ catch {
93
+ return s.length;
94
+ }
95
+ }
96
+ /**
97
+ * JSON 语义清洗 — 与桌面端 cid-database.ts `JSON.parse(JSON.stringify(obj))` 保持一致:
98
+ * 丢 undefined / 函数 / symbol, Date → ISO 字符串, NaN/Infinity → null, -0 → 0,
99
+ * Uint8Array → 索引 map (与桌面端同构, 保证手机端与电脑端同内容同 CID)。
100
+ * 差异: 本函数对循环引用替换为 '[Circular]' 且永不抛 (桌面端会抛)。
101
+ */
102
+ export function jsonClean(value) {
103
+ try {
104
+ const s = JSON.stringify(value, circularReplacer());
105
+ if (typeof s === 'undefined')
106
+ return null; // 与桌面端兜底: undefined 顶层值
107
+ return JSON.parse(s);
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ function circularReplacer() {
114
+ const seen = new WeakSet();
115
+ return (_key, value) => {
116
+ if (typeof value === 'object' && value !== null) {
117
+ const obj = value;
118
+ if (seen.has(obj))
119
+ return '[Circular]';
120
+ seen.add(obj);
121
+ }
122
+ return value;
123
+ };
124
+ }
125
+ // ─────────────────────────── CID 计算 / 校验 (PROOF 阶段) ───────────────────────────
126
+ /**
127
+ * 内容 → 确定性 CID。dag-cbor 会按规范对 map 键排序, 所以 **键序无关**、同内容同 CID。
128
+ * 返回 Promises<string>。永不抛: 极端输入由 jsonClean 兜底为 null。
129
+ */
130
+ export async function computeCid(obj) {
131
+ const cleaned = jsonClean(obj);
132
+ const bytes = dagCbor.encode(cleaned);
133
+ const hash = await sha256.digest(bytes);
134
+ return CID.createV1(0x71, hash).toString();
135
+ }
136
+ /** 文本 → 确定性 CID (纯文本场景, 例如协议里的 content/摘要字段) */
137
+ export async function cidFromText(text) {
138
+ return computeCid(String(text));
139
+ }
140
+ /**
141
+ * 验 CID: 重算并比对 (PROOF 阶段用 —— 拿交易记录/registry 里的 resultCid 与重算值对账)。
142
+ * objOrText 为 string → 按文本算; 否则按对象算。永不抛。
143
+ */
144
+ export async function verifyContent(cid, objOrText) {
145
+ const expected = normalizeCid(cid);
146
+ if (!expected)
147
+ return { ok: false, match: false, error: 'cid 不能为空' };
148
+ try {
149
+ const actual = typeof objOrText === 'string' ? await cidFromText(objOrText) : await computeCid(objOrText);
150
+ return { ok: true, match: actual === expected, expected, actual };
151
+ }
152
+ catch (err) {
153
+ return { ok: false, match: false, expected, error: errMsg(err) };
154
+ }
155
+ }
156
+ /**
157
+ * Agent 执行结果 → CID (PROOF 阶段: 结果 CID 进交易记录 / agent registry)。
158
+ * 规范化 = jsonClean (丢 undefined、键序无关), 保证同一结果在同一设备/跨设备算得同一 CID。
159
+ * 注意: 结果里若含 wall-clock 时间戳 / 随机数等易变字段, 请调用方先剔除 —— 它们会让 CID 每次不同。
160
+ * 永不抛 (极端情况退化为错误描述对象的 CID)。
161
+ */
162
+ export async function resultCid(result) {
163
+ try {
164
+ return await computeCid(result);
165
+ }
166
+ catch (err) {
167
+ try {
168
+ return await computeCid({ __unhashedResult: errMsg(err) });
169
+ }
170
+ catch {
171
+ // 最后兜底: 连错误描述都算不出来时返回空串 (不抛)
172
+ return '';
173
+ }
174
+ }
175
+ }
176
+ export const IPFS_CONFIG_KEY = 'bolloon_ipfs_config';
177
+ export const DEFAULT_GATEWAYS = [
178
+ 'https://ipfs.io',
179
+ 'https://dweb.link',
180
+ 'https://cloudflare-ipfs.com',
181
+ ];
182
+ export const DEFAULT_IPFS_CONFIG = {
183
+ mode: 'public',
184
+ gateways: [...DEFAULT_GATEWAYS],
185
+ };
186
+ const VALID_MODES = ['public', 'remote', 'pinata'];
187
+ /**
188
+ * 读配置 (默认 mode='public' + 三个公共网关)。存储损坏 / 缺字段一律回落到默认值, 永不抛。
189
+ */
190
+ export function getIpfsConfig(storage = defaultStorage()) {
191
+ const base = { mode: 'public', gateways: [...DEFAULT_GATEWAYS] };
192
+ try {
193
+ const raw = storage.getItem(IPFS_CONFIG_KEY);
194
+ if (!raw)
195
+ return base;
196
+ const parsed = JSON.parse(raw);
197
+ if (!parsed || typeof parsed !== 'object')
198
+ return base;
199
+ const mode = VALID_MODES.includes(parsed.mode) ? parsed.mode : 'public';
200
+ const gateways = Array.isArray(parsed.gateways) && parsed.gateways.filter((g) => typeof g === 'string' && g).length > 0
201
+ ? parsed.gateways.filter((g) => typeof g === 'string' && g)
202
+ : [...DEFAULT_GATEWAYS];
203
+ const cfg = { mode, gateways };
204
+ if (typeof parsed.apiUrl === 'string')
205
+ cfg.apiUrl = parsed.apiUrl;
206
+ if (typeof parsed.gatewayUrl === 'string')
207
+ cfg.gatewayUrl = parsed.gatewayUrl;
208
+ if (typeof parsed.pinataKey === 'string')
209
+ cfg.pinataKey = parsed.pinataKey;
210
+ if (typeof parsed.pinataSecret === 'string')
211
+ cfg.pinataSecret = parsed.pinataSecret;
212
+ return cfg;
213
+ }
214
+ catch {
215
+ return base;
216
+ }
217
+ }
218
+ /**
219
+ * 写配置 (与现有配置合并, 支持只改 mode)。返回 { ok, config } 或 { ok:false, error }。永不抛。
220
+ */
221
+ export function setIpfsConfig(cfg, storage = defaultStorage()) {
222
+ try {
223
+ if (cfg.mode !== undefined && !VALID_MODES.includes(cfg.mode)) {
224
+ return { ok: false, error: `非法 mode: ${String(cfg.mode)} (应为 public|remote|pinata)` };
225
+ }
226
+ const merged = { ...getIpfsConfig(storage), ...cfg };
227
+ if (cfg.gateways !== undefined) {
228
+ const gs = (cfg.gateways || []).filter((g) => typeof g === 'string' && g);
229
+ merged.gateways = gs.length > 0 ? gs : [...DEFAULT_GATEWAYS];
230
+ }
231
+ // 只存有效字段 (undefined 不入 JSON)
232
+ const clean = { mode: merged.mode, gateways: merged.gateways };
233
+ if (merged.apiUrl)
234
+ clean.apiUrl = merged.apiUrl;
235
+ if (merged.gatewayUrl)
236
+ clean.gatewayUrl = merged.gatewayUrl;
237
+ if (merged.pinataKey)
238
+ clean.pinataKey = merged.pinataKey;
239
+ if (merged.pinataSecret)
240
+ clean.pinataSecret = merged.pinataSecret;
241
+ storage.setItem(IPFS_CONFIG_KEY, JSON.stringify(clean));
242
+ return { ok: true, config: clean };
243
+ }
244
+ catch (err) {
245
+ return { ok: false, error: errMsg(err) };
246
+ }
247
+ }
248
+ // ─────────────────────────── 本地缓存 (资源存储阶段 · 离线读) ───────────────────────────
249
+ export const IPFS_CACHE_INDEX_KEY = 'bolloon_ipfs_cache_index';
250
+ export const IPFS_CACHE_PREFIX = 'bolloon_ipfs_cache:';
251
+ /** 最多缓存条数 */
252
+ export const IPFS_CACHE_MAX_ENTRIES = 50;
253
+ /** 缓存总字节上限 (2MB) */
254
+ export const IPFS_CACHE_MAX_BYTES = 2 * 1024 * 1024;
255
+ function readIndex(storage) {
256
+ try {
257
+ const raw = storage.getItem(IPFS_CACHE_INDEX_KEY);
258
+ if (!raw)
259
+ return [];
260
+ const parsed = JSON.parse(raw);
261
+ if (!Array.isArray(parsed))
262
+ return [];
263
+ return parsed
264
+ .filter((e) => !!e && typeof e.c === 'string')
265
+ .map((e) => ({ c: e.c, s: typeof e.s === 'number' ? e.s : 0 }));
266
+ }
267
+ catch {
268
+ return [];
269
+ }
270
+ }
271
+ function writeIndex(storage, list) {
272
+ storage.setItem(IPFS_CACHE_INDEX_KEY, JSON.stringify(list));
273
+ }
274
+ /** LRU 淘汰: 超条数或超总字节 → 从最旧 (数组头部) 开始删 */
275
+ function evictIndex(storage, list) {
276
+ const kept = [...list];
277
+ const total = () => kept.reduce((n, e) => n + e.s, 0);
278
+ while (kept.length > IPFS_CACHE_MAX_ENTRIES || total() > IPFS_CACHE_MAX_BYTES) {
279
+ const oldest = kept.shift();
280
+ if (!oldest)
281
+ break;
282
+ try {
283
+ storage.removeItem?.(IPFS_CACHE_PREFIX + oldest.c);
284
+ }
285
+ catch {
286
+ /* 忽略单条删除失败 */
287
+ }
288
+ }
289
+ return kept;
290
+ }
291
+ /** 读缓存; 未命中返回 null。永不抛。命中会刷新 LRU 顺序。 */
292
+ export function ipfsCacheGet(cid, storage = defaultStorage()) {
293
+ try {
294
+ const key = normalizeCid(cid);
295
+ if (!key)
296
+ return null;
297
+ const raw = storage.getItem(IPFS_CACHE_PREFIX + key);
298
+ if (!raw)
299
+ return null;
300
+ const entry = JSON.parse(raw);
301
+ if (!entry || typeof entry.text !== 'string')
302
+ return null;
303
+ // LRU 刷新: 命中项移到末尾 (最新)
304
+ const idx = readIndex(storage);
305
+ const pos = idx.findIndex((e) => e.c === key);
306
+ if (pos >= 0 && pos !== idx.length - 1) {
307
+ const [hit] = idx.splice(pos, 1);
308
+ idx.push(hit);
309
+ writeIndex(storage, idx);
310
+ }
311
+ return entry.text;
312
+ }
313
+ catch {
314
+ return null;
315
+ }
316
+ }
317
+ /**
318
+ * 写缓存 (含上限保护: 最多 50 条 / 总计 2MB, 超出按 LRU 淘汰)。
319
+ * 单条超过总上限 → 拒绝缓存并返回原因。永不抛。
320
+ */
321
+ export function ipfsCacheSet(cid, text, storage = defaultStorage()) {
322
+ try {
323
+ const key = normalizeCid(cid);
324
+ if (!key)
325
+ return { ok: false, error: 'cid 不能为空' };
326
+ if (typeof text !== 'string')
327
+ return { ok: false, error: 'text 必须是字符串' };
328
+ const size = byteLength(text);
329
+ if (size > IPFS_CACHE_MAX_BYTES) {
330
+ return { ok: false, error: `内容过大 (${size} 字节 > 上限 ${IPFS_CACHE_MAX_BYTES}), 不缓存` };
331
+ }
332
+ storage.setItem(IPFS_CACHE_PREFIX + key, JSON.stringify({ t: Date.now(), s: size, text }));
333
+ const idx = readIndex(storage).filter((e) => e.c !== key);
334
+ idx.push({ c: key, s: size });
335
+ writeIndex(storage, evictIndex(storage, idx));
336
+ return { ok: true, size };
337
+ }
338
+ catch (err) {
339
+ return { ok: false, error: errMsg(err) };
340
+ }
341
+ }
342
+ export const PINATA_PIN_URL = 'https://api.pinata.cloud/pinning/pinJSONToIPFS';
343
+ const timeoutSignal = (ms, outer) => {
344
+ const ABORT = typeof AbortController !== 'undefined' ? AbortController : undefined;
345
+ if (!ABORT || typeof setTimeout === 'undefined')
346
+ return { signal: undefined, done: () => undefined };
347
+ const ctrl = new ABORT();
348
+ const timer = setTimeout(() => {
349
+ try {
350
+ ctrl.abort();
351
+ }
352
+ catch {
353
+ /* 忽略 */
354
+ }
355
+ }, ms);
356
+ return {
357
+ signal: outer ?? ctrl.signal,
358
+ done: () => clearTimeout(timer),
359
+ };
360
+ };
361
+ /**
362
+ * 手机端 IPFS 客户端 —— **继承 @diap/sdk/browser 的 IpfsClient** (2026-09-11 重接)。
363
+ *
364
+ * 来自 SDK (真实现, 不再自写): newPublicOnly / newWithRemoteNode / newWithPinata / upload /
365
+ * get / getApiUrl / getGatewayUrl / pin / ensureKeyExists / publishIpns / ... 全量继承。
366
+ * 本子类只加一件事: **可注入 fetchImpl** (测试 / 代理用)。
367
+ * - 未注入 fetchImpl → upload/get 一律 `super.*`, 手机端真实跑的就是 SDK 代码 (全局 fetch)。
368
+ * - 注入 fetchImpl → 走本模块的等价实现 (multipart / Pinata / 网关回退, 语义同 SDK),
369
+ * 这样测试与代理能在不碰全局 fetch 的前提下驱动网络。
370
+ * 两者都会 throw (与 SDK 一致), 公开导出函数 (ipfsUpload/ipfsFetch) 捕获它, 对外永不抛。
371
+ */
372
+ export class BolloonIpfsClient extends SdkIpfsClient {
373
+ pApiUrl;
374
+ pGatewayUrl;
375
+ pPinataKey;
376
+ pPinataSecret;
377
+ pTimeout;
378
+ pPublicGateways;
379
+ /** 仅当调用方**显式注入** fetchImpl 时非空; null 表示「委托 SDK 真实现」 */
380
+ pFetchImpl;
381
+ constructor(apiUrl, gatewayUrl, pinataApiKey, pinataApiSecret, timeoutSeconds = 30, fetchImpl, publicGateways = [...DEFAULT_GATEWAYS]) {
382
+ super(apiUrl ?? null, gatewayUrl ?? null, pinataApiKey ?? null, pinataApiSecret ?? null, timeoutSeconds);
383
+ this.pApiUrl = apiUrl || null;
384
+ this.pGatewayUrl = gatewayUrl || null;
385
+ this.pPinataKey = pinataApiKey || null;
386
+ this.pPinataSecret = pinataApiSecret || null;
387
+ this.pTimeout = Math.max(1, timeoutSeconds) * 1000;
388
+ this.pPublicGateways = publicGateways.length > 0 ? publicGateways : [...DEFAULT_GATEWAYS];
389
+ this.pFetchImpl = fetchImpl ?? null;
390
+ }
391
+ /** 只用公共网关 (读为主) — 与 SDK 同名同参, 额外允许注入 fetchImpl */
392
+ static async newPublicOnly(timeoutSeconds = 30, fetchImpl) {
393
+ return new BolloonIpfsClient(null, null, null, null, timeoutSeconds, fetchImpl);
394
+ }
395
+ /** 用远端 IPFS HTTP API — 与 SDK 同名同参, 额外允许注入 fetchImpl */
396
+ static async newWithRemoteNode(apiUrl, gatewayUrl, timeoutSeconds = 30, fetchImpl) {
397
+ return new BolloonIpfsClient(apiUrl, gatewayUrl, null, null, timeoutSeconds, fetchImpl);
398
+ }
399
+ /** 用 Pinata 托管 — 与 SDK 同名同参, 额外允许注入 fetchImpl */
400
+ static async newWithPinata(apiKey, apiSecret, timeoutSeconds = 30, fetchImpl) {
401
+ return new BolloonIpfsClient(null, null, apiKey, apiSecret, timeoutSeconds, fetchImpl);
402
+ }
403
+ getApiUrl() {
404
+ return this.pApiUrl;
405
+ }
406
+ getGatewayUrl() {
407
+ return this.pGatewayUrl;
408
+ }
409
+ /** 上传内容 (资源存储阶段)。未注入 fetchImpl → 委托 SDK; 注入了 → 本模块可注入实现。 */
410
+ async upload(content, name = 'data') {
411
+ if (!this.pFetchImpl)
412
+ return super.upload(content, name);
413
+ if (this.pApiUrl)
414
+ return this.uploadToRemoteApiViaFetch(content, name);
415
+ if (this.pPinataKey && this.pPinataSecret)
416
+ return this.uploadToPinataViaFetch(content, name);
417
+ throw new Error('未配置任何 IPFS 上传方式: 缺少远程 API 地址或 Pinata 凭据');
418
+ }
419
+ async uploadToRemoteApiViaFetch(content, name) {
420
+ const doFetch = this.pFetchImpl;
421
+ const url = `${this.pApiUrl}/api/v0/add?pin=true`;
422
+ const { signal, done } = timeoutSignal(this.pTimeout);
423
+ try {
424
+ // 优先 multipart (与 SDK 一致); 环境无 FormData/Blob 时退化为原始 body
425
+ let body = content;
426
+ const headers = { 'User-Agent': 'bolloon-mobile-ipfs/1.0' };
427
+ if (typeof FormData !== 'undefined' && typeof Blob !== 'undefined') {
428
+ const form = new FormData();
429
+ form.append('file', new Blob([content], { type: 'application/json' }), name);
430
+ body = form;
431
+ }
432
+ else {
433
+ headers['Content-Type'] = 'application/json';
434
+ }
435
+ const res = await doFetch(url, { method: 'POST', body, headers, signal });
436
+ if (!res.ok) {
437
+ const errText = await res.text().catch(() => '');
438
+ throw new Error(`上传失败: ${res.status ?? '?'} - ${String(errText).slice(0, 200)}`);
439
+ }
440
+ const result = (await res.json());
441
+ const cid = result?.Hash;
442
+ if (!cid)
443
+ throw new Error('IPFS 响应中缺少 Hash 字段');
444
+ const size = result?.Size !== undefined ? Number(result.Size) : byteLength(content);
445
+ return { cid, size: Number.isFinite(size) ? size : byteLength(content), uploadedAt: new Date().toISOString(), provider: 'remote_api' };
446
+ }
447
+ catch (err) {
448
+ // 网络层异常也要带上"上传"语义, 便于 UI 直接展示
449
+ const m = errMsg(err);
450
+ throw new Error(/上传请求失败|上传失败/.test(m) ? m : `上传请求失败: ${url} (${m})`);
451
+ }
452
+ finally {
453
+ done();
454
+ }
455
+ }
456
+ async uploadToPinataViaFetch(content, name) {
457
+ const doFetch = this.pFetchImpl;
458
+ let jsonContent;
459
+ try {
460
+ jsonContent = JSON.parse(content);
461
+ }
462
+ catch {
463
+ jsonContent = { data: content };
464
+ }
465
+ const body = JSON.stringify({
466
+ pinataContent: jsonContent,
467
+ pinataMetadata: { name, keyvalues: { type: 'bolloon-agent-result', uploaded_by: 'bolloon-mobile-ipfs' } },
468
+ });
469
+ const { signal, done } = timeoutSignal(this.pTimeout);
470
+ try {
471
+ const res = await doFetch(PINATA_PIN_URL, {
472
+ method: 'POST',
473
+ headers: {
474
+ 'Content-Type': 'application/json',
475
+ pinata_api_key: this.pPinataKey,
476
+ pinata_secret_api_key: this.pPinataSecret,
477
+ },
478
+ body,
479
+ signal,
480
+ });
481
+ if (!res.ok) {
482
+ const errText = await res.text().catch(() => '');
483
+ throw new Error(`Pinata 返回错误 ${res.status ?? '?'}: ${String(errText).slice(0, 200)}`);
484
+ }
485
+ const out = (await res.json());
486
+ if (!out?.IpfsHash)
487
+ throw new Error('Pinata 响应中缺少 IpfsHash 字段');
488
+ return {
489
+ cid: out.IpfsHash,
490
+ size: typeof out.PinSize === 'number' ? out.PinSize : byteLength(content),
491
+ uploadedAt: new Date().toISOString(),
492
+ provider: 'Pinata',
493
+ };
494
+ }
495
+ catch (err) {
496
+ const m = errMsg(err);
497
+ throw new Error(/Pinata 上传请求失败|Pinata 返回错误/.test(m) ? m : `Pinata 上传请求失败 (${m})`);
498
+ }
499
+ finally {
500
+ done();
501
+ }
502
+ }
503
+ /** 读取内容 (资源读取阶段)。未注入 fetchImpl → 委托 SDK; 注入了 → 本模块网关回退实现。 */
504
+ async get(cid) {
505
+ if (!this.pFetchImpl)
506
+ return super.get(cid);
507
+ const doFetch = this.pFetchImpl;
508
+ const key = normalizeCid(cid);
509
+ if (!key)
510
+ throw new Error('cid 不能为空');
511
+ const tried = [];
512
+ const candidates = [...(this.pGatewayUrl ? [this.pGatewayUrl] : []), ...this.pPublicGateways];
513
+ for (const gw of candidates) {
514
+ try {
515
+ return await this.getFromGatewayViaFetch(doFetch, gw, key);
516
+ }
517
+ catch (err) {
518
+ tried.push(`${gw}: ${errMsg(err)}`);
519
+ }
520
+ }
521
+ throw new Error(`无法从任何网关获取内容 (${tried.join(' | ')})`);
522
+ }
523
+ async getFromGatewayViaFetch(doFetch, gatewayUrl, cid) {
524
+ const url = `${gatewayUrl.replace(/\/+$/, '')}/ipfs/${cid}`;
525
+ const { signal, done } = timeoutSignal(this.pTimeout);
526
+ try {
527
+ const res = await doFetch(url, { method: 'GET', headers: { 'User-Agent': 'bolloon-mobile-ipfs/1.0' }, signal });
528
+ if (!res.ok)
529
+ throw new Error(`网关返回错误: ${res.status ?? '?'}`);
530
+ return await res.text();
531
+ }
532
+ finally {
533
+ done();
534
+ }
535
+ }
536
+ }
537
+ /** 按配置造一个等价客户端; 配置不足返回 null (不抛) */
538
+ export function createIpfsClient(config, opts = {}) {
539
+ try {
540
+ const t = opts.timeoutSec ?? 30;
541
+ const gateways = config.gateways && config.gateways.length > 0 ? config.gateways : [...DEFAULT_GATEWAYS];
542
+ if (config.mode === 'remote' && config.apiUrl) {
543
+ return new BolloonIpfsClient(config.apiUrl, config.gatewayUrl ?? gateways[0], null, null, t, opts.fetchImpl, gateways);
544
+ }
545
+ if (config.mode === 'pinata' && config.pinataKey && config.pinataSecret) {
546
+ return new BolloonIpfsClient(null, null, config.pinataKey, config.pinataSecret, t, opts.fetchImpl, gateways);
547
+ }
548
+ if (config.mode === 'public') {
549
+ // public 只读为主; 若同时配了 Pinata 凭据, 允许走 Pinata 上传 (见 ipfsUpload)
550
+ return new BolloonIpfsClient(null, config.gatewayUrl ?? null, null, null, t, opts.fetchImpl, gateways);
551
+ }
552
+ return null;
553
+ }
554
+ catch {
555
+ return null;
556
+ }
557
+ }
558
+ /**
559
+ * 上传内容到远端 IPFS (协议 资源存储阶段)。按 config.mode 选通道:
560
+ * remote → 远端 IPFS HTTP API; pinata → Pinata; public → 无托管上传通道, 明确失败 (但配了 Pinata 凭据则走 Pinata)。
561
+ * 失败一律返回 { ok:false, error }, 永不抛。
562
+ */
563
+ export async function ipfsUpload(content, name = 'data', opts = {}) {
564
+ try {
565
+ const cfg = opts.config ?? getIpfsConfig(opts.storage);
566
+ const text = typeof content === 'string' ? content : JSON.stringify(jsonClean(content));
567
+ // 1) 注入的 SDK 客户端优先 (用户自己的协议 SDK)
568
+ if (opts.client) {
569
+ try {
570
+ const r = await opts.client.upload(text, name);
571
+ if (!r || !r.cid)
572
+ return { ok: false, error: 'SDK client.upload 未返回 cid' };
573
+ return { ok: true, cid: r.cid, provider: r.provider || 'diap-sdk', size: r.size };
574
+ }
575
+ catch (err) {
576
+ return { ok: false, error: `diap-sdk 上传失败: ${errMsg(err)}` };
577
+ }
578
+ }
579
+ // 2) mode=public: 公共网关只读, 没有托管上传通道
580
+ if (cfg.mode === 'public') {
581
+ if (cfg.pinataKey && cfg.pinataSecret) {
582
+ const pinata = await BolloonIpfsClient.newWithPinata(cfg.pinataKey, cfg.pinataSecret, opts.timeoutSec ?? 30, opts.fetchImpl);
583
+ const r = await pinata.upload(text, name);
584
+ return { ok: true, cid: r.cid, provider: r.provider, size: r.size };
585
+ }
586
+ return {
587
+ ok: false,
588
+ error: "mode='public' 的公共网关只支持读取, 无法上传。请 setIpfsConfig 切到 mode='remote' (填 apiUrl) 或 mode='pinata' (填 pinataKey/pinataSecret), 或给 ipfsUpload 传 client。",
589
+ };
590
+ }
591
+ // 3) mode=remote: 需要 apiUrl
592
+ if (cfg.mode === 'remote') {
593
+ if (!cfg.apiUrl)
594
+ return { ok: false, error: "mode='remote' 缺少 apiUrl" };
595
+ const client = await BolloonIpfsClient.newWithRemoteNode(cfg.apiUrl, cfg.gatewayUrl ?? (cfg.gateways?.[0] ?? DEFAULT_GATEWAYS[0]), opts.timeoutSec ?? 30, opts.fetchImpl);
596
+ const r = await client.upload(text, name);
597
+ return { ok: true, cid: r.cid, provider: r.provider, size: r.size };
598
+ }
599
+ // 4) mode=pinata: 需要 pinataKey + pinataSecret
600
+ if (!cfg.pinataKey || !cfg.pinataSecret) {
601
+ return { ok: false, error: "mode='pinata' 缺少 pinataKey / pinataSecret" };
602
+ }
603
+ const pinata = await BolloonIpfsClient.newWithPinata(cfg.pinataKey, cfg.pinataSecret, opts.timeoutSec ?? 30, opts.fetchImpl);
604
+ const r = await pinata.upload(text, name);
605
+ return { ok: true, cid: r.cid, provider: r.provider, size: r.size };
606
+ }
607
+ catch (err) {
608
+ return { ok: false, error: errMsg(err) };
609
+ }
610
+ }
611
+ /**
612
+ * 读取内容 (协议 资源读取阶段): 本地缓存 → (可选) SDK 客户端 → 网关逐个回退 → 命中即写缓存。
613
+ * 失败返回 { ok:false, error }, 永不抛。
614
+ */
615
+ export async function ipfsFetch(cid, opts = {}) {
616
+ const key = normalizeCid(cid);
617
+ if (!key)
618
+ return { ok: false, error: 'cid 不能为空' };
619
+ const storage = opts.storage ?? defaultStorage();
620
+ const useCache = opts.useCache !== false;
621
+ // 1) 本地缓存优先 (命中则零网络)
622
+ if (useCache) {
623
+ const cached = ipfsCacheGet(key, storage);
624
+ if (cached !== null)
625
+ return { ok: true, text: cached, from: 'cache' };
626
+ }
627
+ const errors = [];
628
+ // 2) 注入的 SDK 客户端 (用户自己的协议 SDK)
629
+ if (opts.client) {
630
+ try {
631
+ const text = await opts.client.get(key);
632
+ if (typeof text === 'string') {
633
+ if (useCache)
634
+ ipfsCacheSet(key, text, storage);
635
+ return { ok: true, text, from: 'diap-sdk' };
636
+ }
637
+ errors.push('diap-sdk: 返回非字符串');
638
+ }
639
+ catch (err) {
640
+ errors.push(`diap-sdk: ${errMsg(err)}`);
641
+ }
642
+ }
643
+ // 3) 网关逐个回退
644
+ const cfg = getIpfsConfig(storage);
645
+ const gateways = opts.gateways && opts.gateways.length > 0
646
+ ? opts.gateways
647
+ : cfg.gateways && cfg.gateways.length > 0
648
+ ? cfg.gateways
649
+ : [...DEFAULT_GATEWAYS];
650
+ const doFetch = opts.fetchImpl ?? (typeof fetch !== 'undefined' ? fetch : null);
651
+ if (!doFetch)
652
+ return { ok: false, error: '当前环境没有 fetch (请注入 fetchImpl)' };
653
+ for (const gw of gateways) {
654
+ const base = String(gw).replace(/\/+$/, '');
655
+ const url = `${base}/ipfs/${key}`;
656
+ const { signal, done } = timeoutSignal((opts.timeoutSec ?? 30) * 1000);
657
+ try {
658
+ const res = await doFetch(url, { method: 'GET', headers: { 'User-Agent': 'bolloon-mobile-ipfs/1.0' }, signal });
659
+ if (!res.ok) {
660
+ errors.push(`${base}: HTTP ${res.status ?? '?'}`);
661
+ continue;
662
+ }
663
+ const text = await res.text();
664
+ if (typeof text !== 'string') {
665
+ errors.push(`${base}: 响应非文本`);
666
+ continue;
667
+ }
668
+ if (useCache)
669
+ ipfsCacheSet(key, text, storage);
670
+ return { ok: true, text, from: base };
671
+ }
672
+ catch (err) {
673
+ errors.push(`${base}: ${errMsg(err)}`);
674
+ }
675
+ finally {
676
+ done();
677
+ }
678
+ }
679
+ return { ok: false, error: `所有网关都失败 (${errors.join(' | ')})` };
680
+ }