@rei-standard/amsg-server 2.3.2 → 2.4.0-next.2

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
@@ -9,7 +9,7 @@
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`,向后兼容。
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
13
 
14
14
  ## 安装
15
15
 
package/dist/index.cjs CHANGED
@@ -190,7 +190,8 @@ function validateScheduleMessagePayload(payload) {
190
190
  }
191
191
  const avatarErr = validateAvatarUrl(payload.avatarUrl);
192
192
  if (avatarErr) {
193
- return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: avatarErr, details: { invalidFields: ["avatarUrl"] } };
193
+ console.warn("[amsg-server] avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u7F6E\u7A7A\uFF1A", avatarErr);
194
+ payload.avatarUrl = null;
194
195
  }
195
196
  if (payload.uuid && !isValidUUID(payload.uuid)) {
196
197
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["uuid (invalid UUID format)"] } };
@@ -482,6 +483,10 @@ function isUniqueViolation(error) {
482
483
 
483
484
  // src/server/lib/message-processor.js
484
485
 
486
+
487
+
488
+
489
+ var _amsgshared = require('@rei-standard/amsg-shared');
485
490
  var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
486
491
  function splitOnceByRegex(chunk, regex) {
487
492
  const out = chunk.split(regex).reduce((acc, part, i, arr) => {
@@ -502,6 +507,22 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
502
507
  }
503
508
  return chunks.length > 0 ? chunks : [messageContent];
504
509
  }
510
+ function readReasoningContent(llmResponse) {
511
+ if (!llmResponse || typeof llmResponse !== "object") return null;
512
+ const choices = (
513
+ /** @type {{ choices?: unknown }} */
514
+ llmResponse.choices
515
+ );
516
+ if (!Array.isArray(choices) || choices.length === 0) return null;
517
+ const message = (
518
+ /** @type {{ message?: { reasoning_content?: unknown } }} */
519
+ _optionalChain([choices, 'access', _2 => _2[0], 'optionalAccess', _3 => _3.message])
520
+ );
521
+ const raw = _optionalChain([message, 'optionalAccess', _4 => _4.reasoning_content]);
522
+ if (typeof raw !== "string") return null;
523
+ const trimmed = raw.trim();
524
+ return trimmed.length > 0 ? trimmed : null;
525
+ }
505
526
  async function processSingleMessage(task, ctx, providedMasterKey) {
506
527
  try {
507
528
  const masterKey = providedMasterKey || ctx.masterKey;
@@ -511,19 +532,24 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
511
532
  const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
512
533
  const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
513
534
  let messageContent;
535
+ let llmResponse = null;
514
536
  if (decryptedPayload.messageType === "fixed") {
515
537
  messageContent = decryptedPayload.userMessage;
516
538
  } else if (decryptedPayload.messageType === "instant") {
517
539
  const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
518
540
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
519
- messageContent = await _callAI(decryptedPayload);
541
+ const aiResult = await _callAI(decryptedPayload);
542
+ messageContent = aiResult.content;
543
+ llmResponse = aiResult.response;
520
544
  } else if (decryptedPayload.userMessage) {
521
545
  messageContent = decryptedPayload.userMessage;
522
546
  } else {
523
547
  throw new Error("Invalid instant message: no content source available");
524
548
  }
525
549
  } else if (decryptedPayload.messageType === "prompted" || decryptedPayload.messageType === "auto") {
526
- messageContent = await _callAI(decryptedPayload);
550
+ const aiResult = await _callAI(decryptedPayload);
551
+ messageContent = aiResult.content;
552
+ llmResponse = aiResult.response;
527
553
  } else {
528
554
  throw new Error("Invalid message configuration: no content source available");
529
555
  }
@@ -532,23 +558,48 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
532
558
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
533
559
  }
534
560
  const pushSubscription = decryptedPayload.pushSubscription;
535
- for (let i = 0; i < messages.length; i++) {
536
- const notificationPayload = {
561
+ const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${_crypto.randomUUID.call(void 0, )}`;
562
+ const source = decryptedPayload.messageType === "instant" ? "instant" : "scheduled";
563
+ const messageSubtype = decryptedPayload.messageSubtype || "chat";
564
+ const avatarUrl = decryptedPayload.avatarUrl || null;
565
+ const metadata = decryptedPayload.metadata || {};
566
+ const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${_crypto.randomUUID.call(void 0, )}_instant`;
567
+ const reasoning = readReasoningContent(llmResponse);
568
+ if (reasoning) {
569
+ const reasoningPush = _amsgshared.buildReasoningPush.call(void 0, {
570
+ messageType: decryptedPayload.messageType,
571
+ source,
572
+ messageId: `${messageIdBase}_reasoning`,
573
+ sessionId,
574
+ reasoningContent: reasoning,
575
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
537
576
  title: `\u6765\u81EA ${decryptedPayload.contactName}`,
577
+ contactName: decryptedPayload.contactName,
578
+ avatarUrl,
579
+ messageSubtype,
580
+ metadata
581
+ });
582
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
583
+ await new Promise((resolve) => setTimeout(resolve, 1500));
584
+ }
585
+ for (let i = 0; i < messages.length; i++) {
586
+ const contentPush = _amsgshared.buildContentPush.call(void 0, {
587
+ messageType: decryptedPayload.messageType,
588
+ source,
589
+ messageId: `${messageIdBase}_${i}`,
590
+ sessionId,
538
591
  message: messages[i],
592
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
593
+ title: `\u6765\u81EA ${decryptedPayload.contactName}`,
539
594
  contactName: decryptedPayload.contactName,
540
- messageId: `msg_${_crypto.randomUUID.call(void 0, )}_${task.id || "instant"}_${i}`,
595
+ avatarUrl,
596
+ messageSubtype,
541
597
  messageIndex: i + 1,
542
598
  totalMessages: messages.length,
543
- messageType: decryptedPayload.messageType,
544
- messageSubtype: decryptedPayload.messageSubtype || "chat",
545
599
  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));
600
+ metadata
601
+ });
602
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
552
603
  if (i < messages.length - 1) {
553
604
  await new Promise((resolve) => setTimeout(resolve, 1500));
554
605
  }
@@ -643,11 +694,11 @@ async function _callAI(payload) {
643
694
  );
644
695
  }
645
696
  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]);
697
+ const content = _optionalChain([aiData, 'optionalAccess', _5 => _5.choices, 'optionalAccess', _6 => _6[0], 'optionalAccess', _7 => _7.message, 'optionalAccess', _8 => _8.content]);
647
698
  if (typeof content !== "string" || !content.trim()) {
648
699
  throw new Error("AI API error: response missing choices[0].message.content");
649
700
  }
650
- return content.trim();
701
+ return { response: aiData, content: content.trim() };
651
702
  }
652
703
  function buildAiRequestBody(payload) {
653
704
  const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
@@ -1102,7 +1153,8 @@ function createUpdateMessageHandler(ctx) {
1102
1153
  if (Object.prototype.hasOwnProperty.call(updates, "avatarUrl")) {
1103
1154
  const avatarErr = validateAvatarUrl(updates.avatarUrl);
1104
1155
  if (avatarErr) {
1105
- return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: avatarErr, details: { invalidFields: ["avatarUrl"] } } } };
1156
+ console.warn("[amsg-server] update-message avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u5FFD\u7565\uFF1A", avatarErr);
1157
+ delete updates.avatarUrl;
1106
1158
  }
1107
1159
  }
1108
1160
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
@@ -1326,7 +1378,7 @@ async function resolveBlobStore(namespace) {
1326
1378
  }
1327
1379
  try {
1328
1380
  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]);
1381
+ const getStore = blobModule.getStore || _optionalChain([blobModule, 'access', _9 => _9.default, 'optionalAccess', _10 => _10.getStore]);
1330
1382
  if (typeof getStore === "function") {
1331
1383
  return getStore({ name: namespace });
1332
1384
  }
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
@@ -315,7 +316,11 @@ function validateScheduleMessagePayload(payload) {
315
316
 
316
317
  const avatarErr = validateAvatarUrl(payload.avatarUrl);
317
318
  if (avatarErr) {
318
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } };
319
+ // Soft-strip: a bad avatarUrl (data: URI / oversized / malformed) used to
320
+ // 400 the whole schedule. Avatar is cosmetic — drop the field, log, and
321
+ // let the rest of the task ship. See standards §6.2.
322
+ console.warn('[amsg-server] avatarUrl 不合法,已置空:', avatarErr);
323
+ payload.avatarUrl = null;
319
324
  }
320
325
  if (payload.uuid && !isValidUUID(payload.uuid)) {
321
326
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
@@ -793,11 +798,23 @@ function isUniqueViolation(error) {
793
798
 
794
799
  /**
795
800
  * Message Processor (SDK version)
796
- * ReiStandard SDK v2.0.1
801
+ * ReiStandard amsg-server v2.4.0
802
+ *
803
+ * Handles single message content generation and Web Push delivery for
804
+ * scheduled tasks (`fixed` / `prompted` / `auto`) and the legacy
805
+ * via-server instant path (`messageType: 'instant'`).
797
806
  *
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.
807
+ * Push wire shape comes from `@rei-standard/amsg-shared`'s
808
+ * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
809
+ * routes on `messageKind`. Server-driven pushes always carry
810
+ * `source: 'instant'` (for the legacy in-server instant) or
811
+ * `source: 'scheduled'` (for everything else).
812
+ *
813
+ * v2.4.0: when the LLM response carries non-empty
814
+ * `choices[0].message.reasoning_content`, the processor now emits a
815
+ * standalone `ReasoningPush` **before** the `ContentPush` burst.
816
+ * `messagesSent` in the return value continues to reflect the sentence
817
+ * count only (reasoning is an auxiliary push, not a sentence).
801
818
  */
802
819
 
803
820
 
@@ -850,6 +867,25 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
850
867
  return chunks.length > 0 ? chunks : [messageContent];
851
868
  }
852
869
 
870
+ /**
871
+ * Read `choices[0].message.reasoning_content` as a non-empty trimmed
872
+ * string, or null when absent / empty. Mirrors
873
+ * `amsg-instant/src/message-processor.js#readReasoningContent`.
874
+ *
875
+ * @param {unknown} llmResponse
876
+ * @returns {string | null}
877
+ */
878
+ function readReasoningContent(llmResponse) {
879
+ if (!llmResponse || typeof llmResponse !== 'object') return null;
880
+ const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices;
881
+ if (!Array.isArray(choices) || choices.length === 0) return null;
882
+ const message = /** @type {{ message?: { reasoning_content?: unknown } }} */ (choices[0])?.message;
883
+ const raw = message?.reasoning_content;
884
+ if (typeof raw !== 'string') return null;
885
+ const trimmed = raw.trim();
886
+ return trimmed.length > 0 ? trimmed : null;
887
+ }
888
+
853
889
  /**
854
890
  * @typedef {Object} ProcessorContext
855
891
  * @property {Object} webpush - The web-push module instance (already VAPID-configured).
@@ -876,14 +912,19 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
876
912
  const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
877
913
 
878
914
  let messageContent;
915
+ /** @type {unknown} */
916
+ let llmResponse = null;
879
917
 
