@rei-standard/amsg-server 2.3.1 → 2.4.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rei-standard/amsg-server
2
2
 
3
- `@rei-standard/amsg-server` 是 ReiStandard 主动消息标准的服务端 SDK(v2.0.1),提供 Blob 租户配置、租户 token 鉴权和标准路由处理器。
3
+ `@rei-standard/amsg-server` 是 ReiStandard 主动消息标准的服务端 SDK:Blob 租户配置、`tenantToken` / `cronToken` 鉴权、标准路由处理器。API 规范见 [API 技术规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md)。
4
4
 
5
5
  ## v2.0.1 变更摘要
6
6
 
@@ -9,6 +9,8 @@
9
9
  - 业务端点统一使用 `Authorization: Bearer <tenantToken>`
10
10
  - `send-notifications` 支持 `cronToken`(Header 或 query token)
11
11
 
12
+ 2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 严格校验)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-严格校验);行为已对齐 `amsg-instant`,向后兼容。
13
+
12
14
  ## 安装
13
15
 
14
16
  ```bash
@@ -52,7 +54,7 @@ const rei = await createReiServer({
52
54
 
53
55
  ## 关于 `messageType: 'instant'`
54
56
 
55
- > **Note**:新代码的 instant 消息请用 [@rei-standard/amsg-instant](../instant/README.md),跳过本端点的"建任务 → 处理 → 删任务" DB 来回。本端点的 `instant` 分支为兼容保留,行为不变、不会有运行时警告。
57
+ > **Note**:新代码的 instant 消息请用 [@rei-standard/amsg-instant](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md),跳过本端点的"建任务 → 处理 → 删任务" DB 来回。本端点的 `instant` 分支为兼容保留,行为不变、不会有运行时警告。
56
58
 
57
59
  ## AI 接口 `apiUrl` 约束
58
60
 
@@ -130,17 +132,45 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
130
132
  ## 运行环境与要求
131
133
 
132
134
  - Node.js `>=20`
133
- - 必须安装 `web-push`
134
- - 必须安装 `@netlify/blobs`
135
- - 必须安装至少一个数据库驱动(`@neondatabase/serverless` 或 `pg`)
136
- - 必须配置:
137
- - `VAPID_EMAIL`
138
- - `NEXT_PUBLIC_VAPID_PUBLIC_KEY`
139
- - `VAPID_PRIVATE_KEY`
140
- - `TENANT_CONFIG_KEK`
141
- - `TENANT_TOKEN_SIGNING_KEY`
142
- - 可选配置:
143
- - `INIT_SECRET`(配置后 `init-tenant` 必须带 `X-Init-Secret`)
135
+ - 必须装:`web-push`、`@netlify/blobs`、以及至少一个数据库驱动(`@neondatabase/serverless` 或 `pg`)
136
+
137
+ ### 环境变量
138
+
139
+ 必填:
140
+
141
+ - `VAPID_EMAIL` — VAPID 联系邮箱
142
+ - `NEXT_PUBLIC_VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` — VAPID 公私钥对,[vapidkeys.com](https://vapidkeys.com) 生成
143
+ - `TENANT_CONFIG_KEK` — 加密 Blob 里租户配置(含 db connection、masterKey)的 KEK
144
+ - `TENANT_TOKEN_SIGNING_KEY` — `tenantToken` / `cronToken` 的 HMAC 签名密钥
145
+
146
+ 可选:
147
+
148
+ - `INIT_SECRET` — 配了之后 `POST /api/v1/init-tenant` 必须带 `X-Init-Secret` 头才能初始化租户
149
+ - `PUBLIC_BASE_URL` — 生产域名(如 `https://your-domain.com`),用来让 `init-tenant` 返回完整 `cronWebhookUrl`
150
+ - `VERCEL_PROTECTION_BYPASS` — Vercel 部署 + Preview Protection 时给 cron 走的 bypass key
151
+
152
+ `TENANT_CONFIG_KEK` / `TENANT_TOKEN_SIGNING_KEY` / `INIT_SECRET` 推荐:
153
+
154
+ ```bash
155
+ openssl rand -base64 32
156
+ ```
157
+
158
+ ### `.env` 模板
159
+
160
+ ```dotenv
161
+ VAPID_EMAIL=youremail@example.com
162
+ NEXT_PUBLIC_VAPID_PUBLIC_KEY=YOUR-PUBLIC-KEY
163
+ VAPID_PRIVATE_KEY=YOUR-PRIVATE-KEY
164
+ TENANT_CONFIG_KEK=YOUR-KEK-SECRET
165
+ TENANT_TOKEN_SIGNING_KEY=YOUR-TOKEN-SIGNING-KEY
166
+
167
+ # 可选
168
+ INIT_SECRET=YOUR-INIT-SECRET
169
+ PUBLIC_BASE_URL=https://your-domain.com
170
+ VERCEL_PROTECTION_BYPASS=YOUR_BYPASS_KEY
171
+ ```
172
+
173
+ Vercel 部署配置可参考 [`examples/vercel.json.example`](https://github.com/Tosd0/ReiStandard/blob/main/examples/vercel.json.example)。
144
174
 
145
175
  ## 相关链接(绝对 URL)
146
176
 
package/dist/index.cjs CHANGED
@@ -482,6 +482,10 @@ function isUniqueViolation(error) {
482
482
 
483
483
  // src/server/lib/message-processor.js
484
484
 
485
+
486
+
487
+
488
+ var _amsgshared = require('@rei-standard/amsg-shared');
485
489
  var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
486
490
  function splitOnceByRegex(chunk, regex) {
487
491
  const out = chunk.split(regex).reduce((acc, part, i, arr) => {
@@ -502,6 +506,22 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
502
506
  }
503
507
  return chunks.length > 0 ? chunks : [messageContent];
504
508
  }
509
+ function readReasoningContent(llmResponse) {
510
+ if (!llmResponse || typeof llmResponse !== "object") return null;
511
+ const choices = (
512
+ /** @type {{ choices?: unknown }} */
513
+ llmResponse.choices
514
+ );
515
+ if (!Array.isArray(choices) || choices.length === 0) return null;
516
+ const message = (
517
+ /** @type {{ message?: { reasoning_content?: unknown } }} */
518
+ _optionalChain([choices, 'access', _2 => _2[0], 'optionalAccess', _3 => _3.message])
519
+ );
520
+ const raw = _optionalChain([message, 'optionalAccess', _4 => _4.reasoning_content]);
521
+ if (typeof raw !== "string") return null;
522
+ const trimmed = raw.trim();
523
+ return trimmed.length > 0 ? trimmed : null;
524
+ }
505
525
  async function processSingleMessage(task, ctx, providedMasterKey) {
506
526
  try {
507
527
  const masterKey = providedMasterKey || ctx.masterKey;
@@ -511,19 +531,24 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
511
531
  const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
512
532
  const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
513
533
  let messageContent;
534
+ let llmResponse = null;
514
535
  if (decryptedPayload.messageType === "fixed") {
515
536
  messageContent = decryptedPayload.userMessage;
516
537
  } else if (decryptedPayload.messageType === "instant") {
517
538
  const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
518
539
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
519
- messageContent = await _callAI(decryptedPayload);
540
+ const aiResult = await _callAI(decryptedPayload);
541
+ messageContent = aiResult.content;
542
+ llmResponse = aiResult.response;
520
543
  } else if (decryptedPayload.userMessage) {
521
544
  messageContent = decryptedPayload.userMessage;
522
545
  } else {
523
546
  throw new Error("Invalid instant message: no content source available");
524
547
  }
525
548
  } else if (decryptedPayload.messageType === "prompted" || decryptedPayload.messageType === "auto") {
526
- messageContent = await _callAI(decryptedPayload);
549
+ const aiResult = await _callAI(decryptedPayload);
550
+ messageContent = aiResult.content;
551
+ llmResponse = aiResult.response;
527
552
  } else {
528
553
  throw new Error("Invalid message configuration: no content source available");
529
554
  }
@@ -532,23 +557,48 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
532
557
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
533
558
  }
534
559
  const pushSubscription = decryptedPayload.pushSubscription;
535
- for (let i = 0; i < messages.length; i++) {
536
- const notificationPayload = {
560
+ const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${_crypto.randomUUID.call(void 0, )}`;
561
+ const source = decryptedPayload.messageType === "instant" ? "instant" : "scheduled";
562
+ const messageSubtype = decryptedPayload.messageSubtype || "chat";
563
+ const avatarUrl = decryptedPayload.avatarUrl || null;
564
+ const metadata = decryptedPayload.metadata || {};
565
+ const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${_crypto.randomUUID.call(void 0, )}_instant`;
566
+ const reasoning = readReasoningContent(llmResponse);
567
+ if (reasoning) {
568
+ const reasoningPush = _amsgshared.buildReasoningPush.call(void 0, {
569
+ messageType: decryptedPayload.messageType,
570
+ source,
571
+ messageId: `${messageIdBase}_reasoning`,
572
+ sessionId,
573
+ reasoningContent: reasoning,
574
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
537
575
  title: `\u6765\u81EA ${decryptedPayload.contactName}`,
576
+ contactName: decryptedPayload.contactName,
577
+ avatarUrl,
578
+ messageSubtype,
579
+ metadata
580
+ });
581
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
582
+ await new Promise((resolve) => setTimeout(resolve, 1500));
583
+ }
584
+ for (let i = 0; i < messages.length; i++) {
585
+ const contentPush = _amsgshared.buildContentPush.call(void 0, {
586
+ messageType: decryptedPayload.messageType,
587
+ source,
588
+ messageId: `${messageIdBase}_${i}`,
589
+ sessionId,
538
590
  message: messages[i],
591
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
592
+ title: `\u6765\u81EA ${decryptedPayload.contactName}`,
539
593
  contactName: decryptedPayload.contactName,
540
- messageId: `msg_${_crypto.randomUUID.call(void 0, )}_${task.id || "instant"}_${i}`,
594
+ avatarUrl,
595
+ messageSubtype,
541
596
  messageIndex: i + 1,
542
597
  totalMessages: messages.length,
543
- messageType: decryptedPayload.messageType,
544
- messageSubtype: decryptedPayload.messageSubtype || "chat",
545
598
  taskId: task.id || null,
546
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
547
- source: decryptedPayload.messageType === "instant" ? "instant" : "scheduled",
548
- avatarUrl: decryptedPayload.avatarUrl || null,
549
- metadata: decryptedPayload.metadata || {}
550
- };
551
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
599
+ metadata
600
+ });
601
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
552
602
  if (i < messages.length - 1) {
553
603
  await new Promise((resolve) => setTimeout(resolve, 1500));
554
604
  }
@@ -643,11 +693,11 @@ async function _callAI(payload) {
643
693
  );
644
694
  }
645
695
  const aiData = await aiResponse.json();
646
- const content = _optionalChain([aiData, 'optionalAccess', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]);
696
+ const content = _optionalChain([aiData, 'optionalAccess', _5 => _5.choices, 'optionalAccess', _6 => _6[0], 'optionalAccess', _7 => _7.message, 'optionalAccess', _8 => _8.content]);
647
697
  if (typeof content !== "string" || !content.trim()) {
648
698
  throw new Error("AI API error: response missing choices[0].message.content");
649
699
  }
650
- return content.trim();
700
+ return { response: aiData, content: content.trim() };
651
701
  }
652
702
  function buildAiRequestBody(payload) {
653
703
  const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
@@ -1326,7 +1376,7 @@ async function resolveBlobStore(namespace) {
1326
1376
  }
1327
1377
  try {
1328
1378
  const blobModule = await Promise.resolve().then(() => _interopRequireWildcard(require("@netlify/blobs")));
1329
- const getStore = blobModule.getStore || _optionalChain([blobModule, 'access', _6 => _6.default, 'optionalAccess', _7 => _7.getStore]);
1379
+ const getStore = blobModule.getStore || _optionalChain([blobModule, 'access', _9 => _9.default, 'optionalAccess', _10 => _10.getStore]);
1330
1380
  if (typeof getStore === "function") {
1331
1381
  return getStore({ name: namespace });
1332
1382
  }
package/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createDecipheriv, createHash, randomBytes, createCipheriv, randomUUID, timingSafeEqual, createHmac } from 'crypto';
2
+ import { buildReasoningPush, buildContentPush } from '@rei-standard/amsg-shared';
2
3
 
3
4
  /**
4
5
  * Adapter Factory
@@ -793,11 +794,23 @@ function isUniqueViolation(error) {
793
794
 
794
795
  /**
795
796
  * Message Processor (SDK version)
796
- * ReiStandard SDK v2.0.1
797
+ * ReiStandard amsg-server v2.4.0
798
+ *
799
+ * Handles single message content generation and Web Push delivery for
800
+ * scheduled tasks (`fixed` / `prompted` / `auto`) and the legacy
801
+ * via-server instant path (`messageType: 'instant'`).
797
802
  *
798
- * Handles single message content generation and Web Push delivery.
799
- * Receives its dependencies (encryption helpers, webpush, VAPID config)
800
- * via a context object so that it stays free of process.env references.
803
+ * Push wire shape comes from `@rei-standard/amsg-shared`'s
804
+ * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
805
+ * routes on `messageKind`. Server-driven pushes always carry
806
+ * `source: 'instant'` (for the legacy in-server instant) or
807
+ * `source: 'scheduled'` (for everything else).
808
+ *
809
+ * v2.4.0: when the LLM response carries non-empty
810
+ * `choices[0].message.reasoning_content`, the processor now emits a
811
+ * standalone `ReasoningPush` **before** the `ContentPush` burst.
812
+ * `messagesSent` in the return value continues to reflect the sentence
813
+ * count only (reasoning is an auxiliary push, not a sentence).
801
814
  */
802
815
 
803
816
 
@@ -850,6 +863,25 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
850
863
  return chunks.length > 0 ? chunks : [messageContent];
851
864
  }
852
865
 
866
+ /**
867
+ * Read `choices[0].message.reasoning_content` as a non-empty trimmed
868
+ * string, or null when absent / empty. Mirrors
869
+ * `amsg-instant/src/message-processor.js#readReasoningContent`.
870
+ *
871
+ * @param {unknown} llmResponse
872
+ * @returns {string | null}
873
+ */
874
+ function readReasoningContent(llmResponse) {
875
+ if (!llmResponse || typeof llmResponse !== 'object') return null;
876
+ const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices;
877
+ if (!Array.isArray(choices) || choices.length === 0) return null;
878
+ const message = /** @type {{ message?: { reasoning_content?: unknown } }} */ (choices[0])?.message;
879
+ const raw = message?.reasoning_content;
880
+ if (typeof raw !== 'string') return null;
881
+ const trimmed = raw.trim();
882
+ return trimmed.length > 0 ? trimmed : null;
883
+ }
884
+
853
885
  /**
854
886
  * @typedef {Object} ProcessorContext
855
887
  * @property {Object} webpush - The web-push module instance (already VAPID-configured).
@@ -876,14 +908,19 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
876
908
  const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
877
909
 
878
910
  let messageContent;
911
+ /** @type {unknown} */
912
+ let llmResponse = null;
879
913
 
880
914
  if (decryptedPayload.messageType === 'fixed') {
881
915
  messageContent = decryptedPayload.userMessage;
882
916
 
883
917
  } else if (decryptedPayload.messageType === 'instant') {
884
- const hasPrompt = !!decryptedPayload.completePrompt || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
918
+ const hasPrompt = !!decryptedPayload.completePrompt
919
+ || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
885
920
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
886
- messageContent = await _callAI(decryptedPayload);
921
+ const aiResult = await _callAI(decryptedPayload);
922
+ messageContent = aiResult.content;
923
+ llmResponse = aiResult.response;
887
924
  } else if (decryptedPayload.userMessage) {
888
925
  messageContent = decryptedPayload.userMessage;
889
926
  } else {
@@ -891,7 +928,9 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
891
928
  }
892
929
 
893
930
  } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
894
- messageContent = await _callAI(decryptedPayload);
931
+ const aiResult = await _callAI(decryptedPayload);
932
+ messageContent = aiResult.content;
933
+ llmResponse = aiResult.response;
895
934
  } else {
896
935
  throw new Error('Invalid message configuration: no content source available');
897
936
  }
@@ -908,25 +947,68 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
908
947
  }
