@rei-standard/amsg-shared 0.4.0-next.6 → 0.4.0-next.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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. `auto` follows SW defaults. |
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. |
@@ -78,12 +78,56 @@ omit all four.
78
78
  | `tag` | `string?` | Notification grouping tag. |
79
79
  | `renotify` | `boolean?` | Re-alert when a matching `tag` replaces an existing notification. |
80
80
  | `requireInteraction` | `boolean?` | Keep the notification visible until the user dismisses it. |
81
- | `silent` | `boolean?` | Suppress notification sound and vibration. |
81
+ | `silent` | `(boolean \| 'when-visible')?` | Suppress notification sound and vibration — see [选哪个 `silent`](#选哪个-silent). |
82
82
  | `data` | `Record<string, unknown>?` | Custom data passed to the notification. |
83
83
 
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
+ ### 选哪个 `silent`
105
+
106
+ `silent` 管的是「响不响铃、震不震」,跟弹不弹(`show`)是两件独立的事——通知照样进通知中心,只是安静地进。
107
+
108
+ | 值 | SW 那边 | 什么时候用 |
109
+ |---|---|---|
110
+ | 不配 / `false` | 正常响铃震动 | 默认 |
111
+ | `true` | 一律不响 | 一串连着来的通知配 `tag` 折叠,只想在通知中心留个痕迹 |
112
+ | `'when-visible'` | 有可见窗口就静音,没有就照常响 | 页面自己会把内容画出来的那类消息:用户正看着页面就别再响一声,人切后台了照样叫得动 |
113
+
114
+ `'when-visible'` 只能由 Service Worker 当场算:发送端发推那一刻并不知道用户此刻在不在前台,写死 `silent: true` 的话,用户切到后台收到的那条也不会响。判定用的是跟 `show: 'when-hidden'` 同一套窗口可见性口径,一条 payload 只算一次。
115
+
116
+ ### `notificationIntent(payload)`
117
+
118
+ 把上面这套规则算成一个值,`'always'` / `'when-hidden'` / `'never'`:`notification.show` 说了算,没说才按 `messageKind` 走默认。两端读同一份——SW 拿它决定要不要 `showNotification`,发送端拿它决定这条值不值得占用推送通道(`'never'` 的 payload 推过去不会有任何可见反馈,`@rei-standard/amsg-server` 只把它落进收件箱,等客户端上线 `GET /outbox?since=` 补拉)。
119
+
120
+ ```js
121
+ import { notificationIntent } from '@rei-standard/amsg-shared';
122
+
123
+ notificationIntent({ messageKind: 'content' }); // 'always'
124
+ notificationIntent({ messageKind: 'reasoning' }); // 'never'
125
+ notificationIntent({ messageKind: 'reasoning', notification: { show: 'always' } }); // 'always'
126
+ notificationIntent({ messageKind: 'content', notification: { show: false } }); // 'never'
127
+ ```
128
+
129
+ `'when-hidden'` 单独占一档,因为它到底弹不弹要看当下有没有可见窗口,那只有 SW 知道;发送端把它当「可能会弹」照发。
130
+
87
131
  ---
88
132
 
89
133
  ## Per-kind fields
@@ -168,7 +212,7 @@ The legacy `type` field is **gone** — do not look for it on
168
212
  两处与别的 kind 不同:
169
213
 
170
214
  - **投递路径**:产出方(`@rei-standard/amsg-server` 的 `ctx.emitResult()`)除了推送,还把它落进服务端收件箱,客户端下次 `GET /outbox?since=` 一定拿得到。
171
- - **通知**:SW 侧默认弹(与 `content` 同待遇,其余三种是静默送给页面)——结果往往正是「跑完了,回来看看」那句话。不想弹就带 `notification: { show: false }`。
215
+ - **通知**:SW 侧默认弹(与 `content` 同待遇,其余三种是静默送给页面)——结果往往正是「跑完了,回来看看」那句话。不想弹就带 `notification: { show: false }`:这一档 `amsg-server` 不发推送、只落收件箱,客户端补拉时拿到(见[选哪个 `show`](#选哪个-show))。
172
216
 
173
217
  ---
174
218
 
package/dist/index.cjs CHANGED
@@ -66,6 +66,7 @@ __export(src_exports, {
66
66
  jsonToBase64Url: () => jsonToBase64Url,
67
67
  normalizeAiApiUrl: () => normalizeAiApiUrl,
68
68
  normalizeVapidSubject: () => normalizeVapidSubject,
69
+ notificationIntent: () => notificationIntent,
69
70
  randomBytes: () => randomBytes,
70
71
  randomUUID: () => randomUUID,
71
72
  readReasoningContent: () => readReasoningContent,
@@ -845,6 +846,17 @@ var PUSH_SOURCE = Object.freeze({
845
846
  INSTANT: "instant",
846
847
  SCHEDULED: "scheduled"
847
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
+ }
848
860
  function requireField(kind, field, value) {
849
861
  if (value === void 0 || value === null || value === "") {
850
862
  throw new Error(`[amsg-shared] ${kind}: '${field}' is required`);
@@ -952,11 +964,14 @@ function validateNotificationArg(kind, value) {
952
964
  throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`);
953
965
  }
954
966
  }
955
- for (const f of ["renotify", "requireInteraction", "silent"]) {
967
+ for (const f of ["renotify", "requireInteraction"]) {
956
968
  if (n[f] !== void 0 && typeof n[f] !== "boolean") {
957
969
  throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`);
958
970
  }
959
971
  }
972
+ if (n.silent !== void 0 && typeof n.silent !== "boolean" && n.silent !== "when-visible") {
973
+ throw new Error(`[amsg-shared] ${kind}: 'notification.silent' must be true, false, or "when-visible"`);
974
+ }
960
975
  if (n.data !== void 0 && (n.data === null || typeof n.data !== "object" || Array.isArray(n.data))) {
961
976
  throw new Error(`[amsg-shared] ${kind}: 'notification.data' must be a plain object when present`);
962
977
  }
package/dist/index.d.cts CHANGED
@@ -1,3 +1,110 @@
1
+ /**
2
+ * Fields present on every push, regardless of kind. Discriminator
3
+ * fields (`messageKind`) and kind-specific fields live on the kind
4
+ * interfaces below.
5
+ *
6
+ * `metadata` is a passthrough namespace owned by the caller. Packages
7
+ * are forbidden from writing their own fields into `metadata` — any
8
+ * protocol-level data goes on top-level fields.
9
+ *
10
+ * Scheduling identity (`taskId` / `taskUuid` / `recurrenceType` /
11
+ * `occurrenceMs`) is stamped by `@rei-standard/amsg-server` on every push
12
+ * that came out of a scheduled task row. It tells the client which task
13
+ * this is, whether the task will come back, and which nominal fire time
14
+ * produced this burst — so a task the client never created (one the
15
+ * character scheduled for itself at fire time) still arrives fully
16
+ * identified. Pushes with no task behind them (amsg-instant) omit all four.
17
+ *
18
+ * @typedef {Object} AmsgPushCommon
19
+ * @property {MessageType} messageType - How the push was produced.
20
+ * @property {PushSource} source - Which sub-package routed it.
21
+ * @property {string} messageId - Unique per push. Format owned by the producer.
22
+ * @property {string} sessionId - Shared across all pushes from one LLM round (reasoning + content) and across iterations of a single agentic-loop request. Opaque id — do not parse it for task identity, read the fields below.
23
+ * @property {string} timestamp - ISO 8601 timestamp at producer.
24
+ * @property {string} [messageSubtype] - Caller-defined business namespace. Defaults to 'chat' at producers.
25
+ * @property {Object} [metadata] - Caller passthrough. Packages MUST NOT write here.
26
+ * @property {NotificationDirective} [notification] - SW notification strategy.
27
+ * @property {number | string | null} [taskId] - Scheduled task row id.
28
+ * @property {string | null} [taskUuid] - Scheduled task uuid (the id the scheduling side chose).
29
+ * @property {'none' | 'daily' | 'weekly'} [recurrenceType] - Whether that task fires again.
30
+ * @property {number | null} [occurrenceMs] - Nominal fire time of this occurrence (epoch ms).
31
+ */
32
+ /**
33
+ * SW-rendering directive. Mirrors the fields that `amsg-sw`'s
34
+ * `createNotificationFromPayload` consumes (`notification.{show,title,body,icon,badge,tag,renotify,requireInteraction,silent,data}`)
35
+ * so producers get builder validation for the fields the SW actually reads.
36
+ *
37
+ * Routing in SW:
38
+ * - By default (`show: "auto"` or omitted), `messageKind: 'content'` / `'result'` (and legacy
39
+ * un-kinded payloads) will display a system notification. `reasoning` / `tool_request` /
40
+ * `error` will dispatch silently.
41
+ * - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
42
+ * - `silent: "when-visible"` is resolved by the SW at render time from the
43
+ * same window-visibility check `show: "when-hidden"` uses: silent while a
44
+ * `visibilityState === "visible"` window client exists, audible otherwise.
45
+ * `true` / `false` stay literal.
46
+ * - When rendering, `notification.*` is consulted first, with per-field
47
+ * fallback to the matching top-level payload fields (`title`,
48
+ * `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`,
49
+ * `renotify`, `requireInteraction`, `silent`, `data`), and finally to
50
+ * the SW's `defaultIcon` / `defaultBadge` options (boolean knobs
51
+ * default to `false` at the SW). Prefer setting overrides under
52
+ * `notification` for explicitness; top-level fallback exists so that
53
+ * legacy un-namespaced payloads keep working byte-for-byte.
54
+ *
55
+ * 选 `show` 的口径只有一条,跟机型无关:**要推就一定弹**(`"always"`,嫌打扰
56
+ * 配 `tag` 折叠 + `silent`),**不想弹就别推**(不配或配 `false`,内容落服务端
57
+ * 收件箱,等客户端上线补拉)。收到 push 却不弹通知是违约:Firefox 按配额退
58
+ * 订,iOS 过了订阅宽限期直接吊销。`"when-hidden"` 是给老部署留的兼容档,新代
59
+ * 码不选。`show: false` 在两端也不是同一件事——有收件箱的发送端见到它根本不发
60
+ * 这条 push,SW 压根收不到。
61
+ *
62
+ * 想要「前台安静、切后台照常响」就配 `silent: "when-visible"`:`show` 照旧
63
+ * `"always"`(通知一定弹出来,那笔账不欠),响不响铃由 SW 收到时看有没有可见
64
+ * 窗口现算。发送端定不了这件事——它发推那一刻并不知道用户在不在前台,写死
65
+ * `silent: true` 的结果是切后台也不响。
66
+ *
67
+ * 各档取舍见本包 README 的「选哪个 `show`」与 `@rei-standard/amsg-sw` README
68
+ * 的「不展示通知的代价」。
69
+ *
70
+ * @typedef {Object} NotificationDirective
71
+ * @property {"auto" | "always" | "when-hidden" | false} [show] - Rendering strategy. Defaults to "auto" (render only if messageKind is content).
72
+ * @property {string} [title] - Notification title override (falls back to top-level `title`, then `来自 {contactName}`).
73
+ * @property {string} [body] - Notification body override (falls back to top-level `body`, then `message`).
74
+ * @property {string} [icon] - Icon URL override (falls back to top-level `icon`/`avatarUrl`, then SW `defaultIcon`).
75
+ * @property {string} [badge] - Badge URL override (falls back to top-level `badge`, then SW `defaultBadge`).
76
+ * @property {string} [tag] - Notification grouping tag; matching tag replaces the prior notification (falls back to top-level `tag`, then `messageId`, then a generated unique tag).
77
+ * @property {boolean} [renotify] - When tag matches, still vibrate/sound (falls back to top-level `renotify`, default false at SW).
78
+ * @property {boolean} [requireInteraction] - Notification stays until user dismisses (falls back to top-level `requireInteraction`, default false at SW).
79
+ * @property {boolean | "when-visible"} [silent] - Suppress sound and vibration. `true` / `false` are literal; `"when-visible"` is decided by the SW at render time — silent while a window client is visible, audible otherwise (falls back to top-level `silent`, default false at SW).
80
+ * @property {Record<string, unknown>} [data] - Custom payload data to attach to the notification (falls back to top-level `data`).
81
+ */
82
+ /**
83
+ * 这条 payload 到了客户端会不会弹系统通知——发送端与 SW 共用的这一份判定。
84
+ *
85
+ * 三个取值:
86
+ *
87
+ * | 返回值 | 意思 |
88
+ * |----------------|------|
89
+ * | `'always'` | 一定弹 |
90
+ * | `'when-hidden'`| 看有没有可见窗口,由 SW 当场定(发送端无从知道)。兼容档,新代码用 `'always'` |
91
+ * | `'never'` | 一定不弹 |
92
+ *
93
+ * 判定顺序跟 SW 里一模一样:`notification.show` 说了算,没说才按 `messageKind`
94
+ * 走默认(`content` / `result` 与缺 kind 的 2.0.x 老 payload 弹,`reasoning` /
95
+ * `tool_request` / `error` 不弹)。
96
+ *
97
+ * 两端各拿它做一件事:
98
+ *
99
+ * - **SW**:决定要不要 `showNotification`。
100
+ * - **发送端**:决定这条值不值得占用推送通道。`'never'` 的 payload 推过去就是
101
+ * 一次「收了 push 却不弹通知」,浏览器那边要记账;有收件箱兜底的发送端
102
+ * (`@rei-standard/amsg-server`)因此只落行、不推,等客户端上线补拉。
103
+ *
104
+ * @param {Record<string, unknown>|null|undefined} payload
105
+ * @returns {'always' | 'when-hidden' | 'never'}
106
+ */
107
+ export function notificationIntent(payload: Record<string, unknown> | null | undefined): "always" | "when-hidden" | "never";
1
108
  /**
2
109
  * Build a {@link ContentPush}. Use this for legacy sentence-split
3
110
  * bursts (set `messageIndex` 1-based + `totalMessages`) or for a
@@ -576,6 +683,10 @@ export type AmsgPushCommon = {
576
683
  * un-kinded payloads) will display a system notification. `reasoning` / `tool_request` /
577
684
  * `error` will dispatch silently.
578
685
  * - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
686
+ * - `silent: "when-visible"` is resolved by the SW at render time from the
687
+ * same window-visibility check `show: "when-hidden"` uses: silent while a
688
+ * `visibilityState === "visible"` window client exists, audible otherwise.
689
+ * `true` / `false` stay literal.
579
690
  * - When rendering, `notification.*` is consulted first, with per-field
580
691
  * fallback to the matching top-level payload fields (`title`,
581
692
  * `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`,
@@ -584,6 +695,21 @@ export type AmsgPushCommon = {
584
695
  * default to `false` at the SW). Prefer setting overrides under
585
696
  * `notification` for explicitness; top-level fallback exists so that
586
697
  * legacy un-namespaced payloads keep working byte-for-byte.
698
+ *
699
+ * 选 `show` 的口径只有一条,跟机型无关:**要推就一定弹**(`"always"`,嫌打扰
700
+ * 配 `tag` 折叠 + `silent`),**不想弹就别推**(不配或配 `false`,内容落服务端
701
+ * 收件箱,等客户端上线补拉)。收到 push 却不弹通知是违约:Firefox 按配额退
702
+ * 订,iOS 过了订阅宽限期直接吊销。`"when-hidden"` 是给老部署留的兼容档,新代
703
+ * 码不选。`show: false` 在两端也不是同一件事——有收件箱的发送端见到它根本不发
704
+ * 这条 push,SW 压根收不到。
705
+ *
706
+ * 想要「前台安静、切后台照常响」就配 `silent: "when-visible"`:`show` 照旧
707
+ * `"always"`(通知一定弹出来,那笔账不欠),响不响铃由 SW 收到时看有没有可见
708
+ * 窗口现算。发送端定不了这件事——它发推那一刻并不知道用户在不在前台,写死
709
+ * `silent: true` 的结果是切后台也不响。
710
+ *
711
+ * 各档取舍见本包 README 的「选哪个 `show`」与 `@rei-standard/amsg-sw` README
712
+ * 的「不展示通知的代价」。
587
713
  */
588
714
  export type NotificationDirective = {
589
715
  /**
@@ -619,9 +745,9 @@ export type NotificationDirective = {
619
745
  */
620
746
  requireInteraction?: boolean;
621
747
  /**
622
- * - Suppress sound and vibration (falls back to top-level `silent`, default false at SW).
748
+ * - Suppress sound and vibration. `true` / `false` are literal; `"when-visible"` is decided by the SW at render time — silent while a window client is visible, audible otherwise (falls back to top-level `silent`, default false at SW).
623
749
  */
624
- silent?: boolean;
750
+ silent?: boolean | "when-visible";
625
751
  /**
626
752
  * - Custom payload data to attach to the notification (falls back to top-level `data`).
627
753
  */
package/dist/index.d.ts CHANGED
@@ -1,3 +1,110 @@
1
+ /**
2
+ * Fields present on every push, regardless of kind. Discriminator
3
+ * fields (`messageKind`) and kind-specific fields live on the kind
4
+ * interfaces below.
5
+ *
6
+ * `metadata` is a passthrough namespace owned by the caller. Packages
7
+ * are forbidden from writing their own fields into `metadata` — any
8
+ * protocol-level data goes on top-level fields.
9
+ *
10
+ * Scheduling identity (`taskId` / `taskUuid` / `recurrenceType` /
11
+ * `occurrenceMs`) is stamped by `@rei-standard/amsg-server` on every push
12
+ * that came out of a scheduled task row. It tells the client which task
13
+ * this is, whether the task will come back, and which nominal fire time
14
+ * produced this burst — so a task the client never created (one the
15
+ * character scheduled for itself at fire time) still arrives fully
16
+ * identified. Pushes with no task behind them (amsg-instant) omit all four.
17
+ *
18
+ * @typedef {Object} AmsgPushCommon
19
+ * @property {MessageType} messageType - How the push was produced.
20
+ * @property {PushSource} source - Which sub-package routed it.
21
+ * @property {string} messageId - Unique per push. Format owned by the producer.
22
+ * @property {string} sessionId - Shared across all pushes from one LLM round (reasoning + content) and across iterations of a single agentic-loop request. Opaque id — do not parse it for task identity, read the fields below.
23
+ * @property {string} timestamp - ISO 8601 timestamp at producer.
24
+ * @property {string} [messageSubtype] - Caller-defined business namespace. Defaults to 'chat' at producers.
25
+ * @property {Object} [metadata] - Caller passthrough. Packages MUST NOT write here.
26
+ * @property {NotificationDirective} [notification] - SW notification strategy.
27
+ * @property {number | string | null} [taskId] - Scheduled task row id.
28
+ * @property {string | null} [taskUuid] - Scheduled task uuid (the id the scheduling side chose).
29
+ * @property {'none' | 'daily' | 'weekly'} [recurrenceType] - Whether that task fires again.
30
+ * @property {number | null} [occurrenceMs] - Nominal fire time of this occurrence (epoch ms).
31
+ */
32
+ /**
33
+ * SW-rendering directive. Mirrors the fields that `amsg-sw`'s
34
+ * `createNotificationFromPayload` consumes (`notification.{show,title,body,icon,badge,tag,renotify,requireInteraction,silent,data}`)
35
+ * so producers get builder validation for the fields the SW actually reads.
36
+ *
37
+ * Routing in SW:
38
+ * - By default (`show: "auto"` or omitted), `messageKind: 'content'` / `'result'` (and legacy
39
+ * un-kinded payloads) will display a system notification. `reasoning` / `tool_request` /
40
+ * `error` will dispatch silently.
41
+ * - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
42
+ * - `silent: "when-visible"` is resolved by the SW at render time from the
43
+ * same window-visibility check `show: "when-hidden"` uses: silent while a
44
+ * `visibilityState === "visible"` window client exists, audible otherwise.
45
+ * `true` / `false` stay literal.
46
+ * - When rendering, `notification.*` is consulted first, with per-field
47
+ * fallback to the matching top-level payload fields (`title`,
48
+ * `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`,
49
+ * `renotify`, `requireInteraction`, `silent`, `data`), and finally to
50
+ * the SW's `defaultIcon` / `defaultBadge` options (boolean knobs
51
+ * default to `false` at the SW). Prefer setting overrides under
52
+ * `notification` for explicitness; top-level fallback exists so that
53
+ * legacy un-namespaced payloads keep working byte-for-byte.
54
+ *
55
+ * 选 `show` 的口径只有一条,跟机型无关:**要推就一定弹**(`"always"`,嫌打扰
56
+ * 配 `tag` 折叠 + `silent`),**不想弹就别推**(不配或配 `false`,内容落服务端
57
+ * 收件箱,等客户端上线补拉)。收到 push 却不弹通知是违约:Firefox 按配额退
58
+ * 订,iOS 过了订阅宽限期直接吊销。`"when-hidden"` 是给老部署留的兼容档,新代
59
+ * 码不选。`show: false` 在两端也不是同一件事——有收件箱的发送端见到它根本不发
60
+ * 这条 push,SW 压根收不到。
61
+ *
62
+ * 想要「前台安静、切后台照常响」就配 `silent: "when-visible"`:`show` 照旧
63
+ * `"always"`(通知一定弹出来,那笔账不欠),响不响铃由 SW 收到时看有没有可见
64
+ * 窗口现算。发送端定不了这件事——它发推那一刻并不知道用户在不在前台,写死
65
+ * `silent: true` 的结果是切后台也不响。
66
+ *
67
+ * 各档取舍见本包 README 的「选哪个 `show`」与 `@rei-standard/amsg-sw` README
68
+ * 的「不展示通知的代价」。
69
+ *
70
+ * @typedef {Object} NotificationDirective
71
+ * @property {"auto" | "always" | "when-hidden" | false} [show] - Rendering strategy. Defaults to "auto" (render only if messageKind is content).
72
+ * @property {string} [title] - Notification title override (falls back to top-level `title`, then `来自 {contactName}`).
73
+ * @property {string} [body] - Notification body override (falls back to top-level `body`, then `message`).
74
+ * @property {string} [icon] - Icon URL override (falls back to top-level `icon`/`avatarUrl`, then SW `defaultIcon`).
75
+ * @property {string} [badge] - Badge URL override (falls back to top-level `badge`, then SW `defaultBadge`).
76
+ * @property {string} [tag] - Notification grouping tag; matching tag replaces the prior notification (falls back to top-level `tag`, then `messageId`, then a generated unique tag).
77
+ * @property {boolean} [renotify] - When tag matches, still vibrate/sound (falls back to top-level `renotify`, default false at SW).
78
+ * @property {boolean} [requireInteraction] - Notification stays until user dismisses (falls back to top-level `requireInteraction`, default false at SW).
79
+ * @property {boolean | "when-visible"} [silent] - Suppress sound and vibration. `true` / `false` are literal; `"when-visible"` is decided by the SW at render time — silent while a window client is visible, audible otherwise (falls back to top-level `silent`, default false at SW).
80
+ * @property {Record<string, unknown>} [data] - Custom payload data to attach to the notification (falls back to top-level `data`).
81
+ */
82
+ /**
83
+ * 这条 payload 到了客户端会不会弹系统通知——发送端与 SW 共用的这一份判定。
84
+ *
85
+ * 三个取值:
86
+ *
87
+ * | 返回值 | 意思 |
88
+ * |----------------|------|
89
+ * | `'always'` | 一定弹 |
90
+ * | `'when-hidden'`| 看有没有可见窗口,由 SW 当场定(发送端无从知道)。兼容档,新代码用 `'always'` |
91
+ * | `'never'` | 一定不弹 |
92
+ *
93
+ * 判定顺序跟 SW 里一模一样:`notification.show` 说了算,没说才按 `messageKind`
94
+ * 走默认(`content` / `result` 与缺 kind 的 2.0.x 老 payload 弹,`reasoning` /
95
+ * `tool_request` / `error` 不弹)。
96
+ *
97
+ * 两端各拿它做一件事:
98
+ *
99
+ * - **SW**:决定要不要 `showNotification`。
100
+ * - **发送端**:决定这条值不值得占用推送通道。`'never'` 的 payload 推过去就是
101
+ * 一次「收了 push 却不弹通知」,浏览器那边要记账;有收件箱兜底的发送端
102
+ * (`@rei-standard/amsg-server`)因此只落行、不推,等客户端上线补拉。
103
+ *
104
+ * @param {Record<string, unknown>|null|undefined} payload
105
+ * @returns {'always' | 'when-hidden' | 'never'}
106
+ */
107
+ export function notificationIntent(payload: Record<string, unknown> | null | undefined): "always" | "when-hidden" | "never";
1
108
  /**
2
109
  * Build a {@link ContentPush}. Use this for legacy sentence-split
3
110
  * bursts (set `messageIndex` 1-based + `totalMessages`) or for a
@@ -576,6 +683,10 @@ export type AmsgPushCommon = {
576
683
  * un-kinded payloads) will display a system notification. `reasoning` / `tool_request` /
577
684
  * `error` will dispatch silently.
578
685
  * - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
686
+ * - `silent: "when-visible"` is resolved by the SW at render time from the
687
+ * same window-visibility check `show: "when-hidden"` uses: silent while a
688
+ * `visibilityState === "visible"` window client exists, audible otherwise.
689
+ * `true` / `false` stay literal.
579
690
  * - When rendering, `notification.*` is consulted first, with per-field
580
691
  * fallback to the matching top-level payload fields (`title`,
581
692
  * `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`,
@@ -584,6 +695,21 @@ export type AmsgPushCommon = {
584
695
  * default to `false` at the SW). Prefer setting overrides under
585
696
  * `notification` for explicitness; top-level fallback exists so that
586
697
  * legacy un-namespaced payloads keep working byte-for-byte.
698
+ *
699
+ * 选 `show` 的口径只有一条,跟机型无关:**要推就一定弹**(`"always"`,嫌打扰
700
+ * 配 `tag` 折叠 + `silent`),**不想弹就别推**(不配或配 `false`,内容落服务端
701
+ * 收件箱,等客户端上线补拉)。收到 push 却不弹通知是违约:Firefox 按配额退
702
+ * 订,iOS 过了订阅宽限期直接吊销。`"when-hidden"` 是给老部署留的兼容档,新代
703
+ * 码不选。`show: false` 在两端也不是同一件事——有收件箱的发送端见到它根本不发
704
+ * 这条 push,SW 压根收不到。
705
+ *
706
+ * 想要「前台安静、切后台照常响」就配 `silent: "when-visible"`:`show` 照旧
707
+ * `"always"`(通知一定弹出来,那笔账不欠),响不响铃由 SW 收到时看有没有可见
708
+ * 窗口现算。发送端定不了这件事——它发推那一刻并不知道用户在不在前台,写死
709
+ * `silent: true` 的结果是切后台也不响。
710
+ *
711
+ * 各档取舍见本包 README 的「选哪个 `show`」与 `@rei-standard/amsg-sw` README
712
+ * 的「不展示通知的代价」。
587
713
  */
588
714
  export type NotificationDirective = {
589
715
  /**
@@ -619,9 +745,9 @@ export type NotificationDirective = {
619
745
  */
620
746
  requireInteraction?: boolean;
621
747
  /**
622
- * - Suppress sound and vibration (falls back to top-level `silent`, default false at SW).
748
+ * - Suppress sound and vibration. `true` / `false` are literal; `"when-visible"` is decided by the SW at render time — silent while a window client is visible, audible otherwise (falls back to top-level `silent`, default false at SW).
623
749
  */
624
- silent?: boolean;
750
+ silent?: boolean | "when-visible";
625
751
  /**
626
752
  * - Custom payload data to attach to the notification (falls back to top-level `data`).
627
753
  */
package/dist/index.mjs CHANGED
@@ -761,6 +761,17 @@ var PUSH_SOURCE = Object.freeze({
761
761
  INSTANT: "instant",
762
762
  SCHEDULED: "scheduled"
763
763
  });
764
+ function notificationIntent(payload) {
765
+ const notification = payload && typeof payload === "object" && payload.notification;
766
+ const show = notification && typeof notification === "object" ? notification.show : void 0;
767
+ if (show === "always") return "always";
768
+ if (show === "when-hidden") return "when-hidden";
769
+ if (show === false) return "never";
770
+ if (!payload || typeof payload !== "object") return "never";
771
+ const kind = payload.messageKind;
772
+ if (kind === void 0 || kind === null) return "always";
773
+ return kind === MESSAGE_KIND.CONTENT || kind === MESSAGE_KIND.RESULT ? "always" : "never";
774
+ }
764
775
  function requireField(kind, field, value) {
765
776
  if (value === void 0 || value === null || value === "") {
766
777
  throw new Error(`[amsg-shared] ${kind}: '${field}' is required`);
@@ -868,11 +879,14 @@ function validateNotificationArg(kind, value) {
868
879
  throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`);
869
880
  }
870
881
  }
871
- for (const f of ["renotify", "requireInteraction", "silent"]) {
882
+ for (const f of ["renotify", "requireInteraction"]) {
872
883
  if (n[f] !== void 0 && typeof n[f] !== "boolean") {
873
884
  throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`);
874
885
  }
875
886
  }
887
+ if (n.silent !== void 0 && typeof n.silent !== "boolean" && n.silent !== "when-visible") {
888
+ throw new Error(`[amsg-shared] ${kind}: 'notification.silent' must be true, false, or "when-visible"`);
889
+ }
876
890
  if (n.data !== void 0 && (n.data === null || typeof n.data !== "object" || Array.isArray(n.data))) {
877
891
  throw new Error(`[amsg-shared] ${kind}: 'notification.data' must be a plain object when present`);
878
892
  }
@@ -1262,6 +1276,7 @@ export {
1262
1276
  jsonToBase64Url,
1263
1277
  normalizeAiApiUrl,
1264
1278
  normalizeVapidSubject,
1279
+ notificationIntent,
1265
1280
  randomBytes,
1266
1281
  randomUUID,
1267
1282
  readReasoningContent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-shared",
3
- "version": "0.4.0-next.6",
3
+ "version": "0.4.0-next.8",
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",