@rei-standard/amsg-server 2.5.1 → 2.5.3
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 +14 -4
- package/dist/index.cjs +16 -65
- package/dist/index.d.cts +49 -111
- package/dist/index.d.ts +49 -111
- package/dist/index.mjs +8 -57
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -54,15 +54,25 @@ const rei = await createReiServer({
|
|
|
54
54
|
|
|
55
55
|
## 关于 `messageType: 'instant'`
|
|
56
56
|
|
|
57
|
-
>
|
|
57
|
+
> **两条 instant 路径,按各自特点选一条(都是正式支持路径):**
|
|
58
|
+
> - **本端点的 `messageType: 'instant'`**(create task → process by UUID → delete task):任务先写进数据库再处理,投递不绑在请求连接上——客户端断开也没关系,任务行还在,能继续跑、能重试,想跑多久跑多久。适合**有数据库、需要长时间生成或保证消息零丢失**的场景。
|
|
59
|
+
> - **[@rei-standard/amsg-instant](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)**:纯 SSE 流 + Web Push backup,不需要数据库,适合无状态边缘运行时(如 Cloudflare Workers)。它的处理挂在响应连接上,客户端一断开就只剩平台给的那点宽限期把活干完(Deno Deploy 实测 ≈20-30s),所以适合**能快速跑完的短即时消息**。
|
|
58
60
|
|
|
59
61
|
## AI 接口 `apiUrl` 约束
|
|
60
62
|
|
|
61
63
|
当 `messageType` 为 `prompted` / `auto`,或 `instant` 使用 AI 配置时:
|
|
62
64
|
|
|
63
|
-
- `apiUrl`
|
|
64
|
-
- SDK
|
|
65
|
-
|
|
65
|
+
- `apiUrl` 是聊天端点 URL(例如:`https://api.openai.com/v1/chat/completions`),必须能 `new URL(...)` 解析。
|
|
66
|
+
- SDK 对 OpenAI 风格路径做**幂等**补全(去首尾空白、去尾部多余 `/` 后):
|
|
67
|
+
|
|
68
|
+
| 输入 | 输出 |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `https://api.openai.com`(裸域名) | `https://api.openai.com/v1/chat/completions` |
|
|
71
|
+
| `https://api.openai.com/v1`(版本段结尾) | `https://api.openai.com/v1/chat/completions`(不重复加 `/v1`) |
|
|
72
|
+
| `https://api.openai.com/v1/chat/completions` | 原样返回 |
|
|
73
|
+
| `https://api.anthropic.com/v1/messages`(其他自定义路径) | 原样返回,不猜 |
|
|
74
|
+
|
|
75
|
+
- 规则幂等,传完整 URL 不会被改坏;代理路径很特殊时直接传完整 `…/chat/completions` 绕开补全。
|
|
66
76
|
- `maxTokens` 为可选字段:传了就映射为 `max_tokens`;不传则不指定(由上游模型默认策略决定)。
|
|
67
77
|
|
|
68
78
|
如果上游返回 `405 Method Not Allowed`,通常表示 `apiUrl` 指向了基础域名而非聊天端点,请优先检查配置值。
|
package/dist/index.cjs
CHANGED
|
@@ -27,6 +27,7 @@ async function createAdapter(config) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// src/server/lib/validation.js
|
|
30
|
+
var _amsgshared = require('@rei-standard/amsg-shared');
|
|
30
31
|
function isValidISO8601(dateString) {
|
|
31
32
|
const date = new Date(dateString);
|
|
32
33
|
return date instanceof Date && !isNaN(date.getTime());
|
|
@@ -48,23 +49,6 @@ function isValidUUIDv4(uuid) {
|
|
|
48
49
|
return uuidV4Regex.test(uuid);
|
|
49
50
|
}
|
|
50
51
|
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
52
|
var SPLIT_PATTERN_MAX_LENGTH = 200;
|
|
69
53
|
var SPLIT_PATTERN_MAX_ITEMS = 10;
|
|
70
54
|
function validateSplitPattern(value) {
|
|
@@ -188,7 +172,7 @@ function validateScheduleMessagePayload(payload) {
|
|
|
188
172
|
if (payload.temperature !== void 0 && payload.temperature !== null && (typeof payload.temperature !== "number" || !Number.isFinite(payload.temperature))) {
|
|
189
173
|
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)"] } };
|
|
190
174
|
}
|
|
191
|
-
const avatarErr = validateAvatarUrl(payload.avatarUrl);
|
|
175
|
+
const avatarErr = _amsgshared.validateAvatarUrl.call(void 0, payload.avatarUrl);
|
|
192
176
|
if (avatarErr) {
|
|
193
177
|
console.warn("[amsg-server] avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u7F6E\u7A7A\uFF1A", avatarErr);
|
|
194
178
|
payload.avatarUrl = null;
|
|
@@ -486,8 +470,11 @@ function isUniqueViolation(error) {
|
|
|
486
470
|
|
|
487
471
|
|
|
488
472
|
|
|
489
|
-
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
|
|
490
476
|
var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
477
|
+
var SLEEP_BETWEEN_MESSAGES_MS = 1500;
|
|
491
478
|
function splitOnceByRegex(chunk, regex) {
|
|
492
479
|
const out = chunk.split(regex).reduce((acc, part, i, arr) => {
|
|
493
480
|
if (i % 2 === 0 && part.trim()) {
|
|
@@ -507,38 +494,6 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
|
507
494
|
}
|
|
508
495
|
return chunks.length > 0 ? chunks : [messageContent];
|
|
509
496
|
}
|
|
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, 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") {
|
|
523
|
-
const trimmed = raw.trim();
|
|
524
|
-
if (trimmed.length > 0) return trimmed;
|
|
525
|
-
}
|
|
526
|
-
const content = _optionalChain([message, 'optionalAccess', _5 => _5.content]);
|
|
527
|
-
if (typeof content === "string") {
|
|
528
|
-
const match = content.match(REASONING_TAG_RE);
|
|
529
|
-
if (match) {
|
|
530
|
-
const trimmed = match[2].trim();
|
|
531
|
-
if (trimmed.length > 0) return trimmed;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return null;
|
|
535
|
-
}
|
|
536
|
-
var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
|
|
537
|
-
var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
|
|
538
|
-
function stripReasoningTags(content) {
|
|
539
|
-
if (typeof content !== "string" || !content.includes("<")) return content;
|
|
540
|
-
return content.replace(REASONING_TAG_RE_G, "").trim();
|
|
541
|
-
}
|
|
542
497
|
async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
543
498
|
try {
|
|
544
499
|
const masterKey = providedMasterKey || ctx.masterKey;
|
|
@@ -569,9 +524,9 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
569
524
|
} else {
|
|
570
525
|
throw new Error("Invalid message configuration: no content source available");
|
|
571
526
|
}
|
|
572
|
-
const reasoning = readReasoningContent(llmResponse);
|
|
527
|
+
const reasoning = _amsgshared.readReasoningContent.call(void 0, llmResponse);
|
|
573
528
|
if (reasoning) {
|
|
574
|
-
messageContent = stripReasoningTags(messageContent);
|
|
529
|
+
messageContent = _amsgshared.stripReasoningTags.call(void 0, messageContent);
|
|
575
530
|
}
|
|
576
531
|
const messages = splitMessageIntoSentences(messageContent, _nullishCoalesce(decryptedPayload.splitPattern, () => ( null)));
|
|
577
532
|
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
@@ -599,7 +554,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
599
554
|
metadata
|
|
600
555
|
});
|
|
601
556
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
|
|
602
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
557
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
603
558
|
}
|
|
604
559
|
for (let i = 0; i < messages.length; i++) {
|
|
605
560
|
const contentPush = _amsgshared.buildContentPush.call(void 0, {
|
|
@@ -620,7 +575,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
620
575
|
});
|
|
621
576
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
|
|
622
577
|
if (i < messages.length - 1) {
|
|
623
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
578
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
624
579
|
}
|
|
625
580
|
}
|
|
626
581
|
return { success: true, messagesSent: messages.length };
|
|
@@ -713,7 +668,7 @@ async function _callAI(payload) {
|
|
|
713
668
|
);
|
|
714
669
|
}
|
|
715
670
|
const aiData = await aiResponse.json();
|
|
716
|
-
const content = _optionalChain([aiData, 'optionalAccess',
|
|
671
|
+
const content = _optionalChain([aiData, 'optionalAccess', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]);
|
|
717
672
|
if (typeof content !== "string" || !content.trim()) {
|
|
718
673
|
throw new Error("AI API error: response missing choices[0].message.content");
|
|
719
674
|
}
|
|
@@ -1170,7 +1125,7 @@ function createUpdateMessageHandler(ctx) {
|
|
|
1170
1125
|
}
|
|
1171
1126
|
}
|
|
1172
1127
|
if (Object.prototype.hasOwnProperty.call(updates, "avatarUrl")) {
|
|
1173
|
-
const avatarErr = validateAvatarUrl(updates.avatarUrl);
|
|
1128
|
+
const avatarErr = _amsgshared.validateAvatarUrl.call(void 0, updates.avatarUrl);
|
|
1174
1129
|
if (avatarErr) {
|
|
1175
1130
|
console.warn("[amsg-server] update-message avatarUrl \u4E0D\u5408\u6CD5\uFF0C\u5DF2\u5FFD\u7565\uFF1A", avatarErr);
|
|
1176
1131
|
delete updates.avatarUrl;
|
|
@@ -1397,7 +1352,7 @@ async function resolveBlobStore(namespace) {
|
|
|
1397
1352
|
}
|
|
1398
1353
|
try {
|
|
1399
1354
|
const blobModule = await Promise.resolve().then(() => _interopRequireWildcard(require("@netlify/blobs")));
|
|
1400
|
-
const getStore = blobModule.getStore || _optionalChain([blobModule, 'access',
|
|
1355
|
+
const getStore = blobModule.getStore || _optionalChain([blobModule, 'access', _6 => _6.default, 'optionalAccess', _7 => _7.getStore]);
|
|
1401
1356
|
if (typeof getStore === "function") {
|
|
1402
1357
|
return getStore({ name: namespace });
|
|
1403
1358
|
}
|
|
@@ -1654,11 +1609,7 @@ function createTenantContextManager(options) {
|
|
|
1654
1609
|
}
|
|
1655
1610
|
|
|
1656
1611
|
// src/server/index.js
|
|
1657
|
-
|
|
1658
|
-
const trimmedEmail = String(email || "").trim();
|
|
1659
|
-
if (!trimmedEmail) return "";
|
|
1660
|
-
return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`;
|
|
1661
|
-
}
|
|
1612
|
+
|
|
1662
1613
|
async function createReiServer(config) {
|
|
1663
1614
|
if (!config) throw new Error("[rei-standard-amsg-server] config is required");
|
|
1664
1615
|
if (!config.tenant) throw new Error("[rei-standard-amsg-server] tenant config is required");
|
|
@@ -1674,7 +1625,7 @@ async function createReiServer(config) {
|
|
|
1674
1625
|
const vapid = config.vapid || {};
|
|
1675
1626
|
if (vapid.email && vapid.publicKey && vapid.privateKey) {
|
|
1676
1627
|
webpushModule.setVapidDetails(
|
|
1677
|
-
normalizeVapidSubject(vapid.email),
|
|
1628
|
+
_amsgshared.normalizeVapidSubject.call(void 0, vapid.email),
|
|
1678
1629
|
vapid.publicKey,
|
|
1679
1630
|
vapid.privateKey
|
|
1680
1631
|
);
|
|
@@ -1731,4 +1682,4 @@ async function createReiServer(config) {
|
|
|
1731
1682
|
|
|
1732
1683
|
|
|
1733
1684
|
|
|
1734
|
-
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;
|
|
1685
|
+
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 = _amsgshared.validateAvatarUrl; exports.validateLlmMessagesArray = validateLlmMessagesArray; exports.validateScheduleMessagePayload = validateScheduleMessagePayload; exports.validateSplitPattern = validateSplitPattern; exports.verifyTenantToken = verifyTenantToken;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { validateAvatarUrl, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, normalizeVapidSubject } from '@rei-standard/amsg-shared';
|
|
2
|
+
export { validateAvatarUrl } from '@rei-standard/amsg-shared';
|
|
1
3
|
import { createDecipheriv, createHash, randomBytes, createCipheriv, randomUUID, timingSafeEqual, createHmac } from 'crypto';
|
|
2
|
-
import { buildReasoningPush, buildContentPush } from '@rei-standard/amsg-shared';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Adapter Factory
|
|
@@ -58,6 +59,7 @@ async function createAdapter(config) {
|
|
|
58
59
|
* ReiStandard SDK v2.0.1
|
|
59
60
|
*/
|
|
60
61
|
|
|
62
|
+
|
|
61
63
|
/**
|
|
62
64
|
* Validate ISO 8601 date string.
|
|
63
65
|
* @param {string} dateString
|
|
@@ -104,43 +106,12 @@ function isValidUUIDv4(uuid) {
|
|
|
104
106
|
|
|
105
107
|
const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
|
|
106
108
|
|
|
107
|
-
const AVATAR_URL_MAX_LENGTH = 2048;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
|
|
111
|
-
* base64-encoded inline images) and anything longer than 2048 chars, both
|
|
112
|
-
* of which are the dominant trigger for downstream 413 / Web Push 4 KB
|
|
113
|
-
* payload errors. Returns an error message string, or null when valid.
|
|
114
|
-
*
|
|
115
|
-
* Mirrors @rei-standard/amsg-instant's `validateAvatarUrl` (kept in lockstep
|
|
116
|
-
* on purpose — both packages forward `avatarUrl` to the same SW push payload).
|
|
117
|
-
*
|
|
118
|
-
* @param {unknown} value
|
|
119
|
-
* @returns {string | null}
|
|
120
|
-
*/
|
|
121
|
-
function validateAvatarUrl(value) {
|
|
122
|
-
if (value === undefined || value === null) return null;
|
|
123
|
-
if (typeof value !== 'string') {
|
|
124
|
-
return 'avatarUrl 必须是字符串';
|
|
125
|
-
}
|
|
126
|
-
if (/^data:/i.test(value)) {
|
|
127
|
-
return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
|
|
128
|
-
}
|
|
129
|
-
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
130
|
-
return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
|
|
131
|
-
}
|
|
132
|
-
if (!isValidUrl(value)) {
|
|
133
|
-
return 'avatarUrl 不是合法 URL';
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
109
|
const SPLIT_PATTERN_MAX_LENGTH = 200;
|
|
139
110
|
const SPLIT_PATTERN_MAX_ITEMS = 10;
|
|
140
111
|
|
|
141
112
|
/**
|
|
142
|
-
* Validate the optional `splitPattern` field
|
|
143
|
-
*
|
|
113
|
+
* Validate the optional `splitPattern` field (amsg-server scheduled tasks
|
|
114
|
+
* only; amsg-instant 0.8.0 dropped its request-level `splitPattern`).
|
|
144
115
|
* Accepts `string`, `string[]`, or absent/null. Returns an error message
|
|
145
116
|
* string, or null when valid.
|
|
146
117
|
*
|
|
@@ -801,13 +772,13 @@ function isUniqueViolation(error) {
|
|
|
801
772
|
* ReiStandard amsg-server v2.4.0
|
|
802
773
|
*
|
|
803
774
|
* Handles single message content generation and Web Push delivery for
|
|
804
|
-
* scheduled tasks (`fixed` / `prompted` / `auto`) and the
|
|
805
|
-
*
|
|
775
|
+
* scheduled tasks (`fixed` / `prompted` / `auto`) and the
|
|
776
|
+
* in-server instant path (`messageType: 'instant'`).
|
|
806
777
|
*
|
|
807
778
|
* Push wire shape comes from `@rei-standard/amsg-shared`'s
|
|
808
779
|
* discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
|
|
809
780
|
* routes on `messageKind`. Server-driven pushes always carry
|
|
810
|
-
* `source: 'instant'` (for the
|
|
781
|
+
* `source: 'instant'` (for the in-server instant path) or
|
|
811
782
|
* `source: 'scheduled'` (for everything else).
|
|
812
783
|
*
|
|
813
784
|
* v2.4.0: when the LLM response carries non-empty
|
|
@@ -820,6 +791,11 @@ function isUniqueViolation(error) {
|
|
|
820
791
|
|
|
821
792
|
const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
822
793
|
|
|
794
|
+
// Pacing between consecutive Web Push deliveries (reasoning → content, and
|
|
795
|
+
// between content sentences) so the client renders a natural typing cadence.
|
|
796
|
+
// Kept equal to amsg-instant's SLEEP_BETWEEN_MESSAGES_MS default.
|
|
797
|
+
const SLEEP_BETWEEN_MESSAGES_MS = 1500;
|
|
798
|
+
|
|
823
799
|
/**
|
|
824
800
|
* Split a single chunk by one regex; on no-match return [chunk] so a later
|
|
825
801
|
* regex in a cascade can still take a swing at it.
|
|
@@ -839,11 +815,10 @@ function splitOnceByRegex(chunk, regex) {
|
|
|
839
815
|
}
|
|
840
816
|
|
|
841
817
|
/**
|
|
842
|
-
* Sentence splitter
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
* both copies; if you fix a bug here, fix it in the instant copy too.
|
|
818
|
+
* Sentence splitter for amsg-server's scheduled `splitPattern` feature
|
|
819
|
+
* (see standards §6.1). Server-only: amsg-instant 0.8.0 dropped its
|
|
820
|
+
* request-level `splitPattern`, so there is no instant counterpart to keep
|
|
821
|
+
* in lockstep.
|
|
847
822
|
*
|
|
848
823
|
* @param {string} messageContent
|
|
849
824
|
* @param {string | string[] | null} [splitPattern=null]
|
|
@@ -867,54 +842,6 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
|
867
842
|
return chunks.length > 0 ? chunks : [messageContent];
|
|
868
843
|
}
|
|
869
844
|
|
|
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, content?: unknown } }} */ (choices[0])?.message;
|
|
883
|
-
|
|
884
|
-
const raw = message?.reasoning_content;
|
|
885
|
-
if (typeof raw === 'string') {
|
|
886
|
-
const trimmed = raw.trim();
|
|
887
|
-
if (trimmed.length > 0) return trimmed;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
const content = message?.content;
|
|
891
|
-
if (typeof content === 'string') {
|
|
892
|
-
const match = content.match(REASONING_TAG_RE);
|
|
893
|
-
if (match) {
|
|
894
|
-
const trimmed = match[2].trim();
|
|
895
|
-
if (trimmed.length > 0) return trimmed;
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
return null;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
const REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
|
|
903
|
-
const REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
|
|
904
|
-
|
|
905
|
-
/**
|
|
906
|
-
* Mirrors `amsg-instant/src/message-processor.js#stripReasoningTags`.
|
|
907
|
-
* Removes any private chain-of-thought markup leaking through
|
|
908
|
-
* `message.content` so it does not also ship inside ContentPush.
|
|
909
|
-
*
|
|
910
|
-
* @param {string} content
|
|
911
|
-
* @returns {string}
|
|
912
|
-
*/
|
|
913
|
-
function stripReasoningTags(content) {
|
|
914
|
-
if (typeof content !== 'string' || !content.includes('<')) return content;
|
|
915
|
-
return content.replace(REASONING_TAG_RE_G, '').trim();
|
|
916
|
-
}
|
|
917
|
-
|
|
918
845
|
/**
|
|
919
846
|
* @typedef {Object} ProcessorContext
|
|
920
847
|
* @property {Object} webpush - The web-push module instance (already VAPID-configured).
|
|
@@ -1004,7 +931,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1004
931
|
// `messageId` format — deterministic when we have a task.id so a
|
|
1005
932
|
// retry produces the same id for the same (task, sentence) pair
|
|
1006
933
|
// (downstream dedupers can key on it). Falls back to a UUID for
|
|
1007
|
-
// the
|
|
934
|
+
// the in-server instant path that has no row id.
|
|
1008
935
|
const messageIdBase = task.id != null
|
|
1009
936
|
? `msg_task_${task.id}`
|
|
1010
937
|
: `msg_${randomUUID()}_instant`;
|
|
@@ -1028,7 +955,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1028
955
|
metadata,
|
|
1029
956
|
});
|
|
1030
957
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
|
|
1031
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
958
|
+
await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
1032
959
|
}
|
|
1033
960
|
|
|
1034
961
|
for (let i = 0; i < messages.length; i++) {
|
|
@@ -1052,7 +979,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1052
979
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
|
|
1053
980
|
|
|
1054
981
|
if (i < messages.length - 1) {
|
|
1055
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
982
|
+
await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
1056
983
|
}
|
|
1057
984
|
}
|
|
1058
985
|
|
|
@@ -1398,12 +1325,20 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1398
1325
|
const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
|
|
1399
1326
|
|
|
1400
1327
|
/**
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1328
|
+
* In-server instant path. Delivers an instant message through this
|
|
1329
|
+
* server's own task queue (create task → process by UUID → delete task).
|
|
1330
|
+
* The task is written to the database before processing, so delivery is
|
|
1331
|
+
* not tied to the request connection: even if the client disconnects, the
|
|
1332
|
+
* row stays and the generation keeps running (and can be retried) for as
|
|
1333
|
+
* long as it needs. Use this when you have a database and want long or
|
|
1334
|
+
* guaranteed-complete generations with no dropped messages.
|
|
1335
|
+
*
|
|
1336
|
+
* The stateless alternative is `@rei-standard/amsg-instant`: it streams
|
|
1337
|
+
* over SSE with a Web Push backup and needs no database, which makes it a
|
|
1338
|
+
* good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
|
|
1339
|
+
* response connection, so after the client disconnects it only has the
|
|
1340
|
+
* platform's brief grace window to finish (≈20-30s observed on Deno
|
|
1341
|
+
* Deploy) — ideal for short instant messages that complete quickly.
|
|
1407
1342
|
*/
|
|
1408
1343
|
// Instant type: check VAPID before creating the task to avoid orphaned rows
|
|
1409
1344
|
if (payload.messageType === 'instant') {
|
|
@@ -1459,11 +1394,20 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1459
1394
|
}
|
|
1460
1395
|
|
|
1461
1396
|
/**
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1397
|
+
* In-server instant path. Delivers an instant message through this
|
|
1398
|
+
* server's own task queue (create task → process by UUID → delete task).
|
|
1399
|
+
* The task is written to the database before processing, so delivery is
|
|
1400
|
+
* not tied to the request connection: even if the client disconnects, the
|
|
1401
|
+
* row stays and the generation keeps running (and can be retried) for as
|
|
1402
|
+
* long as it needs. Use this when you have a database and want long or
|
|
1403
|
+
* guaranteed-complete generations with no dropped messages.
|
|
1404
|
+
*
|
|
1405
|
+
* The stateless alternative is `@rei-standard/amsg-instant`: it streams
|
|
1406
|
+
* over SSE with a Web Push backup and needs no database, which makes it a
|
|
1407
|
+
* good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
|
|
1408
|
+
* response connection, so after the client disconnects it only has the
|
|
1409
|
+
* platform's brief grace window to finish (≈20-30s observed on Deno
|
|
1410
|
+
* Deploy) — ideal for short instant messages that complete quickly.
|
|
1467
1411
|
*/
|
|
1468
1412
|
// Instant type: send immediately
|
|
1469
1413
|
if (payload.messageType === 'instant') {
|
|
@@ -2481,12 +2425,6 @@ function createTenantContextManager(options) {
|
|
|
2481
2425
|
*/
|
|
2482
2426
|
|
|
2483
2427
|
|
|
2484
|
-
function normalizeVapidSubject(email) {
|
|
2485
|
-
const trimmedEmail = String(email || '').trim();
|
|
2486
|
-
if (!trimmedEmail) return '';
|
|
2487
|
-
return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`;
|
|
2488
|
-
}
|
|
2489
|
-
|
|
2490
2428
|
/**
|
|
2491
2429
|
* @typedef {Object} VapidConfig
|
|
2492
2430
|
* @property {string} [email] - VAPID contact email (e.g. mailto:…).
|
|
@@ -2598,4 +2536,4 @@ async function createReiServer(config) {
|
|
|
2598
2536
|
};
|
|
2599
2537
|
}
|
|
2600
2538
|
|
|
2601
|
-
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl,
|
|
2539
|
+
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateLlmMessagesArray, validateScheduleMessagePayload, validateSplitPattern, verifyTenantToken };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { validateAvatarUrl, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, normalizeVapidSubject } from '@rei-standard/amsg-shared';
|
|
2
|
+
export { validateAvatarUrl } from '@rei-standard/amsg-shared';
|
|
1
3
|
import { createDecipheriv, createHash, randomBytes, createCipheriv, randomUUID, timingSafeEqual, createHmac } from 'crypto';
|
|
2
|
-
import { buildReasoningPush, buildContentPush } from '@rei-standard/amsg-shared';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Adapter Factory
|
|
@@ -58,6 +59,7 @@ async function createAdapter(config) {
|
|
|
58
59
|
* ReiStandard SDK v2.0.1
|
|
59
60
|
*/
|
|
60
61
|
|
|
62
|
+
|
|
61
63
|
/**
|
|
62
64
|
* Validate ISO 8601 date string.
|
|
63
65
|
* @param {string} dateString
|
|
@@ -104,43 +106,12 @@ function isValidUUIDv4(uuid) {
|
|
|
104
106
|
|
|
105
107
|
const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
|
|
106
108
|
|
|
107
|
-
const AVATAR_URL_MAX_LENGTH = 2048;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
|
|
111
|
-
* base64-encoded inline images) and anything longer than 2048 chars, both
|
|
112
|
-
* of which are the dominant trigger for downstream 413 / Web Push 4 KB
|
|
113
|
-
* payload errors. Returns an error message string, or null when valid.
|
|
114
|
-
*
|
|
115
|
-
* Mirrors @rei-standard/amsg-instant's `validateAvatarUrl` (kept in lockstep
|
|
116
|
-
* on purpose — both packages forward `avatarUrl` to the same SW push payload).
|
|
117
|
-
*
|
|
118
|
-
* @param {unknown} value
|
|
119
|
-
* @returns {string | null}
|
|
120
|
-
*/
|
|
121
|
-
function validateAvatarUrl(value) {
|
|
122
|
-
if (value === undefined || value === null) return null;
|
|
123
|
-
if (typeof value !== 'string') {
|
|
124
|
-
return 'avatarUrl 必须是字符串';
|
|
125
|
-
}
|
|
126
|
-
if (/^data:/i.test(value)) {
|
|
127
|
-
return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
|
|
128
|
-
}
|
|
129
|
-
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
130
|
-
return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
|
|
131
|
-
}
|
|
132
|
-
if (!isValidUrl(value)) {
|
|
133
|
-
return 'avatarUrl 不是合法 URL';
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
109
|
const SPLIT_PATTERN_MAX_LENGTH = 200;
|
|
139
110
|
const SPLIT_PATTERN_MAX_ITEMS = 10;
|
|
140
111
|
|
|
141
112
|
/**
|
|
142
|
-
* Validate the optional `splitPattern` field
|
|
143
|
-
*
|
|
113
|
+
* Validate the optional `splitPattern` field (amsg-server scheduled tasks
|
|
114
|
+
* only; amsg-instant 0.8.0 dropped its request-level `splitPattern`).
|
|
144
115
|
* Accepts `string`, `string[]`, or absent/null. Returns an error message
|
|
145
116
|
* string, or null when valid.
|
|
146
117
|
*
|
|
@@ -801,13 +772,13 @@ function isUniqueViolation(error) {
|
|
|
801
772
|
* ReiStandard amsg-server v2.4.0
|
|
802
773
|
*
|
|
803
774
|
* Handles single message content generation and Web Push delivery for
|
|
804
|
-
* scheduled tasks (`fixed` / `prompted` / `auto`) and the
|
|
805
|
-
*
|
|
775
|
+
* scheduled tasks (`fixed` / `prompted` / `auto`) and the
|
|
776
|
+
* in-server instant path (`messageType: 'instant'`).
|
|
806
777
|
*
|
|
807
778
|
* Push wire shape comes from `@rei-standard/amsg-shared`'s
|
|
808
779
|
* discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
|
|
809
780
|
* routes on `messageKind`. Server-driven pushes always carry
|
|
810
|
-
* `source: 'instant'` (for the
|
|
781
|
+
* `source: 'instant'` (for the in-server instant path) or
|
|
811
782
|
* `source: 'scheduled'` (for everything else).
|
|
812
783
|
*
|
|
813
784
|
* v2.4.0: when the LLM response carries non-empty
|
|
@@ -820,6 +791,11 @@ function isUniqueViolation(error) {
|
|
|
820
791
|
|
|
821
792
|
const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
822
793
|
|
|
794
|
+
// Pacing between consecutive Web Push deliveries (reasoning → content, and
|
|
795
|
+
// between content sentences) so the client renders a natural typing cadence.
|
|
796
|
+
// Kept equal to amsg-instant's SLEEP_BETWEEN_MESSAGES_MS default.
|
|
797
|
+
const SLEEP_BETWEEN_MESSAGES_MS = 1500;
|
|
798
|
+
|
|
823
799
|
/**
|
|
824
800
|
* Split a single chunk by one regex; on no-match return [chunk] so a later
|
|
825
801
|
* regex in a cascade can still take a swing at it.
|
|
@@ -839,11 +815,10 @@ function splitOnceByRegex(chunk, regex) {
|
|
|
839
815
|
}
|
|
840
816
|
|
|
841
817
|
/**
|
|
842
|
-
* Sentence splitter
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
* both copies; if you fix a bug here, fix it in the instant copy too.
|
|
818
|
+
* Sentence splitter for amsg-server's scheduled `splitPattern` feature
|
|
819
|
+
* (see standards §6.1). Server-only: amsg-instant 0.8.0 dropped its
|
|
820
|
+
* request-level `splitPattern`, so there is no instant counterpart to keep
|
|
821
|
+
* in lockstep.
|
|
847
822
|
*
|
|
848
823
|
* @param {string} messageContent
|
|
849
824
|
* @param {string | string[] | null} [splitPattern=null]
|
|
@@ -867,54 +842,6 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
|
867
842
|
return chunks.length > 0 ? chunks : [messageContent];
|
|
868
843
|
}
|
|
869
844
|
|
|
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, content?: unknown } }} */ (choices[0])?.message;
|
|
883
|
-
|
|
884
|
-
const raw = message?.reasoning_content;
|
|
885
|
-
if (typeof raw === 'string') {
|
|
886
|
-
const trimmed = raw.trim();
|
|
887
|
-
if (trimmed.length > 0) return trimmed;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
const content = message?.content;
|
|
891
|
-
if (typeof content === 'string') {
|
|
892
|
-
const match = content.match(REASONING_TAG_RE);
|
|
893
|
-
if (match) {
|
|
894
|
-
const trimmed = match[2].trim();
|
|
895
|
-
if (trimmed.length > 0) return trimmed;
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
return null;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
const REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
|
|
903
|
-
const REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
|
|
904
|
-
|
|
905
|
-
/**
|
|
906
|
-
* Mirrors `amsg-instant/src/message-processor.js#stripReasoningTags`.
|
|
907
|
-
* Removes any private chain-of-thought markup leaking through
|
|
908
|
-
* `message.content` so it does not also ship inside ContentPush.
|
|
909
|
-
*
|
|
910
|
-
* @param {string} content
|
|
911
|
-
* @returns {string}
|
|
912
|
-
*/
|
|
913
|
-
function stripReasoningTags(content) {
|
|
914
|
-
if (typeof content !== 'string' || !content.includes('<')) return content;
|
|
915
|
-
return content.replace(REASONING_TAG_RE_G, '').trim();
|
|
916
|
-
}
|
|
917
|
-
|
|
918
845
|
/**
|
|
919
846
|
* @typedef {Object} ProcessorContext
|
|
920
847
|
* @property {Object} webpush - The web-push module instance (already VAPID-configured).
|
|
@@ -1004,7 +931,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1004
931
|
// `messageId` format — deterministic when we have a task.id so a
|
|
1005
932
|
// retry produces the same id for the same (task, sentence) pair
|
|
1006
933
|
// (downstream dedupers can key on it). Falls back to a UUID for
|
|
1007
|
-
// the
|
|
934
|
+
// the in-server instant path that has no row id.
|
|
1008
935
|
const messageIdBase = task.id != null
|
|
1009
936
|
? `msg_task_${task.id}`
|
|
1010
937
|
: `msg_${randomUUID()}_instant`;
|
|
@@ -1028,7 +955,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1028
955
|
metadata,
|
|
1029
956
|
});
|
|
1030
957
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
|
|
1031
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
958
|
+
await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
1032
959
|
}
|
|
1033
960
|
|
|
1034
961
|
for (let i = 0; i < messages.length; i++) {
|
|
@@ -1052,7 +979,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1052
979
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
|
|
1053
980
|
|
|
1054
981
|
if (i < messages.length - 1) {
|
|
1055
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
982
|
+
await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
1056
983
|
}
|
|
1057
984
|
}
|
|
1058
985
|
|
|
@@ -1398,12 +1325,20 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1398
1325
|
const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
|
|
1399
1326
|
|
|
1400
1327
|
/**
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1328
|
+
* In-server instant path. Delivers an instant message through this
|
|
1329
|
+
* server's own task queue (create task → process by UUID → delete task).
|
|
1330
|
+
* The task is written to the database before processing, so delivery is
|
|
1331
|
+
* not tied to the request connection: even if the client disconnects, the
|
|
1332
|
+
* row stays and the generation keeps running (and can be retried) for as
|
|
1333
|
+
* long as it needs. Use this when you have a database and want long or
|
|
1334
|
+
* guaranteed-complete generations with no dropped messages.
|
|
1335
|
+
*
|
|
1336
|
+
* The stateless alternative is `@rei-standard/amsg-instant`: it streams
|
|
1337
|
+
* over SSE with a Web Push backup and needs no database, which makes it a
|
|
1338
|
+
* good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
|
|
1339
|
+
* response connection, so after the client disconnects it only has the
|
|
1340
|
+
* platform's brief grace window to finish (≈20-30s observed on Deno
|
|
1341
|
+
* Deploy) — ideal for short instant messages that complete quickly.
|
|
1407
1342
|
*/
|
|
1408
1343
|
// Instant type: check VAPID before creating the task to avoid orphaned rows
|
|
1409
1344
|
if (payload.messageType === 'instant') {
|
|
@@ -1459,11 +1394,20 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1459
1394
|
}
|
|
1460
1395
|
|
|
1461
1396
|
/**
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1397
|
+
* In-server instant path. Delivers an instant message through this
|
|
1398
|
+
* server's own task queue (create task → process by UUID → delete task).
|
|
1399
|
+
* The task is written to the database before processing, so delivery is
|
|
1400
|
+
* not tied to the request connection: even if the client disconnects, the
|
|
1401
|
+
* row stays and the generation keeps running (and can be retried) for as
|
|
1402
|
+
* long as it needs. Use this when you have a database and want long or
|
|
1403
|
+
* guaranteed-complete generations with no dropped messages.
|
|
1404
|
+
*
|
|
1405
|
+
* The stateless alternative is `@rei-standard/amsg-instant`: it streams
|
|
1406
|
+
* over SSE with a Web Push backup and needs no database, which makes it a
|
|
1407
|
+
* good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
|
|
1408
|
+
* response connection, so after the client disconnects it only has the
|
|
1409
|
+
* platform's brief grace window to finish (≈20-30s observed on Deno
|
|
1410
|
+
* Deploy) — ideal for short instant messages that complete quickly.
|
|
1467
1411
|
*/
|
|
1468
1412
|
// Instant type: send immediately
|
|
1469
1413
|
if (payload.messageType === 'instant') {
|
|
@@ -2481,12 +2425,6 @@ function createTenantContextManager(options) {
|
|
|
2481
2425
|
*/
|
|
2482
2426
|
|
|
2483
2427
|
|
|
2484
|
-
function normalizeVapidSubject(email) {
|
|
2485
|
-
const trimmedEmail = String(email || '').trim();
|
|
2486
|
-
if (!trimmedEmail) return '';
|
|
2487
|
-
return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`;
|
|
2488
|
-
}
|
|
2489
|
-
|
|
2490
2428
|
/**
|
|
2491
2429
|
* @typedef {Object} VapidConfig
|
|
2492
2430
|
* @property {string} [email] - VAPID contact email (e.g. mailto:…).
|
|
@@ -2598,4 +2536,4 @@ async function createReiServer(config) {
|
|
|
2598
2536
|
};
|
|
2599
2537
|
}
|
|
2600
2538
|
|
|
2601
|
-
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl,
|
|
2539
|
+
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateLlmMessagesArray, validateScheduleMessagePayload, validateSplitPattern, verifyTenantToken };
|
package/dist/index.mjs
CHANGED
|
@@ -27,6 +27,7 @@ async function createAdapter(config) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// src/server/lib/validation.js
|
|
30
|
+
import { validateAvatarUrl } from "@rei-standard/amsg-shared";
|
|
30
31
|
function isValidISO8601(dateString) {
|
|
31
32
|
const date = new Date(dateString);
|
|
32
33
|
return date instanceof Date && !isNaN(date.getTime());
|
|
@@ -48,23 +49,6 @@ function isValidUUIDv4(uuid) {
|
|
|
48
49
|
return uuidV4Regex.test(uuid);
|
|
49
50
|
}
|
|
50
51
|
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
52
|
var SPLIT_PATTERN_MAX_LENGTH = 200;
|
|
69
53
|
var SPLIT_PATTERN_MAX_ITEMS = 10;
|
|
70
54
|
function validateSplitPattern(value) {
|
|
@@ -485,9 +469,12 @@ function isUniqueViolation(error) {
|
|
|
485
469
|
import { randomUUID } from "crypto";
|
|
486
470
|
import {
|
|
487
471
|
buildContentPush,
|
|
488
|
-
buildReasoningPush
|
|
472
|
+
buildReasoningPush,
|
|
473
|
+
readReasoningContent,
|
|
474
|
+
stripReasoningTags
|
|
489
475
|
} from "@rei-standard/amsg-shared";
|
|
490
476
|
var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
477
|
+
var SLEEP_BETWEEN_MESSAGES_MS = 1500;
|
|
491
478
|
function splitOnceByRegex(chunk, regex) {
|
|
492
479
|
const out = chunk.split(regex).reduce((acc, part, i, arr) => {
|
|
493
480
|
if (i % 2 === 0 && part.trim()) {
|
|
@@ -507,38 +494,6 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
|
507
494
|
}
|
|
508
495
|
return chunks.length > 0 ? chunks : [messageContent];
|
|
509
496
|
}
|
|
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, content?: unknown } }} */
|
|
519
|
-
choices[0]?.message
|
|
520
|
-
);
|
|
521
|
-
const raw = message?.reasoning_content;
|
|
522
|
-
if (typeof raw === "string") {
|
|
523
|
-
const trimmed = raw.trim();
|
|
524
|
-
if (trimmed.length > 0) return trimmed;
|
|
525
|
-
}
|
|
526
|
-
const content = message?.content;
|
|
527
|
-
if (typeof content === "string") {
|
|
528
|
-
const match = content.match(REASONING_TAG_RE);
|
|
529
|
-
if (match) {
|
|
530
|
-
const trimmed = match[2].trim();
|
|
531
|
-
if (trimmed.length > 0) return trimmed;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return null;
|
|
535
|
-
}
|
|
536
|
-
var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
|
|
537
|
-
var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
|
|
538
|
-
function stripReasoningTags(content) {
|
|
539
|
-
if (typeof content !== "string" || !content.includes("<")) return content;
|
|
540
|
-
return content.replace(REASONING_TAG_RE_G, "").trim();
|
|
541
|
-
}
|
|
542
497
|
async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
543
498
|
try {
|
|
544
499
|
const masterKey = providedMasterKey || ctx.masterKey;
|
|
@@ -599,7 +554,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
599
554
|
metadata
|
|
600
555
|
});
|
|
601
556
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
|
|
602
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
557
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
603
558
|
}
|
|
604
559
|
for (let i = 0; i < messages.length; i++) {
|
|
605
560
|
const contentPush = buildContentPush({
|
|
@@ -620,7 +575,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
620
575
|
});
|
|
621
576
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
|
|
622
577
|
if (i < messages.length - 1) {
|
|
623
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
578
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
624
579
|
}
|
|
625
580
|
}
|
|
626
581
|
return { success: true, messagesSent: messages.length };
|
|
@@ -1654,11 +1609,7 @@ function createTenantContextManager(options) {
|
|
|
1654
1609
|
}
|
|
1655
1610
|
|
|
1656
1611
|
// src/server/index.js
|
|
1657
|
-
|
|
1658
|
-
const trimmedEmail = String(email || "").trim();
|
|
1659
|
-
if (!trimmedEmail) return "";
|
|
1660
|
-
return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`;
|
|
1661
|
-
}
|
|
1612
|
+
import { normalizeVapidSubject } from "@rei-standard/amsg-shared";
|
|
1662
1613
|
async function createReiServer(config) {
|
|
1663
1614
|
if (!config) throw new Error("[rei-standard-amsg-server] config is required");
|
|
1664
1615
|
if (!config.tenant) throw new Error("[rei-standard-amsg-server] tenant config is required");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rei-standard/amsg-server",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.3",
|
|
4
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"node": ">=20"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@rei-standard/amsg-shared": "0.
|
|
36
|
+
"@rei-standard/amsg-shared": "^0.3.0",
|
|
37
37
|
"web-push": "^3.6.7",
|
|
38
38
|
"@netlify/blobs": "^8.1.0"
|
|
39
39
|
},
|