909
948
 
910
949
  const pushSubscription = decryptedPayload.pushSubscription;
950
+ // sessionId is shared across the optional ReasoningPush and every
951
+ // ContentPush from this LLM round. Pin it to the task id when
952
+ // available (scheduled tasks) so retries reuse the same id;
953
+ // otherwise mint a UUID.
954
+ const sessionId = task.id != null
955
+ ? `sess_task_${task.id}`
956
+ : `sess_${randomUUID()}`;
957
+ const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled';
958
+ const messageSubtype = decryptedPayload.messageSubtype || 'chat';
959
+ const avatarUrl = decryptedPayload.avatarUrl || null;
960
+ const metadata = decryptedPayload.metadata || {};
961
+
962
+ // `messageId` format — deterministic when we have a task.id so a
963
+ // retry produces the same id for the same (task, sentence) pair
964
+ // (downstream dedupers can key on it). Falls back to a UUID for
965
+ // the legacy in-server instant path that has no row id.
966
+ const messageIdBase = task.id != null
967
+ ? `msg_task_${task.id}`
968
+ : `msg_${randomUUID()}_instant`;
969
+
970
+ // ReasoningPush — auto-emitted before the content burst when the
971
+ // LLM response carried non-empty reasoning_content. `fixed` and
972
+ // explicit-userMessage paths produce no LLM response, so this
973
+ // block is naturally skipped for them (llmResponse stays null).
974
+ const reasoning = readReasoningContent(llmResponse);
975
+ if (reasoning) {
976
+ const reasoningPush = buildReasoningPush({
977
+ messageType: decryptedPayload.messageType,
978
+ source,
979
+ messageId: `${messageIdBase}_reasoning`,
980
+ sessionId,
981
+ reasoningContent: reasoning,
982
+ timestamp: new Date().toISOString(),
983
+ title: `来自 ${decryptedPayload.contactName}`,
984
+ contactName: decryptedPayload.contactName,
985
+ avatarUrl,
986
+ messageSubtype,
987
+ metadata,
988
+ });
989
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
990
+ await new Promise(resolve => setTimeout(resolve, 1500));
991
+ }
911
992
 