880
918
  if (decryptedPayload.messageType === 'fixed') {
881
919
  messageContent = decryptedPayload.userMessage;
882
920
 
883
921
  } else if (decryptedPayload.messageType === 'instant') {
884
- const hasPrompt = !!decryptedPayload.completePrompt || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
922
+ const hasPrompt = !!decryptedPayload.completePrompt
923
+ || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
885
924
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
886
- messageContent = await _callAI(decryptedPayload);
925
+ const aiResult = await _callAI(decryptedPayload);
926
+ messageContent = aiResult.content;
927
+ llmResponse = aiResult.response;
887
928
  } else if (decryptedPayload.userMessage) {
888
929
  messageContent = decryptedPayload.userMessage;
889
930
  } else {
@@ -891,7 +932,9 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
891
932
  }
892
933
 
893
934
  } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
894
- messageContent = await _callAI(decryptedPayload);
935
+ const aiResult = await _callAI(decryptedPayload);
936
+ messageContent = aiResult.content;
937
+ llmResponse = aiResult.response;
895
938
  } else {
896
939
  throw new Error('Invalid message configuration: no content source available');
897
940
  }
@@ -908,25 +951,68 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
908
951
  }
909
952
 
910
953
  const pushSubscription = decryptedPayload.pushSubscription;
