@rei-standard/amsg-shared 0.4.0-next.5 → 0.4.0-next.7
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 +68 -3
- package/dist/index.cjs +314 -6
- package/dist/index.d.cts +196 -8
- package/dist/index.d.ts +196 -8
- package/dist/index.mjs +314 -6
- package/dist/llm-call.d.cts +19 -23
- package/dist/llm-call.d.ts +19 -23
- package/dist/multipart.d.cts +26 -0
- package/dist/multipart.d.ts +26 -0
- package/dist/protocol.d.cts +36 -0
- package/dist/protocol.d.ts +36 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ A single push is described by three independent dimensions:
|
|
|
17
17
|
|----------------|-------------------|-------------------------------------------------------|--------------------|
|
|
18
18
|
| Dispatch | `messageType` | `instant` / `fixed` / `prompted` / `auto` | Package (fixed) |
|
|
19
19
|
| Business | `messageSubtype` | Any string | Caller (free-form) |
|
|
20
|
-
| Content | `messageKind` | `content` / `reasoning` / `tool_request` / `error`
|
|
20
|
+
| Content | `messageKind` | `content` / `reasoning` / `tool_request` / `error` / `result` | Package (fixed) |
|
|
21
21
|
|
|
22
22
|
`messageType` answers **how this push was produced** (one-shot
|
|
23
23
|
`instant` worker, scheduled `fixed` ping, AI-`prompted` reply, fully
|
|
@@ -70,7 +70,7 @@ omit all four.
|
|
|
70
70
|
|
|
71
71
|
| Field | Type | Notes |
|
|
72
72
|
|----------------------|-------------------------------------------|-------|
|
|
73
|
-
| `show` | `'auto' \| 'always' \| 'when-hidden' \| false` | Display policy
|
|
73
|
+
| `show` | `'auto' \| 'always' \| 'when-hidden' \| false` | Display policy — see [选哪个 `show`](#选哪个-show). |
|
|
74
74
|
| `title` | `string?` | Notification title override. |
|
|
75
75
|
| `body` | `string?` | Notification body override. |
|
|
76
76
|
| `icon` | `string?` | Notification icon URL. |
|
|
@@ -84,6 +84,38 @@ omit all four.
|
|
|
84
84
|
Unknown fields are preserved for forward compatibility, but the known
|
|
85
85
|
fields above are validated by the builders when present.
|
|
86
86
|
|
|
87
|
+
### 选哪个 `show`
|
|
88
|
+
|
|
89
|
+
订阅是按 `userVisibleOnly: true` 建的,收到 push 却不弹通知就是违约:Chrome 替你弹一条通用横幅,Firefox 有配额、超了退订,iOS 给新订阅几天宽限期(跟条数无关)、过期后一条不弹就吊销订阅。所以口径只有一条,跟机型无关——**要推就一定弹,不想弹就别推。**
|
|
90
|
+
|
|
91
|
+
| 值 | SW 那边 | 什么时候用 |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| 不配 / `'auto'` | 按 `messageKind` 走默认(`content` / `result` 弹,其余不弹) | 默认,多数 payload 不用管 |
|
|
94
|
+
| `'always'` | 一定弹 | 要推的一律用它。嫌打扰配 `tag` 折叠加 `silent`,而不是不弹 |
|
|
95
|
+
| `false` | 一定不弹 | 明说这条不弹。有收件箱的发送端据此**根本不发这条 push**,内容落收件箱等客户端补拉 |
|
|
96
|
+
| `'when-hidden'` | 有可见窗口就不弹 | 兼容档,新代码不选——应用在前台时它就是一条不弹的 push,那笔账照记 |
|
|
97
|
+
|
|
98
|
+
所以 `show: false` 在发送端和接收端不是同一件事:有服务端收件箱的发送端(`@rei-standard/amsg-server` 单用户线)见到它压根不发,SW 那边收不到;没有收件箱的发送端才是「推过去、SW 不弹」。
|
|
99
|
+
|
|
100
|
+
想要「立刻推到、但页面自己渲染」没有专门的档:用 `'always'` + `tag` + `silent`,页面自绘不受影响(`postMessage` 跟弹不弹通知无关)。
|
|
101
|
+
|
|
102
|
+
完整取舍见 [`@rei-standard/amsg-sw` README 的「不展示通知的代价」](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/sw/README.md#不展示通知的代价)。
|
|
103
|
+
|
|
104
|
+
### `notificationIntent(payload)`
|
|
105
|
+
|
|
106
|
+
把上面这套规则算成一个值,`'always'` / `'when-hidden'` / `'never'`:`notification.show` 说了算,没说才按 `messageKind` 走默认。两端读同一份——SW 拿它决定要不要 `showNotification`,发送端拿它决定这条值不值得占用推送通道(`'never'` 的 payload 推过去不会有任何可见反馈,`@rei-standard/amsg-server` 只把它落进收件箱,等客户端上线 `GET /outbox?since=` 补拉)。
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
import { notificationIntent } from '@rei-standard/amsg-shared';
|
|
110
|
+
|
|
111
|
+
notificationIntent({ messageKind: 'content' }); // 'always'
|
|
112
|
+
notificationIntent({ messageKind: 'reasoning' }); // 'never'
|
|
113
|
+
notificationIntent({ messageKind: 'reasoning', notification: { show: 'always' } }); // 'always'
|
|
114
|
+
notificationIntent({ messageKind: 'content', notification: { show: false } }); // 'never'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`'when-hidden'` 单独占一档,因为它到底弹不弹要看当下有没有可见窗口,那只有 SW 知道;发送端把它当「可能会弹」照发。
|
|
118
|
+
|
|
87
119
|
---
|
|
88
120
|
|
|
89
121
|
## Per-kind fields
|
|
@@ -154,6 +186,22 @@ Replaces the legacy 0.7.0 `{ type: 'error', code: '...' }` envelope.
|
|
|
154
186
|
The legacy `type` field is **gone** — do not look for it on
|
|
155
187
|
`ErrorPush`.
|
|
156
188
|
|
|
189
|
+
### `ResultPush` — 宿主自定义的一条结果
|
|
190
|
+
|
|
191
|
+
| Field | Type | Notes |
|
|
192
|
+
|---------------|------------|----------------------------------------------------------------|
|
|
193
|
+
| `messageKind` | `'result'` | Discriminator. |
|
|
194
|
+
| `resultKind` | `string` | 宿主给这类结果起的名字(`'fire-pack'`、`'ledger-entry'`……),客户端按它分流。 |
|
|
195
|
+
| `title` | `string?` | 通知标题的兜底(`notification.title` 优先)。 |
|
|
196
|
+
| `body` | `string?` | 通知正文的兜底(`notification.body` 优先)。 |
|
|
197
|
+
|
|
198
|
+
不是聊天内容,而是「这次跑完产出了点什么,客户端拿去自己消化」:整理好的一份数据、一条账目、后台生成的产物。形状由宿主定,`buildResultPush` 是唯一**保留自己不认识的字段**的 builder——白名单式的复制会把内容删掉一半。
|
|
199
|
+
|
|
200
|
+
两处与别的 kind 不同:
|
|
201
|
+
|
|
202
|
+
- **投递路径**:产出方(`@rei-standard/amsg-server` 的 `ctx.emitResult()`)除了推送,还把它落进服务端收件箱,客户端下次 `GET /outbox?since=` 一定拿得到。
|
|
203
|
+
- **通知**:SW 侧默认弹(与 `content` 同待遇,其余三种是静默送给页面)——结果往往正是「跑完了,回来看看」那句话。不想弹就带 `notification: { show: false }`:这一档 `amsg-server` 不发推送、只落收件箱,客户端补拉时拿到(见[选哪个 `show`](#选哪个-show))。
|
|
204
|
+
|
|
157
205
|
---
|
|
158
206
|
|
|
159
207
|
## Usage
|
|
@@ -184,6 +232,9 @@ function dispatch(push: AmsgPush) {
|
|
|
184
232
|
case 'error':
|
|
185
233
|
console.error(push.code, push.message);
|
|
186
234
|
break;
|
|
235
|
+
case 'result':
|
|
236
|
+
// push.resultKind is `string` — 宿主自己的结果,按它分流
|
|
237
|
+
break;
|
|
187
238
|
}
|
|
188
239
|
}
|
|
189
240
|
```
|
|
@@ -196,6 +247,7 @@ import {
|
|
|
196
247
|
buildReasoningPush,
|
|
197
248
|
buildToolRequestPush,
|
|
198
249
|
buildErrorPush,
|
|
250
|
+
buildResultPush,
|
|
199
251
|
} from '@rei-standard/amsg-shared';
|
|
200
252
|
|
|
201
253
|
// One sentence in an N-split burst
|
|
@@ -238,12 +290,24 @@ const error = buildErrorPush({
|
|
|
238
290
|
message: 'onLLMOutput threw: ...',
|
|
239
291
|
iteration: 2,
|
|
240
292
|
});
|
|
293
|
+
|
|
294
|
+
// 宿主自定义的一条结果(认识以外的字段原样保留)
|
|
295
|
+
const result = buildResultPush({
|
|
296
|
+
messageType: 'auto',
|
|
297
|
+
source: 'scheduled',
|
|
298
|
+
messageId: 'msg_task_7@1700000000000_result_0',
|
|
299
|
+
sessionId: 'sess_abc',
|
|
300
|
+
resultKind: 'fire-pack',
|
|
301
|
+
packId: 'pack_42',
|
|
302
|
+
entries: [{ id: 1 }, { id: 2 }],
|
|
303
|
+
notification: { title: '整理好了', body: '点开看看' },
|
|
304
|
+
});
|
|
241
305
|
```
|
|
242
306
|
|
|
243
307
|
### Type guards
|
|
244
308
|
|
|
245
309
|
```js
|
|
246
|
-
import { isContentPush, isReasoningPush, isToolRequestPush, isErrorPush } from '@rei-standard/amsg-shared';
|
|
310
|
+
import { isContentPush, isReasoningPush, isToolRequestPush, isErrorPush, isResultPush } from '@rei-standard/amsg-shared';
|
|
247
311
|
|
|
248
312
|
if (isContentPush(push)) {
|
|
249
313
|
// push.message is `string`
|
|
@@ -261,6 +325,7 @@ MESSAGE_KIND.CONTENT; // 'content'
|
|
|
261
325
|
MESSAGE_KIND.REASONING; // 'reasoning'
|
|
262
326
|
MESSAGE_KIND.TOOL_REQUEST; // 'tool_request'
|
|
263
327
|
MESSAGE_KIND.ERROR; // 'error'
|
|
328
|
+
MESSAGE_KIND.RESULT; // 'result'
|
|
264
329
|
|
|
265
330
|
MESSAGE_TYPE.INSTANT; // 'instant'
|
|
266
331
|
MESSAGE_TYPE.FIXED; // 'fixed'
|
package/dist/index.cjs
CHANGED
|
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
var src_exports = {};
|
|
21
21
|
__export(src_exports, {
|
|
22
22
|
AVATAR_URL_MAX_LENGTH: () => AVATAR_URL_MAX_LENGTH,
|
|
23
|
+
DEFAULT_MULTIPART_CHUNK_BYTES: () => DEFAULT_MULTIPART_CHUNK_BYTES,
|
|
23
24
|
DEFAULT_MULTIPART_MAX_CHUNKS: () => DEFAULT_MULTIPART_MAX_CHUNKS,
|
|
24
25
|
DEFAULT_MULTIPART_MAX_TOTAL_BYTES: () => DEFAULT_MULTIPART_MAX_TOTAL_BYTES,
|
|
25
26
|
DEFAULT_MULTIPART_TTL_MS: () => DEFAULT_MULTIPART_TTL_MS,
|
|
@@ -27,6 +28,7 @@ __export(src_exports, {
|
|
|
27
28
|
MESSAGE_KIND: () => MESSAGE_KIND,
|
|
28
29
|
MESSAGE_TYPE: () => MESSAGE_TYPE,
|
|
29
30
|
MULTIPART_ENCODING: () => MULTIPART_ENCODING,
|
|
31
|
+
MULTIPART_FAILURE_REASON: () => MULTIPART_FAILURE_REASON,
|
|
30
32
|
MULTIPART_MESSAGE_KIND: () => MULTIPART_MESSAGE_KIND,
|
|
31
33
|
MULTIPART_VERSION: () => MULTIPART_VERSION,
|
|
32
34
|
PUSH_SOURCE: () => PUSH_SOURCE,
|
|
@@ -39,7 +41,9 @@ __export(src_exports, {
|
|
|
39
41
|
buildContentPush: () => buildContentPush,
|
|
40
42
|
buildErrorPush: () => buildErrorPush,
|
|
41
43
|
buildLlmRequestBody: () => buildLlmRequestBody,
|
|
44
|
+
buildMultipartPushPayloads: () => buildMultipartPushPayloads,
|
|
42
45
|
buildReasoningPush: () => buildReasoningPush,
|
|
46
|
+
buildResultPush: () => buildResultPush,
|
|
43
47
|
buildSessionContext: () => buildSessionContext,
|
|
44
48
|
buildToolRequestPush: () => buildToolRequestPush,
|
|
45
49
|
buildVapidJwt: () => buildVapidJwt,
|
|
@@ -56,14 +60,17 @@ __export(src_exports, {
|
|
|
56
60
|
isContentPush: () => isContentPush,
|
|
57
61
|
isErrorPush: () => isErrorPush,
|
|
58
62
|
isReasoningPush: () => isReasoningPush,
|
|
63
|
+
isResultPush: () => isResultPush,
|
|
59
64
|
isToolRequestPush: () => isToolRequestPush,
|
|
60
65
|
isValidUrl: () => isValidUrl,
|
|
61
66
|
jsonToBase64Url: () => jsonToBase64Url,
|
|
62
67
|
normalizeAiApiUrl: () => normalizeAiApiUrl,
|
|
63
68
|
normalizeVapidSubject: () => normalizeVapidSubject,
|
|
69
|
+
notificationIntent: () => notificationIntent,
|
|
64
70
|
randomBytes: () => randomBytes,
|
|
65
71
|
randomUUID: () => randomUUID,
|
|
66
72
|
readReasoningContent: () => readReasoningContent,
|
|
73
|
+
redactCredentials: () => redactCredentials,
|
|
67
74
|
sendWebPush: () => sendWebPush,
|
|
68
75
|
stripReasoningTags: () => stripReasoningTags,
|
|
69
76
|
timingSafeEqualBytes: () => timingSafeEqualBytes,
|
|
@@ -230,6 +237,9 @@ function validateLlmMessagesShape(messages) {
|
|
|
230
237
|
}
|
|
231
238
|
|
|
232
239
|
// src/llm-call.js
|
|
240
|
+
var UPSTREAM_ERROR_DETAIL_MAX_CHARS = 300;
|
|
241
|
+
var UPSTREAM_ERROR_CODE_MAX_CHARS = 64;
|
|
242
|
+
var UPSTREAM_ERROR_BODY_MAX_BYTES = 16 * 1024;
|
|
233
243
|
async function callLlm(payload, options = {}) {
|
|
234
244
|
const requireContent = options.requireContent !== false;
|
|
235
245
|
const timeoutMs = typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 3e5;
|
|
@@ -246,13 +256,18 @@ async function callLlm(payload, options = {}) {
|
|
|
246
256
|
signal: AbortSignal.timeout(timeoutMs)
|
|
247
257
|
});
|
|
248
258
|
if (!aiResponse.ok) {
|
|
259
|
+
const detail = await readUpstreamErrorDetail(aiResponse);
|
|
249
260
|
if (aiResponse.status === 405) {
|
|
250
|
-
throw
|
|
251
|
-
`AI API error: 405 Method Not Allowed. apiUrl must point to a full chat endpoint (for example: /chat/completions). Received: ${normalizedApiUrl}
|
|
261
|
+
throw buildUpstreamError(
|
|
262
|
+
`AI API error: 405 Method Not Allowed. apiUrl must point to a full chat endpoint (for example: /chat/completions). Received: ${normalizedApiUrl}`,
|
|
263
|
+
aiResponse.status,
|
|
264
|
+
detail
|
|
252
265
|
);
|
|
253
266
|
}
|
|
254
|
-
throw
|
|
255
|
-
`AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}
|
|
267
|
+
throw buildUpstreamError(
|
|
268
|
+
`AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}`,
|
|
269
|
+
aiResponse.status,
|
|
270
|
+
detail
|
|
256
271
|
);
|
|
257
272
|
}
|
|
258
273
|
const aiData = await aiResponse.json();
|
|
@@ -319,6 +334,169 @@ function normalizeAiApiUrl(apiUrl) {
|
|
|
319
334
|
parsed.pathname = path;
|
|
320
335
|
return parsed.toString();
|
|
321
336
|
}
|
|
337
|
+
function buildUpstreamError(summary, status, detail) {
|
|
338
|
+
const error = new Error(
|
|
339
|
+
summary + (detail.message ? ` \u2014 ${detail.message}` : "") + (detail.code ? ` (provider code: ${detail.code})` : "")
|
|
340
|
+
);
|
|
341
|
+
error.code = "LLM_CALL_FAILED";
|
|
342
|
+
error.llmStatus = status;
|
|
343
|
+
if (detail.code) error.providerCode = detail.code;
|
|
344
|
+
return error;
|
|
345
|
+
}
|
|
346
|
+
async function readUpstreamErrorDetail(response) {
|
|
347
|
+
let raw;
|
|
348
|
+
let truncated = false;
|
|
349
|
+
try {
|
|
350
|
+
({ text: raw, truncated } = await readBoundedBody(response));
|
|
351
|
+
} catch {
|
|
352
|
+
return { message: "", code: "" };
|
|
353
|
+
}
|
|
354
|
+
if (typeof raw !== "string" || !raw.trim()) return { message: "", code: "" };
|
|
355
|
+
let body;
|
|
356
|
+
try {
|
|
357
|
+
body = JSON.parse(raw);
|
|
358
|
+
} catch {
|
|
359
|
+
if (truncated && looksLikeJson(raw)) {
|
|
360
|
+
const salvaged = salvageJsonStringFields(raw);
|
|
361
|
+
return {
|
|
362
|
+
message: clampDetail(salvaged.message || TRUNCATED_BODY_NOTE),
|
|
363
|
+
code: clampCode(salvaged.code)
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return { message: clampDetail(raw), code: "" };
|
|
367
|
+
}
|
|
368
|
+
const envelope = body && typeof body === "object" ? body : {};
|
|
369
|
+
const inner = envelope.error && typeof envelope.error === "object" ? envelope.error : {};
|
|
370
|
+
const message = firstNonEmptyString(
|
|
371
|
+
inner.message,
|
|
372
|
+
// OpenAI / Anthropic / Gemini
|
|
373
|
+
typeof envelope.error === "string" ? envelope.error : "",
|
|
374
|
+
// `{ error: "unauthorized" }`
|
|
375
|
+
envelope.message,
|
|
376
|
+
// 一批中转把 message 放最外层
|
|
377
|
+
envelope.detail
|
|
378
|
+
// FastAPI 风格的自建中转
|
|
379
|
+
) || raw;
|
|
380
|
+
const code = firstNonEmptyString(
|
|
381
|
+
inner.code,
|
|
382
|
+
// OpenAI:invalid_api_key / insufficient_quota / context_length_exceeded
|
|
383
|
+
inner.status,
|
|
384
|
+
// Gemini:INVALID_ARGUMENT / RESOURCE_EXHAUSTED
|
|
385
|
+
inner.type,
|
|
386
|
+
// Anthropic:invalid_request_error / overloaded_error
|
|
387
|
+
envelope.code
|
|
388
|
+
);
|
|
389
|
+
return { message: clampDetail(message), code: clampCode(code) };
|
|
390
|
+
}
|
|
391
|
+
async function readBoundedBody(response) {
|
|
392
|
+
const body = response && response.body;
|
|
393
|
+
if (!body || typeof body.getReader !== "function") {
|
|
394
|
+
const text = typeof response.text === "function" ? await response.text() : "";
|
|
395
|
+
return { text, truncated: false };
|
|
396
|
+
}
|
|
397
|
+
const reader = body.getReader();
|
|
398
|
+
const chunks = [];
|
|
399
|
+
let bytes = 0;
|
|
400
|
+
try {
|
|
401
|
+
while (bytes < UPSTREAM_ERROR_BODY_MAX_BYTES) {
|
|
402
|
+
const { done, value } = await reader.read();
|
|
403
|
+
if (done) break;
|
|
404
|
+
if (!value || !value.length) continue;
|
|
405
|
+
chunks.push(value);
|
|
406
|
+
bytes += value.length;
|
|
407
|
+
}
|
|
408
|
+
} finally {
|
|
409
|
+
try {
|
|
410
|
+
await reader.cancel();
|
|
411
|
+
} catch {
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
text: utf8Decode(concatBytes(...chunks)),
|
|
416
|
+
truncated: bytes >= UPSTREAM_ERROR_BODY_MAX_BYTES
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
var TRUNCATED_BODY_NOTE = "upstream error body was truncated before any readable message";
|
|
420
|
+
var JSON_STRING_FIELD = /"(message|detail|code|status|type)"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
|
|
421
|
+
function looksLikeJson(raw) {
|
|
422
|
+
return /^\s*[{[]/.test(raw);
|
|
423
|
+
}
|
|
424
|
+
function salvageJsonStringFields(raw) {
|
|
425
|
+
const found = {};
|
|
426
|
+
for (const match of raw.matchAll(JSON_STRING_FIELD)) {
|
|
427
|
+
if (found[match[1]] === void 0) found[match[1]] = unescapeJsonString(match[2]);
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
message: firstNonEmptyString(found.message, found.detail),
|
|
431
|
+
code: firstNonEmptyString(found.code, found.status, found.type)
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function unescapeJsonString(value) {
|
|
435
|
+
try {
|
|
436
|
+
return JSON.parse(`"${value}"`);
|
|
437
|
+
} catch {
|
|
438
|
+
return value;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function clampDetail(text) {
|
|
442
|
+
const flattened = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
443
|
+
const safe = redactCredentials(flattened);
|
|
444
|
+
return safe.length > UPSTREAM_ERROR_DETAIL_MAX_CHARS ? `${safe.slice(0, UPSTREAM_ERROR_DETAIL_MAX_CHARS - 1)}\u2026` : safe;
|
|
445
|
+
}
|
|
446
|
+
function clampCode(code) {
|
|
447
|
+
return String(code ?? "").replace(/\s+/g, " ").trim().slice(0, UPSTREAM_ERROR_CODE_MAX_CHARS);
|
|
448
|
+
}
|
|
449
|
+
var CREDENTIAL_LIKE_TOKEN = /\b[A-Za-z]{2,6}-[A-Za-z0-9_-]{16,}/g;
|
|
450
|
+
var LONG_OPAQUE_RUN = /[A-Za-z0-9+/_.-]{48,}/g;
|
|
451
|
+
var MODEL_ID_LIKE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]{1,12})+$/;
|
|
452
|
+
var MODEL_ID_MAX_CHARS = 64;
|
|
453
|
+
var CREDENTIAL_PREFIX_SEGMENTS = /* @__PURE__ */ new Set([
|
|
454
|
+
"sk",
|
|
455
|
+
"pk",
|
|
456
|
+
"ak",
|
|
457
|
+
"api",
|
|
458
|
+
"apikey",
|
|
459
|
+
"key",
|
|
460
|
+
"token",
|
|
461
|
+
"secret",
|
|
462
|
+
"auth",
|
|
463
|
+
"bearer",
|
|
464
|
+
"session",
|
|
465
|
+
"sess",
|
|
466
|
+
"pat",
|
|
467
|
+
"xai",
|
|
468
|
+
"gsk"
|
|
469
|
+
]);
|
|
470
|
+
var UUID_SHAPE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/;
|
|
471
|
+
function alternationCount(segment) {
|
|
472
|
+
return (segment.match(/[a-z]+|[0-9]+/g) || []).length;
|
|
473
|
+
}
|
|
474
|
+
function looksLikeModelId(token) {
|
|
475
|
+
if (token.length > MODEL_ID_MAX_CHARS) return false;
|
|
476
|
+
if (!MODEL_ID_LIKE.test(token)) return false;
|
|
477
|
+
if (UUID_SHAPE.test(token)) return false;
|
|
478
|
+
const segments = token.split(/[.-]/);
|
|
479
|
+
if (CREDENTIAL_PREFIX_SEGMENTS.has(segments[0])) return false;
|
|
480
|
+
const randomLooking = segments.filter((segment) => alternationCount(segment) >= 3);
|
|
481
|
+
if (randomLooking.length === 0) return true;
|
|
482
|
+
return randomLooking.length === 1 && randomLooking[0].length <= 5;
|
|
483
|
+
}
|
|
484
|
+
function redactCredentials(text) {
|
|
485
|
+
let s = text;
|
|
486
|
+
s = s.replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
|
|
487
|
+
s = s.replace(CREDENTIAL_LIKE_TOKEN, (token) => looksLikeModelId(token) ? token : "[redacted]");
|
|
488
|
+
s = s.replace(LONG_OPAQUE_RUN, (run) => {
|
|
489
|
+
const token = run.replace(/^[._+/-]+/, "").replace(/[._+/-]+$/, "");
|
|
490
|
+
return looksLikeModelId(token) ? run : "[redacted]";
|
|
491
|
+
});
|
|
492
|
+
return s;
|
|
493
|
+
}
|
|
494
|
+
function firstNonEmptyString(...values) {
|
|
495
|
+
for (const value of values) {
|
|
496
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
497
|
+
}
|
|
498
|
+
return "";
|
|
499
|
+
}
|
|
322
500
|
|
|
323
501
|
// src/webpush.js
|
|
324
502
|
var KEY_INFO_PREFIX = utf8("WebPush: info\0");
|
|
@@ -550,23 +728,113 @@ var REI_SW_EVENT = Object.freeze({
|
|
|
550
728
|
REASONING_RECEIVED: "rei-amsg-reasoning-received",
|
|
551
729
|
TOOL_REQUEST_RECEIVED: "rei-amsg-tool-request-received",
|
|
552
730
|
ERROR_RECEIVED: "rei-amsg-error-received",
|
|
731
|
+
/** 宿主自定义的一条结果(`messageKind: 'result'`),不是聊天内容。 */
|
|
732
|
+
RESULT_RECEIVED: "rei-amsg-result-received",
|
|
553
733
|
MULTIPART_EXPIRED: "rei-amsg-multipart-expired",
|
|
554
734
|
UNKNOWN_RECEIVED: "rei-amsg-unknown-received"
|
|
555
735
|
});
|
|
736
|
+
var MULTIPART_FAILURE_REASON = Object.freeze({
|
|
737
|
+
/** TTL 到期仍未收齐,或收到的分片本身已经过期。 */
|
|
738
|
+
TTL_EXPIRED: "ttl-expired",
|
|
739
|
+
/** 分片信封不合规:version / encoding 对不上、index 越界、chunk 不是合法 base64url。 */
|
|
740
|
+
INVALID_CHUNK: "invalid-chunk",
|
|
741
|
+
/** 同一个 id 的分片报了不一样的 total / encoding,已收的部分拼不回去。 */
|
|
742
|
+
CHUNK_CONFLICT: "chunk-conflict",
|
|
743
|
+
/** 累计字节数超过 maxTotalBytes。 */
|
|
744
|
+
SIZE_LIMIT_EXCEEDED: "size-limit-exceeded",
|
|
745
|
+
/** 收齐了但拼不回原 payload(缺片、超限、JSON 解不开)。 */
|
|
746
|
+
RESTORE_FAILED: "restore-failed",
|
|
747
|
+
/** 分片仓库(IndexedDB)读写失败。 */
|
|
748
|
+
STORAGE_FAILED: "storage-failed",
|
|
749
|
+
/** 接收端把 multipart 关了(`multipart.enabled === false`),分片没法重组。 */
|
|
750
|
+
DISABLED: "disabled"
|
|
751
|
+
});
|
|
556
752
|
var REI_SW_MESSAGE_TYPE = Object.freeze({
|
|
557
753
|
ENQUEUE_REQUEST: "REI_ENQUEUE_REQUEST",
|
|
558
754
|
DELIVER: "REI_AMSG_DELIVER",
|
|
559
755
|
FLUSH_QUEUE: "REI_FLUSH_QUEUE",
|
|
560
|
-
|
|
756
|
+
/**
|
|
757
|
+
* 入队的点对点回执:谁发的 ENQUEUE_REQUEST 就回给谁一条,一次一条。
|
|
758
|
+
* 没转 MessagePort 过来时会落到全局的 `navigator.serviceWorker` message
|
|
759
|
+
* 监听器上。
|
|
760
|
+
*/
|
|
761
|
+
QUEUE_RESULT: "REI_QUEUE_RESULT",
|
|
762
|
+
/**
|
|
763
|
+
* 队列请求被永久拒绝、即将从队列里删掉时广播给所有窗口的一条。
|
|
764
|
+
*
|
|
765
|
+
* 跟 QUEUE_RESULT 分开是因为两者的收信人不是一回事:这条是广播,可能来自后台
|
|
766
|
+
* `sync` 冲刷、说的也可能是另一条八竿子打不着的旧请求。共用一个 type 的话,
|
|
767
|
+
* 页面等自己那条入队回执时会先收到这一条、当成自己的结果处理。
|
|
768
|
+
*/
|
|
769
|
+
QUEUE_DROPPED: "REI_QUEUE_DROPPED"
|
|
561
770
|
});
|
|
562
771
|
var REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER;
|
|
563
772
|
|
|
773
|
+
// src/multipart.js
|
|
774
|
+
var DEFAULT_MULTIPART_CHUNK_BYTES = 1800;
|
|
775
|
+
function buildMultipartPushPayloads(payload, options = {}) {
|
|
776
|
+
const maxChunkBytes = resolvePositiveInteger(
|
|
777
|
+
options.maxChunkBytes,
|
|
778
|
+
DEFAULT_MULTIPART_CHUNK_BYTES,
|
|
779
|
+
"maxChunkBytes"
|
|
780
|
+
);
|
|
781
|
+
const ttlMs = resolvePositiveInteger(options.ttlMs, DEFAULT_MULTIPART_TTL_MS, "ttlMs");
|
|
782
|
+
const id = typeof options.id === "string" && options.id.trim() ? options.id.trim() : `mp_${randomUUID()}`;
|
|
783
|
+
let serialized = typeof options.serializedPayload === "string" ? options.serializedPayload : void 0;
|
|
784
|
+
if (serialized === void 0) {
|
|
785
|
+
try {
|
|
786
|
+
serialized = JSON.stringify(payload);
|
|
787
|
+
} catch (error) {
|
|
788
|
+
throw new TypeError(`buildMultipartPushPayloads: payload is not JSON-serializable: ${error?.message ?? error}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (typeof serialized !== "string") {
|
|
792
|
+
throw new TypeError("buildMultipartPushPayloads: payload serialized to a non-string");
|
|
793
|
+
}
|
|
794
|
+
const bytes = utf8(serialized);
|
|
795
|
+
const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes));
|
|
796
|
+
const createdAt = Date.now();
|
|
797
|
+
const originalMessageKind = payload && typeof payload === "object" ? (
|
|
798
|
+
/** @type {{ messageKind?: unknown }} */
|
|
799
|
+
payload.messageKind
|
|
800
|
+
) : void 0;
|
|
801
|
+
const parts = [];
|
|
802
|
+
for (let i = 0; i < total; i++) {
|
|
803
|
+
const start = i * maxChunkBytes;
|
|
804
|
+
const end = Math.min(start + maxChunkBytes, bytes.byteLength);
|
|
805
|
+
const chunkBytes = bytes.subarray(start, end);
|
|
806
|
+
parts.push({
|
|
807
|
+
messageKind: MULTIPART_MESSAGE_KIND,
|
|
808
|
+
multipart: {
|
|
809
|
+
version: MULTIPART_VERSION,
|
|
810
|
+
id,
|
|
811
|
+
index: i + 1,
|
|
812
|
+
total,
|
|
813
|
+
encoding: MULTIPART_ENCODING,
|
|
814
|
+
originalMessageKind: typeof originalMessageKind === "string" ? originalMessageKind : null,
|
|
815
|
+
createdAt,
|
|
816
|
+
ttlMs
|
|
817
|
+
},
|
|
818
|
+
chunk: bytesToBase64Url(chunkBytes)
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
return parts;
|
|
822
|
+
}
|
|
823
|
+
function resolvePositiveInteger(value, fallback, fieldName) {
|
|
824
|
+
if (value === void 0 || value === null) return fallback;
|
|
825
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
826
|
+
throw new TypeError(`buildMultipartPushPayloads: ${fieldName} must be a positive integer`);
|
|
827
|
+
}
|
|
828
|
+
return value;
|
|
829
|
+
}
|
|
830
|
+
|
|
564
831
|
// src/index.js
|
|
565
832
|
var MESSAGE_KIND = Object.freeze({
|
|
566
833
|
CONTENT: "content",
|
|
567
834
|
REASONING: "reasoning",
|
|
568
835
|
TOOL_REQUEST: "tool_request",
|
|
569
|
-
ERROR: "error"
|
|
836
|
+
ERROR: "error",
|
|
837
|
+
RESULT: "result"
|
|
570
838
|
});
|
|
571
839
|
var MESSAGE_TYPE = Object.freeze({
|
|
572
840
|
INSTANT: "instant",
|
|
@@ -578,6 +846,17 @@ var PUSH_SOURCE = Object.freeze({
|
|
|
578
846
|
INSTANT: "instant",
|
|
579
847
|
SCHEDULED: "scheduled"
|
|
580
848
|
});
|
|
849
|
+
function notificationIntent(payload) {
|
|
850
|
+
const notification = payload && typeof payload === "object" && payload.notification;
|
|
851
|
+
const show = notification && typeof notification === "object" ? notification.show : void 0;
|
|
852
|
+
if (show === "always") return "always";
|
|
853
|
+
if (show === "when-hidden") return "when-hidden";
|
|
854
|
+
if (show === false) return "never";
|
|
855
|
+
if (!payload || typeof payload !== "object") return "never";
|
|
856
|
+
const kind = payload.messageKind;
|
|
857
|
+
if (kind === void 0 || kind === null) return "always";
|
|
858
|
+
return kind === MESSAGE_KIND.CONTENT || kind === MESSAGE_KIND.RESULT ? "always" : "never";
|
|
859
|
+
}
|
|
581
860
|
function requireField(kind, field, value) {
|
|
582
861
|
if (value === void 0 || value === null || value === "") {
|
|
583
862
|
throw new Error(`[amsg-shared] ${kind}: '${field}' is required`);
|
|
@@ -718,8 +997,33 @@ function buildErrorPush(args) {
|
|
|
718
997
|
if (args.messageSubtype !== void 0) push.messageSubtype = args.messageSubtype;
|
|
719
998
|
if (args.metadata !== void 0) push.metadata = args.metadata;
|
|
720
999
|
if (args.notification !== void 0) push.notification = args.notification;
|
|
1000
|
+
if (args.llmStatus !== void 0) push.llmStatus = args.llmStatus;
|
|
1001
|
+
if (args.providerCode !== void 0) push.providerCode = args.providerCode;
|
|
721
1002
|
return push;
|
|
722
1003
|
}
|
|
1004
|
+
function buildResultPush(args) {
|
|
1005
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
1006
|
+
throw new Error("[amsg-shared] ResultPush: payload must be a plain object");
|
|
1007
|
+
}
|
|
1008
|
+
requireField("ResultPush", "messageType", args.messageType);
|
|
1009
|
+
requireField("ResultPush", "source", args.source);
|
|
1010
|
+
requireField("ResultPush", "messageId", args.messageId);
|
|
1011
|
+
requireField("ResultPush", "sessionId", args.sessionId);
|
|
1012
|
+
if (typeof args.resultKind !== "string" || !args.resultKind.trim()) {
|
|
1013
|
+
throw new Error(
|
|
1014
|
+
"[amsg-shared] ResultPush: 'resultKind' must be a non-empty string\uFF08\u7ED9\u8FD9\u7C7B\u7ED3\u679C\u8D77\u4E2A\u540D\u5B57\uFF0C\u5BA2\u6237\u7AEF\u6309\u5B83\u5206\u6D41\uFF09"
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
validateNotificationArg("ResultPush", args.notification);
|
|
1018
|
+
return (
|
|
1019
|
+
/** @type {ResultPush} */
|
|
1020
|
+
{
|
|
1021
|
+
...args,
|
|
1022
|
+
messageKind: "result",
|
|
1023
|
+
timestamp: args.timestamp || (/* @__PURE__ */ new Date()).toISOString()
|
|
1024
|
+
}
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
723
1027
|
function isContentPush(value) {
|
|
724
1028
|
return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
|
|
725
1029
|
value.messageKind === "content";
|
|
@@ -736,6 +1040,10 @@ function isErrorPush(value) {
|
|
|
736
1040
|
return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
|
|
737
1041
|
value.messageKind === "error";
|
|
738
1042
|
}
|
|
1043
|
+
function isResultPush(value) {
|
|
1044
|
+
return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
|
|
1045
|
+
value.messageKind === "result";
|
|
1046
|
+
}
|
|
739
1047
|
var REASONING_CHUNK_ENCODER = new TextEncoder();
|
|
740
1048
|
var REASONING_CHUNK_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
741
1049
|
function chunkReasoningByUtf8Bytes(text, maxBytes) {
|