912
993
  for (let i = 0; i < messages.length; i++) {
913
- const notificationPayload = {
914
- title: `来自 ${decryptedPayload.contactName}`,
994
+ const contentPush = buildContentPush({
995
+ messageType: decryptedPayload.messageType,
996
+ source,
997
+ messageId: `${messageIdBase}_${i}`,
998
+ sessionId,
915
999
  message: messages[i],
1000
+ timestamp: new Date().toISOString(),
1001
+ title: `来自 ${decryptedPayload.contactName}`,
916
1002
  contactName: decryptedPayload.contactName,
917
- messageId: `msg_${randomUUID()}_${task.id || 'instant'}_${i}`,
1003
+ avatarUrl,
1004
+ messageSubtype,
918
1005
  messageIndex: i + 1,
919
1006
  totalMessages: messages.length,
920
- messageType: decryptedPayload.messageType,
921
- messageSubtype: decryptedPayload.messageSubtype || 'chat',
922
1007
  taskId: task.id || null,
923
- timestamp: new Date().toISOString(),
924
- source: decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled',
925
- avatarUrl: decryptedPayload.avatarUrl || null,
926
- metadata: decryptedPayload.metadata || {}
927
- };
1008
+ metadata,
1009
+ });
928
1010
 
929
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
1011
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
930
1012
 
931
1013
  if (i < messages.length - 1) {
932
1014
  await new Promise(resolve => setTimeout(resolve, 1500));
@@ -1030,7 +1112,15 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
1030
1112
 
1031
1113
  /**
1032
1114
  * Call an OpenAI-compatible API.
1115
+ *
1116
+ * Returns the full response object alongside the extracted (trimmed)
1117
+ * `content` string. Callers that only need the text can ignore
1118
+ * `response`; callers that want `reasoning_content` / `tool_calls`
1119
+ * read from `response.choices[0].message`.
1120
+ *
1033
1121
  * @private
1122
+ * @param {Object} payload
1123
+ * @returns {Promise<{ response: unknown, content: string }>}
1034
1124
  */
1035
1125
  async function _callAI(payload) {
1036
1126
  const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
@@ -1067,7 +1157,7 @@ async function _callAI(payload) {
1067
1157
  throw new Error('AI API error: response missing choices[0].message.content');
1068
1158
  }
1069
1159
 
1070
- return content.trim();
1160
+ return { response: aiData, content: content.trim() };
1071
1161
  }
1072
1162
 
1073
1163
  /**
@@ -1125,20 +1215,6 @@ function buildAiRequestBody(payload) {
1125
1215
  * to avoid an architectural dependency (server should not depend on the
1126
1216
  * stateless worker package).
1127
1217
  *
1128
- * Rules (idempotent — running it twice equals running it once):
1129
- * - Already ends with `/chat/completions` → leave as-is.
1130
- * - Bare host (no path or just `/`) → append `/v1/chat/completions`.
1131
- * - Path ends with a version segment like `/v1`,
1132
- * `/v2`, … (with or without trailing slash) → append only
1133
- * `/chat/completions`; never doubles `/v1` for
1134
- * callers who already include it.
1135
- * - Anything else (custom path that doesn't match the
1136
- * OpenAI shape, e.g. `/v1/messages` for
1137
- * Anthropic-style proxies) → leave as-is. The caller
1138
- * knows their own routing.
1139
- *
1140
- * Query string is preserved verbatim.
1141
- *
1142
1218
  * @param {string} apiUrl
1143
1219
  * @returns {string}
1144
1220
  */
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createDecipheriv, createHash, randomBytes, createCipheriv, randomUUID, timingSafeEqual, createHmac } from 'crypto';
2
+ import { buildReasoningPush, buildContentPush } from '@rei-standard/amsg-shared';
2
3
 
3
4
  /**
4
5
  * Adapter Factory
@@ -793,11 +794,23 @@ function isUniqueViolation(error) {
793
794
 
794
795
  /**
795
796
  * Message Processor (SDK version)
796
- * ReiStandard SDK v2.0.1
797
+ * ReiStandard amsg-server v2.4.0
798
+ *
799
+ * Handles single message content generation and Web Push delivery for
800
+ * scheduled tasks (`fixed` / `prompted` / `auto`) and the legacy
801
+ * via-server instant path (`messageType: 'instant'`).
797
802
  *
798
- * Handles single message content generation and Web Push delivery.
799
- * Receives its dependencies (encryption helpers, webpush, VAPID config)
800
- * via a context object so that it stays free of process.env references.
803
+ * Push wire shape comes from `@rei-standard/amsg-shared`'s
804
+ * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
805
+ * routes on `messageKind`. Server-driven pushes always carry
806
+ * `source: 'instant'` (for the legacy in-server instant) or
807
+ * `source: 'scheduled'` (for everything else).
808
+ *
809
+ * v2.4.0: when the LLM response carries non-empty
810
+ * `choices[0].message.reasoning_content`, the processor now emits a
811
+ * standalone `ReasoningPush` **before** the `ContentPush` burst.
812
+ * `messagesSent` in the return value continues to reflect the sentence
813
+ * count only (reasoning is an auxiliary push, not a sentence).
801
814
  */
802
815
 
803
816
 
@@ -850,6 +863,25 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
850
863
  return chunks.length > 0 ? chunks : [messageContent];
851
864
  }