954
+ // sessionId is shared across the optional ReasoningPush and every
955
+ // ContentPush from this LLM round. Pin it to the task id when
956
+ // available (scheduled tasks) so retries reuse the same id;
957
+ // otherwise mint a UUID.
958
+ const sessionId = task.id != null
959
+ ? `sess_task_${task.id}`
960
+ : `sess_${randomUUID()}`;
961
+ const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled';
962
+ const messageSubtype = decryptedPayload.messageSubtype || 'chat';
963
+ const avatarUrl = decryptedPayload.avatarUrl || null;
964
+ const metadata = decryptedPayload.metadata || {};
965
+
966
+ // `messageId` format — deterministic when we have a task.id so a
967
+ // retry produces the same id for the same (task, sentence) pair
968
+ // (downstream dedupers can key on it). Falls back to a UUID for
969
+ // the legacy in-server instant path that has no row id.
970
+ const messageIdBase = task.id != null
971
+ ? `msg_task_${task.id}`
972
+ : `msg_${randomUUID()}_instant`;
973
+
974
+ // ReasoningPush — auto-emitted before the content burst when the
975
+ // LLM response carried non-empty reasoning_content. `fixed` and
976
+ // explicit-userMessage paths produce no LLM response, so this
977
+ // block is naturally skipped for them (llmResponse stays null).
978
+ const reasoning = readReasoningContent(llmResponse);
979
+ if (reasoning) {
980
+ const reasoningPush = buildReasoningPush({
981
+ messageType: decryptedPayload.messageType,
982
+ source,
983
+ messageId: `${messageIdBase}_reasoning`,
984
+ sessionId,
985
+ reasoningContent: reasoning,
986
+ timestamp: new Date().toISOString(),
987
+ title: `来自 ${decryptedPayload.contactName}`,
988
+ contactName: decryptedPayload.contactName,
989
+ avatarUrl,
990
+ messageSubtype,
991
+ metadata,
992
+ });
993
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
994
+ await new Promise(resolve => setTimeout(resolve, 1500));
995
+ }
911
996
 
