@rei-standard/amsg-server 2.2.0 → 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/README.md +17 -0
- package/dist/index.cjs +96 -14
- package/dist/index.d.cts +157 -17
- package/dist/index.d.ts +157 -17
- package/dist/index.mjs +93 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,9 +76,26 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
|
|
|
76
76
|
|
|
77
77
|
可选 `temperature?: number` 透传给 LLM:`completePrompt` 路径未传时默认 0.8(保持旧行为);`messages` 路径未传时**不发**,让上游主路径自己决定。
|
|
78
78
|
|
|
79
|
+
## 自定义分句正则 `splitPattern`(2.3.0+)
|
|
80
|
+
|
|
81
|
+
`processSingleMessage` 默认按 `/([。!?!?]+)/` 把 LLM 返回的整段文本切成多条推送(每条间隔 1.5s)。`splitPattern` 让调用方覆盖这个正则:
|
|
82
|
+
|
|
83
|
+
- `splitPattern: string` —— 单个正则 source(不带 flags)。例:`"([\\n]+)"` 按换行切。
|
|
84
|
+
- `splitPattern: string[]` —— **级联**应用:第一个正则切完,每段再用第二个切。例:`["(\\n\\n+)", "([。!?!?]+)"]` 先按段落、再按句号。
|
|
85
|
+
- 不传 / `null` / `[]` → 走默认正则,行为字节级与 2.2.x 一致;老库存任务(无此字段)零迁移。
|
|
86
|
+
|
|
87
|
+
**约定**:
|
|
88
|
+
|
|
89
|
+
- 传**正则 source**,不要带两边的 `/.../` 也不要带尾部 flag(`/foo/i` 会被当字面量斜杠 + 字面量 `i` 匹配)。需要大小写不敏感请用 `[Aa]` 这种字符类替代。
|
|
90
|
+
- 想让分隔符回贴到前一段(与默认行为一致),把分隔符包进 `(...)` 捕获组。库不会自动包——传 `"\\n+"` 而不是 `"(\\n+)"` 会得到首尾相连、分隔符丢失的奇怪结果。
|
|
91
|
+
- 数组语义是级联,不是"任一匹配就切"。后者请自己用 `|` 合一条正则。
|
|
92
|
+
- 限制:每项 ≤ 200 字符,数组 ≤ 10 项;非法或无法 `new RegExp(...)` 通过 → `400 INVALID_PARAMETERS`(schedule)/ `400 INVALID_UPDATE_DATA`(update)。
|
|
93
|
+
- `update-message` 显式传 `splitPattern: null` 可重置回默认;不传则保留原值。
|
|
94
|
+
|
|
79
95
|
## 导出(新增)
|
|
80
96
|
|
|
81
97
|
- `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。和 `@rei-standard/amsg-instant` 的校验规则字节级一致。
|
|
98
|
+
- `validateSplitPattern(value)` — 同步预校验 splitPattern(string / string[] / null),返回 `string | null`。
|
|
82
99
|
|
|
83
100
|
## 一体化初始化流程
|
|
84
101
|
|
package/dist/index.cjs
CHANGED
|
@@ -48,6 +48,48 @@ function isValidUUIDv4(uuid) {
|
|
|
48
48
|
return uuidV4Regex.test(uuid);
|
|
49
49
|
}
|
|
50
50
|
var VALID_LLM_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
|
|
51
|
+
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
52
|
+
function validateAvatarUrl(value) {
|
|
53
|
+
if (value === void 0 || value === null) return null;
|
|
54
|
+
if (typeof value !== "string") {
|
|
55
|
+
return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
|
|
56
|
+
}
|
|
57
|
+
if (/^data:/i.test(value)) {
|
|
58
|
+
return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
|
|
59
|
+
}
|
|
60
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
61
|
+
return `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
|
|
62
|
+
}
|
|
63
|
+
if (!isValidUrl(value)) {
|
|
64
|
+
return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
var SPLIT_PATTERN_MAX_LENGTH = 200;
|
|
69
|
+
var SPLIT_PATTERN_MAX_ITEMS = 10;
|
|
70
|
+
function validateSplitPattern(value) {
|
|
71
|
+
if (value === void 0 || value === null) return null;
|
|
72
|
+
const isArray = Array.isArray(value);
|
|
73
|
+
const items = isArray ? value : [value];
|
|
74
|
+
if (isArray && items.length === 0) return null;
|
|
75
|
+
if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
|
|
76
|
+
return `splitPattern \u6570\u7EC4\u6700\u591A ${SPLIT_PATTERN_MAX_ITEMS} \u9879`;
|
|
77
|
+
}
|
|
78
|
+
for (let i = 0; i < items.length; i++) {
|
|
79
|
+
const s = items[i];
|
|
80
|
+
const label = isArray ? `splitPattern[${i}]` : "splitPattern";
|
|
81
|
+
if (typeof s !== "string") return `${label} \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`;
|
|
82
|
+
if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
|
|
83
|
+
return `${label} \u4E0D\u80FD\u8D85\u8FC7 ${SPLIT_PATTERN_MAX_LENGTH} \u5B57\u7B26`;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
new RegExp(s);
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return `${label} \u4E0D\u662F\u6709\u6548\u6B63\u5219\u8868\u8FBE\u5F0F`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
51
93
|
function validateLlmMessagesArray(messages) {
|
|
52
94
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
53
95
|
return "messages must be a non-empty array";
|
|
@@ -146,8 +188,9 @@ function validateScheduleMessagePayload(payload) {
|
|
|
146
188
|
if (payload.temperature !== void 0 && payload.temperature !== null && (typeof payload.temperature !== "number" || !Number.isFinite(payload.temperature))) {
|
|
147
189
|
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature (must be a finite number)"] } };
|
|
148
190
|
}
|
|
149
|
-
|
|
150
|
-
|
|
191
|
+
const avatarErr = validateAvatarUrl(payload.avatarUrl);
|
|
192
|
+
if (avatarErr) {
|
|
193
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: avatarErr, details: { invalidFields: ["avatarUrl"] } };
|
|
151
194
|
}
|
|
152
195
|
if (payload.uuid && !isValidUUID(payload.uuid)) {
|
|
153
196
|
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)"] } };
|
|
@@ -155,6 +198,10 @@ function validateScheduleMessagePayload(payload) {
|
|
|
155
198
|
if (payload.messageSubtype !== void 0 && payload.messageSubtype !== null && typeof payload.messageSubtype !== "string") {
|
|
156
199
|
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["messageSubtype"] } };
|
|
157
200
|
}
|
|
201
|
+
const splitErr = validateSplitPattern(payload.splitPattern);
|
|
202
|
+
if (splitErr) {
|
|
203
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: splitErr, details: { invalidFields: ["splitPattern"] } };
|
|
204
|
+
}
|
|
158
205
|
return { valid: true };
|
|
159
206
|
}
|
|
160
207
|
|
|
@@ -435,6 +482,26 @@ function isUniqueViolation(error) {
|
|
|
435
482
|
|
|
436
483
|
// src/server/lib/message-processor.js
|
|
437
484
|
|
|
485
|
+
var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
486
|
+
function splitOnceByRegex(chunk, regex) {
|
|
487
|
+
const out = chunk.split(regex).reduce((acc, part, i, arr) => {
|
|
488
|
+
if (i % 2 === 0 && part.trim()) {
|
|
489
|
+
const punctuation = arr[i + 1] || "";
|
|
490
|
+
acc.push(part.trim() + punctuation);
|
|
491
|
+
}
|
|
492
|
+
return acc;
|
|
493
|
+
}, []).filter((s) => s.length > 0);
|
|
494
|
+
return out.length > 0 ? out : [chunk];
|
|
495
|
+
}
|
|
496
|
+
function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
497
|
+
const sources = splitPattern == null ? null : Array.isArray(splitPattern) ? splitPattern : [splitPattern];
|
|
498
|
+
const regexes = sources && sources.length > 0 ? sources.map((s) => new RegExp(s)) : [DEFAULT_SPLIT_REGEX];
|
|
499
|
+
let chunks = [messageContent];
|
|
500
|
+
for (const regex of regexes) {
|
|
501
|
+
chunks = chunks.flatMap((c) => splitOnceByRegex(c, regex));
|
|
502
|
+
}
|
|
503
|
+
return chunks.length > 0 ? chunks : [messageContent];
|
|
504
|
+
}
|
|
438
505
|
async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
439
506
|
try {
|
|
440
507
|
const masterKey = providedMasterKey || ctx.masterKey;
|
|
@@ -460,14 +527,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
460
527
|
} else {
|
|
461
528
|
throw new Error("Invalid message configuration: no content source available");
|
|
462
529
|
}
|
|
463
|
-
const
|
|
464
|
-
if (i % 2 === 0 && part.trim()) {
|
|
465
|
-
const punctuation = arr[i + 1] || "";
|
|
466
|
-
acc.push(part.trim() + punctuation);
|
|
467
|
-
}
|
|
468
|
-
return acc;
|
|
469
|
-
}, []).filter((s) => s.length > 0);
|
|
470
|
-
const messages = sentences.length > 0 ? sentences : [messageContent];
|
|
530
|
+
const messages = splitMessageIntoSentences(messageContent, _nullishCoalesce(decryptedPayload.splitPattern, () => ( null)));
|
|
471
531
|
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
472
532
|
throw new Error("VAPID configuration missing - push notifications cannot be sent");
|
|
473
533
|
}
|
|
@@ -583,7 +643,7 @@ async function _callAI(payload) {
|
|
|
583
643
|
);
|
|
584
644
|
}
|
|
585
645
|
const aiData = await aiResponse.json();
|
|
586
|
-
const content = _optionalChain([aiData, 'optionalAccess',
|
|
646
|
+
const content = _optionalChain([aiData, 'optionalAccess', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]);
|
|
587
647
|
if (typeof content !== "string" || !content.trim()) {
|
|
588
648
|
throw new Error("AI API error: response missing choices[0].message.content");
|
|
589
649
|
}
|
|
@@ -706,6 +766,10 @@ function createScheduleMessageHandler(ctx) {
|
|
|
706
766
|
messages: Array.isArray(payload.messages) ? payload.messages : null,
|
|
707
767
|
maxTokens: _nullishCoalesce(payload.maxTokens, () => ( null)),
|
|
708
768
|
temperature: _nullishCoalesce(payload.temperature, () => ( null)),
|
|
769
|
+
// 0.6.0+: optional caller-provided regex (string or string[]) used by
|
|
770
|
+
// the message processor to chunk LLM output into individual pushes.
|
|
771
|
+
// null → processor falls back to the default /([。!?!?]+)/ regex.
|
|
772
|
+
splitPattern: _nullishCoalesce(payload.splitPattern, () => ( null)),
|
|
709
773
|
pushSubscription: payload.pushSubscription,
|
|
710
774
|
metadata: payload.metadata || {}
|
|
711
775
|
};
|
|
@@ -1029,6 +1093,18 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1029
1093
|
if (Object.prototype.hasOwnProperty.call(updates, "temperature") && updates.temperature !== null && (typeof updates.temperature !== "number" || !Number.isFinite(updates.temperature))) {
|
|
1030
1094
|
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature"] } } } };
|
|
1031
1095
|
}
|
|
1096
|
+
if (Object.prototype.hasOwnProperty.call(updates, "splitPattern")) {
|
|
1097
|
+
const splitErr = validateSplitPattern(updates.splitPattern);
|
|
1098
|
+
if (splitErr) {
|
|
1099
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: splitErr, details: { invalidFields: ["splitPattern"] } } } };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (Object.prototype.hasOwnProperty.call(updates, "avatarUrl")) {
|
|
1103
|
+
const avatarErr = validateAvatarUrl(updates.avatarUrl);
|
|
1104
|
+
if (avatarErr) {
|
|
1105
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: avatarErr, details: { invalidFields: ["avatarUrl"] } } } };
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1032
1108
|
const existingTask = await db.getTaskByUuid(taskUuid, userId);
|
|
1033
1109
|
if (!existingTask) {
|
|
1034
1110
|
const taskStatus = await db.getTaskStatus(taskUuid, userId);
|
|
@@ -1054,7 +1130,11 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1054
1130
|
...updates.avatarUrl && { avatarUrl: updates.avatarUrl },
|
|
1055
1131
|
...updates.metadata && { metadata: updates.metadata },
|
|
1056
1132
|
...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: _nullishCoalesce(updates.maxTokens, () => ( null)) },
|
|
1057
|
-
...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: _nullishCoalesce(updates.temperature, () => ( null)) }
|
|
1133
|
+
...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: _nullishCoalesce(updates.temperature, () => ( null)) },
|
|
1134
|
+
// splitPattern: hasOwnProperty so that explicit `null` (= revert to
|
|
1135
|
+
// default) doesn't get swallowed by truthy-spread the way the optional
|
|
1136
|
+
// string fields above are.
|
|
1137
|
+
...Object.prototype.hasOwnProperty.call(updates, "splitPattern") && { splitPattern: _nullishCoalesce(updates.splitPattern, () => ( null)) }
|
|
1058
1138
|
};
|
|
1059
1139
|
const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
|
|
1060
1140
|
const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
|
|
@@ -1246,7 +1326,7 @@ async function resolveBlobStore(namespace) {
|
|
|
1246
1326
|
}
|
|
1247
1327
|
try {
|
|
1248
1328
|
const blobModule = await Promise.resolve().then(() => _interopRequireWildcard(require("@netlify/blobs")));
|
|
1249
|
-
const getStore = blobModule.getStore || _optionalChain([blobModule, 'access',
|
|
1329
|
+
const getStore = blobModule.getStore || _optionalChain([blobModule, 'access', _6 => _6.default, 'optionalAccess', _7 => _7.getStore]);
|
|
1250
1330
|
if (typeof getStore === "function") {
|
|
1251
1331
|
return getStore({ name: namespace });
|
|
1252
1332
|
}
|
|
@@ -1578,4 +1658,6 @@ async function createReiServer(config) {
|
|
|
1578
1658
|
|
|
1579
1659
|
|
|
1580
1660
|
|
|
1581
|
-
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
exports.createAdapter = createAdapter; exports.createReiServer = createReiServer; exports.createTenantToken = createTenantToken; exports.decryptFromStorage = decryptFromStorage; exports.decryptPayload = decryptPayload; exports.deriveUserEncryptionKey = deriveUserEncryptionKey; exports.encryptForStorage = encryptForStorage; exports.isValidISO8601 = isValidISO8601; exports.isValidUUID = isValidUUID; exports.isValidUUIDv4 = isValidUUIDv4; exports.isValidUrl = isValidUrl; exports.validateAvatarUrl = validateAvatarUrl; exports.validateLlmMessagesArray = validateLlmMessagesArray; exports.validateScheduleMessagePayload = validateScheduleMessagePayload; exports.validateSplitPattern = validateSplitPattern; exports.verifyTenantToken = verifyTenantToken;
|
package/dist/index.d.cts
CHANGED
|
@@ -103,6 +103,78 @@ function isValidUUIDv4(uuid) {
|
|
|
103
103
|
|
|
104
104
|
const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
|
|
105
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
|
+
|
|
106
178
|
/**
|
|
107
179
|
* Validate an OpenAI-style messages array. Same shape contract as
|
|
108
180
|
* `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
|
|
@@ -241,8 +313,9 @@ function validateScheduleMessagePayload(payload) {
|
|
|
241
313
|
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
|
|
242
314
|
}
|
|
243
315
|
|
|
244
|
-
|
|
245
|
-
|
|
316
|
+
const avatarErr = validateAvatarUrl(payload.avatarUrl);
|
|
317
|
+
if (avatarErr) {
|
|
318
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } };
|
|
246
319
|
}
|
|
247
320
|
if (payload.uuid && !isValidUUID(payload.uuid)) {
|
|
248
321
|
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
|
|
@@ -258,6 +331,11 @@ function validateScheduleMessagePayload(payload) {
|
|
|
258
331
|
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
|
|
259
332
|
}
|
|
260
333
|
|
|
334
|
+
const splitErr = validateSplitPattern(payload.splitPattern);
|
|
335
|
+
if (splitErr) {
|
|
336
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
|
|
337
|
+
}
|
|
338
|
+
|
|
261
339
|
return { valid: true };
|
|
262
340
|
}
|
|
263
341
|
|
|
@@ -723,6 +801,55 @@ function isUniqueViolation(error) {
|
|
|
723
801
|
*/
|
|
724
802
|
|
|
725
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
|
+
|
|
726
853
|
/**
|
|
727
854
|
* @typedef {Object} ProcessorContext
|
|
728
855
|
* @property {Object} webpush - The web-push module instance (already VAPID-configured).
|
|
@@ -769,19 +896,12 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
769
896
|
throw new Error('Invalid message configuration: no content source available');
|
|
770
897
|
}
|
|
771
898
|
|
|
772
|
-
// Sentence splitting
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
acc.push(part.trim() + punctuation);
|
|
779
|
-
}
|
|
780
|
-
return acc;
|
|
781
|
-
}, [])
|
|
782
|
-
.filter(s => s.length > 0);
|
|
783
|
-
|
|
784
|
-
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);
|
|
785
905
|
|
|
786
906
|
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
787
907
|
throw new Error('VAPID configuration missing - push notifications cannot be sent');
|
|
@@ -1150,6 +1270,10 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1150
1270
|
messages: Array.isArray(payload.messages) ? payload.messages : null,
|
|
1151
1271
|
maxTokens: payload.maxTokens ?? null,
|
|
1152
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,
|
|
1153
1277
|
pushSubscription: payload.pushSubscription,
|
|
1154
1278
|
metadata: payload.metadata || {}
|
|
1155
1279
|
};
|
|
@@ -1569,6 +1693,18 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1569
1693
|
) {
|
|
1570
1694
|
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
|
|
1571
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
|
+
}
|
|
1572
1708
|
|
|
1573
1709
|
// Fetch existing task
|
|
1574
1710
|
const existingTask = await db.getTaskByUuid(taskUuid, userId);
|
|
@@ -1603,7 +1739,11 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1603
1739
|
...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
|
|
1604
1740
|
...(updates.metadata && { metadata: updates.metadata }),
|
|
1605
1741
|
...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
|
|
1606
|
-
...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? 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 })
|
|
1607
1747
|
};
|
|
1608
1748
|
|
|
1609
1749
|
const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
|
|
@@ -2337,4 +2477,4 @@ async function createReiServer(config) {
|
|
|
2337
2477
|
};
|
|
2338
2478
|
}
|
|
2339
2479
|
|
|
2340
|
-
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateLlmMessagesArray, validateScheduleMessagePayload, verifyTenantToken };
|
|
2480
|
+
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateAvatarUrl, validateLlmMessagesArray, validateScheduleMessagePayload, validateSplitPattern, verifyTenantToken };
|
package/dist/index.d.ts
CHANGED
|
@@ -103,6 +103,78 @@ function isValidUUIDv4(uuid) {
|
|
|
103
103
|
|
|
104
104
|
const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
|
|
105
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
|
+
|
|
106
178
|
/**
|
|
107
179
|
* Validate an OpenAI-style messages array. Same shape contract as
|
|
108
180
|
* `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
|
|
@@ -241,8 +313,9 @@ function validateScheduleMessagePayload(payload) {
|
|
|
241
313
|
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
|
|
242
314
|
}
|
|
243
315
|
|
|
244
|
-
|
|
245
|
-
|
|
316
|
+
const avatarErr = validateAvatarUrl(payload.avatarUrl);
|
|
317
|
+
if (avatarErr) {
|
|
318
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } };
|
|
246
319
|
}
|
|
247
320
|
if (payload.uuid && !isValidUUID(payload.uuid)) {
|
|
248
321
|
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
|
|
@@ -258,6 +331,11 @@ function validateScheduleMessagePayload(payload) {
|
|
|
258
331
|
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
|
|
259
332
|
}
|
|
260
333
|
|
|
334
|
+
const splitErr = validateSplitPattern(payload.splitPattern);
|
|
335
|
+
if (splitErr) {
|
|
336
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
|
|
337
|
+
}
|
|
338
|
+
|
|
261
339
|
return { valid: true };
|
|
262
340
|
}
|
|
263
341
|
|
|
@@ -723,6 +801,55 @@ function isUniqueViolation(error) {
|
|
|
723
801
|
*/
|
|
724
802
|
|
|
725
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
|
+
|
|
726
853
|
/**
|
|
727
854
|
* @typedef {Object} ProcessorContext
|
|
728
855
|
* @property {Object} webpush - The web-push module instance (already VAPID-configured).
|
|
@@ -769,19 +896,12 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
769
896
|
throw new Error('Invalid message configuration: no content source available');
|
|
770
897
|
}
|
|
771
898
|
|
|
772
|
-
// Sentence splitting
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
acc.push(part.trim() + punctuation);
|
|
779
|
-
}
|
|
780
|
-
return acc;
|
|
781
|
-
}, [])
|
|
782
|
-
.filter(s => s.length > 0);
|
|
783
|
-
|
|
784
|
-
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);
|
|
785
905
|
|
|
786
906
|
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
787
907
|
throw new Error('VAPID configuration missing - push notifications cannot be sent');
|
|
@@ -1150,6 +1270,10 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1150
1270
|
messages: Array.isArray(payload.messages) ? payload.messages : null,
|
|
1151
1271
|
maxTokens: payload.maxTokens ?? null,
|
|
1152
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,
|
|
1153
1277
|
pushSubscription: payload.pushSubscription,
|
|
1154
1278
|
metadata: payload.metadata || {}
|
|
1155
1279
|
};
|
|
@@ -1569,6 +1693,18 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1569
1693
|
) {
|
|
1570
1694
|
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
|
|
1571
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
|
+
}
|
|
1572
1708
|
|
|
1573
1709
|
// Fetch existing task
|
|
1574
1710
|
const existingTask = await db.getTaskByUuid(taskUuid, userId);
|
|
@@ -1603,7 +1739,11 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1603
1739
|
...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
|
|
1604
1740
|
...(updates.metadata && { metadata: updates.metadata }),
|
|
1605
1741
|
...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
|
|
1606
|
-
...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? 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 })
|
|
1607
1747
|
};
|
|
1608
1748
|
|
|
1609
1749
|
const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
|
|
@@ -2337,4 +2477,4 @@ async function createReiServer(config) {
|
|
|
2337
2477
|
};
|
|
2338
2478
|
}
|
|
2339
2479
|
|
|
2340
|
-
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateLlmMessagesArray, validateScheduleMessagePayload, verifyTenantToken };
|
|
2480
|
+
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateAvatarUrl, validateLlmMessagesArray, validateScheduleMessagePayload, validateSplitPattern, verifyTenantToken };
|
package/dist/index.mjs
CHANGED
|
@@ -48,6 +48,48 @@ function isValidUUIDv4(uuid) {
|
|
|
48
48
|
return uuidV4Regex.test(uuid);
|
|
49
49
|
}
|
|
50
50
|
var VALID_LLM_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
|
|
51
|
+
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
52
|
+
function validateAvatarUrl(value) {
|
|
53
|
+
if (value === void 0 || value === null) return null;
|
|
54
|
+
if (typeof value !== "string") {
|
|
55
|
+
return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
|
|
56
|
+
}
|
|
57
|
+
if (/^data:/i.test(value)) {
|
|
58
|
+
return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
|
|
59
|
+
}
|
|
60
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
61
|
+
return `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
|
|
62
|
+
}
|
|
63
|
+
if (!isValidUrl(value)) {
|
|
64
|
+
return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
var SPLIT_PATTERN_MAX_LENGTH = 200;
|
|
69
|
+
var SPLIT_PATTERN_MAX_ITEMS = 10;
|
|
70
|
+
function validateSplitPattern(value) {
|
|
71
|
+
if (value === void 0 || value === null) return null;
|
|
72
|
+
const isArray = Array.isArray(value);
|
|
73
|
+
const items = isArray ? value : [value];
|
|
74
|
+
if (isArray && items.length === 0) return null;
|
|
75
|
+
if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
|
|
76
|
+
return `splitPattern \u6570\u7EC4\u6700\u591A ${SPLIT_PATTERN_MAX_ITEMS} \u9879`;
|
|
77
|
+
}
|
|
78
|
+
for (let i = 0; i < items.length; i++) {
|
|
79
|
+
const s = items[i];
|
|
80
|
+
const label = isArray ? `splitPattern[${i}]` : "splitPattern";
|
|
81
|
+
if (typeof s !== "string") return `${label} \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`;
|
|
82
|
+
if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
|
|
83
|
+
return `${label} \u4E0D\u80FD\u8D85\u8FC7 ${SPLIT_PATTERN_MAX_LENGTH} \u5B57\u7B26`;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
new RegExp(s);
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return `${label} \u4E0D\u662F\u6709\u6548\u6B63\u5219\u8868\u8FBE\u5F0F`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
51
93
|
function validateLlmMessagesArray(messages) {
|
|
52
94
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
53
95
|
return "messages must be a non-empty array";
|
|
@@ -146,8 +188,9 @@ function validateScheduleMessagePayload(payload) {
|
|
|
146
188
|
if (payload.temperature !== void 0 && payload.temperature !== null && (typeof payload.temperature !== "number" || !Number.isFinite(payload.temperature))) {
|
|
147
189
|
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature (must be a finite number)"] } };
|
|
148
190
|
}
|
|
149
|
-
|
|
150
|
-
|
|
191
|
+
const avatarErr = validateAvatarUrl(payload.avatarUrl);
|
|
192
|
+
if (avatarErr) {
|
|
193
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: avatarErr, details: { invalidFields: ["avatarUrl"] } };
|
|
151
194
|
}
|
|
152
195
|
if (payload.uuid && !isValidUUID(payload.uuid)) {
|
|
153
196
|
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)"] } };
|
|
@@ -155,6 +198,10 @@ function validateScheduleMessagePayload(payload) {
|
|
|
155
198
|
if (payload.messageSubtype !== void 0 && payload.messageSubtype !== null && typeof payload.messageSubtype !== "string") {
|
|
156
199
|
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["messageSubtype"] } };
|
|
157
200
|
}
|
|
201
|
+
const splitErr = validateSplitPattern(payload.splitPattern);
|
|
202
|
+
if (splitErr) {
|
|
203
|
+
return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: splitErr, details: { invalidFields: ["splitPattern"] } };
|
|
204
|
+
}
|
|
158
205
|
return { valid: true };
|
|
159
206
|
}
|
|
160
207
|
|
|
@@ -435,6 +482,26 @@ function isUniqueViolation(error) {
|
|
|
435
482
|
|
|
436
483
|
// src/server/lib/message-processor.js
|
|
437
484
|
import { randomUUID } from "crypto";
|
|
485
|
+
var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
486
|
+
function splitOnceByRegex(chunk, regex) {
|
|
487
|
+
const out = chunk.split(regex).reduce((acc, part, i, arr) => {
|
|
488
|
+
if (i % 2 === 0 && part.trim()) {
|
|
489
|
+
const punctuation = arr[i + 1] || "";
|
|
490
|
+
acc.push(part.trim() + punctuation);
|
|
491
|
+
}
|
|
492
|
+
return acc;
|
|
493
|
+
}, []).filter((s) => s.length > 0);
|
|
494
|
+
return out.length > 0 ? out : [chunk];
|
|
495
|
+
}
|
|
496
|
+
function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
497
|
+
const sources = splitPattern == null ? null : Array.isArray(splitPattern) ? splitPattern : [splitPattern];
|
|
498
|
+
const regexes = sources && sources.length > 0 ? sources.map((s) => new RegExp(s)) : [DEFAULT_SPLIT_REGEX];
|
|
499
|
+
let chunks = [messageContent];
|
|
500
|
+
for (const regex of regexes) {
|
|
501
|
+
chunks = chunks.flatMap((c) => splitOnceByRegex(c, regex));
|
|
502
|
+
}
|
|
503
|
+
return chunks.length > 0 ? chunks : [messageContent];
|
|
504
|
+
}
|
|
438
505
|
async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
439
506
|
try {
|
|
440
507
|
const masterKey = providedMasterKey || ctx.masterKey;
|
|
@@ -460,14 +527,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
460
527
|
} else {
|
|
461
528
|
throw new Error("Invalid message configuration: no content source available");
|
|
462
529
|
}
|
|
463
|
-
const
|
|
464
|
-
if (i % 2 === 0 && part.trim()) {
|
|
465
|
-
const punctuation = arr[i + 1] || "";
|
|
466
|
-
acc.push(part.trim() + punctuation);
|
|
467
|
-
}
|
|
468
|
-
return acc;
|
|
469
|
-
}, []).filter((s) => s.length > 0);
|
|
470
|
-
const messages = sentences.length > 0 ? sentences : [messageContent];
|
|
530
|
+
const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
|
|
471
531
|
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
472
532
|
throw new Error("VAPID configuration missing - push notifications cannot be sent");
|
|
473
533
|
}
|
|
@@ -706,6 +766,10 @@ function createScheduleMessageHandler(ctx) {
|
|
|
706
766
|
messages: Array.isArray(payload.messages) ? payload.messages : null,
|
|
707
767
|
maxTokens: payload.maxTokens ?? null,
|
|
708
768
|
temperature: payload.temperature ?? null,
|
|
769
|
+
// 0.6.0+: optional caller-provided regex (string or string[]) used by
|
|
770
|
+
// the message processor to chunk LLM output into individual pushes.
|
|
771
|
+
// null → processor falls back to the default /([。!?!?]+)/ regex.
|
|
772
|
+
splitPattern: payload.splitPattern ?? null,
|
|
709
773
|
pushSubscription: payload.pushSubscription,
|
|
710
774
|
metadata: payload.metadata || {}
|
|
711
775
|
};
|
|
@@ -1029,6 +1093,18 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1029
1093
|
if (Object.prototype.hasOwnProperty.call(updates, "temperature") && updates.temperature !== null && (typeof updates.temperature !== "number" || !Number.isFinite(updates.temperature))) {
|
|
1030
1094
|
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature"] } } } };
|
|
1031
1095
|
}
|
|
1096
|
+
if (Object.prototype.hasOwnProperty.call(updates, "splitPattern")) {
|
|
1097
|
+
const splitErr = validateSplitPattern(updates.splitPattern);
|
|
1098
|
+
if (splitErr) {
|
|
1099
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: splitErr, details: { invalidFields: ["splitPattern"] } } } };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (Object.prototype.hasOwnProperty.call(updates, "avatarUrl")) {
|
|
1103
|
+
const avatarErr = validateAvatarUrl(updates.avatarUrl);
|
|
1104
|
+
if (avatarErr) {
|
|
1105
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: avatarErr, details: { invalidFields: ["avatarUrl"] } } } };
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1032
1108
|
const existingTask = await db.getTaskByUuid(taskUuid, userId);
|
|
1033
1109
|
if (!existingTask) {
|
|
1034
1110
|
const taskStatus = await db.getTaskStatus(taskUuid, userId);
|
|
@@ -1054,7 +1130,11 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1054
1130
|
...updates.avatarUrl && { avatarUrl: updates.avatarUrl },
|
|
1055
1131
|
...updates.metadata && { metadata: updates.metadata },
|
|
1056
1132
|
...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: updates.maxTokens ?? null },
|
|
1057
|
-
...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: updates.temperature ?? null }
|
|
1133
|
+
...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: updates.temperature ?? null },
|
|
1134
|
+
// splitPattern: hasOwnProperty so that explicit `null` (= revert to
|
|
1135
|
+
// default) doesn't get swallowed by truthy-spread the way the optional
|
|
1136
|
+
// string fields above are.
|
|
1137
|
+
...Object.prototype.hasOwnProperty.call(updates, "splitPattern") && { splitPattern: updates.splitPattern ?? null }
|
|
1058
1138
|
};
|
|
1059
1139
|
const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
|
|
1060
1140
|
const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
|
|
@@ -1575,7 +1655,9 @@ export {
|
|
|
1575
1655
|
isValidUUID,
|
|
1576
1656
|
isValidUUIDv4,
|
|
1577
1657
|
isValidUrl,
|
|
1658
|
+
validateAvatarUrl,
|
|
1578
1659
|
validateLlmMessagesArray,
|
|
1579
1660
|
validateScheduleMessagePayload,
|
|
1661
|
+
validateSplitPattern,
|
|
1580
1662
|
verifyTenantToken
|
|
1581
1663
|
};
|