852
865
 
866
+ /**
867
+ * Read `choices[0].message.reasoning_content` as a non-empty trimmed
868
+ * string, or null when absent / empty. Mirrors
869
+ * `amsg-instant/src/message-processor.js#readReasoningContent`.
870
+ *
871
+ * @param {unknown} llmResponse
872
+ * @returns {string | null}
873
+ */
874
+ function readReasoningContent(llmResponse) {
875
+ if (!llmResponse || typeof llmResponse !== 'object') return null;
876
+ const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices;
877
+ if (!Array.isArray(choices) || choices.length === 0) return null;
878
+ const message = /** @type {{ message?: { reasoning_content?: unknown } }} */ (choices[0])?.message;
879
+ const raw = message?.reasoning_content;
880
+ if (typeof raw !== 'string') return null;
881
+ const trimmed = raw.trim();
882
+ return trimmed.length > 0 ? trimmed : null;
883
+ }
884
+
853
885
  /**
854
886
  * @typedef {Object} ProcessorContext
855
887
  * @property {Object} webpush - The web-push module instance (already VAPID-configured).
@@ -876,14 +908,19 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
876
908
  const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
877
909
 
878
910
  let messageContent;
911
+ /** @type {unknown} */
912
+ let llmResponse = null;
879
913
 