912
997
  for (let i = 0; i < messages.length; i++) {
913
- const notificationPayload = {
914
- title: `来自 ${decryptedPayload.contactName}`,
998
+ const contentPush = buildContentPush({
999
+ messageType: decryptedPayload.messageType,
1000
+ source,
1001
+ messageId: `${messageIdBase}_${i}`,
1002
+ sessionId,
915
1003
  message: messages[i],
1004
+ timestamp: new Date().toISOString(),
1005
+ title: `来自 ${decryptedPayload.contactName}`,
916
1006
  contactName: decryptedPayload.contactName,
917
- messageId: `msg_${randomUUID()}_${task.id || 'instant'}_${i}`,
1007
+ avatarUrl,
1008
+ messageSubtype,
918
1009
  messageIndex: i + 1,
919
1010
  totalMessages: messages.length,
920
- messageType: decryptedPayload.messageType,
921
- messageSubtype: decryptedPayload.messageSubtype || 'chat',
922
1011
  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
- };
1012
+ metadata,
1013
+ });
928
1014
 
929
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
1015
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
930
1016
 
931
1017
  if (i < messages.length - 1) {
932
1018
  await new Promise(resolve => setTimeout(resolve, 1500));
@@ -1030,7 +1116,15 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
1030
1116
 
1031
1117
  /**
1032
1118
  * Call an OpenAI-compatible API.
1119
+ *
1120
+ * Returns the full response object alongside the extracted (trimmed)
1121
+ * `content` string. Callers that only need the text can ignore
1122
+ * `response`; callers that want `reasoning_content` / `tool_calls`
1123
+ * read from `response.choices[0].message`.
1124
+ *
1033
1125
  * @private
1126
+ * @param {Object} payload
1127
+ * @returns {Promise<{ response: unknown, content: string }>}
1034
1128
  */
1035
1129
  async function _callAI(payload) {
1036
1130
  const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
@@ -1067,7 +1161,7 @@ async function _callAI(payload) {
1067
1161
  throw new Error('AI API error: response missing choices[0].message.content');
1068
1162
  }
1069
1163
 
1070
- return content.trim();
1164
+ return { response: aiData, content: content.trim() };
1071
1165
  }
1072
1166
 
1073
1167
  /**
@@ -1125,20 +1219,6 @@ function buildAiRequestBody(payload) {
1125
1219
  * to avoid an architectural dependency (server should not depend on the
1126
1220
  * stateless worker package).
1127
1221
  *
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
1222
  * @param {string} apiUrl
1143
1223
  * @returns {string}
1144
1224
  */
@@ -1702,7 +1782,11 @@ function createUpdateMessageHandler(ctx) {
1702
1782
  if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
1703
1783
  const avatarErr = validateAvatarUrl(updates.avatarUrl);
1704
1784
  if (avatarErr) {
1705
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: avatarErr, details: { invalidFields: ['avatarUrl'] } } } };
1785
+ // Soft-strip: drop the bad avatarUrl from the patch (keeps the
1786
+ // existing stored avatar untouched) and continue applying the rest
1787
+ // of the update. See standards §6.2.
1788
+ console.warn('[amsg-server] update-message avatarUrl 不合法,已忽略:', avatarErr);
1789
+ delete updates.avatarUrl;
1706
1790
  }
