@rei-standard/amsg-server 2.1.1 → 2.3.1

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/dist/index.d.ts CHANGED
@@ -101,6 +101,112 @@ function isValidUUIDv4(uuid) {
101
101
  return uuidV4Regex.test(uuid);
102
102
  }
103
103
 
104
+ const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
105
+
106
+ const AVATAR_URL_MAX_LENGTH = 2048;
107
+
108
+ /**
109
+ * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
110
+ * base64-encoded inline images) and anything longer than 2048 chars, both
111
+ * of which are the dominant trigger for downstream 413 / Web Push 4 KB
112
+ * payload errors. Returns an error message string, or null when valid.
113
+ *
114
+ * Mirrors @rei-standard/amsg-instant's `validateAvatarUrl` (kept in lockstep
115
+ * on purpose — both packages forward `avatarUrl` to the same SW push payload).
116
+ *
117
+ * @param {unknown} value
118
+ * @returns {string | null}
119
+ */
120
+ function validateAvatarUrl(value) {
121
+ if (value === undefined || value === null) return null;
122
+ if (typeof value !== 'string') {
123
+ return 'avatarUrl 必须是字符串';
124
+ }
125
+ if (/^data:/i.test(value)) {
126
+ return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
127
+ }
128
+ if (value.length > AVATAR_URL_MAX_LENGTH) {
129
+ return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
130
+ }
131
+ if (!isValidUrl(value)) {
132
+ return 'avatarUrl 不是合法 URL';
133
+ }
134
+ return null;
135
+ }
136
+
137
+ const SPLIT_PATTERN_MAX_LENGTH = 200;
138
+ const SPLIT_PATTERN_MAX_ITEMS = 10;
139
+
140
+ /**
141
+ * Validate the optional `splitPattern` field. Mirrors
142
+ * @rei-standard/amsg-instant's `validateSplitPattern` (kept in lockstep).
143
+ * Accepts `string`, `string[]`, or absent/null. Returns an error message
144
+ * string, or null when valid.
145
+ *
146
+ * Limits (per-item length ≤ 200, array ≤ 10 items, must compile via
147
+ * `new RegExp(item)`) are an **input-size guard**, NOT a ReDoS defense —
148
+ * a 6-character pattern like `(a+)+$` is enough to trigger catastrophic
149
+ * backtracking. The real backstop is Worker / runtime CPU limits + the
150
+ * fact that splitPattern is stored under the user's own encrypted task
151
+ * and matched against output from the user's own LLM API key, so the
152
+ * blast radius is self-inflicted only (no cross-tenant attack surface).
153
+ *
154
+ * @param {unknown} value
155
+ * @returns {string | null}
156
+ */
157
+ function validateSplitPattern(value) {
158
+ if (value === undefined || value === null) return null;
159
+ const isArray = Array.isArray(value);
160
+ const items = isArray ? value : [value];
161
+ if (isArray && items.length === 0) return null; // empty array = use default
162
+ if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
163
+ return `splitPattern 数组最多 ${SPLIT_PATTERN_MAX_ITEMS} 项`;
164
+ }
165
+ for (let i = 0; i < items.length; i++) {
166
+ const s = items[i];
167
+ const label = isArray ? `splitPattern[${i}]` : 'splitPattern';
168
+ if (typeof s !== 'string') return `${label} 必须是字符串`;
169
+ if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
170
+ return `${label} 不能超过 ${SPLIT_PATTERN_MAX_LENGTH} 字符`;
171
+ }
172
+ try { new RegExp(s); }
173
+ catch (_) { return `${label} 不是有效正则表达式`; }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Validate an OpenAI-style messages array. Same shape contract as
180
+ * `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
181
+ * end up forwarding this to the same LLM body).
182
+ *
183
+ * @param {unknown} messages
184
+ * @returns {string | null} Error message, or null if valid.
185
+ */
186
+ function validateLlmMessagesArray(messages) {
187
+ if (!Array.isArray(messages) || messages.length === 0) {
188
+ return 'messages must be a non-empty array';
189
+ }
190
+ for (let i = 0; i < messages.length; i++) {
191
+ const m = messages[i];
192
+ if (!m || typeof m !== 'object' || Array.isArray(m)) {
193
+ return `messages[${i}] must be an object`;
194
+ }
195
+ if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
196
+ return `messages[${i}].role must be one of system / user / assistant / tool`;
197
+ }
198
+ if (typeof m.content === 'string') {
199
+ if (!m.content) return `messages[${i}].content must be a non-empty string`;
200
+ } else if (Array.isArray(m.content)) {
201
+ if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
202
+ // Element schema is intentionally not enforced — passed through to LLM as-is.
203
+ } else {
204
+ return `messages[${i}].content must be a non-empty string or a non-empty array`;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
104
210
  /**
105
211
  * Validate the schedule-message request payload.
106
212
  *
@@ -146,9 +252,40 @@ function validateScheduleMessagePayload(payload) {
146
252
  }
147
253
  }
148
254
 
255
+ // ─── Prompt schema (shared by prompted / auto / instant AI configs) ──
256
+ //
257
+ // Callers provide *exactly one of* `completePrompt` (string) or `messages`
258
+ // (OpenAI-style array). Same contract as @rei-standard/amsg-instant; the
259
+ // server's LLM path forwards either verbatim.
260
+ const promptCheck = (() => {
261
+ const hasCompletePrompt = payload.completePrompt !== undefined && payload.completePrompt !== null && payload.completePrompt !== '';
262
+ const hasMessages = payload.messages !== undefined && payload.messages !== null;
263
+ if (hasCompletePrompt && hasMessages) {
264
+ return {
265
+ error: { code: 'INVALID_PARAMETERS', message: 'exactly one of `completePrompt` or `messages` must be provided(两者不能同时出现)', details: { invalidFields: ['completePrompt', 'messages'] } },
266
+ hasCompletePrompt: true, hasMessages: true,
267
+ };
268
+ }
269
+ if (hasMessages) {
270
+ const err = validateLlmMessagesArray(payload.messages);
271
+ if (err) {
272
+ return {
273
+ error: { code: 'INVALID_PARAMETERS', message: err, details: { invalidFields: ['messages'] } },
274
+ hasCompletePrompt: false, hasMessages: true,
275
+ };
276
+ }
277
+ }
278
+ return { error: null, hasCompletePrompt, hasMessages };
279
+ })();
280
+
281
+ if (promptCheck.error) {
282
+ return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
283
+ }
284
+ const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
285
+
149
286
  if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
150
287
  const missingAiFields = [];
151
- if (!payload.completePrompt) missingAiFields.push('completePrompt');
288
+ if (!hasPrompt) missingAiFields.push('completePrompt or messages');
152
289
  if (!payload.apiUrl) missingAiFields.push('apiUrl');
153
290
  if (!payload.apiKey) missingAiFields.push('apiKey');
154
291
  if (!payload.primaryModel) missingAiFields.push('primaryModel');
@@ -161,15 +298,24 @@ function validateScheduleMessagePayload(payload) {
161
298
  if (payload.recurrenceType && payload.recurrenceType !== 'none') {
162
299
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
163
300
  }
164
- const hasAiConfig = payload.completePrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
301
+ const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
165
302
  const hasUserMessage = payload.userMessage;
166
303
  if (!hasAiConfig && !hasUserMessage) {
167
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or (completePrompt + apiUrl + apiKey + primaryModel)'] } };
304
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)'] } };
168
305
  }
169
306
  }
170
307
 
171
- if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
172
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['avatarUrl (invalid URL format)'] } };
308
+ if (
309
+ payload.temperature !== undefined &&
310
+ payload.temperature !== null &&
311
+ (typeof payload.temperature !== 'number' || !Number.isFinite(payload.temperature))
312
+ ) {
313
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
314
+ }
315
+
316
+ const avatarErr = validateAvatarUrl(payload.avatarUrl);
317
+ if (avatarErr) {
318
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } };
173
319
  }
174
320
  if (payload.uuid && !isValidUUID(payload.uuid)) {
175
321
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
@@ -185,6 +331,11 @@ function validateScheduleMessagePayload(payload) {
185
331
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
186
332
  }
187
333
 
334
+ const splitErr = validateSplitPattern(payload.splitPattern);
335
+ if (splitErr) {
336
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
337
+ }
338
+
188
339
  return { valid: true };
189
340
  }
190
341
 
@@ -650,6 +801,55 @@ function isUniqueViolation(error) {
650
801
  */
651
802
 
652
803
 
804
+ const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
805
+
806
+ /**
807
+ * Split a single chunk by one regex; on no-match return [chunk] so a later
808
+ * regex in a cascade can still take a swing at it.
809
+ */
810
+ function splitOnceByRegex(chunk, regex) {
811
+ const out = chunk
812
+ .split(regex)
813
+ .reduce((acc, part, i, arr) => {
814
+ if (i % 2 === 0 && part.trim()) {
815
+ const punctuation = arr[i + 1] || '';
816
+ acc.push(part.trim() + punctuation);
817
+ }
818
+ return acc;
819
+ }, [])
820
+ .filter(s => s.length > 0);
821
+ return out.length > 0 ? out : [chunk];
822
+ }
823
+
824
+ /**
825
+ * Sentence splitter — kept byte-for-byte equivalent to
826
+ * `@rei-standard/amsg-instant`'s `splitMessageIntoSentences`. Server carries
827
+ * its own copy to avoid an architectural dependency on the instant package.
828
+ * Name matches the instant export on purpose so cross-package grep finds
829
+ * both copies; if you fix a bug here, fix it in the instant copy too.
830
+ *
831
+ * @param {string} messageContent
832
+ * @param {string | string[] | null} [splitPattern=null]
833
+ * @returns {string[]}
834
+ */
835
+ function splitMessageIntoSentences(messageContent, splitPattern = null) {
836
+ const sources =
837
+ splitPattern == null ? null :
838
+ Array.isArray(splitPattern) ? splitPattern :
839
+ [splitPattern];
840
+
841
+ const regexes = (sources && sources.length > 0)
842
+ ? sources.map(s => new RegExp(s))
843
+ : [DEFAULT_SPLIT_REGEX];
844
+
845
+ let chunks = [messageContent];
846
+ for (const regex of regexes) {
847
+ chunks = chunks.flatMap(c => splitOnceByRegex(c, regex));
848
+ }
849
+
850
+ return chunks.length > 0 ? chunks : [messageContent];
851
+ }
852
+
653
853
  /**
654
854
  * @typedef {Object} ProcessorContext
655
855
  * @property {Object} webpush - The web-push module instance (already VAPID-configured).
@@ -681,7 +881,8 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
681
881
  messageContent = decryptedPayload.userMessage;
682
882
 
683
883
  } else if (decryptedPayload.messageType === 'instant') {
684
- if (decryptedPayload.completePrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
884
+ const hasPrompt = !!decryptedPayload.completePrompt || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
885
+ if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
685
886
  messageContent = await _callAI(decryptedPayload);
686
887
  } else if (decryptedPayload.userMessage) {
687
888
  messageContent = decryptedPayload.userMessage;
@@ -695,19 +896,12 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
695
896
  throw new Error('Invalid message configuration: no content source available');
696
897
  }
697
898
 
698
- // Sentence splitting
699
- const sentences = messageContent
700
- .split(/([。!?!?]+)/)
701
- .reduce((acc, part, i, arr) => {
702
- if (i % 2 === 0 && part.trim()) {
703
- const punctuation = arr[i + 1] || '';
704
- acc.push(part.trim() + punctuation);
705
- }
706
- return acc;
707
- }, [])
708
- .filter(s => s.length > 0);
709
-
710
- const messages = sentences.length > 0 ? sentences : [messageContent];
899
+ // Sentence splitting (mirrors @rei-standard/amsg-instant
900
+ // splitMessageIntoSentences — keep in lockstep; do not drift). Caller may
901
+ // override the default regex via decryptedPayload.splitPattern (string
902
+ // for a single regex, string[] for a cascade). Validation already enforces
903
+ // length cap + RegExp compilability upstream.
904
+ const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
711
905
 
712
906
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
713
907
  throw new Error('VAPID configuration missing - push notifications cannot be sent');
@@ -887,12 +1081,29 @@ async function _callAI(payload) {
887
1081
  * @returns {Object}
888
1082
  */
889
1083
  function buildAiRequestBody(payload) {
1084
+ // messages mode (added in v2.2.0): forward the caller's OpenAI-style array
1085
+ // verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
1086
+ // role injection, no concatenation back to a single user message. Lets
1087
+ // the upstream app preserve system / multi-turn context byte-for-byte
1088
+ // across the schedule-message path.
1089
+ const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
1090
+ ? payload.messages
1091
+ : [{ role: 'user', content: payload.completePrompt }];
1092
+
890
1093
  const requestBody = {
891
1094
  model: payload.primaryModel,
892
- messages: [{ role: 'user', content: payload.completePrompt }],
893
- temperature: 0.8
1095
+ messages: llmMessages,
894
1096
  };
895
1097
 
1098
+ // Match the instant package's behavior: only inject default temperature
1099
+ // for the legacy completePrompt path; messages mode forwards whatever the
1100
+ // upstream app set (or nothing) so behavior matches their main chat path.
1101
+ if (payload.temperature !== undefined && payload.temperature !== null) {
1102
+ requestBody.temperature = payload.temperature;
1103
+ } else if (!Array.isArray(payload.messages)) {
1104
+ requestBody.temperature = 0.8;
1105
+ }
1106
+
896
1107
  if (payload.maxTokens === undefined || payload.maxTokens === null) {
897
1108
  return requestBody;
898
1109
  }
@@ -1052,8 +1263,17 @@ function createScheduleMessageHandler(ctx) {
1052
1263
  apiUrl: payload.apiUrl || null,
1053
1264
  apiKey: payload.apiKey || null,
1054
1265
  primaryModel: payload.primaryModel || null,
1266
+ // Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
1267
+ // style array). Validation has already enforced exactly-one-of, so
1268
+ // exactly one of these will be non-null when an AI config is provided.
1055
1269
  completePrompt: payload.completePrompt || null,
1270
+ messages: Array.isArray(payload.messages) ? payload.messages : null,
1056
1271
  maxTokens: payload.maxTokens ?? null,
1272
+ temperature: payload.temperature ?? null,
1273
+ // 0.6.0+: optional caller-provided regex (string or string[]) used by
1274
+ // the message processor to chunk LLM output into individual pushes.
1275
+ // null → processor falls back to the default /([。!?!?]+)/ regex.
1276
+ splitPattern: payload.splitPattern ?? null,
1057
1277
  pushSubscription: payload.pushSubscription,
1058
1278
  metadata: payload.metadata || {}
1059
1279
  };
@@ -1450,6 +1670,42 @@ function createUpdateMessageHandler(ctx) {
1450
1670
  return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['maxTokens'] } } } };
1451
1671
  }
1452
1672
 
1673
+ // Reject updates that try to set both completePrompt and messages at
1674
+ // once. We don't enforce one-of-required here (callers may patch other
1675
+ // fields), but the two prompt sources are mutually exclusive and must
1676
+ // stay that way in storage too.
1677
+ if (
1678
+ updates.completePrompt &&
1679
+ updates.messages !== undefined && updates.messages !== null
1680
+ ) {
1681
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: 'completePrompt 与 messages 不能同时更新(二选一)', details: { invalidFields: ['completePrompt', 'messages'] } } } };
1682
+ }
1683
+ if (updates.messages !== undefined && updates.messages !== null) {
1684
+ const msgErr = validateLlmMessagesArray(updates.messages);
1685
+ if (msgErr) {
1686
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: msgErr, details: { invalidFields: ['messages'] } } } };
1687
+ }
1688
+ }
1689
+ if (
1690
+ Object.prototype.hasOwnProperty.call(updates, 'temperature') &&
1691
+ updates.temperature !== null &&
1692
+ (typeof updates.temperature !== 'number' || !Number.isFinite(updates.temperature))
1693
+ ) {
1694
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
1695
+ }
1696
+ if (Object.prototype.hasOwnProperty.call(updates, 'splitPattern')) {
1697
+ const splitErr = validateSplitPattern(updates.splitPattern);
1698
+ if (splitErr) {
1699
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: splitErr, details: { invalidFields: ['splitPattern'] } } } };
1700
+ }
1701
+ }
1702
+ if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
1703
+ const avatarErr = validateAvatarUrl(updates.avatarUrl);
1704
+ if (avatarErr) {
1705
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: avatarErr, details: { invalidFields: ['avatarUrl'] } } } };
1706
+ }
1707
+ }
1708
+
1453
1709
  // Fetch existing task
1454
1710
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
1455
1711
 
@@ -1463,14 +1719,31 @@ function createUpdateMessageHandler(ctx) {
1463
1719
 
1464
1720
  const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
1465
1721
 
1722
+ // When the caller switches prompt source (completePrompt ↔ messages),
1723
+ // null out the other so storage stays one-of (matches schedule-message
1724
+ // shape and prevents buildAiRequestBody from accidentally seeing both).
1725
+ const promptUpdates = {};
1726
+ if (updates.completePrompt) {
1727
+ promptUpdates.completePrompt = updates.completePrompt;
1728
+ promptUpdates.messages = null;
1729
+ } else if (updates.messages !== undefined && updates.messages !== null) {
1730
+ promptUpdates.messages = updates.messages;
1731
+ promptUpdates.completePrompt = null;
1732
+ }
1733
+
1466
1734
  const updatedData = {
1467
1735
  ...existingData,
1468
- ...(updates.completePrompt && { completePrompt: updates.completePrompt }),
1736
+ ...promptUpdates,
1469
1737
  ...(updates.userMessage && { userMessage: updates.userMessage }),
1470
1738
  ...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
1471
1739
  ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
1472
1740
  ...(updates.metadata && { metadata: updates.metadata }),
1473
- ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null })
1741
+ ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
1742
+ ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null }),
1743
+ // splitPattern: hasOwnProperty so that explicit `null` (= revert to
1744
+ // default) doesn't get swallowed by truthy-spread the way the optional
1745
+ // string fields above are.
1746
+ ...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null })
1474
1747
  };
1475
1748
 
1476
1749
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
@@ -2204,4 +2477,4 @@ async function createReiServer(config) {
2204
2477
  };
2205
2478
  }
2206
2479
 
2207
- export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateScheduleMessagePayload, verifyTenantToken };
2480
+ export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateAvatarUrl, validateLlmMessagesArray, validateScheduleMessagePayload, validateSplitPattern, verifyTenantToken };