880
914
  if (decryptedPayload.messageType === 'fixed') {
881
915
  messageContent = decryptedPayload.userMessage;
882
916
 
883
917
  } else if (decryptedPayload.messageType === 'instant') {
884
- const hasPrompt = !!decryptedPayload.completePrompt || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
918
+ const hasPrompt = !!decryptedPayload.completePrompt
919
+ || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
885
920
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
886
- messageContent = await _callAI(decryptedPayload);
921
+ const aiResult = await _callAI(decryptedPayload);
922
+ messageContent = aiResult.content;
923
+ llmResponse = aiResult.response;
887
924
  } else if (decryptedPayload.userMessage) {
888
925
  messageContent = decryptedPayload.userMessage;
889
926
  } else {
@@ -891,7 +928,9 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
891
928
  }
892
929
 
893
930
  } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
894
- messageContent = await _callAI(decryptedPayload);
931
+ const aiResult = await _callAI(decryptedPayload);
932
+ messageContent = aiResult.content;
933
+ llmResponse = aiResult.response;
895
934
  } else {
896
935
  throw new Error('Invalid message configuration: no content source available');
897
936
  }
@@ -908,25 +947,68 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
908
947
  }
909
948
 
910
949
  const pushSubscription = decryptedPayload.pushSubscription;
