@rei-standard/amsg-client 2.4.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 +373 -161
- package/dist/index.cjs +545 -144
- package/dist/index.d.cts +796 -156
- package/dist/index.d.ts +796 -156
- package/dist/index.mjs +545 -144
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,47 +1,33 @@
|
|
|
1
1
|
# @rei-standard/amsg-client
|
|
2
2
|
|
|
3
|
-
`@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK
|
|
3
|
+
`@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应、Push 订阅,以及 **送达协调**。
|
|
4
4
|
|
|
5
|
-
## v2.
|
|
5
|
+
## v2.5.0 — `deliver()`:平台无关的送达 primitive
|
|
6
6
|
|
|
7
|
-
新增 `
|
|
7
|
+
新增 `client.deliver(payload, opts)`,把"发出去"和"业务上是否真送达"分离开。它是新代码的**首选入口**。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
旧的 `sendInstant()` / `consumeInstantStream()` 仍然保留可用,但被降级为**低级 transport**——只在你已经自己接好了送达校验时才用,否则会出现「HTTP 200 / SSE 不报错 ≠ 消息真送到」的陷阱(详见下方[为什么需要 `deliver()`](#为什么需要-deliver))。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
> 与 `@rei-standard/amsg-instant` 0.9.0+ 配合最直接,但 `deliver()` 本身**不绑死任何后端 / 平台**——它接收一个普通的 `Promise<ObservedDeliveryReceipt>` 作为"观察通道"信号源,**谁都能接**(Service Worker 广播、Electron IPC、原生桥、轮询、自定义 long-poll……)。
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
// app.js — 用 ReiClient 发即时消息
|
|
15
|
-
import { ReiClient } from '@rei-standard/amsg-client';
|
|
13
|
+
---
|
|
16
14
|
|
|
17
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
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`
|
|
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',
|
|
387
|
+
baseUrl: 'https://instant.example.com',
|
|
115
388
|
instantEncryption: false,
|
|
116
|
-
instantClientToken: 'shared-secret-xyz',
|
|
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
|
-
>
|
|
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
|
-
|
|
399
|
+
---
|
|
133
400
|
|
|
134
|
-
|
|
401
|
+
## `messages` 多轮 / `splitPattern` 自定义分句
|
|
135
402
|
|
|
136
|
-
|
|
403
|
+
`deliver()` / `sendInstant` / `consumeInstantStream` 都是 **payload-agnostic 透传**——这些字段写进 payload 就行,client 不校验,所有错误从 Worker / Server 端返回。
|
|
137
404
|
|
|
138
|
-
|
|
405
|
+
`messages`(OpenAI 格式数组):
|
|
139
406
|
|
|
140
407
|
```js
|
|
141
|
-
await client.
|
|
408
|
+
await client.deliver({
|
|
142
409
|
contactName: 'Rei',
|
|
143
410
|
messages: [
|
|
144
411
|
{ role: 'system', content: '你是 Rei,回复要简短自然。' },
|
|
@@ -146,149 +413,94 @@ await client.sendInstant({
|
|
|
146
413
|
{ role: 'assistant', content: '看了下,下午有阵雨。' },
|
|
147
414
|
{ role: 'user', content: '那提醒我一下带伞' },
|
|
148
415
|
],
|
|
149
|
-
apiUrl: '
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
### `splitPattern` 自定义分句正则(对接 amsg-instant 0.6.0+ / amsg-server 2.3.0+)
|
|
423
|
+
`completePrompt` 和 `messages` **必须恰好二选一**,同时给会被远端返回 `400 INVALID_PAYLOAD_FORMAT`。
|
|
160
424
|
|
|
161
|
-
|
|
425
|
+
`splitPattern`(自定义分句正则,`string | string[]`):
|
|
162
426
|
|
|
163
427
|
```js
|
|
164
|
-
//
|
|
165
|
-
|
|
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()` 通过)。
|
|
181
|
-
|
|
182
432
|
**两个常见坑**:
|
|
183
433
|
|
|
184
|
-
- 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'`
|
|
434
|
+
- 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面斜杠 + 字面 `i`,不是大小写不敏感的 `foo`。要大小写不敏感请用 `[Aa]` 字符类。
|
|
185
435
|
- 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。
|
|
186
436
|
|
|
187
|
-
|
|
437
|
+
---
|
|
188
438
|
|
|
189
|
-
|
|
439
|
+
## 本地软清空与可选 `maxPayloadBytes`
|
|
190
440
|
|
|
191
|
-
|
|
192
|
-
const abort = new AbortController();
|
|
193
|
-
|
|
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
|
-
}
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
请求体跟 `sendInstant()` 完全一样——包括必须的 `pushSubscription`。两条投递路径同时跑:
|
|
212
|
-
|
|
213
|
-
1. **SSE 直送**(首选)——payload 走 `event: payload` 直接到 `onPayload`。
|
|
214
|
-
2. **Web Push always-on backup**——成功 enqueue 的 payload 也会通过 `pushSubscription` 发一份;SSE 写失败 / 客户端断开 / enqueue throw 时也走这条路兜底。
|
|
215
|
-
|
|
216
|
-
同一 `messageId` 两路都到,由 SW 的 dedupe gate 或客户端按 ID 幂等去重收敛成一次业务投递与一次(必要时的)通知。
|
|
217
|
-
|
|
218
|
-
#### 错误语义
|
|
219
|
-
|
|
220
|
-
任何失败——`fetch` 网络异常、非 2xx 响应、非 `text/event-stream` `Content-Type`、SSE `event: error` 帧、`onPayload` 回调抛错、`signal` abort——都会让返回的 Promise reject。`onError` 是**通知性 side-channel**(fire 后照常 throw),不要把它当 try/catch 替代。
|
|
221
|
-
|
|
222
|
-
#### 端点 / transport 配置
|
|
223
|
-
|
|
224
|
-
`endpointPath` 默认 `'/instant'`,按需传 `'/continue'` 续跑 tool result。加密 / 明文两种 transport 与 `sendInstant()` 共享构造器配置(`instantEncryption` / `instantClientToken`),调用方无感。
|
|
225
|
-
|
|
226
|
-
### 本地软清空:`avatarUrl` 与可选 payload 体积上限(2.2.4+ / 2.4.0+)
|
|
227
|
-
|
|
228
|
-
`scheduleMessage` / `sendInstant` / `consumeInstantStream` / `updateMessage` 在发请求**之前**会保留 `avatarUrl` 软清空保护。请求体大小默认不限制;如果你希望在 SDK 本地先挡住过大的请求,可以在构造器显式传 `maxPayloadBytes`:
|
|
441
|
+
`scheduleMessage` / `sendInstant` / `consumeInstantStream` / `deliver` / `updateMessage` 在发请求**之前**会保留 `avatarUrl` 软清空保护。请求体大小默认不限制;要本地护栏可在构造器显式传 `maxPayloadBytes`:
|
|
229
442
|
|
|
230
443
|
```js
|
|
231
444
|
const client = new ReiClient({
|
|
232
445
|
baseUrl: '/api/v1',
|
|
233
446
|
userId,
|
|
234
|
-
maxPayloadBytes: 256_000, //
|
|
447
|
+
maxPayloadBytes: 256_000, // 默认 null / 不限制
|
|
235
448
|
});
|
|
236
449
|
```
|
|
237
450
|
|
|
238
|
-
| 触发条件 | 处理方式 |
|
|
239
|
-
| --- | --- |
|
|
240
|
-
| `payload.avatarUrl`
|
|
241
|
-
| `
|
|
242
|
-
| `payload.avatarUrl` 不是字符串 | 同上 | 类型错误。 |
|
|
243
|
-
| 已配置 `maxPayloadBytes`,且 `JSON.stringify(payload)` UTF-8 字节数超过该值 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 只在调用方主动需要本地请求体护栏时启用。Web Push 单条回复超限由 `amsg-instant` 的 BlobStore / multipart 输出链路处理,不靠 client 限制请求体。 |
|
|
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 }` |
|
|
244
455
|
|
|
245
|
-
头像是装饰字段,单个不合规 URL
|
|
456
|
+
头像是装饰字段,单个不合规 URL 不再让整次调用挂掉。要拦错请监听 `console.warn`。
|
|
246
457
|
|
|
247
458
|
```js
|
|
248
459
|
try {
|
|
249
|
-
await client.
|
|
460
|
+
await client.deliver(payload, { delivery, timeoutMs: 300_000 });
|
|
250
461
|
} catch (err) {
|
|
251
462
|
if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') {
|
|
252
|
-
// err.details = { method: '
|
|
253
|
-
} else {
|
|
254
|
-
throw err;
|
|
255
|
-
}
|
|
463
|
+
// err.details = { method: 'deliver', actualBytes: 87320, limitBytes: 256000 }
|
|
464
|
+
} else { throw err; }
|
|
256
465
|
}
|
|
257
466
|
```
|
|
258
467
|
|
|
259
|
-
|
|
468
|
+
---
|
|
260
469
|
|
|
261
|
-
##
|
|
470
|
+
## 其他工具
|
|
262
471
|
|
|
263
|
-
|
|
472
|
+
`ReiClient` 还有这些方法(与 2.4.x 相比无字节变化):
|
|
264
473
|
|
|
265
|
-
`
|
|
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 订阅封装
|
|
266
479
|
|
|
267
|
-
-
|
|
268
|
-
- `scheduleMessage(payload)`
|
|
269
|
-
- `sendInstant(payload)`
|
|
270
|
-
- `updateMessage(uuid, updates)`
|
|
271
|
-
- `cancelMessage(uuid)`
|
|
272
|
-
- `listMessages(opts)`
|
|
273
|
-
- `subscribePush(vapidPublicKey, registration)`
|
|
480
|
+
以及从 `@rei-standard/amsg-shared` re-export 的运行时常量 / builder / type guard:
|
|
274
481
|
|
|
275
|
-
|
|
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
|
+
## 模块格式与环境
|
|
276
491
|
|
|
277
492
|
- ESM:`import { ReiClient } from '@rei-standard/amsg-client'`
|
|
278
493
|
- CJS:`const { ReiClient } = require('@rei-standard/amsg-client')`
|
|
279
494
|
- 类型:包内提供 `types` 入口(`dist/index.d.ts`)
|
|
280
|
-
|
|
281
|
-
## 运行环境与要求
|
|
282
|
-
|
|
283
|
-
- 浏览器环境(需 `fetch`、`crypto.subtle`)
|
|
495
|
+
- 浏览器环境(需 `fetch`、`crypto.subtle`、`ReadableStream`、`AbortController`)
|
|
284
496
|
- Push 订阅需可用 Service Worker 与 Push API
|
|
285
|
-
- 需要可用的 `baseUrl`(示例:`/api/v1`;明文 instant 模式下可直接是 Worker URL)
|
|
286
497
|
- `userId` 必须是 UUID v4(明文 instant 模式 `instantEncryption: false` 下可省)
|
|
287
498
|
|
|
288
|
-
##
|
|
499
|
+
## 相关链接
|
|
289
500
|
|
|
290
501
|
- [SDK Workspace 总览](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/README.md)
|
|
291
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)
|
|
292
504
|
- [SW 包 README](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/sw/README.md)
|
|
293
505
|
- [Service Worker 规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/service-worker-specification.md)
|
|
294
506
|
- [API 技术规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md)
|