@rei-standard/amsg-client 2.4.0-next.0 → 2.5.0-next.0

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/README.md CHANGED
@@ -1,47 +1,33 @@
1
1
  # @rei-standard/amsg-client
2
2
 
3
- `@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应和 Push 订阅。
3
+ `@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应、Push 订阅,以及 **送达协调**。
4
4
 
5
- ## v2.4.0-next.0SSE consumer
5
+ ## v2.5.0 — `deliver()`:平台无关的送达 primitive
6
6
 
7
- 新增 `consumeInstantStream(payload, endpointPath?, options)`:消费 amsg-instant 0.9.0+ 的 SSE 默认响应,按 frame 解析 `event: payload` / `event: done` / `event: error` 分发到 `options.onPayload`。前台场景下 push 不再绕 push service → SW → IDB → main thread 整条链路,延迟少一个数量级。详见下方 [SSE 流消费](#sse-流消费-consumeinstantstream240配合-amsg-instant-090)。`sendInstant()` 字节级不变;老调用方升级零成本。
7
+ 新增 `client.deliver(payload, opts)`,把"发出去"和"业务上是否真送达"分离开。它是新代码的**首选入口**。
8
8
 
9
- ## v2.3.0 Shared push types
9
+ 旧的 `sendInstant()` / `consumeInstantStream()` 仍然保留可用,但被降级为**低级 transport**——只在你已经自己接好了送达校验时才用,否则会出现「HTTP 200 / SSE 不报错 ≠ 消息真送到」的陷阱(详见下方[为什么需要 `deliver()`](#为什么需要-deliver))。
10
10
 
11
- The client now re-exports `@rei-standard/amsg-shared` 的类型、运行时常量(`MESSAGE_KIND` / `MESSAGE_TYPE` / `PUSH_SOURCE`)、推送 builder(`buildContentPush` 等)和类型守卫(`isContentPush` 等)。调用方可以直接 `import { MessageKind, buildContentPush, isContentPush } from '@rei-standard/amsg-client'`,无需单独再装一个 `@rei-standard/amsg-shared` 依赖。client 本身在运行时不消费这些导出 —— 它们是给同时调 `ReiClient` 又在 Service Worker / 客户端处理推送的 app 用的便利出口。
11
+ > `@rei-standard/amsg-instant` 0.9.0+ 配合最直接,但 `deliver()` 本身**不绑死任何后端 / 平台**——它接收一个普通的 `Promise<ObservedDeliveryReceipt>` 作为"观察通道"信号源,**谁都能接**(Service Worker 广播、Electron IPC、原生桥、轮询、自定义 long-poll……)。
12
12
 
13
- ```js
14
- // app.js — 用 ReiClient 发即时消息
15
- import { ReiClient } from '@rei-standard/amsg-client';
13
+ ---
16
14
 
17
- const client = new ReiClient({
18
- baseUrl: 'https://instant.example.com',
19
- instantEncryption: false,
20
- });
21
- await client.sendInstant({
22
- contactName: 'Rei',
23
- completePrompt: '你是 Rei,用一句话提醒用户带伞',
24
- apiUrl: 'https://api.openai.com/v1/chat/completions',
25
- apiKey: '...',
26
- primaryModel: 'gpt-4o-mini',
27
- pushSubscription: subscription.toJSON(),
28
- });
15
+ ## 目录
29
16
 
30
- // service-worker.js — 用 isContentPush 在收到推送时收窄类型
31
- import { isContentPush } from '@rei-standard/amsg-client';
32
-
33
- self.addEventListener('push', (event) => {
34
- const payload = event.data?.json();
35
- if (isContentPush(payload)) {
36
- // payload 已被收窄为 ContentPush —— 安全读取 payload.message
37
- event.waitUntil(
38
- self.registration.showNotification(payload.contactName ?? 'Rei', {
39
- body: payload.message,
40
- })
41
- );
42
- }
43
- });
44
- ```
17
+ - [快速使用](#快速使用)
18
+ - [`deliver()` 标准用法](#deliver-标准用法)
19
+ - [为什么需要 `deliver()`](#为什么需要-deliver)
20
+ - [`DeliverOptions` 全字段](#deliveroptions-全字段)
21
+ - [五种 `outcome` 含义](#五种-outcome-含义)
22
+ - [接观察通道的几种典型形态](#接观察通道的几种典型形态)
23
+ - [低级 API(`sendInstant` / `consumeInstantStream`)](#低级-apisendinstant--consumeinstantstream)
24
+ - [发送即时消息(加密 vs 明文)](#发送即时消息加密-vs-明文)
25
+ - [`messages` 多轮 / `splitPattern` 自定义分句](#messages-多轮--splitpattern-自定义分句)
26
+ - [本地软清空与可选 `maxPayloadBytes`](#本地软清空与可选-maxpayloadbytes)
27
+ - [其他工具:scheduleMessage / listMessages / subscribePush…](#其他工具)
28
+ - [模块格式与环境](#模块格式与环境)
29
+
30
+ ---
45
31
 
46
32
  ## 安装
47
33
 
@@ -56,17 +42,30 @@ import { ReiClient } from '@rei-standard/amsg-client';
56
42
 
57
43
  const client = new ReiClient({
58
44
  baseUrl: '/api/v1',
59
- userId: '550e8400-e29b-41d4-a716-446655440000'
45
+ userId: '550e8400-e29b-41d4-a716-446655440000',
60
46
  });
61
47
 
62
48
  await client.init();
49
+ ```
50
+
51
+ 发送即时消息(**推荐**走 `deliver()`,下一节展开):
52
+
53
+ ```js
54
+ const result = await client.deliver(payload, {
55
+ delivery: { mode: 'observed', observed: observationPromise },
56
+ timeoutMs: 300_000,
57
+ });
58
+ if (result.ok) {
59
+ // result.outcome === 'delivered' —— 真送达
60
+ }
61
+ ```
63
62
 
63
+ 订 Web Push(如果你的接入方案需要走 push 通道):
64
+
65
+ ```js
64
66
  await navigator.serviceWorker.register('/service-worker.js');
65
67
  const registration = await navigator.serviceWorker.ready;
66
- const subscription = await client.subscribePush(
67
- window.__VAPID_PUBLIC_KEY__,
68
- registration
69
- );
68
+ const subscription = await client.subscribePush(window.__VAPID_PUBLIC_KEY__, registration);
70
69
 
71
70
  await client.scheduleMessage({
72
71
  contactName: 'Rei',
@@ -74,71 +73,339 @@ await client.scheduleMessage({
74
73
  userMessage: '下班记得带伞~',
75
74
  firstSendTime: new Date(Date.now() + 60 * 1000).toISOString(),
76
75
  recurrenceType: 'none',
77
- pushSubscription: subscription.toJSON()
76
+ pushSubscription: subscription.toJSON(),
77
+ });
78
+ ```
79
+
80
+ ---
81
+
82
+ ## `deliver()` 标准用法
83
+
84
+ ```js
85
+ import { ReiClient } from '@rei-standard/amsg-client';
86
+
87
+ const client = new ReiClient({ baseUrl: 'https://instant.example.com', instantEncryption: false });
88
+
89
+ // 1. 准备「观察通道」Promise —— 任何能告诉你"消息已经进库 / 上屏 / 上通知中心"的来源都行。
90
+ // 形状要求:resolve 时给一个 { messageId?, sessionId?, channel? };至少含其中一个 ID。
91
+ const observationPromise = waitForReceipt({ /* 业务上下文 */ });
92
+
93
+ // 2. 发出消息并等送达裁决
94
+ const abort = new AbortController();
95
+ const result = await client.deliver(payload, {
96
+ delivery: { mode: 'observed', observed: observationPromise },
97
+ timeoutMs: 300_000, // 整体预算
98
+ onChunk: (chunk) => routeChunk(chunk), // 可选:SSE 每帧 UI 钩子;抛错被吞,不影响 outcome
99
+ signal: abort.signal, // 可选:caller 主动取消
100
+ });
101
+
102
+ // 3. 五值 outcome —— 每一个都对应**明确**的业务动作
103
+ switch (result.outcome) {
104
+ case 'delivered':
105
+ // 真送达。result.detail.receipt 是你自己 resolve 的那份。
106
+ break;
107
+ case 'cancelled':
108
+ // 用户主动 abort,期间无延迟送达。安静返回,不弹错。
109
+ break;
110
+ case 'timeout':
111
+ if (result.detail.observationChannelStalled) {
112
+ // ⚠ 重要分支:transport 干净结束但观察通道没接力。
113
+ // 多半是 SW / IPC / native 推送处理那一侧挂了 / 卡了。
114
+ // 不要当发送失败,提示"已发送,本机推送通道暂未确认"即可。
115
+ } else {
116
+ // 整体预算耗完,啥信号都没等到。可重试。
117
+ }
118
+ break;
119
+ case 'send-failed':
120
+ // transport 自己挂了(带 detail.transportError),并且没有观察到送达。
121
+ // 这才是「真的发送失败」。
122
+ showError(result.detail.transportError);
123
+ break;
124
+ case 'completed-unconfirmed':
125
+ // 仅 transport-only 模式才出现。下面专门讲。
126
+ break;
127
+ }
128
+ ```
129
+
130
+ `result.detail` 永远有,里面带 `waitedMs` / `transportEnded` / `transportError` / `transportResponse`(JSON 模式)/ `chunkHandlerError` / `cancelledByCaller` / `observationChannelStalled` / `receipt`,按需取诊断信息。
131
+
132
+ ---
133
+
134
+ ## 为什么需要 `deliver()`
135
+
136
+ 如果你的后端是 `@rei-standard/amsg-instant` 0.9.0+,**它默认强制开启 Web Push always-on backup**:同一条业务消息**总是**同时走两条通道下去——
137
+
138
+ 1. SSE 流式直送(前台收到走 `event: payload`)
139
+ 2. Web Push 备份(即使 SSE 成功 enqueue,也照样发一份,由 SW 端按 `messageId` 去重)
140
+
141
+ 这种双通道语义让旧的两条单一信号路径都不再可靠:
142
+
143
+ | 旧 API | 看到的信号 | 实际意味着 |
144
+ | --- | --- | --- |
145
+ | `sendInstant()` 返回 `200` | dispatch 成功 | ❌ **不等于**消费者真收到(push backup 仍可能没到) |
146
+ | `consumeInstantStream()` reject | SSE 这条路断了 | ❌ **不等于**消息没送达(push backup 可能已到) |
147
+
148
+ 最朴素的 naive 代码 `try { await consumeInstantStream() } catch { fail() }` 在这套语义下**必然出错**——iOS 把后台 fetch 杀掉时,SSE reject,用户看到「失败」,但其实 push backup 已经把消息送进去了,过一会儿冒出来。计费、UI 文案、重试逻辑全部错乱。
149
+
150
+ `deliver()` 的解法:
151
+
152
+ - **transport 只是辅助**——它的成败用来收紧延迟,不用来判送达
153
+ - **送达由"观察通道"决定**——caller 提供一个 `Promise<ObservedDeliveryReceipt>`,等业务上"真到了"才 resolve。这条 Promise 怎么实现库不关心,**真正平台无关**
154
+ - **race 四路 + grace + 严格 outcome**——返回值告诉你到底是 delivered / cancelled / timeout / send-failed / completed-unconfirmed 的哪一个,不再让 caller 自己脑补
155
+
156
+ ---
157
+
158
+ ## `DeliverOptions` 全字段
159
+
160
+ ```ts
161
+ interface DeliverOptions {
162
+ delivery:
163
+ | { mode: 'observed'; observed: Promise<ObservedDeliveryReceipt> }
164
+ | { mode: 'transport-only' };
165
+
166
+ timeoutMs: number; // 总预算(含 transport + grace)
167
+ onChunk?: (payload: unknown) => Promise<void> | void; // 可选 SSE 每帧钩子,抛错被吞
168
+ postTransportGraceMs?: number; // transport 结束后等观察的 grace
169
+ // 默认 = min(remaining, max(5000, timeoutMs * 0.1))
170
+ // cancel 路径下生效的是 grace / 2
171
+ signal?: AbortSignal; // 已 aborted → 立即 cancelled,不发 fetch
172
+ // listener 在每个终态会被卸载,长生命周期 signal 反复
173
+ // 调用不会累积
174
+ headers?: Record<string, string>; // 额外请求头;可覆盖 Content-Type,但不能覆盖
175
+ // X-User-Id / X-Payload-Encrypted / X-Encryption-Version
176
+ // / X-Client-Token / Authorization
177
+ authorization?: string; // 透传成 Authorization header(与 sendInstant 对齐)
178
+ endpointPath?: string; // 默认 '/instant',可改 '/continue' 续跑
179
+ }
180
+
181
+ interface ObservedDeliveryReceipt {
182
+ messageId?: string; // 至少一个非空字符串
183
+ sessionId?: string; // ↑
184
+ channel?: string; // 'sw' / 'ipc' / 'native' / 'poll' / 任意诊断 label
185
+ }
186
+ ```
187
+
188
+ ### `delivery.mode` 必须显式选
189
+
190
+ | mode | 何时用 | outcome 取值 |
191
+ | --- | --- | --- |
192
+ | `'observed'` | **99% 用户用这个**。有任何能确认"消息真到了"的 out-of-band 通道 | `delivered` / `cancelled` / `timeout` / `send-failed` |
193
+ | `'transport-only'` | 没有 out-of-band 通道(amsg-instant 0.9+ 默认场景几乎不会用到;某些自定义后端 / 调试场景才会) | `completed-unconfirmed` / `cancelled` / `timeout` / `send-failed` |
194
+
195
+ > 库**不允许**「传一个永不 resolve 的 Promise 假装在 observed 模式」的写法——那等于教人写错代码。模式必须显式声明。
196
+
197
+ ### `postTransportGraceMs`
198
+
199
+ transport 结束后(无论干净结束还是 error)等观察通道的额外窗口。默认公式:
200
+
201
+ ```
202
+ default = min(remainingBudget, max(5000ms, timeoutMs * 0.1))
203
+ ```
204
+
205
+ - 5 秒下限保住极短 timeout 下 grace 不被砍到 0
206
+ - 10% 比例让 30s / 300s / 多分钟级 timeout 都有合理 grace
207
+ - caller 显式传时仍会被 `remainingBudget` cap,不会超出 `timeoutMs` 总预算
208
+
209
+ cancel 路径用的是 `grace / 2`(abort 后只给一半时间等延迟送达,剩下半给清理)。
210
+
211
+ ---
212
+
213
+ ## 五种 `outcome` 含义
214
+
215
+ | outcome | `ok` | 何时出现 | 推荐 caller 行为 |
216
+ | --- | --- | --- | --- |
217
+ | `'delivered'` | ✅ true | observed 模式 + 收到匹配 receipt(任何路径,包括 abort 后 grace 内仍到) | 正常成功路径 |
218
+ | `'cancelled'` | ❌ false | caller `signal.abort()` 触发,且 grace 内没观察到送达 | 安静返回,不弹错(这是用户主动) |
219
+ | `'timeout'` | ❌ false | 总预算耗完;**或** observed 模式 transport 干净结束但 observation 没接力 | 可重试;如带 `observationChannelStalled` 标记则提示「已发送、本机推送通道暂未确认」 |
220
+ | `'send-failed'` | ❌ false | transport 自己挂了(`detail.transportError` 有值)+ 没观察到送达 | 这才是真发送失败,给 `detail.transportError` 报错 |
221
+ | `'completed-unconfirmed'` | ❌ false | **仅 transport-only 模式**,transport 干净结束,无真相信号 | best-effort 乐观,caller 自决怎么判 |
222
+
223
+ 特别注意两个细分:
224
+
225
+ - **`outcome:'timeout'` + `detail.observationChannelStalled:true`** —— transport 都好好结束了,是观察那一侧(SW / IPC / native push handler)没把信号给到 `observed`。多半是观察那侧的实现有问题,不是发送失败。文案应该跟普通 timeout 区分。
226
+ - **`outcome:'delivered'` + `detail.cancelledByCaller:true`** —— 用户切走 / 关页面后,消息在 grace 内仍然送达了(实战常见:iOS Safari 切 tab,几百 ms 后 push 才到)。不算 cancelled。
227
+
228
+ ---
229
+
230
+ ## 接观察通道的几种典型形态
231
+
232
+ `deliver()` 不绑死任何平台。这一节给几个常见形态的 reference 写法——**库里都不内置,全是 caller 自己几行胶水**。
233
+
234
+ ### Service Worker 广播
235
+
236
+ 如果你的 SW 是 `@rei-standard/amsg-sw` 或类似实现,会在落库后 `postMessage` 一份 `{ type: 'REI_AMSG_PUSH', event: 'DELIVER', payload }`。把它包成 Promise:
237
+
238
+ ```js
239
+ function waitForSwReceipt(messageId, signal) {
240
+ return new Promise((resolve, reject) => {
241
+ function handler(e) {
242
+ if (e.data?.type !== 'REI_AMSG_PUSH') return;
243
+ const p = e.data.payload;
244
+ if (p?.messageId === messageId) {
245
+ navigator.serviceWorker.removeEventListener('message', handler);
246
+ resolve({ messageId: p.messageId, sessionId: p.sessionId, channel: 'sw' });
247
+ }
248
+ }
249
+ navigator.serviceWorker.addEventListener('message', handler);
250
+ signal?.addEventListener('abort', () => {
251
+ navigator.serviceWorker.removeEventListener('message', handler);
252
+ reject(new DOMException('aborted', 'AbortError'));
253
+ }, { once: true });
254
+ });
255
+ }
256
+
257
+ await client.deliver(payload, {
258
+ delivery: { mode: 'observed', observed: waitForSwReceipt(payload.messageId, abort.signal) },
259
+ timeoutMs: 300_000,
78
260
  });
79
261
  ```
80
262
 
81
- ## 发送即时消息
263
+ ### Electron / Tauri IPC
82
264
 
83
- 新代码用 `client.sendInstant(payload)`,走 [`@rei-standard/amsg-instant`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)。
265
+ ```js
266
+ function waitForIpcReceipt(messageId) {
267
+ return new Promise((resolve) => {
268
+ const off = window.ipcBridge.on('amsg:received', (p) => {
269
+ if (p.messageId !== messageId) return;
270
+ off();
271
+ resolve({ messageId: p.messageId, channel: 'ipc' });
272
+ });
273
+ });
274
+ }
275
+ ```
276
+
277
+ ### 原生 push 桥(React Native / native WebView)
278
+
279
+ ```js
280
+ function waitForNativeReceipt(messageId) {
281
+ return new Promise((resolve) => {
282
+ const sub = NativeEventEmitter.addListener('amsg-received', (p) => {
283
+ if (p.messageId !== messageId) return;
284
+ sub.remove();
285
+ resolve({ messageId: p.messageId, channel: 'native' });
286
+ });
287
+ });
288
+ }
289
+ ```
290
+
291
+ ### 纯轮询 fallback
292
+
293
+ ```js
294
+ function pollReceipt(messageId, signal) {
295
+ return new Promise((resolve, reject) => {
296
+ const t = setInterval(async () => {
297
+ if (signal.aborted) { clearInterval(t); reject(new DOMException('aborted', 'AbortError')); return; }
298
+ const found = await db.findReceipt(messageId);
299
+ if (found) { clearInterval(t); resolve({ messageId, channel: 'poll' }); }
300
+ }, 1000);
301
+ });
302
+ }
303
+ ```
304
+
305
+ `deliver()` 对这些一视同仁,只看 `Promise` 何时 resolve 出什么。
306
+
307
+ ---
308
+
309
+ ## 低级 API:`sendInstant` / `consumeInstantStream`
310
+
311
+ 这两个 API 仍然保留,但**只在以下情况推荐**:
312
+
313
+ - 你已经在更上层自己接好了送达确认(典型:业务库直接同步落库后就算完成,根本没有"观察通道"概念)
314
+ - 你只需要 SSE 每帧的 UI 钩子,不需要 outcome 裁决
315
+ - 临时调试 / one-off 脚本
316
+
317
+ 不在这些情况下,**用 `deliver()`**。
318
+
319
+ ### `sendInstant(payload, endpointPath?, opts?)`
320
+
321
+ POST JSON 到 instant endpoint,原样返回 worker 的 `{ success, data?, error? }`。
322
+
323
+ > ⚠ **HTTP 200 ≠ delivery confirmation**,当 worker 配了 backup Web Push 时(amsg-instant 0.9.0+ 默认)。`200` 只说明 dispatch 成功,不说明消费者真收到。要正确判断送达,用 `deliver()`。
324
+
325
+ 可选 `opts.expectsBackupPush`:
326
+ - 设 `true` —— 本实例此方法首次调用时 `console.warn` 一次,提醒上述陷阱(migration 期审计有用)
327
+ - 设 `false` —— 显式表示「我知道这点」,永久静音
328
+ - 不传 —— 不警告
329
+
330
+ ### `consumeInstantStream(payload, endpointPath?, options)`
331
+
332
+ POST 并按 SSE 帧解析 `event: payload` / `event: done` / `event: error`,分发到 `options.onPayload`。
333
+
334
+ ```js
335
+ try {
336
+ await client.consumeInstantStream(payload, '/instant', {
337
+ onPayload: async (push) => routePush(push),
338
+ onError: (err) => log.warn('stream error', err),
339
+ onDone: () => stopSpinner(),
340
+ signal: abort.signal,
341
+ });
342
+ } catch (err) {
343
+ // ⚠ reject ≠ delivery failure(详见上面)
344
+ }
345
+ ```
346
+
347
+ > ⚠ **rejection ≠ delivery failure**,当 worker 配了 backup Web Push 时。SSE 可能因为 iOS 杀后台 fetch、网络抖动、worker 5xx 而 reject,但 backup push 仍然把消息送到了。把 reject 当成「发送失败」会导致**虚报失败 + 消息晚到时用户困惑**。要正确判断送达,用 `deliver()`。
348
+
349
+ `opts.expectsBackupPush` 与 `sendInstant` 一致。
350
+
351
+ ---
352
+
353
+ ## 发送即时消息(加密 vs 明文)
354
+
355
+ `deliver()` 与 `sendInstant` 共享同一套 transport 配置,由构造器决定:
84
356
 
85
357
  ### 加密模式(默认;兼容 amsg-server / amsg-instant 0.1.x)
86
358
 
87
359
  ```js
88
360
  const client = new ReiClient({
89
361
  baseUrl: '/api/v1',
90
- customBaseUrls: {
91
- instant: 'https://instant.example.com', // 不传则用 baseUrl
92
- },
362
+ customBaseUrls: { instant: 'https://instant.example.com' },
93
363
  userId: '550e8400-e29b-41d4-a716-446655440000',
94
364
  });
95
365
 
96
366
  await client.init();
97
367
 
98
- await client.sendInstant({
368
+ await client.deliver({
99
369
  contactName: 'Rei',
100
370
  completePrompt: '你是 Rei,用一句话提醒用户带伞',
101
371
  apiUrl: 'https://api.openai.com/v1/chat/completions',
102
372
  apiKey: '...',
103
373
  primaryModel: 'gpt-4o-mini',
104
374
  pushSubscription: subscription.toJSON(),
375
+ }, {
376
+ delivery: { mode: 'observed', observed: observationPromise },
377
+ timeoutMs: 300_000,
105
378
  });
106
379
  ```
107
380
 
108
- > `customBaseUrls` 是按端点名(如 `instant`)覆盖 `baseUrl` 的通用机制;后续其他端点也可以用同一字段独立指定 base URL,不会再加新的命名字段。
381
+ > `customBaseUrls` 是按端点名(如 `instant`)覆盖 `baseUrl` 的通用机制;后续其他端点也可以用同一字段独立指定。
109
382
 
110
- ### 明文模式(配 amsg-instant 0.2.x,单租户自部署)
383
+ ### 明文模式(配 amsg-instant 0.2.x+ / 单租户自部署)
111
384
 
112
385
  ```js
113
386
  const client = new ReiClient({
114
- baseUrl: 'https://instant.example.com', // amsg-instant Worker URL
387
+ baseUrl: 'https://instant.example.com',
115
388
  instantEncryption: false,
116
- instantClientToken: 'shared-secret-xyz', // 可选;Worker 端配了再填
389
+ instantClientToken: 'shared-secret-xyz',
117
390
  });
118
391
 
119
- // init() 在明文模式下是 no-op,调用与否都跑得通
120
- await client.sendInstant({
121
- contactName: 'Rei',
122
- completePrompt: '你是 Rei,用一句话提醒用户带伞',
123
- apiUrl: 'https://api.openai.com/v1/chat/completions',
124
- apiKey: '...',
125
- primaryModel: 'gpt-4o-mini',
126
- pushSubscription: subscription.toJSON(),
127
- });
392
+ // init() 在明文模式下是 no-op,调不调都行
128
393
  ```
129
394
 
130
- > ⚠️ **`instantClientToken` 是弱共享密钥**:它会随前端 bundle 发出去,devtools 一开就能看到。它只防 URL 直接被脚本小子打,不防有心人。要真正的鉴权,用 amsg-instant 的 `tokenSigningKey`(HMAC JWT,配合后端签发短期 token)。
395
+ > **`instantClientToken` 是弱共享密钥**:它会随前端 bundle 发出去,devtools 一开就能看到。只防 URL 直怼,不防有心人。要真正鉴权,用 amsg-instant 的 `tokenSigningKey`(HMAC JWT,配后端签发短期 token)。
396
+
397
+ > ⚠ **双模式陷阱**:`instantEncryption: false` 时 `init()` 变 no-op,`scheduleMessage` / `listMessages` / `updateMessage` 这类**仍走加密**的方法会因 `userKey` 没初始化抛 "Not initialised"。同一前端两类方法都要用,请改回 `instantEncryption: true`(默认)。
131
398
 
132
- > ⚠️ **双模式陷阱**:`instantEncryption: false` 时 `init()` 变成 no-op,`scheduleMessage` / `listMessages` / `updateMessage` 这类**仍走加密**的方法会因 `userKey` 没初始化抛 "Not initialised"。如果同一前端既要 `sendInstant`(明文走 amsg-instant)又要 `scheduleMessage`(加密走 amsg-server),请改回 `instantEncryption: true`(默认)—— amsg-instant 0.1.x 与 amsg-server 用同一份 `userKey` 都吃得下。
399
+ ---
133
400
 
134
- 旧路径 `scheduleMessage({ ...payload, messageType: 'instant' })` 仍然可用(兼容保留,多一次 DB 来回)。
401
+ ## `messages` 多轮 / `splitPattern` 自定义分句
135
402
 
136
- ### `messages` 模式(多轮上下文 / system role,对接 amsg-instant 0.5.0+ / amsg-server 2.2.0+)
403
+ `deliver()` / `sendInstant` / `consumeInstantStream` 都是 **payload-agnostic 透传**——这些字段写进 payload 就行,client 不校验,所有错误从 Worker / Server 端返回。
137
404
 
138
- 需要 system role、保留多轮历史、tool role 这些场景时,把 `completePrompt` 换成标准 OpenAI 格式的 `messages` 数组。client 本身**完全透传**,所以 SDK 端零额外配置:
405
+ `messages`(OpenAI 格式数组):
139
406
 
140
407
  ```js
141
- await client.sendInstant({
408
+ await client.deliver({
142
409
  contactName: 'Rei',
143
410
  messages: [
144
411
  { role: 'system', content: '你是 Rei,回复要简短自然。' },
@@ -146,136 +413,94 @@ await client.sendInstant({
146
413
  { role: 'assistant', content: '看了下,下午有阵雨。' },
147
414
  { role: 'user', content: '那提醒我一下带伞' },
148
415
  ],
149
- apiUrl: 'https://api.openai.com/v1/chat/completions',
416
+ apiUrl: '...',
150
417
  apiKey: '...',
151
418
  primaryModel: 'gpt-4o-mini',
152
- temperature: 0.7, // 可选
153
419
  pushSubscription: subscription.toJSON(),
154
- });
420
+ }, { delivery: ..., timeoutMs: 300_000 });
155
421
  ```
156
422
 
157
- 注意 `completePrompt` 和 `messages` **必须恰好二选一**——两者同时给会被 Worker / Server 端返回 `400 INVALID_PAYLOAD_FORMAT` / `INVALID_PARAMETERS`。`scheduleMessage` 也接受同样的 `messages` 字段(amsg-server 2.2.0+ 起持久化层一并支持),用法相同。
158
-
159
- ### `splitPattern` 自定义分句正则(对接 amsg-instant 0.6.0+ / amsg-server 2.3.0+)
423
+ `completePrompt` 和 `messages` **必须恰好二选一**,同时给会被远端返回 `400 INVALID_PAYLOAD_FORMAT`。
160
424
 
161
- LLM 返回的整段文本默认按 `/([。!?!?]+)/` 切成多条推送。要换成别的正则(按换行、按段落、自定义符号……)就在 payload 里加 `splitPattern`:
425
+ `splitPattern`(自定义分句正则,`string | string[]`):
162
426
 
163
427
  ```js
164
- // 单正则:按换行切
165
- await client.sendInstant({
166
- contactName: 'Rei',
167
- completePrompt: '...',
168
- splitPattern: '([\\n]+)',
169
- // 其余字段同上
170
- });
171
-
172
- // 数组级联:先按段落,每段再按句号
173
- await client.sendInstant({
174
- contactName: 'Rei',
175
- completePrompt: '...',
176
- splitPattern: ['(\\n\\n+)', '([。!?!?]+)'],
177
- });
428
+ splitPattern: '([\\n]+)', // 按换行
429
+ splitPattern: ['(\\n\\n+)', '([。!?!?]+)'], // 数组级联:先段落、再句号
178
430
  ```
179
431
 
180
- `splitPattern` 类型是 `string | string[]`。`scheduleMessage` 也支持,`updateMessage` 可显式传 `splitPattern: null` 重置回默认。client SDK 完全透传不校验,所有错误在 Worker / Server 端返回(每项 ≤ 200 字符、数组 ≤ 10 项、必须能 `new RegExp()` 通过)。
432
+ **两个常见坑**:
181
433
 
182
- **两个常见 footgun**:
183
-
184
- - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。
434
+ - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面斜杠 + 字面 `i`,不是大小写不敏感的 `foo`。要大小写不敏感请用 `[Aa]` 字符类。
185
435
  - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。
186
436
 
187
- ### SSE 流消费 `consumeInstantStream`(2.4.0+,配合 amsg-instant 0.9.0+)
437
+ ---
188
438
 
189
- `sendInstant()` 只在显式 `Accept: application/json` opt-out 模式下使用。amsg-instant 0.9.0 起默认走 SSE 流式传输——每条 push 通过 `event: payload` 直接打到主线程,省掉 push service → SW → IDB → window 的绕路,前台延迟从约 1–3s 降到次百毫秒。前台场景应该改用 `consumeInstantStream()`。
439
+ ## 本地软清空与可选 `maxPayloadBytes`
190
440
 
191
- ```js
192
- const abort = new AbortController();
441
+ `scheduleMessage` / `sendInstant` / `consumeInstantStream` / `deliver` / `updateMessage` 在发请求**之前**会保留 `avatarUrl` 软清空保护。请求体大小默认不限制;要本地护栏可在构造器显式传 `maxPayloadBytes`:
193
442
 
194
- try {
195
- await client.consumeInstantStream(payload, '/instant', {
196
- onPayload: async (push) => {
197
- // 跟 SW 收到的 wire format 字节级一致:含 messageKind / sessionId / messageId
198
- // 等。按 messageKind 分轨写 IDB / 渲染 / 更新 UI 状态机即可。
199
- await routePushToIDB(push);
200
- },
201
- onError: (err) => log.warn('stream error', err), // 通知性,不抑制 throw
202
- onDone: () => stopSpinner(),
203
- signal: abort.signal,
204
- });
205
- } catch (err) {
206
- // 网络 / 协议 / abort / onPayload 抛错都会到这里
207
- showError(err);
208
- }
443
+ ```js
444
+ const client = new ReiClient({
445
+ baseUrl: '/api/v1',
446
+ userId,
447
+ maxPayloadBytes: 256_000, // 默认 null / 不限制
448
+ });
209
449
  ```
210
450
 
211
- 请求体跟 `sendInstant()` 完全一样——包括必须的 `pushSubscription`:SSE 写失败或客户端断开时 amsg-instant 用它做 best-effort fallback push(同一 `messageId`,客户端按 ID 幂等去重即可)。
212
-
213
- #### 错误语义
214
-
215
- 任何失败——`fetch` 网络异常、非 2xx 响应、非 `text/event-stream` `Content-Type`、SSE `event: error` 帧、`onPayload` 回调抛错、`signal` abort——都会让返回的 Promise reject。`onError` 是**通知性 side-channel**(fire 后照常 throw),不要把它当 try/catch 替代。
216
-
217
- #### 端点 / transport 配置
451
+ | 触发条件 | 处理方式 |
452
+ | --- | --- |
453
+ | `payload.avatarUrl` 是 `data:` URI / 长度 > 2048 字符 / 非字符串 | `console.warn` + 在 payload 上把 `avatarUrl` 置为 `null`(`updateMessage` 从 patch 里删除字段,保留服务端原头像),请求照发 |
454
+ | `maxPayloadBytes` 配了,且 `JSON.stringify(payload)` UTF-8 字节数超过该值 | 抛 `Error` with `.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,`.details = { method, actualBytes, limitBytes }` |
218
455
 
219
- `endpointPath` 默认 `'/instant'`,按需传 `'/continue'` 续跑 tool result。加密 / 明文两种 transport 与 `sendInstant()` 共享构造器配置(`instantEncryption` / `instantClientToken`),调用方无感。
220
-
221
- ### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0+)
222
-
223
- `scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护:
224
-
225
- | 触发条件 | 处理方式 | 触发原因(背景说明,不在 message 里) |
226
- | --- | --- | --- |
227
- | `payload.avatarUrl` 以 `data:` 开头(含 `data:image/...;base64,...`) | `console.warn` + 在 payload 上把 `avatarUrl` 置为 `null`,请求照发(`updateMessage` 从 patch 里删除该字段,保留服务端原头像) | base64 内嵌头像把单个 push payload 撑到几十 KB,远端 Web Push 服务直接返回 4KB 超限 / 网关 `413`。 |
228
- | `payload.avatarUrl` 长度 > 2048 字符 | 同上 | 同上。建议用 CDN 缩略图 URL。 |
229
- | `payload.avatarUrl` 不是字符串 | 同上 | 类型错误。 |
230
- | `JSON.stringify(payload)` UTF-8 字节数 > 3072 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 远端网关 / Web Push 4KB 硬上限的本地兜底。 |
231
-
232
- 头像是装饰字段,单个不合规 URL 不再让整次调度 / 推送挂掉;想拦到错误请监听 `console.warn`,或在调用前自己用 `validateAvatarUrl` 预检(server / instant 包都有导出)。`PAYLOAD_TOO_LARGE_LOCAL` 仍然是真正的"整包过大"信号,照常用 try/catch 捕获:
456
+ 头像是装饰字段,单个不合规 URL 不再让整次调用挂掉。要拦错请监听 `console.warn`。
233
457
 
234
458
  ```js
235
459
  try {
236
- await client.sendInstant(payload);
460
+ await client.deliver(payload, { delivery, timeoutMs: 300_000 });
237
461
  } catch (err) {
238
462
  if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') {
239
- // err.details = { method: 'sendInstant', actualBytes: 8732, limitBytes: 3072 }
240
- } else {
241
- throw err;
242
- }
463
+ // err.details = { method: 'deliver', actualBytes: 87320, limitBytes: 256000 }
464
+ } else { throw err; }
243
465
  }
244
466
  ```
245
467
 
246
- 服务端(`@rei-standard/amsg-instant` 0.7.1+ / 0.8.0+,`@rei-standard/amsg-server` 2.3.3+ / 2.4.0+)有同样的软清空二道防线,client 这一道主要省一次远端往返。
468
+ ---
247
469
 
248
- ## 导出 API(Exports)
470
+ ## 其他工具
249
471
 
250
- - `ReiClient`
472
+ `ReiClient` 还有这些方法(与 2.4.x 相比无字节变化):
251
473
 
252
- `ReiClient` 主要方法:
474
+ - `scheduleMessage(payload)` —— 排定 fixed / prompted / auto / instant 任务,加密走 amsg-server
475
+ - `updateMessage(uuid, updates)` —— 改任务字段
476
+ - `cancelMessage(uuid)` —— 取消任务
477
+ - `listMessages(opts)` —— 拉当前 user 的任务列表
478
+ - `subscribePush(vapidPublicKey, registration)` —— 标准 Push API 订阅封装
253
479
 
254
- - `init()`
255
- - `scheduleMessage(payload)`
256
- - `sendInstant(payload)`
257
- - `updateMessage(uuid, updates)`
258
- - `cancelMessage(uuid)`
259
- - `listMessages(opts)`
260
- - `subscribePush(vapidPublicKey, registration)`
480
+ 以及从 `@rei-standard/amsg-shared` re-export 的运行时常量 / builder / type guard:
261
481
 
262
- ## 模块格式与类型(ESM/CJS/Types)
482
+ - `MESSAGE_KIND` / `MESSAGE_TYPE` / `PUSH_SOURCE`
483
+ - `buildContentPush` / `buildReasoningPush` / `buildToolRequestPush` / `buildErrorPush`
484
+ - `isContentPush` / `isReasoningPush` / `isToolRequestPush` / `isErrorPush`
485
+
486
+ 这些在 SW / app 端处理 push 时用得上,单独装 `@rei-standard/amsg-shared` 没必要。
487
+
488
+ ---
489
+
490
+ ## 模块格式与环境
263
491
 
264
492
  - ESM:`import { ReiClient } from '@rei-standard/amsg-client'`
265
493
  - CJS:`const { ReiClient } = require('@rei-standard/amsg-client')`
266
494
  - 类型:包内提供 `types` 入口(`dist/index.d.ts`)
267
-
268
- ## 运行环境与要求
269
-
270
- - 浏览器环境(需 `fetch`、`crypto.subtle`)
495
+ - 浏览器环境(需 `fetch`、`crypto.subtle`、`ReadableStream`、`AbortController`)
271
496
  - Push 订阅需可用 Service Worker 与 Push API
272
- - 需要可用的 `baseUrl`(示例:`/api/v1`;明文 instant 模式下可直接是 Worker URL)
273
497
  - `userId` 必须是 UUID v4(明文 instant 模式 `instantEncryption: false` 下可省)
274
498
 
275
- ## 相关链接(绝对 URL)
499
+ ## 相关链接
276
500
 
277
501
  - [SDK Workspace 总览](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/README.md)
278
502
  - [Server 包 README](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/README.md)
503
+ - [Instant 包 README](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)
279
504
  - [SW 包 README](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/sw/README.md)
280
505
  - [Service Worker 规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/service-worker-specification.md)
281
506
  - [API 技术规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md)