950
+ // sessionId is shared across the optional ReasoningPush and every
951
+ // ContentPush from this LLM round. Pin it to the task id when
952
+ // available (scheduled tasks) so retries reuse the same id;
953
+ // otherwise mint a UUID.
954
+ const sessionId = task.id != null
955
+ ? `sess_task_${task.id}`
956
+ : `sess_${randomUUID()}`;
957
+ const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled';
958
+ const messageSubtype = decryptedPayload.messageSubtype || 'chat';
959
+ const avatarUrl = decryptedPayload.avatarUrl || null;
960
+ const metadata = decryptedPayload.metadata || {};
961
+
962
+ // `messageId` format — deterministic when we have a task.id so a
963
+ // retry produces the same id for the same (task, sentence) pair
964
+ // (downstream dedupers can key on it). Falls back to a UUID for
965
+ // the legacy in-server instant path that has no row id.
966
+ const messageIdBase = task.id != null
967
+ ? `msg_task_${task.id}`
968
+ : `msg_${randomUUID()}_instant`;
969
+
970
+ // ReasoningPush — auto-emitted before the content burst when the
971
+ // LLM response carried non-empty reasoning_content. `fixed` and
972
+ // explicit-userMessage paths produce no LLM response, so this
973
+ // block is naturally skipped for them (llmResponse stays null).
974
+ const reasoning = readReasoningContent(llmResponse);
975
+ if (reasoning) {
976
+ const reasoningPush = buildReasoningPush({
977
+ messageType: decryptedPayload.messageType,
978
+ source,
979
+ messageId: `${messageIdBase}_reasoning`,
980
+ sessionId,
981
+ reasoningContent: reasoning,
982
+ timestamp: new Date().toISOString(),
983
+ title: `来自 ${decryptedPayload.contactName}`,
984
+ contactName: decryptedPayload.contactName,
985
+ avatarUrl,
986
+ messageSubtype,
987
+ metadata,
988
+ });
989
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
990
+ await new Promise(resolve => setTimeout(resolve, 1500));
991
+ }
911
992
 