1707
1791
  }
1708
1792
 
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
@@ -315,7 +316,11 @@ function validateScheduleMessagePayload(payload) {
315
316
 
316
317
  const avatarErr = validateAvatarUrl(payload.avatarUrl);
317
318
  if (avatarErr) {
318
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } };
319
+ // Soft-strip: a bad avatarUrl (data: URI / oversized / malformed) used to
320
+ // 400 the whole schedule. Avatar is cosmetic — drop the field, log, and
321
+ // let the rest of the task ship. See standards §6.2.
322
+ console.warn('[amsg-server] avatarUrl 不合法,已置空:', avatarErr);
323
+ payload.avatarUrl = null;
319
324
  }
320
325
  if (payload.uuid && !isValidUUID(payload.uuid)) {
321
326
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
@@ -793,11 +798,23 @@ function isUniqueViolation(error) {
793
798
 
794
799
  /**
795
800
  * Message Processor (SDK version)
796
- * ReiStandard SDK v2.0.1
801
+ * ReiStandard amsg-server v2.4.0
802
+ *
803
+ * Handles single message content generation and Web Push delivery for
804
+ * scheduled tasks (`fixed` / `prompted` / `auto`) and the legacy
805
+ * via-server instant path (`messageType: 'instant'`).
797
806
  *
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.
807
+ * Push wire shape comes from `@rei-standard/amsg-shared`'s
808
+ * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
809
+ * routes on `messageKind`. Server-driven pushes always carry
810
+ * `source: 'instant'` (for the legacy in-server instant) or
811
+ * `source: 'scheduled'` (for everything else).
812
+ *
813
+ * v2.4.0: when the LLM response carries non-empty
814
+ * `choices[0].message.reasoning_content`, the processor now emits a
815
+ * standalone `ReasoningPush` **before** the `ContentPush` burst.
816
+ * `messagesSent` in the return value continues to reflect the sentence
817
+ * count only (reasoning is an auxiliary push, not a sentence).
801
818
  */
802
819
 
803
820
 
@@ -850,6 +867,25 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
850
867
  return chunks.length > 0 ? chunks : [messageContent];
851
868
  }
852
869
 
870
+ /**
871
+ * Read `choices[0].message.reasoning_content` as a non-empty trimmed
872
+ * string, or null when absent / empty. Mirrors
873
+ * `amsg-instant/src/message-processor.js#readReasoningContent`.
874
+ *
875
+ * @param {unknown} llmResponse
876
+ * @returns {string | null}
877
+ */
878
+ function readReasoningContent(llmResponse) {
879
+ if (!llmResponse || typeof llmResponse !== 'object') return null;
880
+ const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices;
881
+ if (!Array.isArray(choices) || choices.length === 0) return null;
882
+ const message = /** @type {{ message?: { reasoning_content?: unknown } }} */ (choices[0])?.message;
883
+ const raw = message?.reasoning_content;
884
+ if (typeof raw !== 'string') return null;
885
+ const trimmed = raw.trim();
886
+ return trimmed.length > 0 ? trimmed : null;
887
+ }
888
+
853
889
  /**
854
890
  * @typedef {Object} ProcessorContext
855
891
  * @property {Object} webpush - The web-push module instance (already VAPID-configured).
@@ -876,14 +912,19 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
876
912
  const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
877
913
 
878
914
  let messageContent;
915
+ /** @type {unknown} */
916
+ let llmResponse = null;
879
917
 
880
918
  if (decryptedPayload.messageType === 'fixed') {
881
919
  messageContent = decryptedPayload.userMessage;
882
920
 
883
921
  } else if (decryptedPayload.messageType === 'instant') {
884
- const hasPrompt = !!decryptedPayload.completePrompt || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
922
+ const hasPrompt = !!decryptedPayload.completePrompt
923
+ || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
885
924
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
886
- messageContent = await _callAI(decryptedPayload);
925
+ const aiResult = await _callAI(decryptedPayload);
926
+ messageContent = aiResult.content;
927
+ llmResponse = aiResult.response;
887
928
  } else if (decryptedPayload.userMessage) {
888
929
  messageContent = decryptedPayload.userMessage;
889
930
  } else {
@@ -891,7 +932,9 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
891
932
  }
892
933
 
893
934
  } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
