@rei-standard/amsg-shared 0.2.0 → 0.4.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/index.cjs +239 -1
- package/dist/index.d.cts +201 -15
- package/dist/index.d.ts +201 -15
- package/dist/index.mjs +239 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @rei-standard/amsg-shared
|
|
2
2
|
|
|
3
|
-
Lowest layer of the ReiStandard Active Messaging
|
|
4
|
-
the **
|
|
3
|
+
Lowest layer of the ReiStandard Active Messaging stack. Defines
|
|
4
|
+
the **push schema** that `amsg-instant`, `amsg-server`,
|
|
5
5
|
`amsg-sw`, and `amsg-client` all conform to.
|
|
6
6
|
|
|
7
7
|
Zero runtime deps. Does **not** depend on any other amsg package —
|
|
@@ -9,9 +9,9 @@ every other amsg sub-package depends on this one, never the reverse.
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Push schema
|
|
13
13
|
|
|
14
|
-
A single push is described by three
|
|
14
|
+
A single push is described by three independent dimensions:
|
|
15
15
|
|
|
16
16
|
| Axis | Field | Values | Defined by |
|
|
17
17
|
|----------------|-------------------|-------------------------------------------------------|--------------------|
|
|
@@ -22,7 +22,7 @@ A single push is described by three orthogonal axes:
|
|
|
22
22
|
`messageType` answers **how this push was produced** (one-shot
|
|
23
23
|
`instant` worker, scheduled `fixed` ping, AI-`prompted` reply, fully
|
|
24
24
|
`auto`-generated cadence). `messageKind` answers **what it carries**.
|
|
25
|
-
The two are intentionally
|
|
25
|
+
The two are intentionally independent: any `messageType` can carry any
|
|
26
26
|
`messageKind`.
|
|
27
27
|
|
|
28
28
|
There is also `source: 'instant' | 'scheduled'` — the **routing
|
package/dist/index.cjs
CHANGED
|
@@ -19,21 +19,31 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
// src/index.js
|
|
20
20
|
var src_exports = {};
|
|
21
21
|
__export(src_exports, {
|
|
22
|
+
AVATAR_URL_MAX_LENGTH: () => AVATAR_URL_MAX_LENGTH,
|
|
22
23
|
MESSAGE_KIND: () => MESSAGE_KIND,
|
|
23
24
|
MESSAGE_TYPE: () => MESSAGE_TYPE,
|
|
24
25
|
PUSH_SOURCE: () => PUSH_SOURCE,
|
|
26
|
+
assertValidDecision: () => assertValidDecision,
|
|
25
27
|
base64UrlToBytes: () => base64UrlToBytes,
|
|
26
28
|
buildContentPush: () => buildContentPush,
|
|
27
29
|
buildErrorPush: () => buildErrorPush,
|
|
28
30
|
buildReasoningPush: () => buildReasoningPush,
|
|
31
|
+
buildSessionContext: () => buildSessionContext,
|
|
29
32
|
buildToolRequestPush: () => buildToolRequestPush,
|
|
30
33
|
chunkReasoningByUtf8Bytes: () => chunkReasoningByUtf8Bytes,
|
|
31
34
|
concatBytes: () => concatBytes,
|
|
35
|
+
extractAssistantMessage: () => extractAssistantMessage,
|
|
36
|
+
extractToolCallsFromDecision: () => extractToolCallsFromDecision,
|
|
32
37
|
isContentPush: () => isContentPush,
|
|
33
38
|
isErrorPush: () => isErrorPush,
|
|
34
39
|
isReasoningPush: () => isReasoningPush,
|
|
35
40
|
isToolRequestPush: () => isToolRequestPush,
|
|
36
|
-
|
|
41
|
+
isValidUrl: () => isValidUrl,
|
|
42
|
+
normalizeVapidSubject: () => normalizeVapidSubject,
|
|
43
|
+
readReasoningContent: () => readReasoningContent,
|
|
44
|
+
stripReasoningTags: () => stripReasoningTags,
|
|
45
|
+
toUint8: () => toUint8,
|
|
46
|
+
validateAvatarUrl: () => validateAvatarUrl
|
|
37
47
|
});
|
|
38
48
|
module.exports = __toCommonJS(src_exports);
|
|
39
49
|
var MESSAGE_KIND = Object.freeze({
|
|
@@ -264,3 +274,231 @@ function concatBytes(...chunks) {
|
|
|
264
274
|
}
|
|
265
275
|
return out;
|
|
266
276
|
}
|
|
277
|
+
function isValidUrl(value) {
|
|
278
|
+
if (typeof value !== "string") return false;
|
|
279
|
+
try {
|
|
280
|
+
new URL(value);
|
|
281
|
+
return true;
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
287
|
+
function validateAvatarUrl(value) {
|
|
288
|
+
if (value === void 0 || value === null) return null;
|
|
289
|
+
if (typeof value !== "string") {
|
|
290
|
+
return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
|
|
291
|
+
}
|
|
292
|
+
if (/^data:/i.test(value)) {
|
|
293
|
+
return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
|
|
294
|
+
}
|
|
295
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
296
|
+
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`;
|
|
297
|
+
}
|
|
298
|
+
if (!isValidUrl(value)) {
|
|
299
|
+
return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
function normalizeVapidSubject(email) {
|
|
304
|
+
const trimmed = String(email || "").trim();
|
|
305
|
+
if (!trimmed) return "";
|
|
306
|
+
return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`;
|
|
307
|
+
}
|
|
308
|
+
var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
|
|
309
|
+
var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
|
|
310
|
+
function readReasoningContent(llmResponse) {
|
|
311
|
+
if (!llmResponse || typeof llmResponse !== "object") return null;
|
|
312
|
+
const choices = (
|
|
313
|
+
/** @type {{ choices?: unknown }} */
|
|
314
|
+
llmResponse.choices
|
|
315
|
+
);
|
|
316
|
+
if (!Array.isArray(choices) || choices.length === 0) return null;
|
|
317
|
+
const message = (
|
|
318
|
+
/** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */
|
|
319
|
+
choices[0]?.message
|
|
320
|
+
);
|
|
321
|
+
const raw = message?.reasoning_content;
|
|
322
|
+
if (typeof raw === "string") {
|
|
323
|
+
const trimmed = raw.trim();
|
|
324
|
+
if (trimmed.length > 0) return trimmed;
|
|
325
|
+
}
|
|
326
|
+
const content = message?.content;
|
|
327
|
+
if (typeof content === "string") {
|
|
328
|
+
const match = content.match(REASONING_TAG_RE);
|
|
329
|
+
if (match) {
|
|
330
|
+
const trimmed = match[2].trim();
|
|
331
|
+
if (trimmed.length > 0) return trimmed;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
function stripReasoningTags(content) {
|
|
337
|
+
if (typeof content !== "string" || !content.includes("<")) return content;
|
|
338
|
+
return content.replace(REASONING_TAG_RE_G, "").trim();
|
|
339
|
+
}
|
|
340
|
+
function buildSessionContext({
|
|
341
|
+
sessionId,
|
|
342
|
+
messages,
|
|
343
|
+
llmResponse,
|
|
344
|
+
iteration,
|
|
345
|
+
contactName,
|
|
346
|
+
avatarUrl,
|
|
347
|
+
charId,
|
|
348
|
+
metadata
|
|
349
|
+
}) {
|
|
350
|
+
const llmOutputText = readLlmOutputText(llmResponse);
|
|
351
|
+
const ctx = {
|
|
352
|
+
sessionId,
|
|
353
|
+
charId,
|
|
354
|
+
messages,
|
|
355
|
+
llmResponse,
|
|
356
|
+
llmOutputText,
|
|
357
|
+
iteration,
|
|
358
|
+
metadata: metadata && typeof metadata === "object" ? metadata : {},
|
|
359
|
+
contactName,
|
|
360
|
+
avatarUrl: avatarUrl || void 0
|
|
361
|
+
};
|
|
362
|
+
return Object.freeze(ctx);
|
|
363
|
+
}
|
|
364
|
+
function readLlmOutputText(llmResponse) {
|
|
365
|
+
if (!llmResponse || typeof llmResponse !== "object") return "";
|
|
366
|
+
const choices = (
|
|
367
|
+
/** @type {{ choices?: unknown }} */
|
|
368
|
+
llmResponse.choices
|
|
369
|
+
);
|
|
370
|
+
if (!Array.isArray(choices) || choices.length === 0) return "";
|
|
371
|
+
const message = (
|
|
372
|
+
/** @type {{ message?: { content?: unknown } }} */
|
|
373
|
+
choices[0]?.message
|
|
374
|
+
);
|
|
375
|
+
const content = message?.content;
|
|
376
|
+
return typeof content === "string" ? content : "";
|
|
377
|
+
}
|
|
378
|
+
function extractAssistantMessage(llmResponse) {
|
|
379
|
+
const message = llmResponse && typeof llmResponse === "object" && Array.isArray(
|
|
380
|
+
/** @type {{ choices?: unknown }} */
|
|
381
|
+
llmResponse.choices
|
|
382
|
+
) && /** @type {{ choices: Array<{ message?: unknown }> }} */
|
|
383
|
+
llmResponse.choices[0]?.message;
|
|
384
|
+
if (message && typeof message === "object") {
|
|
385
|
+
return (
|
|
386
|
+
/** @type {ChatMessage} */
|
|
387
|
+
message
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return { role: "assistant", content: "" };
|
|
391
|
+
}
|
|
392
|
+
var VALID_DECISIONS = /* @__PURE__ */ new Set(["finish", "tool-request", "continue", "skip-push"]);
|
|
393
|
+
function assertValidDecision(decision, options = {}) {
|
|
394
|
+
const inlineToolCalls = options.inlineToolCalls === true;
|
|
395
|
+
if (!decision || typeof decision !== "object") {
|
|
396
|
+
throw new TypeError(`onLLMOutput returned invalid decision: ${stringifyDecisionForError(decision)}`);
|
|
397
|
+
}
|
|
398
|
+
const tag = (
|
|
399
|
+
/** @type {{ decision?: unknown }} */
|
|
400
|
+
decision.decision
|
|
401
|
+
);
|
|
402
|
+
if (typeof tag !== "string" || !VALID_DECISIONS.has(tag)) {
|
|
403
|
+
throw new TypeError(`onLLMOutput returned invalid decision tag: ${stringifyDecisionForError(tag)}`);
|
|
404
|
+
}
|
|
405
|
+
const hasSingular = Object.prototype.hasOwnProperty.call(decision, "pushPayload");
|
|
406
|
+
const hasPlural = Object.prototype.hasOwnProperty.call(decision, "pushPayloads");
|
|
407
|
+
if (hasSingular) {
|
|
408
|
+
throw new TypeError(
|
|
409
|
+
hasPlural ? "pushPayload (singular) is removed in 0.8.0, use pushPayloads" : "pushPayload (singular) is removed in 0.8.0, use pushPayloads: [yourPayload]"
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (tag === "continue") {
|
|
413
|
+
if (!Array.isArray(
|
|
414
|
+
/** @type {{ nextHistory?: unknown }} */
|
|
415
|
+
decision.nextHistory
|
|
416
|
+
)) {
|
|
417
|
+
throw new TypeError('decision:"continue" requires a nextHistory array');
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (tag === "skip-push") {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (tag === "tool-request" && inlineToolCalls && Object.prototype.hasOwnProperty.call(decision, "toolCalls")) {
|
|
425
|
+
const toolCalls = (
|
|
426
|
+
/** @type {{ toolCalls?: unknown }} */
|
|
427
|
+
decision.toolCalls
|
|
428
|
+
);
|
|
429
|
+
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
|
|
430
|
+
throw new TypeError('decision:"tool-request" toolCalls must be a non-empty array when set');
|
|
431
|
+
}
|
|
432
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
433
|
+
const t = toolCalls[i];
|
|
434
|
+
if (!t || typeof t !== "object" || Array.isArray(t)) {
|
|
435
|
+
throw new TypeError(`toolCalls[${i}] must be a plain object, got ${stringifyDecisionForError(t)}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!hasPlural) return;
|
|
439
|
+
}
|
|
440
|
+
if (!hasPlural || !Array.isArray(
|
|
441
|
+
/** @type {{ pushPayloads?: unknown }} */
|
|
442
|
+
decision.pushPayloads
|
|
443
|
+
)) {
|
|
444
|
+
throw new TypeError(`decision:"${tag}" requires a pushPayloads array`);
|
|
445
|
+
}
|
|
446
|
+
const pushes = (
|
|
447
|
+
/** @type {Array<unknown>} */
|
|
448
|
+
decision.pushPayloads
|
|
449
|
+
);
|
|
450
|
+
if (pushes.length === 0) {
|
|
451
|
+
throw new TypeError("pushPayloads: [] \u2014 use decision: skip-push to skip notification entirely");
|
|
452
|
+
}
|
|
453
|
+
for (let i = 0; i < pushes.length; i++) {
|
|
454
|
+
const p = pushes[i];
|
|
455
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) {
|
|
456
|
+
throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyDecisionForError(p)}`);
|
|
457
|
+
}
|
|
458
|
+
if (Object.prototype.hasOwnProperty.call(p, "splitPattern")) {
|
|
459
|
+
throw new TypeError(`pushPayloads[${i}].splitPattern is removed in 0.8.0; caller is responsible for splitting`);
|
|
460
|
+
}
|
|
461
|
+
if (Object.prototype.hasOwnProperty.call(p, "messageId")) {
|
|
462
|
+
const id = (
|
|
463
|
+
/** @type {{ messageId?: unknown }} */
|
|
464
|
+
p.messageId
|
|
465
|
+
);
|
|
466
|
+
if (typeof id !== "string" || id === "") {
|
|
467
|
+
throw new TypeError(`pushPayloads[${i}].messageId must be a non-empty string when set, got ${stringifyDecisionForError(id)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function extractToolCallsFromDecision(decision) {
|
|
473
|
+
if (!decision || typeof decision !== "object") return [];
|
|
474
|
+
const direct = (
|
|
475
|
+
/** @type {{ toolCalls?: unknown }} */
|
|
476
|
+
decision.toolCalls
|
|
477
|
+
);
|
|
478
|
+
if (Array.isArray(direct) && direct.length > 0) {
|
|
479
|
+
return direct;
|
|
480
|
+
}
|
|
481
|
+
const pushPayloads = (
|
|
482
|
+
/** @type {{ pushPayloads?: unknown }} */
|
|
483
|
+
decision.pushPayloads
|
|
484
|
+
);
|
|
485
|
+
if (!Array.isArray(pushPayloads)) return [];
|
|
486
|
+
const out = [];
|
|
487
|
+
for (const push of pushPayloads) {
|
|
488
|
+
if (push && typeof push === "object" && Array.isArray(
|
|
489
|
+
/** @type {{ toolCalls?: unknown }} */
|
|
490
|
+
push.toolCalls
|
|
491
|
+
)) {
|
|
492
|
+
out.push(.../** @type {{ toolCalls: unknown[] }} */
|
|
493
|
+
push.toolCalls);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
function stringifyDecisionForError(value) {
|
|
499
|
+
try {
|
|
500
|
+
return JSON.stringify(value);
|
|
501
|
+
} catch {
|
|
502
|
+
return String(value);
|
|
503
|
+
}
|
|
504
|
+
}
|
package/dist/index.d.cts
CHANGED
|
@@ -247,6 +247,153 @@ export function base64UrlToBytes(input: string): Uint8Array;
|
|
|
247
247
|
* @returns {Uint8Array}
|
|
248
248
|
*/
|
|
249
249
|
export function concatBytes(...chunks: (Uint8Array | ArrayBuffer | ArrayBufferView)[]): Uint8Array;
|
|
250
|
+
/**
|
|
251
|
+
* True when `value` parses as an absolute URL.
|
|
252
|
+
* @param {unknown} value
|
|
253
|
+
* @returns {boolean}
|
|
254
|
+
*/
|
|
255
|
+
export function isValidUrl(value: unknown): boolean;
|
|
256
|
+
/**
|
|
257
|
+
* Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
|
|
258
|
+
* base64-encoded inline images) and anything longer than
|
|
259
|
+
* {@link AVATAR_URL_MAX_LENGTH} chars — both the dominant trigger for
|
|
260
|
+
* downstream 413 / Web Push 4 KB payload errors — plus anything that doesn't
|
|
261
|
+
* parse as a URL. Returns an error message string, or null when valid.
|
|
262
|
+
*
|
|
263
|
+
* Pure: callers decide how to act on a non-null result (amsg-server /
|
|
264
|
+
* amsg-instant / amsg-client soft-strip + console.warn; see standards §6.2).
|
|
265
|
+
*
|
|
266
|
+
* @param {unknown} value
|
|
267
|
+
* @returns {string | null}
|
|
268
|
+
*/
|
|
269
|
+
export function validateAvatarUrl(value: unknown): string | null;
|
|
270
|
+
/**
|
|
271
|
+
* Normalize a VAPID `sub` (subject) claim. Web Push (RFC 8292) accepts a
|
|
272
|
+
* `mailto:` address or an `http(s):` URL; a bare contact like
|
|
273
|
+
* `you@example.com` is prefixed with `mailto:`. An already-prefixed
|
|
274
|
+
* `mailto:` / `http(s):` value is returned untouched. Empty / blank → `''`.
|
|
275
|
+
*
|
|
276
|
+
* @param {unknown} email
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
export function normalizeVapidSubject(email: unknown): string;
|
|
280
|
+
/**
|
|
281
|
+
* Read `choices[0].message.reasoning_content` as a non-empty trimmed string,
|
|
282
|
+
* or null when absent / empty. Falls back to the first `<think>` span inside
|
|
283
|
+
* `message.content` when a provider inlines reasoning there. Many providers
|
|
284
|
+
* return an empty string instead of omitting the field — treated the same as
|
|
285
|
+
* missing so callers don't emit an empty ReasoningPush.
|
|
286
|
+
*
|
|
287
|
+
* @param {unknown} llmResponse
|
|
288
|
+
* @returns {string | null}
|
|
289
|
+
*/
|
|
290
|
+
export function readReasoningContent(llmResponse: unknown): string | null;
|
|
291
|
+
/**
|
|
292
|
+
* Drop any `<think>` / `<thinking>` / `<thought>` spans from a user-facing
|
|
293
|
+
* content string, so private chain-of-thought leaking through `message.content`
|
|
294
|
+
* does not also ship inside the ContentPush burst.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} content
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function stripReasoningTags(content: string): string;
|
|
300
|
+
/**
|
|
301
|
+
* @typedef {Object} ChatMessage
|
|
302
|
+
* @property {'system' | 'user' | 'assistant' | 'tool'} role
|
|
303
|
+
* @property {string | unknown[] | null} [content]
|
|
304
|
+
* @property {Array<{ id: string, type: 'function', function: { name: string, arguments: string } }>} [tool_calls]
|
|
305
|
+
* @property {string} [tool_call_id]
|
|
306
|
+
* @property {string} [name]
|
|
307
|
+
*/
|
|
308
|
+
/**
|
|
309
|
+
* @typedef {Object} SessionContext
|
|
310
|
+
* @property {string} sessionId
|
|
311
|
+
* @property {string} [charId]
|
|
312
|
+
* @property {ChatMessage[]} messages - Including the just-appended assistant turn.
|
|
313
|
+
* @property {unknown} llmResponse - Full LLM response (choices, usage, …).
|
|
314
|
+
* @property {string} llmOutputText - May be '' for pure tool-call responses.
|
|
315
|
+
* @property {number} iteration - 0-indexed: the round that just finished.
|
|
316
|
+
* @property {Record<string, unknown>} metadata
|
|
317
|
+
* @property {string} contactName
|
|
318
|
+
* @property {string} [avatarUrl]
|
|
319
|
+
*/
|
|
320
|
+
/**
|
|
321
|
+
* Build the frozen SessionContext handed to an onLLMOutput hook.
|
|
322
|
+
*
|
|
323
|
+
* Credentials (apiKey / apiUrl / pushSubscription / vapid / masterKey) are
|
|
324
|
+
* intentionally NOT part of the shape: a console.log(ctx) from a hook must
|
|
325
|
+
* not leak keys, and a third-party hook must not be able to exfiltrate
|
|
326
|
+
* them. Frozen so a hook cannot mutate the live history — if it chooses
|
|
327
|
+
* `decision:'continue'`, the caller still owns its copy.
|
|
328
|
+
*
|
|
329
|
+
* @param {Object} args
|
|
330
|
+
* @param {string} args.sessionId
|
|
331
|
+
* @param {ChatMessage[]} args.messages
|
|
332
|
+
* @param {unknown} args.llmResponse
|
|
333
|
+
* @param {number} args.iteration
|
|
334
|
+
* @param {string} args.contactName
|
|
335
|
+
* @param {string} [args.avatarUrl]
|
|
336
|
+
* @param {string} [args.charId]
|
|
337
|
+
* @param {Record<string, unknown>} [args.metadata]
|
|
338
|
+
* @returns {SessionContext}
|
|
339
|
+
*/
|
|
340
|
+
export function buildSessionContext({ sessionId, messages, llmResponse, iteration, contactName, avatarUrl, charId, metadata, }: {
|
|
341
|
+
sessionId: string;
|
|
342
|
+
messages: ChatMessage[];
|
|
343
|
+
llmResponse: unknown;
|
|
344
|
+
iteration: number;
|
|
345
|
+
contactName: string;
|
|
346
|
+
avatarUrl?: string;
|
|
347
|
+
charId?: string;
|
|
348
|
+
metadata?: Record<string, unknown>;
|
|
349
|
+
}): SessionContext;
|
|
350
|
+
/**
|
|
351
|
+
* Extract the `choices[0].message` whole object — preserving `tool_calls`
|
|
352
|
+
* / `reasoning_content` / `refusal` etc. — for appending to the running
|
|
353
|
+
* history. Falls back to a minimal placeholder when the response is
|
|
354
|
+
* malformed so the hook still gets a chance to react via
|
|
355
|
+
* `llmOutputText === ''`.
|
|
356
|
+
*
|
|
357
|
+
* Critically, we keep the entire message object (not just
|
|
358
|
+
* `{role, content}`): the next round may need to forward a `tool_calls`
|
|
359
|
+
* array to OpenAI alongside the matching tool-result messages, and
|
|
360
|
+
* stripping the field would make the API reject the request.
|
|
361
|
+
*
|
|
362
|
+
* @param {unknown} llmResponse
|
|
363
|
+
* @returns {ChatMessage}
|
|
364
|
+
*/
|
|
365
|
+
export function extractAssistantMessage(llmResponse: unknown): ChatMessage;
|
|
366
|
+
/**
|
|
367
|
+
* Assert that an onLLMOutput hook returned a structurally valid decision.
|
|
368
|
+
* TypeScript discriminated unions don't survive into runtime, and a
|
|
369
|
+
* misbehaving hook can easily return `null` / `{ decision: 'idk' }` /
|
|
370
|
+
* `undefined` — treat any of those as a hook contract violation.
|
|
371
|
+
*
|
|
372
|
+
* Flavors:
|
|
373
|
+
* - default (amsg-instant): 'tool-request' must carry pushPayloads — the
|
|
374
|
+
* tool_request push goes to the client, which executes the tools and
|
|
375
|
+
* POSTs /continue.
|
|
376
|
+
* - `{ inlineToolCalls: true }` (amsg-server fire-time loop): the host
|
|
377
|
+
* executes tools in-process, so 'tool-request' may instead carry a
|
|
378
|
+
* non-empty `toolCalls` array directly; pushPayloads then become
|
|
379
|
+
* optional. pushPayloads-shaped tool-requests stay valid so a
|
|
380
|
+
* classifier written for instant drops in unchanged.
|
|
381
|
+
*
|
|
382
|
+
* @param {unknown} decision
|
|
383
|
+
* @param {{ inlineToolCalls?: boolean }} [options]
|
|
384
|
+
*/
|
|
385
|
+
export function assertValidDecision(decision: unknown, options?: {
|
|
386
|
+
inlineToolCalls?: boolean;
|
|
387
|
+
}): void;
|
|
388
|
+
/**
|
|
389
|
+
* Pull the toolCalls out of a 'tool-request' decision, whichever shape it
|
|
390
|
+
* came in: `decision.toolCalls` directly (server flavor), or embedded in
|
|
391
|
+
* tool_request pushPayloads (instant classifier flavor).
|
|
392
|
+
*
|
|
393
|
+
* @param {unknown} decision
|
|
394
|
+
* @returns {Array<Record<string, unknown>>}
|
|
395
|
+
*/
|
|
396
|
+
export function extractToolCallsFromDecision(decision: unknown): Array<Record<string, unknown>>;
|
|
250
397
|
/**
|
|
251
398
|
* @rei-standard/amsg-shared
|
|
252
399
|
*
|
|
@@ -316,6 +463,8 @@ export const PUSH_SOURCE: Readonly<{
|
|
|
316
463
|
INSTANT: "instant";
|
|
317
464
|
SCHEDULED: "scheduled";
|
|
318
465
|
}>;
|
|
466
|
+
/** Max accepted `avatarUrl` length, in characters. */
|
|
467
|
+
export const AVATAR_URL_MAX_LENGTH: 2048;
|
|
319
468
|
/**
|
|
320
469
|
* Fields present on every push, regardless of kind. Discriminator
|
|
321
470
|
* fields (`messageKind`) and kind-specific fields live on the kind
|
|
@@ -439,24 +588,24 @@ export type ContentPush = AmsgPushCommon & {
|
|
|
439
588
|
* out of the upstream response into its own push. Emitted **before**
|
|
440
589
|
* the matching {@link ContentPush} burst when present and non-empty.
|
|
441
590
|
*
|
|
442
|
-
* Reasoning carries two
|
|
443
|
-
*
|
|
444
|
-
*
|
|
591
|
+
* Reasoning carries two optional "multi-part" axes, both *omitted* when
|
|
592
|
+
* the part count is 1 so the wire stays byte-for-byte compatible with
|
|
593
|
+
* single-shot callers. The type reserves them for forward compatibility;
|
|
594
|
+
* current producers emit a single ReasoningPush and set neither — oversized
|
|
595
|
+
* reasoning rides the generic multipart transport, not a reasoning-only
|
|
596
|
+
* chunk format.
|
|
445
597
|
*
|
|
446
|
-
* - `messageIndex` / `totalMessages` —
|
|
447
|
-
*
|
|
448
|
-
* reasoning into multiple sentences for typing-bubble UX.
|
|
598
|
+
* - `messageIndex` / `totalMessages` — a 1-based part index when a producer
|
|
599
|
+
* splits reasoning into multiple sentences for typing-bubble UX.
|
|
449
600
|
*
|
|
450
|
-
* - `chunkIndex` / `totalChunks` —
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
|
|
456
|
-
* splitter helper.
|
|
601
|
+
* - `chunkIndex` / `totalChunks` — transport-only slicing when a single
|
|
602
|
+
* segment exceeds the Web Push payload limit; SW would reassemble the
|
|
603
|
+
* original `reasoningContent` by sorting on `chunkIndex` within a
|
|
604
|
+
* `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes`
|
|
605
|
+
* for the safe-edge splitter helper.
|
|
457
606
|
*
|
|
458
|
-
* Both axes can coexist on the same push when a sentence-split
|
|
459
|
-
*
|
|
607
|
+
* Both axes can coexist on the same push when a sentence-split segment is
|
|
608
|
+
* itself oversized.
|
|
460
609
|
*/
|
|
461
610
|
export type ReasoningPush = AmsgPushCommon & {
|
|
462
611
|
messageKind: "reasoning";
|
|
@@ -502,6 +651,43 @@ export type ErrorPush = AmsgPushCommon & {
|
|
|
502
651
|
* `switch` on `messageKind` and the compiler narrows automatically.
|
|
503
652
|
*/
|
|
504
653
|
export type AmsgPush = ContentPush | ReasoningPush | ToolRequestPush | ErrorPush;
|
|
654
|
+
export type ChatMessage = {
|
|
655
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
656
|
+
content?: string | unknown[] | null;
|
|
657
|
+
tool_calls?: Array<{
|
|
658
|
+
id: string;
|
|
659
|
+
type: "function";
|
|
660
|
+
function: {
|
|
661
|
+
name: string;
|
|
662
|
+
arguments: string;
|
|
663
|
+
};
|
|
664
|
+
}>;
|
|
665
|
+
tool_call_id?: string;
|
|
666
|
+
name?: string;
|
|
667
|
+
};
|
|
668
|
+
export type SessionContext = {
|
|
669
|
+
sessionId: string;
|
|
670
|
+
charId?: string;
|
|
671
|
+
/**
|
|
672
|
+
* - Including the just-appended assistant turn.
|
|
673
|
+
*/
|
|
674
|
+
messages: ChatMessage[];
|
|
675
|
+
/**
|
|
676
|
+
* - Full LLM response (choices, usage, …).
|
|
677
|
+
*/
|
|
678
|
+
llmResponse: unknown;
|
|
679
|
+
/**
|
|
680
|
+
* - May be '' for pure tool-call responses.
|
|
681
|
+
*/
|
|
682
|
+
llmOutputText: string;
|
|
683
|
+
/**
|
|
684
|
+
* - 0-indexed: the round that just finished.
|
|
685
|
+
*/
|
|
686
|
+
iteration: number;
|
|
687
|
+
metadata: Record<string, unknown>;
|
|
688
|
+
contactName: string;
|
|
689
|
+
avatarUrl?: string;
|
|
690
|
+
};
|
|
505
691
|
/**
|
|
506
692
|
* What the push carries. Fixed enum — packages must not add values.
|
|
507
693
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -247,6 +247,153 @@ export function base64UrlToBytes(input: string): Uint8Array;
|
|
|
247
247
|
* @returns {Uint8Array}
|
|
248
248
|
*/
|
|
249
249
|
export function concatBytes(...chunks: (Uint8Array | ArrayBuffer | ArrayBufferView)[]): Uint8Array;
|
|
250
|
+
/**
|
|
251
|
+
* True when `value` parses as an absolute URL.
|
|
252
|
+
* @param {unknown} value
|
|
253
|
+
* @returns {boolean}
|
|
254
|
+
*/
|
|
255
|
+
export function isValidUrl(value: unknown): boolean;
|
|
256
|
+
/**
|
|
257
|
+
* Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
|
|
258
|
+
* base64-encoded inline images) and anything longer than
|
|
259
|
+
* {@link AVATAR_URL_MAX_LENGTH} chars — both the dominant trigger for
|
|
260
|
+
* downstream 413 / Web Push 4 KB payload errors — plus anything that doesn't
|
|
261
|
+
* parse as a URL. Returns an error message string, or null when valid.
|
|
262
|
+
*
|
|
263
|
+
* Pure: callers decide how to act on a non-null result (amsg-server /
|
|
264
|
+
* amsg-instant / amsg-client soft-strip + console.warn; see standards §6.2).
|
|
265
|
+
*
|
|
266
|
+
* @param {unknown} value
|
|
267
|
+
* @returns {string | null}
|
|
268
|
+
*/
|
|
269
|
+
export function validateAvatarUrl(value: unknown): string | null;
|
|
270
|
+
/**
|
|
271
|
+
* Normalize a VAPID `sub` (subject) claim. Web Push (RFC 8292) accepts a
|
|
272
|
+
* `mailto:` address or an `http(s):` URL; a bare contact like
|
|
273
|
+
* `you@example.com` is prefixed with `mailto:`. An already-prefixed
|
|
274
|
+
* `mailto:` / `http(s):` value is returned untouched. Empty / blank → `''`.
|
|
275
|
+
*
|
|
276
|
+
* @param {unknown} email
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
export function normalizeVapidSubject(email: unknown): string;
|
|
280
|
+
/**
|
|
281
|
+
* Read `choices[0].message.reasoning_content` as a non-empty trimmed string,
|
|
282
|
+
* or null when absent / empty. Falls back to the first `<think>` span inside
|
|
283
|
+
* `message.content` when a provider inlines reasoning there. Many providers
|
|
284
|
+
* return an empty string instead of omitting the field — treated the same as
|
|
285
|
+
* missing so callers don't emit an empty ReasoningPush.
|
|
286
|
+
*
|
|
287
|
+
* @param {unknown} llmResponse
|
|
288
|
+
* @returns {string | null}
|
|
289
|
+
*/
|
|
290
|
+
export function readReasoningContent(llmResponse: unknown): string | null;
|
|
291
|
+
/**
|
|
292
|
+
* Drop any `<think>` / `<thinking>` / `<thought>` spans from a user-facing
|
|
293
|
+
* content string, so private chain-of-thought leaking through `message.content`
|
|
294
|
+
* does not also ship inside the ContentPush burst.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} content
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function stripReasoningTags(content: string): string;
|
|
300
|
+
/**
|
|
301
|
+
* @typedef {Object} ChatMessage
|
|
302
|
+
* @property {'system' | 'user' | 'assistant' | 'tool'} role
|
|
303
|
+
* @property {string | unknown[] | null} [content]
|
|
304
|
+
* @property {Array<{ id: string, type: 'function', function: { name: string, arguments: string } }>} [tool_calls]
|
|
305
|
+
* @property {string} [tool_call_id]
|
|
306
|
+
* @property {string} [name]
|
|
307
|
+
*/
|
|
308
|
+
/**
|
|
309
|
+
* @typedef {Object} SessionContext
|
|
310
|
+
* @property {string} sessionId
|
|
311
|
+
* @property {string} [charId]
|
|
312
|
+
* @property {ChatMessage[]} messages - Including the just-appended assistant turn.
|
|
313
|
+
* @property {unknown} llmResponse - Full LLM response (choices, usage, …).
|
|
314
|
+
* @property {string} llmOutputText - May be '' for pure tool-call responses.
|
|
315
|
+
* @property {number} iteration - 0-indexed: the round that just finished.
|
|
316
|
+
* @property {Record<string, unknown>} metadata
|
|
317
|
+
* @property {string} contactName
|
|
318
|
+
* @property {string} [avatarUrl]
|
|
319
|
+
*/
|
|
320
|
+
/**
|
|
321
|
+
* Build the frozen SessionContext handed to an onLLMOutput hook.
|
|
322
|
+
*
|
|
323
|
+
* Credentials (apiKey / apiUrl / pushSubscription / vapid / masterKey) are
|
|
324
|
+
* intentionally NOT part of the shape: a console.log(ctx) from a hook must
|
|
325
|
+
* not leak keys, and a third-party hook must not be able to exfiltrate
|
|
326
|
+
* them. Frozen so a hook cannot mutate the live history — if it chooses
|
|
327
|
+
* `decision:'continue'`, the caller still owns its copy.
|
|
328
|
+
*
|
|
329
|
+
* @param {Object} args
|
|
330
|
+
* @param {string} args.sessionId
|
|
331
|
+
* @param {ChatMessage[]} args.messages
|
|
332
|
+
* @param {unknown} args.llmResponse
|
|
333
|
+
* @param {number} args.iteration
|
|
334
|
+
* @param {string} args.contactName
|
|
335
|
+
* @param {string} [args.avatarUrl]
|
|
336
|
+
* @param {string} [args.charId]
|
|
337
|
+
* @param {Record<string, unknown>} [args.metadata]
|
|
338
|
+
* @returns {SessionContext}
|
|
339
|
+
*/
|
|
340
|
+
export function buildSessionContext({ sessionId, messages, llmResponse, iteration, contactName, avatarUrl, charId, metadata, }: {
|
|
341
|
+
sessionId: string;
|
|
342
|
+
messages: ChatMessage[];
|
|
343
|
+
llmResponse: unknown;
|
|
344
|
+
iteration: number;
|
|
345
|
+
contactName: string;
|
|
346
|
+
avatarUrl?: string;
|
|
347
|
+
charId?: string;
|
|
348
|
+
metadata?: Record<string, unknown>;
|
|
349
|
+
}): SessionContext;
|
|
350
|
+
/**
|
|
351
|
+
* Extract the `choices[0].message` whole object — preserving `tool_calls`
|
|
352
|
+
* / `reasoning_content` / `refusal` etc. — for appending to the running
|
|
353
|
+
* history. Falls back to a minimal placeholder when the response is
|
|
354
|
+
* malformed so the hook still gets a chance to react via
|
|
355
|
+
* `llmOutputText === ''`.
|
|
356
|
+
*
|
|
357
|
+
* Critically, we keep the entire message object (not just
|
|
358
|
+
* `{role, content}`): the next round may need to forward a `tool_calls`
|
|
359
|
+
* array to OpenAI alongside the matching tool-result messages, and
|
|
360
|
+
* stripping the field would make the API reject the request.
|
|
361
|
+
*
|
|
362
|
+
* @param {unknown} llmResponse
|
|
363
|
+
* @returns {ChatMessage}
|
|
364
|
+
*/
|
|
365
|
+
export function extractAssistantMessage(llmResponse: unknown): ChatMessage;
|
|
366
|
+
/**
|
|
367
|
+
* Assert that an onLLMOutput hook returned a structurally valid decision.
|
|
368
|
+
* TypeScript discriminated unions don't survive into runtime, and a
|
|
369
|
+
* misbehaving hook can easily return `null` / `{ decision: 'idk' }` /
|
|
370
|
+
* `undefined` — treat any of those as a hook contract violation.
|
|
371
|
+
*
|
|
372
|
+
* Flavors:
|
|
373
|
+
* - default (amsg-instant): 'tool-request' must carry pushPayloads — the
|
|
374
|
+
* tool_request push goes to the client, which executes the tools and
|
|
375
|
+
* POSTs /continue.
|
|
376
|
+
* - `{ inlineToolCalls: true }` (amsg-server fire-time loop): the host
|
|
377
|
+
* executes tools in-process, so 'tool-request' may instead carry a
|
|
378
|
+
* non-empty `toolCalls` array directly; pushPayloads then become
|
|
379
|
+
* optional. pushPayloads-shaped tool-requests stay valid so a
|
|
380
|
+
* classifier written for instant drops in unchanged.
|
|
381
|
+
*
|
|
382
|
+
* @param {unknown} decision
|
|
383
|
+
* @param {{ inlineToolCalls?: boolean }} [options]
|
|
384
|
+
*/
|
|
385
|
+
export function assertValidDecision(decision: unknown, options?: {
|
|
386
|
+
inlineToolCalls?: boolean;
|
|
387
|
+
}): void;
|
|
388
|
+
/**
|
|
389
|
+
* Pull the toolCalls out of a 'tool-request' decision, whichever shape it
|
|
390
|
+
* came in: `decision.toolCalls` directly (server flavor), or embedded in
|
|
391
|
+
* tool_request pushPayloads (instant classifier flavor).
|
|
392
|
+
*
|
|
393
|
+
* @param {unknown} decision
|
|
394
|
+
* @returns {Array<Record<string, unknown>>}
|
|
395
|
+
*/
|
|
396
|
+
export function extractToolCallsFromDecision(decision: unknown): Array<Record<string, unknown>>;
|
|
250
397
|
/**
|
|
251
398
|
* @rei-standard/amsg-shared
|
|
252
399
|
*
|
|
@@ -316,6 +463,8 @@ export const PUSH_SOURCE: Readonly<{
|
|
|
316
463
|
INSTANT: "instant";
|
|
317
464
|
SCHEDULED: "scheduled";
|
|
318
465
|
}>;
|
|
466
|
+
/** Max accepted `avatarUrl` length, in characters. */
|
|
467
|
+
export const AVATAR_URL_MAX_LENGTH: 2048;
|
|
319
468
|
/**
|
|
320
469
|
* Fields present on every push, regardless of kind. Discriminator
|
|
321
470
|
* fields (`messageKind`) and kind-specific fields live on the kind
|
|
@@ -439,24 +588,24 @@ export type ContentPush = AmsgPushCommon & {
|
|
|
439
588
|
* out of the upstream response into its own push. Emitted **before**
|
|
440
589
|
* the matching {@link ContentPush} burst when present and non-empty.
|
|
441
590
|
*
|
|
442
|
-
* Reasoning carries two
|
|
443
|
-
*
|
|
444
|
-
*
|
|
591
|
+
* Reasoning carries two optional "multi-part" axes, both *omitted* when
|
|
592
|
+
* the part count is 1 so the wire stays byte-for-byte compatible with
|
|
593
|
+
* single-shot callers. The type reserves them for forward compatibility;
|
|
594
|
+
* current producers emit a single ReasoningPush and set neither — oversized
|
|
595
|
+
* reasoning rides the generic multipart transport, not a reasoning-only
|
|
596
|
+
* chunk format.
|
|
445
597
|
*
|
|
446
|
-
* - `messageIndex` / `totalMessages` —
|
|
447
|
-
*
|
|
448
|
-
* reasoning into multiple sentences for typing-bubble UX.
|
|
598
|
+
* - `messageIndex` / `totalMessages` — a 1-based part index when a producer
|
|
599
|
+
* splits reasoning into multiple sentences for typing-bubble UX.
|
|
449
600
|
*
|
|
450
|
-
* - `chunkIndex` / `totalChunks` —
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
|
|
456
|
-
* splitter helper.
|
|
601
|
+
* - `chunkIndex` / `totalChunks` — transport-only slicing when a single
|
|
602
|
+
* segment exceeds the Web Push payload limit; SW would reassemble the
|
|
603
|
+
* original `reasoningContent` by sorting on `chunkIndex` within a
|
|
604
|
+
* `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes`
|
|
605
|
+
* for the safe-edge splitter helper.
|
|
457
606
|
*
|
|
458
|
-
* Both axes can coexist on the same push when a sentence-split
|
|
459
|
-
*
|
|
607
|
+
* Both axes can coexist on the same push when a sentence-split segment is
|
|
608
|
+
* itself oversized.
|
|
460
609
|
*/
|
|
461
610
|
export type ReasoningPush = AmsgPushCommon & {
|
|
462
611
|
messageKind: "reasoning";
|
|
@@ -502,6 +651,43 @@ export type ErrorPush = AmsgPushCommon & {
|
|
|
502
651
|
* `switch` on `messageKind` and the compiler narrows automatically.
|
|
503
652
|
*/
|
|
504
653
|
export type AmsgPush = ContentPush | ReasoningPush | ToolRequestPush | ErrorPush;
|
|
654
|
+
export type ChatMessage = {
|
|
655
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
656
|
+
content?: string | unknown[] | null;
|
|
657
|
+
tool_calls?: Array<{
|
|
658
|
+
id: string;
|
|
659
|
+
type: "function";
|
|
660
|
+
function: {
|
|
661
|
+
name: string;
|
|
662
|
+
arguments: string;
|
|
663
|
+
};
|
|
664
|
+
}>;
|
|
665
|
+
tool_call_id?: string;
|
|
666
|
+
name?: string;
|
|
667
|
+
};
|
|
668
|
+
export type SessionContext = {
|
|
669
|
+
sessionId: string;
|
|
670
|
+
charId?: string;
|
|
671
|
+
/**
|
|
672
|
+
* - Including the just-appended assistant turn.
|
|
673
|
+
*/
|
|
674
|
+
messages: ChatMessage[];
|
|
675
|
+
/**
|
|
676
|
+
* - Full LLM response (choices, usage, …).
|
|
677
|
+
*/
|
|
678
|
+
llmResponse: unknown;
|
|
679
|
+
/**
|
|
680
|
+
* - May be '' for pure tool-call responses.
|
|
681
|
+
*/
|
|
682
|
+
llmOutputText: string;
|
|
683
|
+
/**
|
|
684
|
+
* - 0-indexed: the round that just finished.
|
|
685
|
+
*/
|
|
686
|
+
iteration: number;
|
|
687
|
+
metadata: Record<string, unknown>;
|
|
688
|
+
contactName: string;
|
|
689
|
+
avatarUrl?: string;
|
|
690
|
+
};
|
|
505
691
|
/**
|
|
506
692
|
* What the push carries. Fixed enum — packages must not add values.
|
|
507
693
|
*/
|
package/dist/index.mjs
CHANGED
|
@@ -227,20 +227,258 @@ function concatBytes(...chunks) {
|
|
|
227
227
|
}
|
|
228
228
|
return out;
|
|
229
229
|
}
|
|
230
|
+
function isValidUrl(value) {
|
|
231
|
+
if (typeof value !== "string") return false;
|
|
232
|
+
try {
|
|
233
|
+
new URL(value);
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
var AVATAR_URL_MAX_LENGTH = 2048;
|
|
240
|
+
function validateAvatarUrl(value) {
|
|
241
|
+
if (value === void 0 || value === null) return null;
|
|
242
|
+
if (typeof value !== "string") {
|
|
243
|
+
return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
|
|
244
|
+
}
|
|
245
|
+
if (/^data:/i.test(value)) {
|
|
246
|
+
return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
|
|
247
|
+
}
|
|
248
|
+
if (value.length > AVATAR_URL_MAX_LENGTH) {
|
|
249
|
+
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`;
|
|
250
|
+
}
|
|
251
|
+
if (!isValidUrl(value)) {
|
|
252
|
+
return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
function normalizeVapidSubject(email) {
|
|
257
|
+
const trimmed = String(email || "").trim();
|
|
258
|
+
if (!trimmed) return "";
|
|
259
|
+
return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`;
|
|
260
|
+
}
|
|
261
|
+
var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
|
|
262
|
+
var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
|
|
263
|
+
function readReasoningContent(llmResponse) {
|
|
264
|
+
if (!llmResponse || typeof llmResponse !== "object") return null;
|
|
265
|
+
const choices = (
|
|
266
|
+
/** @type {{ choices?: unknown }} */
|
|
267
|
+
llmResponse.choices
|
|
268
|
+
);
|
|
269
|
+
if (!Array.isArray(choices) || choices.length === 0) return null;
|
|
270
|
+
const message = (
|
|
271
|
+
/** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */
|
|
272
|
+
choices[0]?.message
|
|
273
|
+
);
|
|
274
|
+
const raw = message?.reasoning_content;
|
|
275
|
+
if (typeof raw === "string") {
|
|
276
|
+
const trimmed = raw.trim();
|
|
277
|
+
if (trimmed.length > 0) return trimmed;
|
|
278
|
+
}
|
|
279
|
+
const content = message?.content;
|
|
280
|
+
if (typeof content === "string") {
|
|
281
|
+
const match = content.match(REASONING_TAG_RE);
|
|
282
|
+
if (match) {
|
|
283
|
+
const trimmed = match[2].trim();
|
|
284
|
+
if (trimmed.length > 0) return trimmed;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function stripReasoningTags(content) {
|
|
290
|
+
if (typeof content !== "string" || !content.includes("<")) return content;
|
|
291
|
+
return content.replace(REASONING_TAG_RE_G, "").trim();
|
|
292
|
+
}
|
|
293
|
+
function buildSessionContext({
|
|
294
|
+
sessionId,
|
|
295
|
+
messages,
|
|
296
|
+
llmResponse,
|
|
297
|
+
iteration,
|
|
298
|
+
contactName,
|
|
299
|
+
avatarUrl,
|
|
300
|
+
charId,
|
|
301
|
+
metadata
|
|
302
|
+
}) {
|
|
303
|
+
const llmOutputText = readLlmOutputText(llmResponse);
|
|
304
|
+
const ctx = {
|
|
305
|
+
sessionId,
|
|
306
|
+
charId,
|
|
307
|
+
messages,
|
|
308
|
+
llmResponse,
|
|
309
|
+
llmOutputText,
|
|
310
|
+
iteration,
|
|
311
|
+
metadata: metadata && typeof metadata === "object" ? metadata : {},
|
|
312
|
+
contactName,
|
|
313
|
+
avatarUrl: avatarUrl || void 0
|
|
314
|
+
};
|
|
315
|
+
return Object.freeze(ctx);
|
|
316
|
+
}
|
|
317
|
+
function readLlmOutputText(llmResponse) {
|
|
318
|
+
if (!llmResponse || typeof llmResponse !== "object") return "";
|
|
319
|
+
const choices = (
|
|
320
|
+
/** @type {{ choices?: unknown }} */
|
|
321
|
+
llmResponse.choices
|
|
322
|
+
);
|
|
323
|
+
if (!Array.isArray(choices) || choices.length === 0) return "";
|
|
324
|
+
const message = (
|
|
325
|
+
/** @type {{ message?: { content?: unknown } }} */
|
|
326
|
+
choices[0]?.message
|
|
327
|
+
);
|
|
328
|
+
const content = message?.content;
|
|
329
|
+
return typeof content === "string" ? content : "";
|
|
330
|
+
}
|
|
331
|
+
function extractAssistantMessage(llmResponse) {
|
|
332
|
+
const message = llmResponse && typeof llmResponse === "object" && Array.isArray(
|
|
333
|
+
/** @type {{ choices?: unknown }} */
|
|
334
|
+
llmResponse.choices
|
|
335
|
+
) && /** @type {{ choices: Array<{ message?: unknown }> }} */
|
|
336
|
+
llmResponse.choices[0]?.message;
|
|
337
|
+
if (message && typeof message === "object") {
|
|
338
|
+
return (
|
|
339
|
+
/** @type {ChatMessage} */
|
|
340
|
+
message
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return { role: "assistant", content: "" };
|
|
344
|
+
}
|
|
345
|
+
var VALID_DECISIONS = /* @__PURE__ */ new Set(["finish", "tool-request", "continue", "skip-push"]);
|
|
346
|
+
function assertValidDecision(decision, options = {}) {
|
|
347
|
+
const inlineToolCalls = options.inlineToolCalls === true;
|
|
348
|
+
if (!decision || typeof decision !== "object") {
|
|
349
|
+
throw new TypeError(`onLLMOutput returned invalid decision: ${stringifyDecisionForError(decision)}`);
|
|
350
|
+
}
|
|
351
|
+
const tag = (
|
|
352
|
+
/** @type {{ decision?: unknown }} */
|
|
353
|
+
decision.decision
|
|
354
|
+
);
|
|
355
|
+
if (typeof tag !== "string" || !VALID_DECISIONS.has(tag)) {
|
|
356
|
+
throw new TypeError(`onLLMOutput returned invalid decision tag: ${stringifyDecisionForError(tag)}`);
|
|
357
|
+
}
|
|
358
|
+
const hasSingular = Object.prototype.hasOwnProperty.call(decision, "pushPayload");
|
|
359
|
+
const hasPlural = Object.prototype.hasOwnProperty.call(decision, "pushPayloads");
|
|
360
|
+
if (hasSingular) {
|
|
361
|
+
throw new TypeError(
|
|
362
|
+
hasPlural ? "pushPayload (singular) is removed in 0.8.0, use pushPayloads" : "pushPayload (singular) is removed in 0.8.0, use pushPayloads: [yourPayload]"
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (tag === "continue") {
|
|
366
|
+
if (!Array.isArray(
|
|
367
|
+
/** @type {{ nextHistory?: unknown }} */
|
|
368
|
+
decision.nextHistory
|
|
369
|
+
)) {
|
|
370
|
+
throw new TypeError('decision:"continue" requires a nextHistory array');
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (tag === "skip-push") {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (tag === "tool-request" && inlineToolCalls && Object.prototype.hasOwnProperty.call(decision, "toolCalls")) {
|
|
378
|
+
const toolCalls = (
|
|
379
|
+
/** @type {{ toolCalls?: unknown }} */
|
|
380
|
+
decision.toolCalls
|
|
381
|
+
);
|
|
382
|
+
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
|
|
383
|
+
throw new TypeError('decision:"tool-request" toolCalls must be a non-empty array when set');
|
|
384
|
+
}
|
|
385
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
386
|
+
const t = toolCalls[i];
|
|
387
|
+
if (!t || typeof t !== "object" || Array.isArray(t)) {
|
|
388
|
+
throw new TypeError(`toolCalls[${i}] must be a plain object, got ${stringifyDecisionForError(t)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (!hasPlural) return;
|
|
392
|
+
}
|
|
393
|
+
if (!hasPlural || !Array.isArray(
|
|
394
|
+
/** @type {{ pushPayloads?: unknown }} */
|
|
395
|
+
decision.pushPayloads
|
|
396
|
+
)) {
|
|
397
|
+
throw new TypeError(`decision:"${tag}" requires a pushPayloads array`);
|
|
398
|
+
}
|
|
399
|
+
const pushes = (
|
|
400
|
+
/** @type {Array<unknown>} */
|
|
401
|
+
decision.pushPayloads
|
|
402
|
+
);
|
|
403
|
+
if (pushes.length === 0) {
|
|
404
|
+
throw new TypeError("pushPayloads: [] \u2014 use decision: skip-push to skip notification entirely");
|
|
405
|
+
}
|
|
406
|
+
for (let i = 0; i < pushes.length; i++) {
|
|
407
|
+
const p = pushes[i];
|
|
408
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) {
|
|
409
|
+
throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyDecisionForError(p)}`);
|
|
410
|
+
}
|
|
411
|
+
if (Object.prototype.hasOwnProperty.call(p, "splitPattern")) {
|
|
412
|
+
throw new TypeError(`pushPayloads[${i}].splitPattern is removed in 0.8.0; caller is responsible for splitting`);
|
|
413
|
+
}
|
|
414
|
+
if (Object.prototype.hasOwnProperty.call(p, "messageId")) {
|
|
415
|
+
const id = (
|
|
416
|
+
/** @type {{ messageId?: unknown }} */
|
|
417
|
+
p.messageId
|
|
418
|
+
);
|
|
419
|
+
if (typeof id !== "string" || id === "") {
|
|
420
|
+
throw new TypeError(`pushPayloads[${i}].messageId must be a non-empty string when set, got ${stringifyDecisionForError(id)}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function extractToolCallsFromDecision(decision) {
|
|
426
|
+
if (!decision || typeof decision !== "object") return [];
|
|
427
|
+
const direct = (
|
|
428
|
+
/** @type {{ toolCalls?: unknown }} */
|
|
429
|
+
decision.toolCalls
|
|
430
|
+
);
|
|
431
|
+
if (Array.isArray(direct) && direct.length > 0) {
|
|
432
|
+
return direct;
|
|
433
|
+
}
|
|
434
|
+
const pushPayloads = (
|
|
435
|
+
/** @type {{ pushPayloads?: unknown }} */
|
|
436
|
+
decision.pushPayloads
|
|
437
|
+
);
|
|
438
|
+
if (!Array.isArray(pushPayloads)) return [];
|
|
439
|
+
const out = [];
|
|
440
|
+
for (const push of pushPayloads) {
|
|
441
|
+
if (push && typeof push === "object" && Array.isArray(
|
|
442
|
+
/** @type {{ toolCalls?: unknown }} */
|
|
443
|
+
push.toolCalls
|
|
444
|
+
)) {
|
|
445
|
+
out.push(.../** @type {{ toolCalls: unknown[] }} */
|
|
446
|
+
push.toolCalls);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
function stringifyDecisionForError(value) {
|
|
452
|
+
try {
|
|
453
|
+
return JSON.stringify(value);
|
|
454
|
+
} catch {
|
|
455
|
+
return String(value);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
230
458
|
export {
|
|
459
|
+
AVATAR_URL_MAX_LENGTH,
|
|
231
460
|
MESSAGE_KIND,
|
|
232
461
|
MESSAGE_TYPE,
|
|
233
462
|
PUSH_SOURCE,
|
|
463
|
+
assertValidDecision,
|
|
234
464
|
base64UrlToBytes,
|
|
235
465
|
buildContentPush,
|
|
236
466
|
buildErrorPush,
|
|
237
467
|
buildReasoningPush,
|
|
468
|
+
buildSessionContext,
|
|
238
469
|
buildToolRequestPush,
|
|
239
470
|
chunkReasoningByUtf8Bytes,
|
|
240
471
|
concatBytes,
|
|
472
|
+
extractAssistantMessage,
|
|
473
|
+
extractToolCallsFromDecision,
|
|
241
474
|
isContentPush,
|
|
242
475
|
isErrorPush,
|
|
243
476
|
isReasoningPush,
|
|
244
477
|
isToolRequestPush,
|
|
245
|
-
|
|
478
|
+
isValidUrl,
|
|
479
|
+
normalizeVapidSubject,
|
|
480
|
+
readReasoningContent,
|
|
481
|
+
stripReasoningTags,
|
|
482
|
+
toUint8,
|
|
483
|
+
validateAvatarUrl
|
|
246
484
|
};
|
package/package.json
CHANGED