@rei-standard/amsg-shared 0.1.0 → 0.3.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 +29 -5
- package/dist/index.cjs +71 -2
- package/dist/index.d.cts +94 -35
- package/dist/index.d.ts +94 -35
- package/dist/index.mjs +71 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @rei-standard/amsg-shared
|
|
2
2
|
|
|
3
|
-
Lowest layer of the ReiStandard Active Messaging
|
|
4
|
-
the **
|
|
3
|
+
Lowest layer of the ReiStandard Active Messaging stack. Defines
|
|
4
|
+
the **push schema** that `amsg-instant`, `amsg-server`,
|
|
5
5
|
`amsg-sw`, and `amsg-client` all conform to.
|
|
6
6
|
|
|
7
7
|
Zero runtime deps. Does **not** depend on any other amsg package —
|
|
@@ -9,9 +9,9 @@ every other amsg sub-package depends on this one, never the reverse.
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Push schema
|
|
13
13
|
|
|
14
|
-
A single push is described by three
|
|
14
|
+
A single push is described by three independent dimensions:
|
|
15
15
|
|
|
16
16
|
| Axis | Field | Values | Defined by |
|
|
17
17
|
|----------------|-------------------|-------------------------------------------------------|--------------------|
|
|
@@ -22,7 +22,7 @@ A single push is described by three orthogonal axes:
|
|
|
22
22
|
`messageType` answers **how this push was produced** (one-shot
|
|
23
23
|
`instant` worker, scheduled `fixed` ping, AI-`prompted` reply, fully
|
|
24
24
|
`auto`-generated cadence). `messageKind` answers **what it carries**.
|
|
25
|
-
The two are intentionally
|
|
25
|
+
The two are intentionally independent: any `messageType` can carry any
|
|
26
26
|
`messageKind`.
|
|
27
27
|
|
|
28
28
|
There is also `source: 'instant' | 'scheduled'` — the **routing
|
|
@@ -48,6 +48,30 @@ origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any
|
|
|
48
48
|
|
|
49
49
|
---
|
|
50
50
|
|
|
51
|
+
## Notification directive
|
|
52
|
+
|
|
53
|
+
`ContentPush` and `ToolRequestPush` can carry an optional
|
|
54
|
+
`notification` object. It is a producer-side hint consumed by
|
|
55
|
+
`@rei-standard/amsg-sw` before rendering a system notification.
|
|
56
|
+
|
|
57
|
+
| Field | Type | Notes |
|
|
58
|
+
|----------------------|-------------------------------------------|-------|
|
|
59
|
+
| `show` | `'auto' \| 'always' \| 'when-hidden' \| false` | Display policy. `auto` follows SW defaults. |
|
|
60
|
+
| `title` | `string?` | Notification title override. |
|
|
61
|
+
| `body` | `string?` | Notification body override. |
|
|
62
|
+
| `icon` | `string?` | Notification icon URL. |
|
|
63
|
+
| `badge` | `string?` | Notification badge URL. |
|
|
64
|
+
| `tag` | `string?` | Notification grouping tag. |
|
|
65
|
+
| `renotify` | `boolean?` | Re-alert when a matching `tag` replaces an existing notification. |
|
|
66
|
+
| `requireInteraction` | `boolean?` | Keep the notification visible until the user dismisses it. |
|
|
67
|
+
| `silent` | `boolean?` | Suppress notification sound and vibration. |
|
|
68
|
+
| `data` | `Record<string, unknown>?` | Custom data passed to the notification. |
|
|
69
|
+
|
|
70
|
+
Unknown fields are preserved for forward compatibility, but the known
|
|
71
|
+
fields above are validated by the builders when present.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
51
75
|
## Per-kind fields
|
|
52
76
|
|
|
53
77
|
### `ContentPush` — final user-facing content
|
package/dist/index.cjs
CHANGED
|
@@ -19,6 +19,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// src/index.js
|
|
20
20
|
var src_exports = {};
|
|
21
21
|
__export(src_exports, {
|
|
22
|
+
AVATAR_URL_MAX_LENGTH: () => AVATAR_URL_MAX_LENGTH,
|
|
22
23
|
MESSAGE_KIND: () => MESSAGE_KIND,
|
|
23
24
|
MESSAGE_TYPE: () => MESSAGE_TYPE,
|
|
24
25
|
PUSH_SOURCE: () => PUSH_SOURCE,
|
|
@@ -33,7 +34,12 @@ __export(src_exports, {
|
|
|
33
34
|
isErrorPush: () => isErrorPush,
|
|
34
35
|
isReasoningPush: () => isReasoningPush,
|
|
35
36
|
isToolRequestPush: () => isToolRequestPush,
|
|
36
|
-
|
|
37
|
+
isValidUrl: () => isValidUrl,
|
|
38
|
+
normalizeVapidSubject: () => normalizeVapidSubject,
|
|
39
|
+
readReasoningContent: () => readReasoningContent,
|
|
40
|
+
stripReasoningTags: () => stripReasoningTags,
|
|
41
|
+
toUint8: () => toUint8,
|
|
42
|
+
validateAvatarUrl: () => validateAvatarUrl
|
|
37
43
|
});
|
|
38
44
|
module.exports = __toCommonJS(src_exports);
|
|
39
45
|
var MESSAGE_KIND = Object.freeze({
|
|
@@ -159,7 +165,7 @@ function validateNotificationArg(kind, value) {
|
|
|
159
165
|
throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`);
|
|
160
166
|
}
|
|
161
167
|
}
|
|
162
|
-
for (const f of ["renotify", "requireInteraction"]) {
|
|
168
|
+
for (const f of ["renotify", "requireInteraction", "silent"]) {
|
|
163
169
|
if (n[f] !== void 0 && typeof n[f] !== "boolean") {
|
|
164
170
|
throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`);
|
|
165
171
|
}
|
|
@@ -264,3 +270,66 @@ function concatBytes(...chunks) {
|
|
|
264
270
|
}
|
|
265
271
|
return out;
|
|
266
272
|
}
|
|
273
|
+
function isValidUrl(value) {
|
|
274
|
+
if (typeof value !== "string") return false;
|
|
275
|
+
try {
|
|
276
|
+
new URL(value);
|
|
277
|
+
return true;
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
283
|
+
function validateAvatarUrl(value) {
|
|
284
|
+
if (value === void 0 || value === null) return null;
|
|
285
|
+
if (typeof value !== "string") {
|
|
286
|
+
return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
|
|
287
|
+
}
|
|
288
|
+
if (/^data:/i.test(value)) {
|
|
289
|
+
return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
|
|
290
|
+
}
|
|
291
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
292
|
+
return `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
|
|
293
|
+
}
|
|
294
|
+
if (!isValidUrl(value)) {
|
|
295
|
+
return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
function normalizeVapidSubject(email) {
|
|
300
|
+
const trimmed = String(email || "").trim();
|
|
301
|
+
if (!trimmed) return "";
|
|
302
|
+
return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`;
|
|
303
|
+
}
|
|
304
|
+
var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
|
|
305
|
+
var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
|
|
306
|
+
function readReasoningContent(llmResponse) {
|
|
307
|
+
if (!llmResponse || typeof llmResponse !== "object") return null;
|
|
308
|
+
const choices = (
|
|
309
|
+
/** @type {{ choices?: unknown }} */
|
|
310
|
+
llmResponse.choices
|
|
311
|
+
);
|
|
312
|
+
if (!Array.isArray(choices) || choices.length === 0) return null;
|
|
313
|
+
const message = (
|
|
314
|
+
/** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */
|
|
315
|
+
choices[0]?.message
|
|
316
|
+
);
|
|
317
|
+
const raw = message?.reasoning_content;
|
|
318
|
+
if (typeof raw === "string") {
|
|
319
|
+
const trimmed = raw.trim();
|
|
320
|
+
if (trimmed.length > 0) return trimmed;
|
|
321
|
+
}
|
|
322
|
+
const content = message?.content;
|
|
323
|
+
if (typeof content === "string") {
|
|
324
|
+
const match = content.match(REASONING_TAG_RE);
|
|
325
|
+
if (match) {
|
|
326
|
+
const trimmed = match[2].trim();
|
|
327
|
+
if (trimmed.length > 0) return trimmed;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
function stripReasoningTags(content) {
|
|
333
|
+
if (typeof content !== "string" || !content.includes("<")) return content;
|
|
334
|
+
return content.replace(REASONING_TAG_RE_G, "").trim();
|
|
335
|
+
}
|
package/dist/index.d.cts
CHANGED
|
@@ -200,10 +200,10 @@ export function isErrorPush(value: unknown): value is ErrorPush;
|
|
|
200
200
|
/**
|
|
201
201
|
* Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
|
|
202
202
|
* always cutting at codepoint boundaries (never inside a multi-byte
|
|
203
|
-
* char).
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
203
|
+
* char). This is a generic byte-safe string helper retained for
|
|
204
|
+
* callers that need deterministic UTF-8 chunking around small Web Push
|
|
205
|
+
* payload budgets; current amsg-instant oversized payload delivery uses
|
|
206
|
+
* BlobStore / generic multipart instead of reasoning-only wire fields.
|
|
207
207
|
*
|
|
208
208
|
* Algorithm: TextEncoder → Uint8Array → backward scan from each
|
|
209
209
|
* candidate cut index until the byte is a UTF-8 lead byte (any byte
|
|
@@ -247,6 +247,56 @@ export function base64UrlToBytes(input: string): Uint8Array;
|
|
|
247
247
|
* @returns {Uint8Array}
|
|
248
248
|
*/
|
|
249
249
|
export function concatBytes(...chunks: (Uint8Array | ArrayBuffer | ArrayBufferView)[]): Uint8Array;
|
|
250
|
+
/**
|
|
251
|
+
* True when `value` parses as an absolute URL.
|
|
252
|
+
* @param {unknown} value
|
|
253
|
+
* @returns {boolean}
|
|
254
|
+
*/
|
|
255
|
+
export function isValidUrl(value: unknown): boolean;
|
|
256
|
+
/**
|
|
257
|
+
* Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
|
|
258
|
+
* base64-encoded inline images) and anything longer than
|
|
259
|
+
* {@link AVATAR_URL_MAX_LENGTH} chars — both the dominant trigger for
|
|
260
|
+
* downstream 413 / Web Push 4 KB payload errors — plus anything that doesn't
|
|
261
|
+
* parse as a URL. Returns an error message string, or null when valid.
|
|
262
|
+
*
|
|
263
|
+
* Pure: callers decide how to act on a non-null result (amsg-server /
|
|
264
|
+
* amsg-instant / amsg-client soft-strip + console.warn; see standards §6.2).
|
|
265
|
+
*
|
|
266
|
+
* @param {unknown} value
|
|
267
|
+
* @returns {string | null}
|
|
268
|
+
*/
|
|
269
|
+
export function validateAvatarUrl(value: unknown): string | null;
|
|
270
|
+
/**
|
|
271
|
+
* Normalize a VAPID `sub` (subject) claim. Web Push (RFC 8292) accepts a
|
|
272
|
+
* `mailto:` address or an `http(s):` URL; a bare contact like
|
|
273
|
+
* `you@example.com` is prefixed with `mailto:`. An already-prefixed
|
|
274
|
+
* `mailto:` / `http(s):` value is returned untouched. Empty / blank → `''`.
|
|
275
|
+
*
|
|
276
|
+
* @param {unknown} email
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
export function normalizeVapidSubject(email: unknown): string;
|
|
280
|
+
/**
|
|
281
|
+
* Read `choices[0].message.reasoning_content` as a non-empty trimmed string,
|
|
282
|
+
* or null when absent / empty. Falls back to the first `<think>` span inside
|
|
283
|
+
* `message.content` when a provider inlines reasoning there. Many providers
|
|
284
|
+
* return an empty string instead of omitting the field — treated the same as
|
|
285
|
+
* missing so callers don't emit an empty ReasoningPush.
|
|
286
|
+
*
|
|
287
|
+
* @param {unknown} llmResponse
|
|
288
|
+
* @returns {string | null}
|
|
289
|
+
*/
|
|
290
|
+
export function readReasoningContent(llmResponse: unknown): string | null;
|
|
291
|
+
/**
|
|
292
|
+
* Drop any `<think>` / `<thinking>` / `<thought>` spans from a user-facing
|
|
293
|
+
* content string, so private chain-of-thought leaking through `message.content`
|
|
294
|
+
* does not also ship inside the ContentPush burst.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} content
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function stripReasoningTags(content: string): string;
|
|
250
300
|
/**
|
|
251
301
|
* @rei-standard/amsg-shared
|
|
252
302
|
*
|
|
@@ -316,6 +366,8 @@ export const PUSH_SOURCE: Readonly<{
|
|
|
316
366
|
INSTANT: "instant";
|
|
317
367
|
SCHEDULED: "scheduled";
|
|
318
368
|
}>;
|
|
369
|
+
/** Max accepted `avatarUrl` length, in characters. */
|
|
370
|
+
export const AVATAR_URL_MAX_LENGTH: 2048;
|
|
319
371
|
/**
|
|
320
372
|
* Fields present on every push, regardless of kind. Discriminator
|
|
321
373
|
* fields (`messageKind`) and kind-specific fields live on the kind
|
|
@@ -361,18 +413,21 @@ export type AmsgPushCommon = {
|
|
|
361
413
|
};
|
|
362
414
|
/**
|
|
363
415
|
* SW-rendering directive. Mirrors the fields that `amsg-sw`'s
|
|
364
|
-
* `createNotificationFromPayload` consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction,data}`)
|
|
365
|
-
*
|
|
416
|
+
* `createNotificationFromPayload` consumes (`notification.{show,title,body,icon,badge,tag,renotify,requireInteraction,silent,data}`)
|
|
417
|
+
* so producers get builder validation for the fields the SW actually reads.
|
|
366
418
|
*
|
|
367
|
-
* Routing in SW
|
|
419
|
+
* Routing in SW:
|
|
368
420
|
* - By default (`show: "auto"` or omitted), `messageKind: 'content'` (and legacy un-kinded payloads)
|
|
369
421
|
* will display a system notification. `reasoning` / `tool_request` / `error` will dispatch silently.
|
|
370
422
|
* - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
|
|
371
|
-
* - When rendering, `notification.*` is consulted, with per-field
|
|
372
|
-
* the top-level
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
423
|
+
* - When rendering, `notification.*` is consulted first, with per-field
|
|
424
|
+
* fallback to the matching top-level payload fields (`title`,
|
|
425
|
+
* `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`,
|
|
426
|
+
* `renotify`, `requireInteraction`, `silent`, `data`), and finally to
|
|
427
|
+
* the SW's `defaultIcon` / `defaultBadge` options (boolean knobs
|
|
428
|
+
* default to `false` at the SW). Prefer setting overrides under
|
|
429
|
+
* `notification` for explicitness; top-level fallback exists so that
|
|
430
|
+
* legacy un-namespaced payloads keep working byte-for-byte.
|
|
376
431
|
*/
|
|
377
432
|
export type NotificationDirective = {
|
|
378
433
|
/**
|
|
@@ -380,35 +435,39 @@ export type NotificationDirective = {
|
|
|
380
435
|
*/
|
|
381
436
|
show?: "auto" | "always" | "when-hidden" | false;
|
|
382
437
|
/**
|
|
383
|
-
* - Notification title override.
|
|
438
|
+
* - Notification title override (falls back to top-level `title`, then `来自 {contactName}`).
|
|
384
439
|
*/
|
|
385
440
|
title?: string;
|
|
386
441
|
/**
|
|
387
|
-
* - Notification body override.
|
|
442
|
+
* - Notification body override (falls back to top-level `body`, then `message`).
|
|
388
443
|
*/
|
|
389
444
|
body?: string;
|
|
390
445
|
/**
|
|
391
|
-
* - Icon URL override (falls back to top-level `avatarUrl
|
|
446
|
+
* - Icon URL override (falls back to top-level `icon`/`avatarUrl`, then SW `defaultIcon`).
|
|
392
447
|
*/
|
|
393
448
|
icon?: string;
|
|
394
449
|
/**
|
|
395
|
-
* - Badge URL override (falls back to SW `defaultBadge`).
|
|
450
|
+
* - Badge URL override (falls back to top-level `badge`, then SW `defaultBadge`).
|
|
396
451
|
*/
|
|
397
452
|
badge?: string;
|
|
398
453
|
/**
|
|
399
|
-
* - Notification grouping tag; matching tag replaces the prior notification.
|
|
454
|
+
* - Notification grouping tag; matching tag replaces the prior notification (falls back to top-level `tag`, then `messageId`, then a generated unique tag).
|
|
400
455
|
*/
|
|
401
456
|
tag?: string;
|
|
402
457
|
/**
|
|
403
|
-
* - When tag matches, still vibrate/sound
|
|
458
|
+
* - When tag matches, still vibrate/sound (falls back to top-level `renotify`, default false at SW).
|
|
404
459
|
*/
|
|
405
460
|
renotify?: boolean;
|
|
406
461
|
/**
|
|
407
|
-
* - Notification stays until user dismisses
|
|
462
|
+
* - Notification stays until user dismisses (falls back to top-level `requireInteraction`, default false at SW).
|
|
408
463
|
*/
|
|
409
464
|
requireInteraction?: boolean;
|
|
410
465
|
/**
|
|
411
|
-
* -
|
|
466
|
+
* - Suppress sound and vibration (falls back to top-level `silent`, default false at SW).
|
|
467
|
+
*/
|
|
468
|
+
silent?: boolean;
|
|
469
|
+
/**
|
|
470
|
+
* - Custom payload data to attach to the notification (falls back to top-level `data`).
|
|
412
471
|
*/
|
|
413
472
|
data?: Record<string, unknown>;
|
|
414
473
|
};
|
|
@@ -432,24 +491,24 @@ export type ContentPush = AmsgPushCommon & {
|
|
|
432
491
|
* out of the upstream response into its own push. Emitted **before**
|
|
433
492
|
* the matching {@link ContentPush} burst when present and non-empty.
|
|
434
493
|
*
|
|
435
|
-
* Reasoning carries two
|
|
436
|
-
*
|
|
437
|
-
*
|
|
494
|
+
* Reasoning carries two optional "multi-part" axes, both *omitted* when
|
|
495
|
+
* the part count is 1 so the wire stays byte-for-byte compatible with
|
|
496
|
+
* single-shot callers. The type reserves them for forward compatibility;
|
|
497
|
+
* current producers emit a single ReasoningPush and set neither — oversized
|
|
498
|
+
* reasoning rides the generic multipart transport, not a reasoning-only
|
|
499
|
+
* chunk format.
|
|
438
500
|
*
|
|
439
|
-
* - `messageIndex` / `totalMessages` —
|
|
440
|
-
*
|
|
441
|
-
* reasoning into multiple sentences for typing-bubble UX.
|
|
501
|
+
* - `messageIndex` / `totalMessages` — a 1-based part index when a producer
|
|
502
|
+
* splits reasoning into multiple sentences for typing-bubble UX.
|
|
442
503
|
*
|
|
443
|
-
* - `chunkIndex` / `totalChunks` —
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
|
|
449
|
-
* splitter helper.
|
|
504
|
+
* - `chunkIndex` / `totalChunks` — transport-only slicing when a single
|
|
505
|
+
* segment exceeds the Web Push payload limit; SW would reassemble the
|
|
506
|
+
* original `reasoningContent` by sorting on `chunkIndex` within a
|
|
507
|
+
* `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes`
|
|
508
|
+
* for the safe-edge splitter helper.
|
|
450
509
|
*
|
|
451
|
-
* Both axes can coexist on the same push when a sentence-split
|
|
452
|
-
*
|
|
510
|
+
* Both axes can coexist on the same push when a sentence-split segment is
|
|
511
|
+
* itself oversized.
|
|
453
512
|
*/
|
|
454
513
|
export type ReasoningPush = AmsgPushCommon & {
|
|
455
514
|
messageKind: "reasoning";
|
package/dist/index.d.ts
CHANGED
|
@@ -200,10 +200,10 @@ export function isErrorPush(value: unknown): value is ErrorPush;
|
|
|
200
200
|
/**
|
|
201
201
|
* Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
|
|
202
202
|
* always cutting at codepoint boundaries (never inside a multi-byte
|
|
203
|
-
* char).
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
203
|
+
* char). This is a generic byte-safe string helper retained for
|
|
204
|
+
* callers that need deterministic UTF-8 chunking around small Web Push
|
|
205
|
+
* payload budgets; current amsg-instant oversized payload delivery uses
|
|
206
|
+
* BlobStore / generic multipart instead of reasoning-only wire fields.
|
|
207
207
|
*
|
|
208
208
|
* Algorithm: TextEncoder → Uint8Array → backward scan from each
|
|
209
209
|
* candidate cut index until the byte is a UTF-8 lead byte (any byte
|
|
@@ -247,6 +247,56 @@ export function base64UrlToBytes(input: string): Uint8Array;
|
|
|
247
247
|
* @returns {Uint8Array}
|
|
248
248
|
*/
|
|
249
249
|
export function concatBytes(...chunks: (Uint8Array | ArrayBuffer | ArrayBufferView)[]): Uint8Array;
|
|
250
|
+
/**
|
|
251
|
+
* True when `value` parses as an absolute URL.
|
|
252
|
+
* @param {unknown} value
|
|
253
|
+
* @returns {boolean}
|
|
254
|
+
*/
|
|
255
|
+
export function isValidUrl(value: unknown): boolean;
|
|
256
|
+
/**
|
|
257
|
+
* Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
|
|
258
|
+
* base64-encoded inline images) and anything longer than
|
|
259
|
+
* {@link AVATAR_URL_MAX_LENGTH} chars — both the dominant trigger for
|
|
260
|
+
* downstream 413 / Web Push 4 KB payload errors — plus anything that doesn't
|
|
261
|
+
* parse as a URL. Returns an error message string, or null when valid.
|
|
262
|
+
*
|
|
263
|
+
* Pure: callers decide how to act on a non-null result (amsg-server /
|
|
264
|
+
* amsg-instant / amsg-client soft-strip + console.warn; see standards §6.2).
|
|
265
|
+
*
|
|
266
|
+
* @param {unknown} value
|
|
267
|
+
* @returns {string | null}
|
|
268
|
+
*/
|
|
269
|
+
export function validateAvatarUrl(value: unknown): string | null;
|
|
270
|
+
/**
|
|
271
|
+
* Normalize a VAPID `sub` (subject) claim. Web Push (RFC 8292) accepts a
|
|
272
|
+
* `mailto:` address or an `http(s):` URL; a bare contact like
|
|
273
|
+
* `you@example.com` is prefixed with `mailto:`. An already-prefixed
|
|
274
|
+
* `mailto:` / `http(s):` value is returned untouched. Empty / blank → `''`.
|
|
275
|
+
*
|
|
276
|
+
* @param {unknown} email
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
export function normalizeVapidSubject(email: unknown): string;
|
|
280
|
+
/**
|
|
281
|
+
* Read `choices[0].message.reasoning_content` as a non-empty trimmed string,
|
|
282
|
+
* or null when absent / empty. Falls back to the first `<think>` span inside
|
|
283
|
+
* `message.content` when a provider inlines reasoning there. Many providers
|
|
284
|
+
* return an empty string instead of omitting the field — treated the same as
|
|
285
|
+
* missing so callers don't emit an empty ReasoningPush.
|
|
286
|
+
*
|
|
287
|
+
* @param {unknown} llmResponse
|
|
288
|
+
* @returns {string | null}
|
|
289
|
+
*/
|
|
290
|
+
export function readReasoningContent(llmResponse: unknown): string | null;
|
|
291
|
+
/**
|
|
292
|
+
* Drop any `<think>` / `<thinking>` / `<thought>` spans from a user-facing
|
|
293
|
+
* content string, so private chain-of-thought leaking through `message.content`
|
|
294
|
+
* does not also ship inside the ContentPush burst.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} content
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function stripReasoningTags(content: string): string;
|
|
250
300
|
/**
|
|
251
301
|
* @rei-standard/amsg-shared
|
|
252
302
|
*
|
|
@@ -316,6 +366,8 @@ export const PUSH_SOURCE: Readonly<{
|
|
|
316
366
|
INSTANT: "instant";
|
|
317
367
|
SCHEDULED: "scheduled";
|
|
318
368
|
}>;
|
|
369
|
+
/** Max accepted `avatarUrl` length, in characters. */
|
|
370
|
+
export const AVATAR_URL_MAX_LENGTH: 2048;
|
|
319
371
|
/**
|
|
320
372
|
* Fields present on every push, regardless of kind. Discriminator
|
|
321
373
|
* fields (`messageKind`) and kind-specific fields live on the kind
|
|
@@ -361,18 +413,21 @@ export type AmsgPushCommon = {
|
|
|
361
413
|
};
|
|
362
414
|
/**
|
|
363
415
|
* SW-rendering directive. Mirrors the fields that `amsg-sw`'s
|
|
364
|
-
* `createNotificationFromPayload` consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction,data}`)
|
|
365
|
-
*
|
|
416
|
+
* `createNotificationFromPayload` consumes (`notification.{show,title,body,icon,badge,tag,renotify,requireInteraction,silent,data}`)
|
|
417
|
+
* so producers get builder validation for the fields the SW actually reads.
|
|
366
418
|
*
|
|
367
|
-
* Routing in SW
|
|
419
|
+
* Routing in SW:
|
|
368
420
|
* - By default (`show: "auto"` or omitted), `messageKind: 'content'` (and legacy un-kinded payloads)
|
|
369
421
|
* will display a system notification. `reasoning` / `tool_request` / `error` will dispatch silently.
|
|
370
422
|
* - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
|
|
371
|
-
* - When rendering, `notification.*` is consulted, with per-field
|
|
372
|
-
* the top-level
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
423
|
+
* - When rendering, `notification.*` is consulted first, with per-field
|
|
424
|
+
* fallback to the matching top-level payload fields (`title`,
|
|
425
|
+
* `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`,
|
|
426
|
+
* `renotify`, `requireInteraction`, `silent`, `data`), and finally to
|
|
427
|
+
* the SW's `defaultIcon` / `defaultBadge` options (boolean knobs
|
|
428
|
+
* default to `false` at the SW). Prefer setting overrides under
|
|
429
|
+
* `notification` for explicitness; top-level fallback exists so that
|
|
430
|
+
* legacy un-namespaced payloads keep working byte-for-byte.
|
|
376
431
|
*/
|
|
377
432
|
export type NotificationDirective = {
|
|
378
433
|
/**
|
|
@@ -380,35 +435,39 @@ export type NotificationDirective = {
|
|
|
380
435
|
*/
|
|
381
436
|
show?: "auto" | "always" | "when-hidden" | false;
|
|
382
437
|
/**
|
|
383
|
-
* - Notification title override.
|
|
438
|
+
* - Notification title override (falls back to top-level `title`, then `来自 {contactName}`).
|
|
384
439
|
*/
|
|
385
440
|
title?: string;
|
|
386
441
|
/**
|
|
387
|
-
* - Notification body override.
|
|
442
|
+
* - Notification body override (falls back to top-level `body`, then `message`).
|
|
388
443
|
*/
|
|
389
444
|
body?: string;
|
|
390
445
|
/**
|
|
391
|
-
* - Icon URL override (falls back to top-level `avatarUrl
|
|
446
|
+
* - Icon URL override (falls back to top-level `icon`/`avatarUrl`, then SW `defaultIcon`).
|
|
392
447
|
*/
|
|
393
448
|
icon?: string;
|
|
394
449
|
/**
|
|
395
|
-
* - Badge URL override (falls back to SW `defaultBadge`).
|
|
450
|
+
* - Badge URL override (falls back to top-level `badge`, then SW `defaultBadge`).
|
|
396
451
|
*/
|
|
397
452
|
badge?: string;
|
|
398
453
|
/**
|
|
399
|
-
* - Notification grouping tag; matching tag replaces the prior notification.
|
|
454
|
+
* - Notification grouping tag; matching tag replaces the prior notification (falls back to top-level `tag`, then `messageId`, then a generated unique tag).
|
|
400
455
|
*/
|
|
401
456
|
tag?: string;
|
|
402
457
|
/**
|
|
403
|
-
* - When tag matches, still vibrate/sound
|
|
458
|
+
* - When tag matches, still vibrate/sound (falls back to top-level `renotify`, default false at SW).
|
|
404
459
|
*/
|
|
405
460
|
renotify?: boolean;
|
|
406
461
|
/**
|
|
407
|
-
* - Notification stays until user dismisses
|
|
462
|
+
* - Notification stays until user dismisses (falls back to top-level `requireInteraction`, default false at SW).
|
|
408
463
|
*/
|
|
409
464
|
requireInteraction?: boolean;
|
|
410
465
|
/**
|
|
411
|
-
* -
|
|
466
|
+
* - Suppress sound and vibration (falls back to top-level `silent`, default false at SW).
|
|
467
|
+
*/
|
|
468
|
+
silent?: boolean;
|
|
469
|
+
/**
|
|
470
|
+
* - Custom payload data to attach to the notification (falls back to top-level `data`).
|
|
412
471
|
*/
|
|
413
472
|
data?: Record<string, unknown>;
|
|
414
473
|
};
|
|
@@ -432,24 +491,24 @@ export type ContentPush = AmsgPushCommon & {
|
|
|
432
491
|
* out of the upstream response into its own push. Emitted **before**
|
|
433
492
|
* the matching {@link ContentPush} burst when present and non-empty.
|
|
434
493
|
*
|
|
435
|
-
* Reasoning carries two
|
|
436
|
-
*
|
|
437
|
-
*
|
|
494
|
+
* Reasoning carries two optional "multi-part" axes, both *omitted* when
|
|
495
|
+
* the part count is 1 so the wire stays byte-for-byte compatible with
|
|
496
|
+
* single-shot callers. The type reserves them for forward compatibility;
|
|
497
|
+
* current producers emit a single ReasoningPush and set neither — oversized
|
|
498
|
+
* reasoning rides the generic multipart transport, not a reasoning-only
|
|
499
|
+
* chunk format.
|
|
438
500
|
*
|
|
439
|
-
* - `messageIndex` / `totalMessages` —
|
|
440
|
-
*
|
|
441
|
-
* reasoning into multiple sentences for typing-bubble UX.
|
|
501
|
+
* - `messageIndex` / `totalMessages` — a 1-based part index when a producer
|
|
502
|
+
* splits reasoning into multiple sentences for typing-bubble UX.
|
|
442
503
|
*
|
|
443
|
-
* - `chunkIndex` / `totalChunks` —
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
|
|
449
|
-
* splitter helper.
|
|
504
|
+
* - `chunkIndex` / `totalChunks` — transport-only slicing when a single
|
|
505
|
+
* segment exceeds the Web Push payload limit; SW would reassemble the
|
|
506
|
+
* original `reasoningContent` by sorting on `chunkIndex` within a
|
|
507
|
+
* `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes`
|
|
508
|
+
* for the safe-edge splitter helper.
|
|
450
509
|
*
|
|
451
|
-
* Both axes can coexist on the same push when a sentence-split
|
|
452
|
-
*
|
|
510
|
+
* Both axes can coexist on the same push when a sentence-split segment is
|
|
511
|
+
* itself oversized.
|
|
453
512
|
*/
|
|
454
513
|
export type ReasoningPush = AmsgPushCommon & {
|
|
455
514
|
messageKind: "reasoning";
|
package/dist/index.mjs
CHANGED
|
@@ -122,7 +122,7 @@ function validateNotificationArg(kind, value) {
|
|
|
122
122
|
throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`);
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
for (const f of ["renotify", "requireInteraction"]) {
|
|
125
|
+
for (const f of ["renotify", "requireInteraction", "silent"]) {
|
|
126
126
|
if (n[f] !== void 0 && typeof n[f] !== "boolean") {
|
|
127
127
|
throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`);
|
|
128
128
|
}
|
|
@@ -227,7 +227,71 @@ function concatBytes(...chunks) {
|
|
|
227
227
|
}
|
|
228
228
|
return out;
|
|
229
229
|
}
|
|
230
|
+
function isValidUrl(value) {
|
|
231
|
+
if (typeof value !== "string") return false;
|
|
232
|
+
try {
|
|
233
|
+
new URL(value);
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
240
|
+
function validateAvatarUrl(value) {
|
|
241
|
+
if (value === void 0 || value === null) return null;
|
|
242
|
+
if (typeof value !== "string") {
|
|
243
|
+
return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
|
|
244
|
+
}
|
|
245
|
+
if (/^data:/i.test(value)) {
|
|
246
|
+
return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
|
|
247
|
+
}
|
|
248
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
249
|
+
return `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
|
|
250
|
+
}
|
|
251
|
+
if (!isValidUrl(value)) {
|
|
252
|
+
return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
function normalizeVapidSubject(email) {
|
|
257
|
+
const trimmed = String(email || "").trim();
|
|
258
|
+
if (!trimmed) return "";
|
|
259
|
+
return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`;
|
|
260
|
+
}
|
|
261
|
+
var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
|
|
262
|
+
var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
|
|
263
|
+
function readReasoningContent(llmResponse) {
|
|
264
|
+
if (!llmResponse || typeof llmResponse !== "object") return null;
|
|
265
|
+
const choices = (
|
|
266
|
+
/** @type {{ choices?: unknown }} */
|
|
267
|
+
llmResponse.choices
|
|
268
|
+
);
|
|
269
|
+
if (!Array.isArray(choices) || choices.length === 0) return null;
|
|
270
|
+
const message = (
|
|
271
|
+
/** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */
|
|
272
|
+
choices[0]?.message
|
|
273
|
+
);
|
|
274
|
+
const raw = message?.reasoning_content;
|
|
275
|
+
if (typeof raw === "string") {
|
|
276
|
+
const trimmed = raw.trim();
|
|
277
|
+
if (trimmed.length > 0) return trimmed;
|
|
278
|
+
}
|
|
279
|
+
const content = message?.content;
|
|
280
|
+
if (typeof content === "string") {
|
|
281
|
+
const match = content.match(REASONING_TAG_RE);
|
|
282
|
+
if (match) {
|
|
283
|
+
const trimmed = match[2].trim();
|
|
284
|
+
if (trimmed.length > 0) return trimmed;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function stripReasoningTags(content) {
|
|
290
|
+
if (typeof content !== "string" || !content.includes("<")) return content;
|
|
291
|
+
return content.replace(REASONING_TAG_RE_G, "").trim();
|
|
292
|
+
}
|
|
230
293
|
export {
|
|
294
|
+
AVATAR_URL_MAX_LENGTH,
|
|
231
295
|
MESSAGE_KIND,
|
|
232
296
|
MESSAGE_TYPE,
|
|
233
297
|
PUSH_SOURCE,
|
|
@@ -242,5 +306,10 @@ export {
|
|
|
242
306
|
isErrorPush,
|
|
243
307
|
isReasoningPush,
|
|
244
308
|
isToolRequestPush,
|
|
245
|
-
|
|
309
|
+
isValidUrl,
|
|
310
|
+
normalizeVapidSubject,
|
|
311
|
+
readReasoningContent,
|
|
312
|
+
stripReasoningTags,
|
|
313
|
+
toUint8,
|
|
314
|
+
validateAvatarUrl
|
|
246
315
|
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rei-standard/amsg-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "https://github.com/Tosd0/ReiStandard",
|
|
7
|
+
"url": "git+https://github.com/Tosd0/ReiStandard.git",
|
|
8
8
|
"directory": "packages/rei-standard-amsg/shared"
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|