894
- messageContent = await _callAI(decryptedPayload);
935
+ const aiResult = await _callAI(decryptedPayload);
936
+ messageContent = aiResult.content;
937
+ llmResponse = aiResult.response;
895
938
  } else {
896
939
  throw new Error('Invalid message configuration: no content source available');
897
940
  }
@@ -908,25 +951,68 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
908
951
  }
909
952
 
910
953
  const pushSubscription = decryptedPayload.pushSubscription;
954
+ // sessionId is shared across the optional ReasoningPush and every
955
+ // ContentPush from this LLM round. Pin it to the task id when
956
+ // available (scheduled tasks) so retries reuse the same id;
957
+ // otherwise mint a UUID.
958
+ const sessionId = task.id != null
959
+ ? `sess_task_${task.id}`
960
+ : `sess_${randomUUID()}`;
961
+ const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled';
962
+ const messageSubtype = decryptedPayload.messageSubtype || 'chat';
963
+ const avatarUrl = decryptedPayload.avatarUrl || null;
964
+ const metadata = decryptedPayload.metadata || {};
965
+
966
+ // `messageId` format — deterministic when we have a task.id so a
967
+ // retry produces the same id for the same (task, sentence) pair
968
+ // (downstream dedupers can key on it). Falls back to a UUID for
969
+ // the legacy in-server instant path that has no row id.
970
+ const messageIdBase = task.id != null
971
+ ? `msg_task_${task.id}`
972
+ : `msg_${randomUUID()}_instant`;
973
+
974
+ // ReasoningPush — auto-emitted before the content burst when the
975
+ // LLM response carried non-empty reasoning_content. `fixed` and
976
+ // explicit-userMessage paths produce no LLM response, so this
977
+ // block is naturally skipped for them (llmResponse stays null).
978
+ const reasoning = readReasoningContent(llmResponse);
979
+ if (reasoning) {
980
+ const reasoningPush = buildReasoningPush({
981
+ messageType: decryptedPayload.messageType,
982
+ source,
983
+ messageId: `${messageIdBase}_reasoning`,
984
+ sessionId,
985
+ reasoningContent: reasoning,
986
+ timestamp: new Date().toISOString(),
987
+ title: `来自 ${decryptedPayload.contactName}`,
988
+ contactName: decryptedPayload.contactName,
989
+ avatarUrl,
990
+ messageSubtype,
991
+ metadata,
992
+ });
993
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
994
+ await new Promise(resolve => setTimeout(resolve, 1500));
995
+ }
911
996
 
