@rei-standard/amsg-shared 0.4.0-next.5 → 0.4.0-next.6
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 +35 -2
- package/dist/index.cjs +302 -6
- package/dist/index.d.cts +88 -8
- package/dist/index.d.ts +88 -8
- package/dist/index.mjs +302 -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
|
|
@@ -154,6 +154,22 @@ Replaces the legacy 0.7.0 `{ type: 'error', code: '...' }` envelope.
|
|
|
154
154
|
The legacy `type` field is **gone** — do not look for it on
|
|
155
155
|
`ErrorPush`.
|
|
156
156
|
|
|
157
|
+
### `ResultPush` — 宿主自定义的一条结果
|
|
158
|
+
|
|
159
|
+
| Field | Type | Notes |
|
|
160
|
+
|---------------|------------|----------------------------------------------------------------|
|
|
161
|
+
| `messageKind` | `'result'` | Discriminator. |
|
|
162
|
+
| `resultKind` | `string` | 宿主给这类结果起的名字(`'fire-pack'`、`'ledger-entry'`……),客户端按它分流。 |
|
|
163
|
+
| `title` | `string?` | 通知标题的兜底(`notification.title` 优先)。 |
|
|
164
|
+
| `body` | `string?` | 通知正文的兜底(`notification.body` 优先)。 |
|
|
165
|
+
|
|
166
|
+
不是聊天内容,而是「这次跑完产出了点什么,客户端拿去自己消化」:整理好的一份数据、一条账目、后台生成的产物。形状由宿主定,`buildResultPush` 是唯一**保留自己不认识的字段**的 builder——白名单式的复制会把内容删掉一半。
|
|
167
|
+
|
|
168
|
+
两处与别的 kind 不同:
|
|
169
|
+
|
|
170
|
+
- **投递路径**:产出方(`@rei-standard/amsg-server` 的 `ctx.emitResult()`)除了推送,还把它落进服务端收件箱,客户端下次 `GET /outbox?since=` 一定拿得到。
|
|
171
|
+
- **通知**:SW 侧默认弹(与 `content` 同待遇,其余三种是静默送给页面)——结果往往正是「跑完了,回来看看」那句话。不想弹就带 `notification: { show: false }`。
|
|
172
|
+
|
|
157
173
|
---
|
|
158
174
|
|
|
159
175
|
## Usage
|
|
@@ -184,6 +200,9 @@ function dispatch(push: AmsgPush) {
|
|
|
184
200
|
case 'error':
|
|
185
201
|
console.error(push.code, push.message);
|
|
186
202
|
break;
|
|
203
|
+
case 'result':
|
|
204
|
+
// push.resultKind is `string` — 宿主自己的结果,按它分流
|
|
205
|
+
break;
|
|
187
206
|
}
|
|
188
207
|
}
|
|
189
208
|
```
|
|
@@ -196,6 +215,7 @@ import {
|
|
|
196
215
|
buildReasoningPush,
|
|
197
216
|
buildToolRequestPush,
|
|
198
217
|
buildErrorPush,
|
|
218
|
+
buildResultPush,
|
|
199
219
|
} from '@rei-standard/amsg-shared';
|
|
200
220
|
|
|
201
221
|
// One sentence in an N-split burst
|
|
@@ -238,12 +258,24 @@ const error = buildErrorPush({
|
|
|
238
258
|
message: 'onLLMOutput threw: ...',
|
|
239
259
|
iteration: 2,
|
|
240
260
|
});
|
|
261
|
+
|
|
262
|
+
// 宿主自定义的一条结果(认识以外的字段原样保留)
|
|
263
|
+
const result = buildResultPush({
|
|
264
|
+
messageType: 'auto',
|
|
265
|
+
source: 'scheduled',
|
|
266
|
+
messageId: 'msg_task_7@1700000000000_result_0',
|
|
267
|
+
sessionId: 'sess_abc',
|
|
268
|
+
resultKind: 'fire-pack',
|
|
269
|
+
packId: 'pack_42',
|
|
270
|
+
entries: [{ id: 1 }, { id: 2 }],
|
|
271
|
+
notification: { title: '整理好了', body: '点开看看' },
|
|
272
|
+
});
|
|
241
273
|
```
|
|
242
274
|
|
|
243
275
|
### Type guards
|
|
244
276
|
|
|
245
277
|
```js
|
|
246
|
-
import { isContentPush, isReasoningPush, isToolRequestPush, isErrorPush } from '@rei-standard/amsg-shared';
|
|
278
|
+
import { isContentPush, isReasoningPush, isToolRequestPush, isErrorPush, isResultPush } from '@rei-standard/amsg-shared';
|
|
247
279
|
|
|
248
280
|
if (isContentPush(push)) {
|
|
249
281
|
// push.message is `string`
|
|
@@ -261,6 +293,7 @@ MESSAGE_KIND.CONTENT; // 'content'
|
|
|
261
293
|
MESSAGE_KIND.REASONING; // 'reasoning'
|
|
262
294
|
MESSAGE_KIND.TOOL_REQUEST; // 'tool_request'
|
|
263
295
|
MESSAGE_KIND.ERROR; // 'error'
|
|
296
|
+
MESSAGE_KIND.RESULT; // 'result'
|
|
264
297
|
|
|
265
298
|
MESSAGE_TYPE.INSTANT; // 'instant'
|
|
266
299
|
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,6 +60,7 @@ __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,
|
|
@@ -64,6 +69,7 @@ __export(src_exports, {
|
|
|
64
69
|
randomBytes: () => randomBytes,
|
|
65
70
|
randomUUID: () => randomUUID,
|
|
66
71
|
readReasoningContent: () => readReasoningContent,
|
|
72
|
+
redactCredentials: () => redactCredentials,
|
|
67
73
|
sendWebPush: () => sendWebPush,
|
|
68
74
|
stripReasoningTags: () => stripReasoningTags,
|
|
69
75
|
timingSafeEqualBytes: () => timingSafeEqualBytes,
|
|
@@ -230,6 +236,9 @@ function validateLlmMessagesShape(messages) {
|
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
// src/llm-call.js
|
|
239
|
+
var UPSTREAM_ERROR_DETAIL_MAX_CHARS = 300;
|
|
240
|
+
var UPSTREAM_ERROR_CODE_MAX_CHARS = 64;
|
|
241
|
+
var UPSTREAM_ERROR_BODY_MAX_BYTES = 16 * 1024;
|
|
233
242
|
async function callLlm(payload, options = {}) {
|
|
234
243
|
const requireContent = options.requireContent !== false;
|
|
235
244
|
const timeoutMs = typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 3e5;
|
|
@@ -246,13 +255,18 @@ async function callLlm(payload, options = {}) {
|
|
|
246
255
|
signal: AbortSignal.timeout(timeoutMs)
|
|
247
256
|
});
|
|
248
257
|
if (!aiResponse.ok) {
|
|
258
|
+
const detail = await readUpstreamErrorDetail(aiResponse);
|
|
249
259
|
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}
|
|
260
|
+
throw buildUpstreamError(
|
|
261
|
+
`AI API error: 405 Method Not Allowed. apiUrl must point to a full chat endpoint (for example: /chat/completions). Received: ${normalizedApiUrl}`,
|
|
262
|
+
aiResponse.status,
|
|
263
|
+
detail
|
|
252
264
|
);
|
|
253
265
|
}
|
|
254
|
-
throw
|
|
255
|
-
`AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}
|
|
266
|
+
throw buildUpstreamError(
|
|
267
|
+
`AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}`,
|
|
268
|
+
aiResponse.status,
|
|
269
|
+
detail
|
|
256
270
|
);
|
|
257
271
|
}
|
|
258
272
|
const aiData = await aiResponse.json();
|
|
@@ -319,6 +333,169 @@ function normalizeAiApiUrl(apiUrl) {
|
|
|
319
333
|
parsed.pathname = path;
|
|
320
334
|
return parsed.toString();
|
|
321
335
|
}
|
|
336
|
+
function buildUpstreamError(summary, status, detail) {
|
|
337
|
+
const error = new Error(
|
|
338
|
+
summary + (detail.message ? ` \u2014 ${detail.message}` : "") + (detail.code ? ` (provider code: ${detail.code})` : "")
|
|
339
|
+
);
|
|
340
|
+
error.code = "LLM_CALL_FAILED";
|
|
341
|
+
error.llmStatus = status;
|
|
342
|
+
if (detail.code) error.providerCode = detail.code;
|
|
343
|
+
return error;
|
|
344
|
+
}
|
|
345
|
+
async function readUpstreamErrorDetail(response) {
|
|
346
|
+
let raw;
|
|
347
|
+
let truncated = false;
|
|
348
|
+
try {
|
|
349
|
+
({ text: raw, truncated } = await readBoundedBody(response));
|
|
350
|
+
} catch {
|
|
351
|
+
return { message: "", code: "" };
|
|
352
|
+
}
|
|
353
|
+
if (typeof raw !== "string" || !raw.trim()) return { message: "", code: "" };
|
|
354
|
+
let body;
|
|
355
|
+
try {
|
|
356
|
+
body = JSON.parse(raw);
|
|
357
|
+
} catch {
|
|
358
|
+
if (truncated && looksLikeJson(raw)) {
|
|
359
|
+
const salvaged = salvageJsonStringFields(raw);
|
|
360
|
+
return {
|
|
361
|
+
message: clampDetail(salvaged.message || TRUNCATED_BODY_NOTE),
|
|
362
|
+
code: clampCode(salvaged.code)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return { message: clampDetail(raw), code: "" };
|
|
366
|
+
}
|
|
367
|
+
const envelope = body && typeof body === "object" ? body : {};
|
|
368
|
+
const inner = envelope.error && typeof envelope.error === "object" ? envelope.error : {};
|
|
369
|
+
const message = firstNonEmptyString(
|
|
370
|
+
inner.message,
|
|
371
|
+
// OpenAI / Anthropic / Gemini
|
|
372
|
+
typeof envelope.error === "string" ? envelope.error : "",
|
|
373
|
+
// `{ error: "unauthorized" }`
|
|
374
|
+
envelope.message,
|
|
375
|
+
// 一批中转把 message 放最外层
|
|
376
|
+
envelope.detail
|
|
377
|
+
// FastAPI 风格的自建中转
|
|
378
|
+
) || raw;
|
|
379
|
+
const code = firstNonEmptyString(
|
|
380
|
+
inner.code,
|
|
381
|
+
// OpenAI:invalid_api_key / insufficient_quota / context_length_exceeded
|
|
382
|
+
inner.status,
|
|
383
|
+
// Gemini:INVALID_ARGUMENT / RESOURCE_EXHAUSTED
|
|
384
|
+
inner.type,
|
|
385
|
+
// Anthropic:invalid_request_error / overloaded_error
|
|
386
|
+
envelope.code
|
|
387
|
+
);
|
|
388
|
+
return { message: clampDetail(message), code: clampCode(code) };
|
|
389
|
+
}
|
|
390
|
+
async function readBoundedBody(response) {
|
|
391
|
+
const body = response && response.body;
|
|
392
|
+
if (!body || typeof body.getReader !== "function") {
|
|
393
|
+
const text = typeof response.text === "function" ? await response.text() : "";
|
|
394
|
+
return { text, truncated: false };
|
|
395
|
+
}
|
|
396
|
+
const reader = body.getReader();
|
|
397
|
+
const chunks = [];
|
|
398
|
+
let bytes = 0;
|
|
399
|
+
try {
|
|
400
|
+
while (bytes < UPSTREAM_ERROR_BODY_MAX_BYTES) {
|
|
401
|
+
const { done, value } = await reader.read();
|
|
402
|
+
if (done) break;
|
|
403
|
+
if (!value || !value.length) continue;
|
|
404
|
+
chunks.push(value);
|
|
405
|
+
bytes += value.length;
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
try {
|
|
409
|
+
await reader.cancel();
|
|
410
|
+
} catch {
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
text: utf8Decode(concatBytes(...chunks)),
|
|
415
|
+
truncated: bytes >= UPSTREAM_ERROR_BODY_MAX_BYTES
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
var TRUNCATED_BODY_NOTE = "upstream error body was truncated before any readable message";
|
|
419
|
+
var JSON_STRING_FIELD = /"(message|detail|code|status|type)"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
|
|
420
|
+
function looksLikeJson(raw) {
|
|
421
|
+
return /^\s*[{[]/.test(raw);
|
|
422
|
+
}
|
|
423
|
+
function salvageJsonStringFields(raw) {
|
|
424
|
+
const found = {};
|
|
425
|
+
for (const match of raw.matchAll(JSON_STRING_FIELD)) {
|
|
426
|
+
if (found[match[1]] === void 0) found[match[1]] = unescapeJsonString(match[2]);
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
message: firstNonEmptyString(found.message, found.detail),
|
|
430
|
+
code: firstNonEmptyString(found.code, found.status, found.type)
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function unescapeJsonString(value) {
|
|
434
|
+
try {
|
|
435
|
+
return JSON.parse(`"${value}"`);
|
|
436
|
+
} catch {
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function clampDetail(text) {
|
|
441
|
+
const flattened = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
442
|
+
const safe = redactCredentials(flattened);
|
|
443
|
+
return safe.length > UPSTREAM_ERROR_DETAIL_MAX_CHARS ? `${safe.slice(0, UPSTREAM_ERROR_DETAIL_MAX_CHARS - 1)}\u2026` : safe;
|
|
444
|
+
}
|
|
445
|
+
function clampCode(code) {
|
|
446
|
+
return String(code ?? "").replace(/\s+/g, " ").trim().slice(0, UPSTREAM_ERROR_CODE_MAX_CHARS);
|
|
447
|
+
}
|
|
448
|
+
var CREDENTIAL_LIKE_TOKEN = /\b[A-Za-z]{2,6}-[A-Za-z0-9_-]{16,}/g;
|
|
449
|
+
var LONG_OPAQUE_RUN = /[A-Za-z0-9+/_.-]{48,}/g;
|
|
450
|
+
var MODEL_ID_LIKE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]{1,12})+$/;
|
|
451
|
+
var MODEL_ID_MAX_CHARS = 64;
|
|
452
|
+
var CREDENTIAL_PREFIX_SEGMENTS = /* @__PURE__ */ new Set([
|
|
453
|
+
"sk",
|
|
454
|
+
"pk",
|
|
455
|
+
"ak",
|
|
456
|
+
"api",
|
|
457
|
+
"apikey",
|
|
458
|
+
"key",
|
|
459
|
+
"token",
|
|
460
|
+
"secret",
|
|
461
|
+
"auth",
|
|
462
|
+
"bearer",
|
|
463
|
+
"session",
|
|
464
|
+
"sess",
|
|
465
|
+
"pat",
|
|
466
|
+
"xai",
|
|
467
|
+
"gsk"
|
|
468
|
+
]);
|
|
469
|
+
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/;
|
|
470
|
+
function alternationCount(segment) {
|
|
471
|
+
return (segment.match(/[a-z]+|[0-9]+/g) || []).length;
|
|
472
|
+
}
|
|
473
|
+
function looksLikeModelId(token) {
|
|
474
|
+
if (token.length > MODEL_ID_MAX_CHARS) return false;
|
|
475
|
+
if (!MODEL_ID_LIKE.test(token)) return false;
|
|
476
|
+
if (UUID_SHAPE.test(token)) return false;
|
|
477
|
+
const segments = token.split(/[.-]/);
|
|
478
|
+
if (CREDENTIAL_PREFIX_SEGMENTS.has(segments[0])) return false;
|
|
479
|
+
const randomLooking = segments.filter((segment) => alternationCount(segment) >= 3);
|
|
480
|
+
if (randomLooking.length === 0) return true;
|
|
481
|
+
return randomLooking.length === 1 && randomLooking[0].length <= 5;
|
|
482
|
+
}
|
|
483
|
+
function redactCredentials(text) {
|
|
484
|
+
let s = text;
|
|
485
|
+
s = s.replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
|
|
486
|
+
s = s.replace(CREDENTIAL_LIKE_TOKEN, (token) => looksLikeModelId(token) ? token : "[redacted]");
|
|
487
|
+
s = s.replace(LONG_OPAQUE_RUN, (run) => {
|
|
488
|
+
const token = run.replace(/^[._+/-]+/, "").replace(/[._+/-]+$/, "");
|
|
489
|
+
return looksLikeModelId(token) ? run : "[redacted]";
|
|
490
|
+
});
|
|
491
|
+
return s;
|
|
492
|
+
}
|
|
493
|
+
function firstNonEmptyString(...values) {
|
|
494
|
+
for (const value of values) {
|
|
495
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
496
|
+
}
|
|
497
|
+
return "";
|
|
498
|
+
}
|
|
322
499
|
|
|
323
500
|
// src/webpush.js
|
|
324
501
|
var KEY_INFO_PREFIX = utf8("WebPush: info\0");
|
|
@@ -550,23 +727,113 @@ var REI_SW_EVENT = Object.freeze({
|
|
|
550
727
|
REASONING_RECEIVED: "rei-amsg-reasoning-received",
|
|
551
728
|
TOOL_REQUEST_RECEIVED: "rei-amsg-tool-request-received",
|
|
552
729
|
ERROR_RECEIVED: "rei-amsg-error-received",
|
|
730
|
+
/** 宿主自定义的一条结果(`messageKind: 'result'`),不是聊天内容。 */
|
|
731
|
+
RESULT_RECEIVED: "rei-amsg-result-received",
|
|
553
732
|
MULTIPART_EXPIRED: "rei-amsg-multipart-expired",
|
|
554
733
|
UNKNOWN_RECEIVED: "rei-amsg-unknown-received"
|
|
555
734
|
});
|
|
735
|
+
var MULTIPART_FAILURE_REASON = Object.freeze({
|
|
736
|
+
/** TTL 到期仍未收齐,或收到的分片本身已经过期。 */
|
|
737
|
+
TTL_EXPIRED: "ttl-expired",
|
|
738
|
+
/** 分片信封不合规:version / encoding 对不上、index 越界、chunk 不是合法 base64url。 */
|
|
739
|
+
INVALID_CHUNK: "invalid-chunk",
|
|
740
|
+
/** 同一个 id 的分片报了不一样的 total / encoding,已收的部分拼不回去。 */
|
|
741
|
+
CHUNK_CONFLICT: "chunk-conflict",
|
|
742
|
+
/** 累计字节数超过 maxTotalBytes。 */
|
|
743
|
+
SIZE_LIMIT_EXCEEDED: "size-limit-exceeded",
|
|
744
|
+
/** 收齐了但拼不回原 payload(缺片、超限、JSON 解不开)。 */
|
|
745
|
+
RESTORE_FAILED: "restore-failed",
|
|
746
|
+
/** 分片仓库(IndexedDB)读写失败。 */
|
|
747
|
+
STORAGE_FAILED: "storage-failed",
|
|
748
|
+
/** 接收端把 multipart 关了(`multipart.enabled === false`),分片没法重组。 */
|
|
749
|
+
DISABLED: "disabled"
|
|
750
|
+
});
|
|
556
751
|
var REI_SW_MESSAGE_TYPE = Object.freeze({
|
|
557
752
|
ENQUEUE_REQUEST: "REI_ENQUEUE_REQUEST",
|
|
558
753
|
DELIVER: "REI_AMSG_DELIVER",
|
|
559
754
|
FLUSH_QUEUE: "REI_FLUSH_QUEUE",
|
|
560
|
-
|
|
755
|
+
/**
|
|
756
|
+
* 入队的点对点回执:谁发的 ENQUEUE_REQUEST 就回给谁一条,一次一条。
|
|
757
|
+
* 没转 MessagePort 过来时会落到全局的 `navigator.serviceWorker` message
|
|
758
|
+
* 监听器上。
|
|
759
|
+
*/
|
|
760
|
+
QUEUE_RESULT: "REI_QUEUE_RESULT",
|
|
761
|
+
/**
|
|
762
|
+
* 队列请求被永久拒绝、即将从队列里删掉时广播给所有窗口的一条。
|
|
763
|
+
*
|
|
764
|
+
* 跟 QUEUE_RESULT 分开是因为两者的收信人不是一回事:这条是广播,可能来自后台
|
|
765
|
+
* `sync` 冲刷、说的也可能是另一条八竿子打不着的旧请求。共用一个 type 的话,
|
|
766
|
+
* 页面等自己那条入队回执时会先收到这一条、当成自己的结果处理。
|
|
767
|
+
*/
|
|
768
|
+
QUEUE_DROPPED: "REI_QUEUE_DROPPED"
|
|
561
769
|
});
|
|
562
770
|
var REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER;
|
|
563
771
|
|
|
772
|
+
// src/multipart.js
|
|
773
|
+
var DEFAULT_MULTIPART_CHUNK_BYTES = 1800;
|
|
774
|
+
function buildMultipartPushPayloads(payload, options = {}) {
|
|
775
|
+
const maxChunkBytes = resolvePositiveInteger(
|
|
776
|
+
options.maxChunkBytes,
|
|
777
|
+
DEFAULT_MULTIPART_CHUNK_BYTES,
|
|
778
|
+
"maxChunkBytes"
|
|
779
|
+
);
|
|
780
|
+
const ttlMs = resolvePositiveInteger(options.ttlMs, DEFAULT_MULTIPART_TTL_MS, "ttlMs");
|
|
781
|
+
const id = typeof options.id === "string" && options.id.trim() ? options.id.trim() : `mp_${randomUUID()}`;
|
|
782
|
+
let serialized = typeof options.serializedPayload === "string" ? options.serializedPayload : void 0;
|
|
783
|
+
if (serialized === void 0) {
|
|
784
|
+
try {
|
|
785
|
+
serialized = JSON.stringify(payload);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
throw new TypeError(`buildMultipartPushPayloads: payload is not JSON-serializable: ${error?.message ?? error}`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (typeof serialized !== "string") {
|
|
791
|
+
throw new TypeError("buildMultipartPushPayloads: payload serialized to a non-string");
|
|
792
|
+
}
|
|
793
|
+
const bytes = utf8(serialized);
|
|
794
|
+
const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes));
|
|
795
|
+
const createdAt = Date.now();
|
|
796
|
+
const originalMessageKind = payload && typeof payload === "object" ? (
|
|
797
|
+
/** @type {{ messageKind?: unknown }} */
|
|
798
|
+
payload.messageKind
|
|
799
|
+
) : void 0;
|
|
800
|
+
const parts = [];
|
|
801
|
+
for (let i = 0; i < total; i++) {
|
|
802
|
+
const start = i * maxChunkBytes;
|
|
803
|
+
const end = Math.min(start + maxChunkBytes, bytes.byteLength);
|
|
804
|
+
const chunkBytes = bytes.subarray(start, end);
|
|
805
|
+
parts.push({
|
|
806
|
+
messageKind: MULTIPART_MESSAGE_KIND,
|
|
807
|
+
multipart: {
|
|
808
|
+
version: MULTIPART_VERSION,
|
|
809
|
+
id,
|
|
810
|
+
index: i + 1,
|
|
811
|
+
total,
|
|
812
|
+
encoding: MULTIPART_ENCODING,
|
|
813
|
+
originalMessageKind: typeof originalMessageKind === "string" ? originalMessageKind : null,
|
|
814
|
+
createdAt,
|
|
815
|
+
ttlMs
|
|
816
|
+
},
|
|
817
|
+
chunk: bytesToBase64Url(chunkBytes)
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return parts;
|
|
821
|
+
}
|
|
822
|
+
function resolvePositiveInteger(value, fallback, fieldName) {
|
|
823
|
+
if (value === void 0 || value === null) return fallback;
|
|
824
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
825
|
+
throw new TypeError(`buildMultipartPushPayloads: ${fieldName} must be a positive integer`);
|
|
826
|
+
}
|
|
827
|
+
return value;
|
|
828
|
+
}
|
|
829
|
+
|
|
564
830
|
// src/index.js
|
|
565
831
|
var MESSAGE_KIND = Object.freeze({
|
|
566
832
|
CONTENT: "content",
|
|
567
833
|
REASONING: "reasoning",
|
|
568
834
|
TOOL_REQUEST: "tool_request",
|
|
569
|
-
ERROR: "error"
|
|
835
|
+
ERROR: "error",
|
|
836
|
+
RESULT: "result"
|
|
570
837
|
});
|
|
571
838
|
var MESSAGE_TYPE = Object.freeze({
|
|
572
839
|
INSTANT: "instant",
|
|
@@ -718,8 +985,33 @@ function buildErrorPush(args) {
|
|
|
718
985
|
if (args.messageSubtype !== void 0) push.messageSubtype = args.messageSubtype;
|
|
719
986
|
if (args.metadata !== void 0) push.metadata = args.metadata;
|
|
720
987
|
if (args.notification !== void 0) push.notification = args.notification;
|
|
988
|
+
if (args.llmStatus !== void 0) push.llmStatus = args.llmStatus;
|
|
989
|
+
if (args.providerCode !== void 0) push.providerCode = args.providerCode;
|
|
721
990
|
return push;
|
|
722
991
|
}
|
|
992
|
+
function buildResultPush(args) {
|
|
993
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
994
|
+
throw new Error("[amsg-shared] ResultPush: payload must be a plain object");
|
|
995
|
+
}
|
|
996
|
+
requireField("ResultPush", "messageType", args.messageType);
|
|
997
|
+
requireField("ResultPush", "source", args.source);
|
|
998
|
+
requireField("ResultPush", "messageId", args.messageId);
|
|
999
|
+
requireField("ResultPush", "sessionId", args.sessionId);
|
|
1000
|
+
if (typeof args.resultKind !== "string" || !args.resultKind.trim()) {
|
|
1001
|
+
throw new Error(
|
|
1002
|
+
"[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"
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
validateNotificationArg("ResultPush", args.notification);
|
|
1006
|
+
return (
|
|
1007
|
+
/** @type {ResultPush} */
|
|
1008
|
+
{
|
|
1009
|
+
...args,
|
|
1010
|
+
messageKind: "result",
|
|
1011
|
+
timestamp: args.timestamp || (/* @__PURE__ */ new Date()).toISOString()
|
|
1012
|
+
}
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
723
1015
|
function isContentPush(value) {
|
|
724
1016
|
return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
|
|
725
1017
|
value.messageKind === "content";
|
|
@@ -736,6 +1028,10 @@ function isErrorPush(value) {
|
|
|
736
1028
|
return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
|
|
737
1029
|
value.messageKind === "error";
|
|
738
1030
|
}
|
|
1031
|
+
function isResultPush(value) {
|
|
1032
|
+
return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
|
|
1033
|
+
value.messageKind === "result";
|
|
1034
|
+
}
|
|
739
1035
|
var REASONING_CHUNK_ENCODER = new TextEncoder();
|
|
740
1036
|
var REASONING_CHUNK_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
741
1037
|
function chunkReasoningByUtf8Bytes(text, maxBytes) {
|
package/dist/index.d.cts
CHANGED
|
@@ -154,6 +154,8 @@ export function buildToolRequestPush(args: {
|
|
|
154
154
|
* @param {string} [args.messageSubtype]
|
|
155
155
|
* @param {Object} [args.metadata]
|
|
156
156
|
* @param {NotificationDirective} [args.notification]
|
|
157
|
+
* @param {number} [args.llmStatus] - 上游 LLM 回的 HTTP 状态码(见 ErrorPush)
|
|
158
|
+
* @param {string} [args.providerCode] - provider 自己的错误码(见 ErrorPush)
|
|
157
159
|
* @returns {ErrorPush}
|
|
158
160
|
*/
|
|
159
161
|
export function buildErrorPush(args: {
|
|
@@ -168,7 +170,42 @@ export function buildErrorPush(args: {
|
|
|
168
170
|
messageSubtype?: string;
|
|
169
171
|
metadata?: any;
|
|
170
172
|
notification?: NotificationDirective;
|
|
173
|
+
llmStatus?: number;
|
|
174
|
+
providerCode?: string;
|
|
171
175
|
}): ErrorPush;
|
|
176
|
+
/**
|
|
177
|
+
* Build a {@link ResultPush} —— 宿主自定义的一条结果。
|
|
178
|
+
*
|
|
179
|
+
* 与另外四个 builder 有一处不同:**认识以外的字段原样保留**。结果的形状本来
|
|
180
|
+
* 就由宿主定,白名单式的复制等于把内容删掉一半。库只保证 `messageKind` 与
|
|
181
|
+
* `timestamp` 到位,其余交给宿主。
|
|
182
|
+
*
|
|
183
|
+
* `resultKind` 必填:宿主给这类结果起的名字(`'fire-pack'`、`'ledger-entry'`
|
|
184
|
+
* ……)。客户端有多种结果时按它分流,不用靠「有没有某个字段」去猜是哪一种。
|
|
185
|
+
*
|
|
186
|
+
* @param {Object} args - 整条 payload;下列几个字段有约束,其余原样带过去。
|
|
187
|
+
* @param {MessageType} args.messageType
|
|
188
|
+
* @param {PushSource} args.source
|
|
189
|
+
* @param {string} args.messageId
|
|
190
|
+
* @param {string} args.sessionId
|
|
191
|
+
* @param {string} args.resultKind - 这类结果的名字(宿主自定义)
|
|
192
|
+
* @param {string} [args.timestamp]
|
|
193
|
+
* @param {string} [args.title] - 通知标题的兜底(`notification.title` 优先)
|
|
194
|
+
* @param {string} [args.body] - 通知正文的兜底(`notification.body` 优先)
|
|
195
|
+
* @param {NotificationDirective} [args.notification]
|
|
196
|
+
* @returns {ResultPush}
|
|
197
|
+
*/
|
|
198
|
+
export function buildResultPush(args: {
|
|
199
|
+
messageType: MessageType;
|
|
200
|
+
source: PushSource;
|
|
201
|
+
messageId: string;
|
|
202
|
+
sessionId: string;
|
|
203
|
+
resultKind: string;
|
|
204
|
+
timestamp?: string;
|
|
205
|
+
title?: string;
|
|
206
|
+
body?: string;
|
|
207
|
+
notification?: NotificationDirective;
|
|
208
|
+
}): ResultPush;
|
|
172
209
|
/**
|
|
173
210
|
* Type guard: returns true if the argument is a {@link ContentPush}.
|
|
174
211
|
*
|
|
@@ -197,6 +234,13 @@ export function isToolRequestPush(value: unknown): value is ToolRequestPush;
|
|
|
197
234
|
* @returns {value is ErrorPush}
|
|
198
235
|
*/
|
|
199
236
|
export function isErrorPush(value: unknown): value is ErrorPush;
|
|
237
|
+
/**
|
|
238
|
+
* Type guard: returns true if the argument is a {@link ResultPush}.
|
|
239
|
+
*
|
|
240
|
+
* @param {unknown} value
|
|
241
|
+
* @returns {value is ResultPush}
|
|
242
|
+
*/
|
|
243
|
+
export function isResultPush(value: unknown): value is ResultPush;
|
|
200
244
|
/**
|
|
201
245
|
* Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
|
|
202
246
|
* always cutting at codepoint boundaries (never inside a multi-byte
|
|
@@ -392,7 +436,8 @@ export function extractToolCallsFromDecision(decision: unknown): Array<Record<st
|
|
|
392
436
|
* Three orthogonal axes:
|
|
393
437
|
* 1. messageType — how the push was produced (instant / fixed / prompted / auto)
|
|
394
438
|
* 2. messageSubtype — caller's business classification (free-form string)
|
|
395
|
-
* 3. messageKind — what the push carries (content / reasoning / tool_request /
|
|
439
|
+
* 3. messageKind — what the push carries (content / reasoning / tool_request /
|
|
440
|
+
* error / result)
|
|
396
441
|
*
|
|
397
442
|
* Zero runtime dependencies. The package is ESM/CJS dual-published and
|
|
398
443
|
* intentionally has no `dependencies:` entry — every other amsg sub-
|
|
@@ -408,7 +453,7 @@ export function extractToolCallsFromDecision(decision: unknown): Array<Record<st
|
|
|
408
453
|
/**
|
|
409
454
|
* What the push carries. Fixed enum — packages must not add values.
|
|
410
455
|
*
|
|
411
|
-
* @typedef {'content' | 'reasoning' | 'tool_request' | 'error'} MessageKind
|
|
456
|
+
* @typedef {'content' | 'reasoning' | 'tool_request' | 'error' | 'result'} MessageKind
|
|
412
457
|
*/
|
|
413
458
|
/**
|
|
414
459
|
* How the push was produced. Fixed enum — packages must not add values.
|
|
@@ -434,6 +479,7 @@ export const MESSAGE_KIND: Readonly<{
|
|
|
434
479
|
REASONING: "reasoning";
|
|
435
480
|
TOOL_REQUEST: "tool_request";
|
|
436
481
|
ERROR: "error";
|
|
482
|
+
RESULT: "result";
|
|
437
483
|
}>;
|
|
438
484
|
/**
|
|
439
485
|
* Runtime constant mirroring the {@link MessageType} type.
|
|
@@ -526,8 +572,9 @@ export type AmsgPushCommon = {
|
|
|
526
572
|
* so producers get builder validation for the fields the SW actually reads.
|
|
527
573
|
*
|
|
528
574
|
* Routing in SW:
|
|
529
|
-
* - By default (`show: "auto"` or omitted), `messageKind: 'content'` (and legacy
|
|
530
|
-
* will display a system notification. `reasoning` / `tool_request` /
|
|
575
|
+
* - By default (`show: "auto"` or omitted), `messageKind: 'content'` / `'result'` (and legacy
|
|
576
|
+
* un-kinded payloads) will display a system notification. `reasoning` / `tool_request` /
|
|
577
|
+
* `error` will dispatch silently.
|
|
531
578
|
* - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
|
|
532
579
|
* - When rendering, `notification.*` is consulted first, with per-field
|
|
533
580
|
* fallback to the matching top-level payload fields (`title`,
|
|
@@ -650,18 +697,50 @@ export type ToolRequestPush = AmsgPushCommon & {
|
|
|
650
697
|
* `{ type: 'error', code: '...' }` envelope. `code` is a stable
|
|
651
698
|
* string; `iteration` is the agentic-loop iteration number when
|
|
652
699
|
* relevant (0 / absent otherwise).
|
|
700
|
+
*
|
|
701
|
+
* `llmStatus` / `providerCode` 是失败来自 LLM 调用时的机读标注:上游回的 HTTP
|
|
702
|
+
* 状态码,以及 provider 自己的错误码(`invalid_api_key` /
|
|
703
|
+
* `context_length_exceeded` 之类)。客户端靠它们分流「Key 失效要去改配置」和
|
|
704
|
+
* 「上游一时抽风等会儿再说」,不用回去正则匹配 `message` 那句人话——那句是给
|
|
705
|
+
* 用户看的,措辞随时会变。失败跟 LLM 无关时两个都不出现。
|
|
653
706
|
*/
|
|
654
707
|
export type ErrorPush = AmsgPushCommon & {
|
|
655
708
|
messageKind: "error";
|
|
656
709
|
code: string;
|
|
657
710
|
message: string;
|
|
658
711
|
iteration?: number;
|
|
712
|
+
llmStatus?: number;
|
|
713
|
+
providerCode?: string;
|
|
714
|
+
};
|
|
715
|
+
/**
|
|
716
|
+
* 宿主自定义的一条结果,不是聊天内容。
|
|
717
|
+
*
|
|
718
|
+
* 用在「这次跑完产出了点什么,客户端拿去自己消化」的场合:整理好的一份数据、
|
|
719
|
+
* 一条账目、一次后台生成的产物。形状由宿主定,库只固定三件事——`messageKind`
|
|
720
|
+
* 是 `'result'`(客户端一眼分得出这不是聊天),`resultKind` 是宿主给这类结果
|
|
721
|
+
* 起的名字(有多种结果时按它分流,不用去猜字段),其余字段随便加。
|
|
722
|
+
*
|
|
723
|
+
* 与另外四种 kind 的区别在投递路径:这一条同时落进服务端收件箱
|
|
724
|
+
* (`message_outbox`),客户端下次 `GET /outbox?since=` 一定拿得到——推送没送
|
|
725
|
+
* 到、内容超过一条推送的 4KB 上限,都不会让它丢。产出方见
|
|
726
|
+
* `@rei-standard/amsg-server` 的 `ctx.emitResult()`。
|
|
727
|
+
*
|
|
728
|
+
* SW 侧默认弹通知(与 `content` 同待遇,其余三种是静默送给页面):结果往往正
|
|
729
|
+
* 是「跑完了,回来看看」那句话。不想弹就带
|
|
730
|
+
* `notification: { show: false }`,标题正文也在 `notification` 里自定义。
|
|
731
|
+
*/
|
|
732
|
+
export type ResultPush = AmsgPushCommon & {
|
|
733
|
+
messageKind: "result";
|
|
734
|
+
resultKind: string;
|
|
735
|
+
title?: string;
|
|
736
|
+
body?: string;
|
|
737
|
+
data?: any;
|
|
659
738
|
};
|
|
660
739
|
/**
|
|
661
740
|
* Discriminated union of all pushes the SW can receive. TS consumers
|
|
662
741
|
* `switch` on `messageKind` and the compiler narrows automatically.
|
|
663
742
|
*/
|
|
664
|
-
export type AmsgPush = ContentPush | ReasoningPush | ToolRequestPush | ErrorPush;
|
|
743
|
+
export type AmsgPush = ContentPush | ReasoningPush | ToolRequestPush | ErrorPush | ResultPush;
|
|
665
744
|
export type ChatMessage = {
|
|
666
745
|
role: "system" | "user" | "assistant" | "tool";
|
|
667
746
|
content?: string | unknown[] | null;
|
|
@@ -710,7 +789,7 @@ export type SessionContext = {
|
|
|
710
789
|
/**
|
|
711
790
|
* What the push carries. Fixed enum — packages must not add values.
|
|
712
791
|
*/
|
|
713
|
-
export type MessageKind = "content" | "reasoning" | "tool_request" | "error";
|
|
792
|
+
export type MessageKind = "content" | "reasoning" | "tool_request" | "error" | "result";
|
|
714
793
|
/**
|
|
715
794
|
* How the push was produced. Fixed enum — packages must not add values.
|
|
716
795
|
*/
|
|
@@ -724,6 +803,7 @@ export type MessageType = "instant" | "fixed" | "prompted" | "auto";
|
|
|
724
803
|
export type PushSource = "instant" | "scheduled";
|
|
725
804
|
export { toUint8, concatBytes, utf8, utf8Decode, bytesToBase64, bytesToBase64Url, base64UrlToBytes, jsonToBase64Url, bytesToHex, hexToBytes, hmacSha256, timingSafeEqualBytes, randomBytes, randomUUID } from "./webcrypto-utils.js";
|
|
726
805
|
export { LLM_MESSAGES_ERROR, validateLlmMessagesShape } from "./llm-messages.js";
|
|
727
|
-
export { callLlm, buildLlmRequestBody, normalizeAiApiUrl } from "./llm-call.js";
|
|
806
|
+
export { callLlm, buildLlmRequestBody, normalizeAiApiUrl, redactCredentials } from "./llm-call.js";
|
|
728
807
|
export { sendWebPush, buildVapidJwt, verifyVapidJwt } from "./webpush.js";
|
|
729
|
-
export { MULTIPART_MESSAGE_KIND, MULTIPART_ENCODING, MULTIPART_VERSION, DEFAULT_MULTIPART_TTL_MS, DEFAULT_MULTIPART_MAX_CHUNKS, DEFAULT_MULTIPART_MAX_TOTAL_BYTES, REI_AMSG_POSTMESSAGE_TYPE, REI_SW_EVENT, REI_SW_MESSAGE_TYPE, REI_AMSG_DELIVER_MESSAGE_TYPE } from "./protocol.js";
|
|
808
|
+
export { MULTIPART_MESSAGE_KIND, MULTIPART_ENCODING, MULTIPART_VERSION, DEFAULT_MULTIPART_TTL_MS, DEFAULT_MULTIPART_MAX_CHUNKS, DEFAULT_MULTIPART_MAX_TOTAL_BYTES, MULTIPART_FAILURE_REASON, REI_AMSG_POSTMESSAGE_TYPE, REI_SW_EVENT, REI_SW_MESSAGE_TYPE, REI_AMSG_DELIVER_MESSAGE_TYPE } from "./protocol.js";
|
|
809
|
+
export { buildMultipartPushPayloads, DEFAULT_MULTIPART_CHUNK_BYTES } from "./multipart.js";
|