912
993
  for (let i = 0; i < messages.length; i++) {
913
- const notificationPayload = {
914
- title: `来自 ${decryptedPayload.contactName}`,
994
+ const contentPush = buildContentPush({
995
+ messageType: decryptedPayload.messageType,
996
+ source,
997
+ messageId: `${messageIdBase}_${i}`,
998
+ sessionId,
915
999
  message: messages[i],
1000
+ timestamp: new Date().toISOString(),
1001
+ title: `来自 ${decryptedPayload.contactName}`,
916
1002
  contactName: decryptedPayload.contactName,
917
- messageId: `msg_${randomUUID()}_${task.id || 'instant'}_${i}`,
1003
+ avatarUrl,
1004
+ messageSubtype,
918
1005
  messageIndex: i + 1,
919
1006
  totalMessages: messages.length,
920
- messageType: decryptedPayload.messageType,
921
- messageSubtype: decryptedPayload.messageSubtype || 'chat',
922
1007
  taskId: task.id || null,
923
- timestamp: new Date().toISOString(),
924
- source: decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled',
925
- avatarUrl: decryptedPayload.avatarUrl || null,
926
- metadata: decryptedPayload.metadata || {}
927
- };
1008
+ metadata,
1009
+ });
928
1010
 
929
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
1011
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
930
1012
 
931
1013
  if (i < messages.length - 1) {
932
1014
  await new Promise(resolve => setTimeout(resolve, 1500));
@@ -1030,7 +1112,15 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
1030
1112
 
1031
1113
  /**
1032
1114
  * Call an OpenAI-compatible API.
1115
+ *
1116
+ * Returns the full response object alongside the extracted (trimmed)
1117
+ * `content` string. Callers that only need the text can ignore
1118
+ * `response`; callers that want `reasoning_content` / `tool_calls`
1119
+ * read from `response.choices[0].message`.
1120
+ *
1033
1121
  * @private
1122
+ * @param {Object} payload
1123
+ * @returns {Promise<{ response: unknown, content: string }>}
1034
1124
  */
1035
1125
  async function _callAI(payload) {
1036
1126
  const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
@@ -1067,7 +1157,7 @@ async function _callAI(payload) {
1067
1157
  throw new Error('AI API error: response missing choices[0].message.content');
1068
1158
  }
1069
1159
 
1070
- return content.trim();
1160
+ return { response: aiData, content: content.trim() };
1071
1161
  }
1072
1162
 
1073
1163
  /**
@@ -1125,20 +1215,6 @@ function buildAiRequestBody(payload) {
1125
1215
  * to avoid an architectural dependency (server should not depend on the
1126
1216
  * stateless worker package).
1127
1217
  *
1128
- * Rules (idempotent — running it twice equals running it once):
1129
- * - Already ends with `/chat/completions` → leave as-is.
1130
- * - Bare host (no path or just `/`) → append `/v1/chat/completions`.
1131
- * - Path ends with a version segment like `/v1`,
1132
- * `/v2`, … (with or without trailing slash) → append only
1133
- * `/chat/completions`; never doubles `/v1` for
1134
- * callers who already include it.
1135
- * - Anything else (custom path that doesn't match the
1136
- * OpenAI shape, e.g. `/v1/messages` for
1137
- * Anthropic-style proxies) → leave as-is. The caller
1138
- * knows their own routing.
1139
- *
1140
- * Query string is preserved verbatim.
1141
- *
1142
1218
  * @param {string} apiUrl
1143
1219
  * @returns {string}
1144
1220
  */
package/dist/index.mjs CHANGED
@@ -482,6 +482,10 @@ function isUniqueViolation(error) {
482
482
 
483
483
  // src/server/lib/message-processor.js
484
484
  import { randomUUID } from "crypto";
485
+ import {
486
+ buildContentPush,
487
+ buildReasoningPush
488
+ } from "@rei-standard/amsg-shared";
485
489
  var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
486
490
  function splitOnceByRegex(chunk, regex) {
487
491
  const out = chunk.split(regex).reduce((acc, part, i, arr) => {
@@ -502,6 +506,22 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
502
506
  }
503
507
  return chunks.length > 0 ? chunks : [messageContent];
504
508
  }
509
+ function readReasoningContent(llmResponse) {
510
+ if (!llmResponse || typeof llmResponse !== "object") return null;
511
+ const choices = (
512
+ /** @type {{ choices?: unknown }} */
513
+ llmResponse.choices
514
+ );
515
+ if (!Array.isArray(choices) || choices.length === 0) return null;
516
+ const message = (
517
+ /** @type {{ message?: { reasoning_content?: unknown } }} */
518
+ choices[0]?.message
519
+ );
520
+ const raw = message?.reasoning_content;
521
+ if (typeof raw !== "string") return null;
522
+ const trimmed = raw.trim();
523
+ return trimmed.length > 0 ? trimmed : null;
524
+ }
505
525
  async function processSingleMessage(task, ctx, providedMasterKey) {
506
526
  try {
507
527
  const masterKey = providedMasterKey || ctx.masterKey;
@@ -511,19 +531,24 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
511
531
  const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
512
532
  const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
513
533
  let messageContent;
534
+ let llmResponse = null;
514
535
  if (decryptedPayload.messageType === "fixed") {
515
536
  messageContent = decryptedPayload.userMessage;
516
537
  } else if (decryptedPayload.messageType === "instant") {
517
538
  const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
518
539
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
519
- messageContent = await _callAI(decryptedPayload);
540
+ const aiResult = await _callAI(decryptedPayload);
541
+ messageContent = aiResult.content;
542
+ llmResponse = aiResult.response;
520
543
  } else if (decryptedPayload.userMessage) {
521
544
  messageContent = decryptedPayload.userMessage;
522
545
  } else {
523
546
  throw new Error("Invalid instant message: no content source available");
524
547
  }
525
548
  } else if (decryptedPayload.messageType === "prompted" || decryptedPayload.messageType === "auto") {
526
- messageContent = await _callAI(decryptedPayload);
549
+ const aiResult = await _callAI(decryptedPayload);
550
+ messageContent = aiResult.content;
551
+ llmResponse = aiResult.response;
527
552
  } else {
528
553
  throw new Error("Invalid message configuration: no content source available");
529
554
  }
@@ -532,23 +557,48 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
532
557
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
533
558
  }
534
559
  const pushSubscription = decryptedPayload.pushSubscription;
535
- for (let i = 0; i < messages.length; i++) {
536
- const notificationPayload = {
560
+ const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
561
+ const source = decryptedPayload.messageType === "instant" ? "instant" : "scheduled";
562
+ const messageSubtype = decryptedPayload.messageSubtype || "chat";
563
+ const avatarUrl = decryptedPayload.avatarUrl || null;
564
+ const metadata = decryptedPayload.metadata || {};
565
+ const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}_instant`;
566
+ const reasoning = readReasoningContent(llmResponse);
567
+ if (reasoning) {
568
+ const reasoningPush = buildReasoningPush({
569
+ messageType: decryptedPayload.messageType,
570
+ source,
571
+ messageId: `${messageIdBase}_reasoning`,
572
+ sessionId,
573
+ reasoningContent: reasoning,
574
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
537
575
  title: `\u6765\u81EA ${decryptedPayload.contactName}`,
576
+ contactName: decryptedPayload.contactName,
577
+ avatarUrl,
578
+ messageSubtype,
579
+ metadata
580
+ });
581
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
582
+ await new Promise((resolve) => setTimeout(resolve, 1500));
583
+ }
584
+ for (let i = 0; i < messages.length; i++) {
585
+ const contentPush = buildContentPush({
586
+ messageType: decryptedPayload.messageType,
587
+ source,
588
+ messageId: `${messageIdBase}_${i}`,
589
+ sessionId,
538
590
  message: messages[i],
591
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
592
+ title: `\u6765\u81EA ${decryptedPayload.contactName}`,
539
593
  contactName: decryptedPayload.contactName,
540
- messageId: `msg_${randomUUID()}_${task.id || "instant"}_${i}`,
594
+ avatarUrl,
595
+ messageSubtype,
541
596
  messageIndex: i + 1,
542
597
  totalMessages: messages.length,
543
- messageType: decryptedPayload.messageType,
544
- messageSubtype: decryptedPayload.messageSubtype || "chat",
545
598
  taskId: task.id || null,
546
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
547
- source: decryptedPayload.messageType === "instant" ? "instant" : "scheduled",
548
- avatarUrl: decryptedPayload.avatarUrl || null,
549
- metadata: decryptedPayload.metadata || {}
550
- };
551
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
599
+ metadata
600
+ });
601
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
552
602
  if (i < messages.length - 1) {
553
603
  await new Promise((resolve) => setTimeout(resolve, 1500));
554
604
  }
@@ -647,7 +697,7 @@ async function _callAI(payload) {
647
697
  if (typeof content !== "string" || !content.trim()) {
648
698
  throw new Error("AI API error: response missing choices[0].message.content");
649
699
  }
650
- return content.trim();
700
+ return { response: aiData, content: content.trim() };
651
701
  }
652
702
  function buildAiRequestBody(payload) {
653
703
  const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-server",
3
- "version": "2.3.1",
4
- "description": "ReiStandard Active Messaging server SDK with pluggable database adapters",
3
+ "version": "2.4.0-next.0",
4
+ "description": "ReiStandard Active Messaging server SDK with pluggable database adapters. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/Tosd0/ReiStandard",
@@ -33,6 +33,7 @@
33
33
  "node": ">=20"
34
34
  },
35
35
  "dependencies": {
36
+ "@rei-standard/amsg-shared": "0.1.0-next.0",
36
37
  "web-push": "^3.6.7",
37
38
  "@netlify/blobs": "^8.1.0"
38
39
  },