912
997
  for (let i = 0; i < messages.length; i++) {
913
- const notificationPayload = {
914
- title: `来自 ${decryptedPayload.contactName}`,
998
+ const contentPush = buildContentPush({
999
+ messageType: decryptedPayload.messageType,
1000
+ source,
1001
+ messageId: `${messageIdBase}_${i}`,
1002
+ sessionId,
915
1003
  message: messages[i],
1004
+ timestamp: new Date().toISOString(),
1005
+ title: `来自 ${decryptedPayload.contactName}`,
916
1006
  contactName: decryptedPayload.contactName,
917
- messageId: `msg_${randomUUID()}_${task.id || 'instant'}_${i}`,
1007
+ avatarUrl,
1008
+ messageSubtype,
918
1009
  messageIndex: i + 1,
919
1010
  totalMessages: messages.length,
920
- messageType: decryptedPayload.messageType,
921
- messageSubtype: decryptedPayload.messageSubtype || 'chat',
922
1011
  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
- };
1012
+ metadata,
1013
+ });
928
1014
 
929
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
1015
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
930
1016
 
931
1017
  if (i < messages.length - 1) {
932
1018
  await new Promise(resolve => setTimeout(resolve, 1500));
@@ -1030,7 +1116,15 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
1030
1116
 
1031
1117
  /**
1032
1118
  * Call an OpenAI-compatible API.
1119
+ *
1120
+ * Returns the full response object alongside the extracted (trimmed)
1121
+ * `content` string. Callers that only need the text can ignore
1122
+ * `response`; callers that want `reasoning_content` / `tool_calls`
1123
+ * read from `response.choices[0].message`.
1124
+ *
1033
1125
  * @private
1126
+ * @param {Object} payload
1127
+ * @returns {Promise<{ response: unknown, content: string }>}
1034
1128
  */
1035
1129
  async function _callAI(payload) {
1036
1130
  const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
@@ -1067,7 +1161,7 @@ async function _callAI(payload) {
1067
1161
  throw new Error('AI API error: response missing choices[0].message.content');
1068
1162
  }
1069
1163
 
1070
- return content.trim();
1164
+ return { response: aiData, content: content.trim() };
1071
1165
  }
1072
1166
 
1073
1167
  /**
@@ -1125,20 +1219,6 @@ function buildAiRequestBody(payload) {
1125
1219
  * to avoid an architectural dependency (server should not depend on the
1126
1220
  * stateless worker package).
1127
1221
  *
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
1222
  * @param {string} apiUrl
1143
1223
  * @returns {string}
1144
1224
  */
@@ -1702,7 +1782,11 @@ function createUpdateMessageHandler(ctx) {
1702
1782
  if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
1703
1783
  const avatarErr = validateAvatarUrl(updates.avatarUrl);
1704
1784
  if (avatarErr) {
1705
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: avatarErr, details: { invalidFields: ['avatarUrl'] } } } };
1785
+ // Soft-strip: drop the bad avatarUrl from the patch (keeps the
1786
+ // existing stored avatar untouched) and continue applying the rest
1787
+ // of the update. See standards §6.2.
1788
+ console.warn('[amsg-server] update-message avatarUrl 不合法,已忽略:', avatarErr);
1789
+ delete updates.avatarUrl;
1706
1790
  }
1707
1791
  }
1708
1792
 
package/dist/index.mjs CHANGED
@@ -190,7 +190,8 @@ function validateScheduleMessagePayload(payload) {
190
190
  }
191
191
  const avatarErr = validateAvatarUrl(payload.avatarUrl);
192
192
  if (avatarErr) {
193
- return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: avatarErr, details: { invalidFields: ["avatarUrl"] } };
193
+ console.warn("[amsg-server] avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u7F6E\u7A7A\uFF1A", avatarErr);
194
+ payload.avatarUrl = null;
194
195
  }
195
196
  if (payload.uuid && !isValidUUID(payload.uuid)) {
196
197
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["uuid (invalid UUID format)"] } };
@@ -482,6 +483,10 @@ function isUniqueViolation(error) {
482
483
 
483
484
  // src/server/lib/message-processor.js
484
485
  import { randomUUID } from "crypto";
486
+ import {
487
+ buildContentPush,
488
+ buildReasoningPush
489
+ } from "@rei-standard/amsg-shared";
485
490
  var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
486
491
  function splitOnceByRegex(chunk, regex) {
487
492
  const out = chunk.split(regex).reduce((acc, part, i, arr) => {
@@ -502,6 +507,22 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
502
507
  }
503
508
  return chunks.length > 0 ? chunks : [messageContent];
504
509
  }
510
+ function readReasoningContent(llmResponse) {
511
+ if (!llmResponse || typeof llmResponse !== "object") return null;
512
+ const choices = (
513
+ /** @type {{ choices?: unknown }} */
514
+ llmResponse.choices
515
+ );
516
+ if (!Array.isArray(choices) || choices.length === 0) return null;
517
+ const message = (
518
+ /** @type {{ message?: { reasoning_content?: unknown } }} */
519
+ choices[0]?.message
520
+ );
521
+ const raw = message?.reasoning_content;
522
+ if (typeof raw !== "string") return null;
523
+ const trimmed = raw.trim();
524
+ return trimmed.length > 0 ? trimmed : null;
525
+ }
505
526
  async function processSingleMessage(task, ctx, providedMasterKey) {
506
527
  try {
507
528
  const masterKey = providedMasterKey || ctx.masterKey;
@@ -511,19 +532,24 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
511
532
  const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
512
533
  const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
513
534
  let messageContent;
535
+ let llmResponse = null;
514
536
  if (decryptedPayload.messageType === "fixed") {
515
537
  messageContent = decryptedPayload.userMessage;
516
538
  } else if (decryptedPayload.messageType === "instant") {
517
539
  const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
518
540
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
519
- messageContent = await _callAI(decryptedPayload);
541
+ const aiResult = await _callAI(decryptedPayload);
542
+ messageContent = aiResult.content;
543
+ llmResponse = aiResult.response;
520
544
  } else if (decryptedPayload.userMessage) {
521
545
  messageContent = decryptedPayload.userMessage;
522
546
  } else {
523
547
  throw new Error("Invalid instant message: no content source available");
524
548
  }
525
549
  } else if (decryptedPayload.messageType === "prompted" || decryptedPayload.messageType === "auto") {
526
- messageContent = await _callAI(decryptedPayload);
550
+ const aiResult = await _callAI(decryptedPayload);
551
+ messageContent = aiResult.content;
552
+ llmResponse = aiResult.response;
527
553
  } else {
528
554
  throw new Error("Invalid message configuration: no content source available");
529
555
  }
@@ -532,23 +558,48 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
532
558
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
533
559
  }
534
560
  const pushSubscription = decryptedPayload.pushSubscription;
535
- for (let i = 0; i < messages.length; i++) {
536
- const notificationPayload = {
561
+ const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
562
+ const source = decryptedPayload.messageType === "instant" ? "instant" : "scheduled";
563
+ const messageSubtype = decryptedPayload.messageSubtype || "chat";
564
+ const avatarUrl = decryptedPayload.avatarUrl || null;
565
+ const metadata = decryptedPayload.metadata || {};
566
+ const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}_instant`;
567
+ const reasoning = readReasoningContent(llmResponse);
568
+ if (reasoning) {
569
+ const reasoningPush = buildReasoningPush({
570
+ messageType: decryptedPayload.messageType,
571
+ source,
572
+ messageId: `${messageIdBase}_reasoning`,
573
+ sessionId,
574
+ reasoningContent: reasoning,
575
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
537
576
  title: `\u6765\u81EA ${decryptedPayload.contactName}`,
577
+ contactName: decryptedPayload.contactName,
578
+ avatarUrl,
579
+ messageSubtype,
580
+ metadata
581
+ });
582
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
583
+ await new Promise((resolve) => setTimeout(resolve, 1500));
584
+ }
585
+ for (let i = 0; i < messages.length; i++) {
586
+ const contentPush = buildContentPush({
587
+ messageType: decryptedPayload.messageType,
588
+ source,
589
+ messageId: `${messageIdBase}_${i}`,
590
+ sessionId,
538
591
  message: messages[i],
592
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
593
+ title: `\u6765\u81EA ${decryptedPayload.contactName}`,
539
594
  contactName: decryptedPayload.contactName,
540
- messageId: `msg_${randomUUID()}_${task.id || "instant"}_${i}`,
595
+ avatarUrl,
596
+ messageSubtype,
541
597
  messageIndex: i + 1,
542
598
  totalMessages: messages.length,
543
- messageType: decryptedPayload.messageType,
544
- messageSubtype: decryptedPayload.messageSubtype || "chat",
545
599
  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));
600
+ metadata
601
+ });
602
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
552
603
  if (i < messages.length - 1) {
553
604
  await new Promise((resolve) => setTimeout(resolve, 1500));
554
605
  }
@@ -647,7 +698,7 @@ async function _callAI(payload) {
647
698
  if (typeof content !== "string" || !content.trim()) {
648
699
  throw new Error("AI API error: response missing choices[0].message.content");
649
700
  }
650
- return content.trim();
701
+ return { response: aiData, content: content.trim() };
651
702
  }
652
703
  function buildAiRequestBody(payload) {
653
704
  const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
@@ -1102,7 +1153,8 @@ function createUpdateMessageHandler(ctx) {
1102
1153
  if (Object.prototype.hasOwnProperty.call(updates, "avatarUrl")) {
1103
1154
  const avatarErr = validateAvatarUrl(updates.avatarUrl);
1104
1155
  if (avatarErr) {
1105
- return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: avatarErr, details: { invalidFields: ["avatarUrl"] } } } };
1156
+ console.warn("[amsg-server] update-message avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u5FFD\u7565\uFF1A", avatarErr);
1157
+ delete updates.avatarUrl;
1106
1158
  }
1107
1159
  }
1108
1160
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-server",
3
- "version": "2.3.2",
4
- "description": "ReiStandard Active Messaging server SDK with pluggable database adapters",
3
+ "version": "2.4.0-next.2",
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.4",
36
37
  "web-push": "^3.6.7",
37
38
  "@netlify/blobs": "^8.1.0"
38
39
  },