@rei-standard/amsg-server 2.6.0-next.2 → 2.6.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-PVNLB6TF.mjs → chunk-OVGQHTSE.mjs} +701 -89
- package/dist/{chunk-GN5QTMCL.cjs → chunk-REX5MCEZ.cjs} +701 -89
- package/dist/{cloudflare-B-E51VjP.d.cts → cloudflare-sVIPkSsf.d.cts} +1179 -242
- package/dist/{cloudflare-B-E51VjP.d.ts → cloudflare-sVIPkSsf.d.ts} +1179 -242
- package/dist/cloudflare.cjs +2 -2
- package/dist/cloudflare.d.cts +1 -1
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.mjs +1 -1
- package/dist/index.cjs +11 -11
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +1 -1
- package/package.json +2 -2
|
@@ -106,10 +106,10 @@ function validateScheduleMessagePayload(payload) {
|
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
if (hasMessages) {
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
109
|
+
const err2 = validateLlmMessagesArray(payload.messages);
|
|
110
|
+
if (err2) {
|
|
111
111
|
return {
|
|
112
|
-
error: { code: "INVALID_PARAMETERS", message:
|
|
112
|
+
error: { code: "INVALID_PARAMETERS", message: err2, details: { invalidFields: ["messages"] } },
|
|
113
113
|
hasCompletePrompt: false,
|
|
114
114
|
hasMessages: true
|
|
115
115
|
};
|
|
@@ -399,8 +399,347 @@ function isUniqueViolation(error) {
|
|
|
399
399
|
|
|
400
400
|
|
|
401
401
|
|
|
402
|
-
|
|
402
|
+
|
|
403
|
+
// src/server/lib/llm.js
|
|
404
|
+
async function callLlm(payload, options = {}) {
|
|
405
|
+
const requireContent = options.requireContent !== false;
|
|
406
|
+
const timeoutMs = typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 3e5;
|
|
407
|
+
const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
|
|
408
|
+
const requestBody = buildAiRequestBody(payload);
|
|
409
|
+
const aiResponse = await fetch(normalizedApiUrl, {
|
|
410
|
+
method: "POST",
|
|
411
|
+
headers: {
|
|
412
|
+
"Content-Type": "application/json",
|
|
413
|
+
"Authorization": `Bearer ${payload.apiKey}`
|
|
414
|
+
},
|
|
415
|
+
body: JSON.stringify(requestBody),
|
|
416
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
417
|
+
});
|
|
418
|
+
if (!aiResponse.ok) {
|
|
419
|
+
if (aiResponse.status === 405) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`AI API error: 405 Method Not Allowed. apiUrl must point to a full chat endpoint (for example: /chat/completions). Received: ${normalizedApiUrl}`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
throw new Error(
|
|
425
|
+
`AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
const aiData = await aiResponse.json();
|
|
429
|
+
const rawContent = _optionalChain([aiData, 'optionalAccess', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]);
|
|
430
|
+
if (requireContent && (typeof rawContent !== "string" || !rawContent.trim())) {
|
|
431
|
+
throw new Error("AI API error: response missing choices[0].message.content");
|
|
432
|
+
}
|
|
433
|
+
return { response: aiData, content: typeof rawContent === "string" ? rawContent.trim() : "" };
|
|
434
|
+
}
|
|
435
|
+
function buildAiRequestBody(payload) {
|
|
436
|
+
const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
|
|
437
|
+
const requestBody = {
|
|
438
|
+
model: payload.primaryModel,
|
|
439
|
+
messages: llmMessages
|
|
440
|
+
};
|
|
441
|
+
if (payload.temperature !== void 0 && payload.temperature !== null) {
|
|
442
|
+
requestBody.temperature = payload.temperature;
|
|
443
|
+
} else if (!Array.isArray(payload.messages)) {
|
|
444
|
+
requestBody.temperature = 0.8;
|
|
445
|
+
}
|
|
446
|
+
if (payload.maxTokens === void 0 || payload.maxTokens === null) {
|
|
447
|
+
return requestBody;
|
|
448
|
+
}
|
|
449
|
+
if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
|
|
450
|
+
throw new Error("Invalid maxTokens: maxTokens must be a positive integer when provided.");
|
|
451
|
+
}
|
|
452
|
+
requestBody.max_tokens = payload.maxTokens;
|
|
453
|
+
return requestBody;
|
|
454
|
+
}
|
|
455
|
+
function normalizeAiApiUrl(apiUrl) {
|
|
456
|
+
if (typeof apiUrl !== "string" || !apiUrl.trim()) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
"Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const trimmedApiUrl = apiUrl.trim();
|
|
462
|
+
let parsedUrl;
|
|
463
|
+
try {
|
|
464
|
+
parsedUrl = new URL(trimmedApiUrl);
|
|
465
|
+
} catch (e3) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
|
|
471
|
+
if (/\/chat\/completions$/.test(path)) {
|
|
472
|
+
} else if (path === "/") {
|
|
473
|
+
path = "/v1/chat/completions";
|
|
474
|
+
} else if (/\/v\d+$/.test(path)) {
|
|
475
|
+
path = `${path}/chat/completions`;
|
|
476
|
+
}
|
|
477
|
+
parsedUrl.pathname = path;
|
|
478
|
+
return parsedUrl.toString();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/server/lib/agentic-fire.js
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
// src/server/lib/state-chunks.js
|
|
490
|
+
|
|
491
|
+
var STATE_CHUNK_SLICE_BYTES = 200 * 1024;
|
|
492
|
+
var DEFAULT_MAX_STATE_VALUE_BYTES = 5 * 1024 * 1024;
|
|
493
|
+
var INTERNAL_STATE_CHAR_RE = /[\u0000-\u001f]/;
|
|
494
|
+
var SEP = "";
|
|
495
|
+
var CHUNK_NS_PREFIX = `${SEP}amsg-chunks${SEP}`;
|
|
496
|
+
var ROOT_MARKER_PREFIX = `${SEP}amsg-chunked${SEP}v1${SEP}`;
|
|
497
|
+
function chunkNamespaceFor(namespace) {
|
|
498
|
+
return CHUNK_NS_PREFIX + namespace;
|
|
499
|
+
}
|
|
500
|
+
function chunkKeyFor(key, index) {
|
|
501
|
+
return `${key}${SEP}${index}`;
|
|
502
|
+
}
|
|
503
|
+
function chunkKeyPrefixFor(key) {
|
|
504
|
+
return `${key}${SEP}`;
|
|
505
|
+
}
|
|
506
|
+
function buildChunkedRootValue(chunkCount) {
|
|
507
|
+
return `${ROOT_MARKER_PREFIX}${chunkCount}`;
|
|
508
|
+
}
|
|
509
|
+
function parseChunkedRootCount(value) {
|
|
510
|
+
if (typeof value !== "string" || !value.startsWith(ROOT_MARKER_PREFIX)) return null;
|
|
511
|
+
const raw = value.slice(ROOT_MARKER_PREFIX.length);
|
|
512
|
+
if (!/^[1-9][0-9]*$/.test(raw)) return null;
|
|
513
|
+
return Number(raw);
|
|
514
|
+
}
|
|
515
|
+
function splitStateValue(value) {
|
|
516
|
+
return _amsgshared.chunkReasoningByUtf8Bytes.call(void 0, value, STATE_CHUNK_SLICE_BYTES);
|
|
517
|
+
}
|
|
518
|
+
async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
|
|
519
|
+
let chunkMap = null;
|
|
520
|
+
const loadChunks = async () => {
|
|
521
|
+
if (chunkMap === null) {
|
|
522
|
+
const chunkRows = await fetchChunkRows();
|
|
523
|
+
chunkMap = new Map(chunkRows.map((row) => [row.key, row]));
|
|
524
|
+
}
|
|
525
|
+
return chunkMap;
|
|
526
|
+
};
|
|
527
|
+
const entries = [];
|
|
528
|
+
for (const row of rows) {
|
|
529
|
+
const count = parseChunkedRootCount(row.value);
|
|
530
|
+
if (count === null) {
|
|
531
|
+
entries.push({
|
|
532
|
+
namespace: row.namespace,
|
|
533
|
+
key: row.key,
|
|
534
|
+
value: await decryptValue(row.value),
|
|
535
|
+
updatedAt: row.updated_at
|
|
536
|
+
});
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const map = await loadChunks();
|
|
540
|
+
const chunkRows = [];
|
|
541
|
+
let intact = true;
|
|
542
|
+
for (let i = 0; i < count; i++) {
|
|
543
|
+
const chunk = map.get(chunkKeyFor(row.key, i));
|
|
544
|
+
if (!chunk || chunk.updated_at !== row.updated_at) {
|
|
545
|
+
intact = false;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
chunkRows.push(chunk);
|
|
549
|
+
}
|
|
550
|
+
if (!intact) continue;
|
|
551
|
+
const parts = await Promise.all(chunkRows.map((chunk) => decryptValue(chunk.value)));
|
|
552
|
+
entries.push({
|
|
553
|
+
namespace: row.namespace,
|
|
554
|
+
key: row.key,
|
|
555
|
+
value: parts.join(""),
|
|
556
|
+
updatedAt: row.updated_at
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return entries;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/server/lib/agentic-fire.js
|
|
563
|
+
var DEFAULT_MAX_TOOL_ITERATIONS = 5;
|
|
564
|
+
var DEFAULT_TOTAL_TIMEOUT_MS = 24e4;
|
|
403
565
|
var SLEEP_BETWEEN_MESSAGES_MS = 1500;
|
|
566
|
+
var CREDENTIAL_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["apiKey", "pushSubscription"]);
|
|
567
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
568
|
+
function taskNeedsLlm(decryptedPayload) {
|
|
569
|
+
const type = decryptedPayload.messageType;
|
|
570
|
+
if (type === "prompted" || type === "auto") return true;
|
|
571
|
+
if (type === "instant") {
|
|
572
|
+
return !!(decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel);
|
|
573
|
+
}
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
function buildHookTask(task, decryptedPayload) {
|
|
577
|
+
const safe = {};
|
|
578
|
+
for (const [key, value] of Object.entries(decryptedPayload)) {
|
|
579
|
+
if (!CREDENTIAL_PAYLOAD_KEYS.has(key)) safe[key] = value;
|
|
580
|
+
}
|
|
581
|
+
return Object.freeze({
|
|
582
|
+
...safe,
|
|
583
|
+
id: _nullishCoalesce(task.id, () => ( null)),
|
|
584
|
+
uuid: _nullishCoalesce(task.uuid, () => ( null)),
|
|
585
|
+
nextSendAt: _nullishCoalesce(task.next_send_at, () => ( null)),
|
|
586
|
+
retryCount: _nullishCoalesce(task.retry_count, () => ( 0))
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
function normalizeBeforeFireResult(result) {
|
|
590
|
+
if (Array.isArray(result)) {
|
|
591
|
+
return { messages: result };
|
|
592
|
+
}
|
|
593
|
+
if (result && typeof result === "object" && Array.isArray(result.messages)) {
|
|
594
|
+
return {
|
|
595
|
+
messages: result.messages,
|
|
596
|
+
maxToolIterations: result.maxToolIterations,
|
|
597
|
+
totalTimeoutMs: result.totalTimeoutMs
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
throw new TypeError(
|
|
601
|
+
"AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | null"
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
function firstPositiveInt(values, fallback) {
|
|
605
|
+
for (const v of values) {
|
|
606
|
+
if (Number.isInteger(v) && v > 0) return v;
|
|
607
|
+
}
|
|
608
|
+
return fallback;
|
|
609
|
+
}
|
|
610
|
+
function firstPositiveNumber(values, fallback) {
|
|
611
|
+
for (const v of values) {
|
|
612
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
|
|
613
|
+
}
|
|
614
|
+
return fallback;
|
|
615
|
+
}
|
|
616
|
+
async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
617
|
+
const hooks = ctx.hooks;
|
|
618
|
+
if (typeof hooks.onLLMOutput !== "function") {
|
|
619
|
+
throw new Error("AGENTIC_CONFIG_ERROR: hooks.onBeforeFire requires hooks.onLLMOutput to classify LLM rounds");
|
|
620
|
+
}
|
|
621
|
+
const nowFn = typeof ctx._agenticNow === "function" ? ctx._agenticNow : Date.now;
|
|
622
|
+
const sleep = typeof ctx._agenticSleep === "function" ? ctx._agenticSleep : defaultSleep;
|
|
623
|
+
const readState = async (namespace) => {
|
|
624
|
+
if (typeof namespace !== "string" || !namespace.trim()) {
|
|
625
|
+
throw new TypeError("readState(namespace) requires a non-empty string");
|
|
626
|
+
}
|
|
627
|
+
if (!ctx.db || typeof ctx.db.getClientState !== "function") return [];
|
|
628
|
+
const rows = await ctx.db.getClientState(task.user_id, namespace);
|
|
629
|
+
return resolveClientStateEntries(
|
|
630
|
+
rows,
|
|
631
|
+
() => ctx.db.getClientState(task.user_id, chunkNamespaceFor(namespace)),
|
|
632
|
+
(value) => decryptFromStorage(value, userKey)
|
|
633
|
+
);
|
|
634
|
+
};
|
|
635
|
+
const scratch = {};
|
|
636
|
+
const fireCtx = Object.freeze({
|
|
637
|
+
task: buildHookTask(task, decryptedPayload),
|
|
638
|
+
userId: task.user_id,
|
|
639
|
+
readState,
|
|
640
|
+
now: new Date(nowFn()),
|
|
641
|
+
scratch
|
|
642
|
+
});
|
|
643
|
+
const before = await hooks.onBeforeFire(fireCtx);
|
|
644
|
+
if (before == null) return { handled: false };
|
|
645
|
+
const normalized = normalizeBeforeFireResult(before);
|
|
646
|
+
const maxToolIterations = firstPositiveInt(
|
|
647
|
+
[normalized.maxToolIterations, ctx.maxToolIterations],
|
|
648
|
+
DEFAULT_MAX_TOOL_ITERATIONS
|
|
649
|
+
);
|
|
650
|
+
const totalTimeoutMs = firstPositiveNumber(
|
|
651
|
+
[normalized.totalTimeoutMs, ctx.totalTimeoutMs],
|
|
652
|
+
DEFAULT_TOTAL_TIMEOUT_MS
|
|
653
|
+
);
|
|
654
|
+
const deadline = nowFn() + totalTimeoutMs;
|
|
655
|
+
const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
|
|
656
|
+
let messages = normalized.messages.slice();
|
|
657
|
+
for (let iteration = 0; iteration < maxToolIterations; iteration++) {
|
|
658
|
+
if (nowFn() >= deadline) {
|
|
659
|
+
throw new Error(`AGENTIC_TOTAL_TIMEOUT: fire chain exceeded ${totalTimeoutMs}ms after ${iteration} LLM round(s)`);
|
|
660
|
+
}
|
|
661
|
+
const roundTimeoutMs = Math.max(1, Math.min(3e5, deadline - nowFn()));
|
|
662
|
+
const { response: llmResponse } = await callLlm(
|
|
663
|
+
{ ...decryptedPayload, messages },
|
|
664
|
+
{ requireContent: false, timeoutMs: roundTimeoutMs }
|
|
665
|
+
);
|
|
666
|
+
const assistantMessage = _amsgshared.extractAssistantMessage.call(void 0, llmResponse);
|
|
667
|
+
messages = [...messages, assistantMessage];
|
|
668
|
+
const sessionCtx = _amsgshared.buildSessionContext.call(void 0, {
|
|
669
|
+
sessionId,
|
|
670
|
+
messages,
|
|
671
|
+
llmResponse,
|
|
672
|
+
iteration,
|
|
673
|
+
contactName: decryptedPayload.contactName,
|
|
674
|
+
avatarUrl: decryptedPayload.avatarUrl || void 0,
|
|
675
|
+
charId: decryptedPayload.charId,
|
|
676
|
+
metadata: decryptedPayload.metadata,
|
|
677
|
+
scratch
|
|
678
|
+
});
|
|
679
|
+
const decision = await hooks.onLLMOutput(sessionCtx);
|
|
680
|
+
_amsgshared.assertValidDecision.call(void 0, decision, { inlineToolCalls: true });
|
|
681
|
+
if (decision.decision === "continue") {
|
|
682
|
+
messages = decision.nextHistory.slice();
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (decision.decision === "skip-push") {
|
|
686
|
+
return { handled: true, result: { success: true, messagesSent: 0, status: "skipped", iterations: iteration + 1 } };
|
|
687
|
+
}
|
|
688
|
+
if (decision.decision === "finish") {
|
|
689
|
+
const messagesSent = await sendHookPushPayloads(decision.pushPayloads, decryptedPayload, ctx, sessionId, task, sleep);
|
|
690
|
+
return { handled: true, result: { success: true, messagesSent, status: "finished", iterations: iteration + 1 } };
|
|
691
|
+
}
|
|
692
|
+
const toolCalls = _amsgshared.extractToolCallsFromDecision.call(void 0, decision);
|
|
693
|
+
if (toolCalls.length === 0) {
|
|
694
|
+
throw new Error("AGENTIC_EMPTY_TOOL_REQUEST: tool-request decision carried no toolCalls (neither decision.toolCalls nor pushPayloads[].toolCalls)");
|
|
695
|
+
}
|
|
696
|
+
if (typeof hooks.executeToolCalls !== "function") {
|
|
697
|
+
throw new Error("AGENTIC_CONFIG_ERROR: onLLMOutput returned tool-request but hooks.executeToolCalls is not configured");
|
|
698
|
+
}
|
|
699
|
+
if (iteration === maxToolIterations - 1) {
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
let toolResults;
|
|
703
|
+
try {
|
|
704
|
+
toolResults = await hooks.executeToolCalls(toolCalls, sessionCtx);
|
|
705
|
+
if (!Array.isArray(toolResults)) {
|
|
706
|
+
throw new TypeError('executeToolCalls must resolve to an array of { tool_call_id, role: "tool", content }');
|
|
707
|
+
}
|
|
708
|
+
} catch (error) {
|
|
709
|
+
toolResults = toolCalls.map((toolCall) => ({
|
|
710
|
+
tool_call_id: toolCall && typeof toolCall === "object" && typeof toolCall.id === "string" ? toolCall.id : "",
|
|
711
|
+
role: "tool",
|
|
712
|
+
content: `Tool execution failed: ${_nullishCoalesce(_optionalChain([error, 'optionalAccess', _6 => _6.message]), () => ( String(error)))}`
|
|
713
|
+
}));
|
|
714
|
+
}
|
|
715
|
+
const assistantWithTools = Array.isArray(assistantMessage.tool_calls) && assistantMessage.tool_calls.length > 0 ? assistantMessage : { ...assistantMessage, tool_calls: toolCalls };
|
|
716
|
+
messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
|
|
717
|
+
}
|
|
718
|
+
throw new Error(`AGENTIC_LOOP_EXCEEDED: no finish/skip-push decision within ${maxToolIterations} LLM round(s)`);
|
|
719
|
+
}
|
|
720
|
+
async function sendHookPushPayloads(pushPayloads, decryptedPayload, ctx, sessionId, task, sleep) {
|
|
721
|
+
if (!ctx.vapid || !ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
722
|
+
throw new Error("VAPID configuration missing - push notifications cannot be sent");
|
|
723
|
+
}
|
|
724
|
+
const pushSubscription = decryptedPayload.pushSubscription;
|
|
725
|
+
const total = pushPayloads.length;
|
|
726
|
+
const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}`;
|
|
727
|
+
for (let i = 0; i < total; i++) {
|
|
728
|
+
const push = { ...pushPayloads[i] };
|
|
729
|
+
if (typeof push.messageId !== "string" || !push.messageId) push.messageId = `${messageIdBase}_hook_${i}`;
|
|
730
|
+
if (typeof push.sessionId !== "string" || !push.sessionId) push.sessionId = sessionId;
|
|
731
|
+
if (typeof push.timestamp !== "string" || !push.timestamp) push.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
732
|
+
push.messageIndex = i + 1;
|
|
733
|
+
push.totalMessages = total;
|
|
734
|
+
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(push));
|
|
735
|
+
if (i < total - 1) await sleep(SLEEP_BETWEEN_MESSAGES_MS);
|
|
736
|
+
}
|
|
737
|
+
return total;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// src/server/lib/message-processor.js
|
|
741
|
+
var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
742
|
+
var SLEEP_BETWEEN_MESSAGES_MS2 = 1500;
|
|
404
743
|
function splitOnceByRegex(chunk, regex) {
|
|
405
744
|
const out = chunk.split(regex).reduce((acc, part, i, arr) => {
|
|
406
745
|
if (i % 2 === 0 && part.trim()) {
|
|
@@ -428,6 +767,10 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
428
767
|
}
|
|
429
768
|
const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
|
|
430
769
|
const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
|
|
770
|
+
if (ctx.hooks && typeof ctx.hooks.onBeforeFire === "function" && taskNeedsLlm(decryptedPayload)) {
|
|
771
|
+
const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
|
|
772
|
+
if (agentic.handled) return agentic.result;
|
|
773
|
+
}
|
|
431
774
|
let messageContent;
|
|
432
775
|
let llmResponse = null;
|
|
433
776
|
if (decryptedPayload.messageType === "fixed") {
|
|
@@ -435,7 +778,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
435
778
|
} else if (decryptedPayload.messageType === "instant") {
|
|
436
779
|
const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
|
|
437
780
|
if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
|
|
438
|
-
const aiResult = await
|
|
781
|
+
const aiResult = await callLlm(decryptedPayload);
|
|
439
782
|
messageContent = aiResult.content;
|
|
440
783
|
llmResponse = aiResult.response;
|
|
441
784
|
} else if (decryptedPayload.userMessage) {
|
|
@@ -444,7 +787,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
444
787
|
throw new Error("Invalid instant message: no content source available");
|
|
445
788
|
}
|
|
446
789
|
} else if (decryptedPayload.messageType === "prompted" || decryptedPayload.messageType === "auto") {
|
|
447
|
-
const aiResult = await
|
|
790
|
+
const aiResult = await callLlm(decryptedPayload);
|
|
448
791
|
messageContent = aiResult.content;
|
|
449
792
|
llmResponse = aiResult.response;
|
|
450
793
|
} else {
|
|
@@ -480,7 +823,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
480
823
|
metadata
|
|
481
824
|
});
|
|
482
825
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
|
|
483
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
826
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
|
|
484
827
|
}
|
|
485
828
|
for (let i = 0; i < messages.length; i++) {
|
|
486
829
|
const contentPush = _amsgshared.buildContentPush.call(void 0, {
|
|
@@ -501,7 +844,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
501
844
|
});
|
|
502
845
|
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
|
|
503
846
|
if (i < messages.length - 1) {
|
|
504
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
847
|
+
await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
|
|
505
848
|
}
|
|
506
849
|
}
|
|
507
850
|
return { success: true, messagesSent: messages.length };
|
|
@@ -571,80 +914,6 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
|
|
|
571
914
|
return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
|
|
572
915
|
}
|
|
573
916
|
}
|
|
574
|
-
async function _callAI(payload) {
|
|
575
|
-
const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
|
|
576
|
-
const requestBody = buildAiRequestBody(payload);
|
|
577
|
-
const aiResponse = await fetch(normalizedApiUrl, {
|
|
578
|
-
method: "POST",
|
|
579
|
-
headers: {
|
|
580
|
-
"Content-Type": "application/json",
|
|
581
|
-
"Authorization": `Bearer ${payload.apiKey}`
|
|
582
|
-
},
|
|
583
|
-
body: JSON.stringify(requestBody),
|
|
584
|
-
signal: AbortSignal.timeout(3e5)
|
|
585
|
-
});
|
|
586
|
-
if (!aiResponse.ok) {
|
|
587
|
-
if (aiResponse.status === 405) {
|
|
588
|
-
throw new Error(
|
|
589
|
-
`AI API error: 405 Method Not Allowed. apiUrl must point to a full chat endpoint (for example: /chat/completions). Received: ${normalizedApiUrl}`
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
throw new Error(
|
|
593
|
-
`AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}`
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
const aiData = await aiResponse.json();
|
|
597
|
-
const content = _optionalChain([aiData, 'optionalAccess', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]);
|
|
598
|
-
if (typeof content !== "string" || !content.trim()) {
|
|
599
|
-
throw new Error("AI API error: response missing choices[0].message.content");
|
|
600
|
-
}
|
|
601
|
-
return { response: aiData, content: content.trim() };
|
|
602
|
-
}
|
|
603
|
-
function buildAiRequestBody(payload) {
|
|
604
|
-
const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
|
|
605
|
-
const requestBody = {
|
|
606
|
-
model: payload.primaryModel,
|
|
607
|
-
messages: llmMessages
|
|
608
|
-
};
|
|
609
|
-
if (payload.temperature !== void 0 && payload.temperature !== null) {
|
|
610
|
-
requestBody.temperature = payload.temperature;
|
|
611
|
-
} else if (!Array.isArray(payload.messages)) {
|
|
612
|
-
requestBody.temperature = 0.8;
|
|
613
|
-
}
|
|
614
|
-
if (payload.maxTokens === void 0 || payload.maxTokens === null) {
|
|
615
|
-
return requestBody;
|
|
616
|
-
}
|
|
617
|
-
if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
|
|
618
|
-
throw new Error("Invalid maxTokens: maxTokens must be a positive integer when provided.");
|
|
619
|
-
}
|
|
620
|
-
requestBody.max_tokens = payload.maxTokens;
|
|
621
|
-
return requestBody;
|
|
622
|
-
}
|
|
623
|
-
function normalizeAiApiUrl(apiUrl) {
|
|
624
|
-
if (typeof apiUrl !== "string" || !apiUrl.trim()) {
|
|
625
|
-
throw new Error(
|
|
626
|
-
"Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
const trimmedApiUrl = apiUrl.trim();
|
|
630
|
-
let parsedUrl;
|
|
631
|
-
try {
|
|
632
|
-
parsedUrl = new URL(trimmedApiUrl);
|
|
633
|
-
} catch (e3) {
|
|
634
|
-
throw new Error(
|
|
635
|
-
`Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
|
|
636
|
-
);
|
|
637
|
-
}
|
|
638
|
-
let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
|
|
639
|
-
if (/\/chat\/completions$/.test(path)) {
|
|
640
|
-
} else if (path === "/") {
|
|
641
|
-
path = "/v1/chat/completions";
|
|
642
|
-
} else if (/\/v\d+$/.test(path)) {
|
|
643
|
-
path = `${path}/chat/completions`;
|
|
644
|
-
}
|
|
645
|
-
parsedUrl.pathname = path;
|
|
646
|
-
return parsedUrl.toString();
|
|
647
|
-
}
|
|
648
917
|
|
|
649
918
|
// src/server/handlers/schedule-message.js
|
|
650
919
|
function createScheduleMessageHandler(ctx) {
|
|
@@ -1224,6 +1493,16 @@ var SQLITE_INDEXES = [
|
|
|
1224
1493
|
critical: true
|
|
1225
1494
|
}
|
|
1226
1495
|
];
|
|
1496
|
+
var CLIENT_STATE_TABLE_SQL = `
|
|
1497
|
+
CREATE TABLE IF NOT EXISTS client_state (
|
|
1498
|
+
user_id TEXT NOT NULL,
|
|
1499
|
+
namespace TEXT NOT NULL,
|
|
1500
|
+
key TEXT NOT NULL,
|
|
1501
|
+
value TEXT NOT NULL,
|
|
1502
|
+
updated_at INTEGER NOT NULL,
|
|
1503
|
+
PRIMARY KEY (user_id, namespace, key)
|
|
1504
|
+
)
|
|
1505
|
+
`;
|
|
1227
1506
|
|
|
1228
1507
|
// src/server/adapters/d1.js
|
|
1229
1508
|
var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
|
|
@@ -1237,6 +1516,9 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
|
|
|
1237
1516
|
"created_at",
|
|
1238
1517
|
"updated_at"
|
|
1239
1518
|
]);
|
|
1519
|
+
function escapeLikePrefix(prefix) {
|
|
1520
|
+
return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
1521
|
+
}
|
|
1240
1522
|
var D1Adapter = class {
|
|
1241
1523
|
/** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
|
|
1242
1524
|
constructor(db) {
|
|
@@ -1256,6 +1538,7 @@ var D1Adapter = class {
|
|
|
1256
1538
|
}
|
|
1257
1539
|
async initSchema() {
|
|
1258
1540
|
await this._db.prepare(SQLITE_TABLE_SQL).run();
|
|
1541
|
+
await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
|
|
1259
1542
|
const indexResults = [];
|
|
1260
1543
|
for (const index of SQLITE_INDEXES) {
|
|
1261
1544
|
try {
|
|
@@ -1282,6 +1565,7 @@ var D1Adapter = class {
|
|
|
1282
1565
|
}
|
|
1283
1566
|
async dropSchema() {
|
|
1284
1567
|
await this._db.prepare("DROP TABLE IF EXISTS scheduled_messages").run();
|
|
1568
|
+
await this._db.prepare("DROP TABLE IF EXISTS client_state").run();
|
|
1285
1569
|
}
|
|
1286
1570
|
async createTask(params) {
|
|
1287
1571
|
const now = this._now();
|
|
@@ -1410,6 +1694,89 @@ var D1Adapter = class {
|
|
|
1410
1694
|
).bind(uuid, userId).first();
|
|
1411
1695
|
return row ? row.status : null;
|
|
1412
1696
|
}
|
|
1697
|
+
// ── client_state (single-user cloud state mirror) ──────────────────────
|
|
1698
|
+
/**
|
|
1699
|
+
* Batch upsert. Last-write-wins per (namespace, key): an entry older
|
|
1700
|
+
* than the stored row (updatedAt strictly lower) is skipped; equal or
|
|
1701
|
+
* newer overwrites. Values arrive pre-encrypted (the handler encrypts).
|
|
1702
|
+
*
|
|
1703
|
+
* `cleanups` 是分块存储的清理项(见 lib/state-chunks.js):在同一 batch 里
|
|
1704
|
+
* 先于 upsert 执行,按 (namespace, key 前缀) 删掉旧写入留下的切片行;
|
|
1705
|
+
* `updated_at <= ?` 条件保证陈旧批次删不动更新写入的行。
|
|
1706
|
+
*
|
|
1707
|
+
* Uses D1's batch() — one network round trip for the whole set (implicit
|
|
1708
|
+
* transaction). The client calls this endpoint inside its few-seconds
|
|
1709
|
+
* background window, so N sequential round trips could eat the whole
|
|
1710
|
+
* window. Bindings without batch() (e.g. the sqlite test shim, custom
|
|
1711
|
+
* adapters) fall back to a sequential loop.
|
|
1712
|
+
*
|
|
1713
|
+
* @param {string} userId
|
|
1714
|
+
* @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
|
|
1715
|
+
* @param {Array<{ namespace: string, keyPrefix: string, updatedAt: number }>} [cleanups]
|
|
1716
|
+
* @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
|
|
1717
|
+
* `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
|
|
1718
|
+
*/
|
|
1719
|
+
async upsertClientState(userId, entries, cleanups = []) {
|
|
1720
|
+
const UPSERT_SQL = `INSERT INTO client_state (user_id, namespace, key, value, updated_at)
|
|
1721
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1722
|
+
ON CONFLICT (user_id, namespace, key) DO UPDATE SET
|
|
1723
|
+
value = excluded.value,
|
|
1724
|
+
updated_at = excluded.updated_at
|
|
1725
|
+
WHERE excluded.updated_at >= client_state.updated_at`;
|
|
1726
|
+
const CLEANUP_SQL = `DELETE FROM client_state
|
|
1727
|
+
WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;
|
|
1728
|
+
const buildStatements = () => [
|
|
1729
|
+
...cleanups.map(
|
|
1730
|
+
(c) => this._db.prepare(CLEANUP_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
|
|
1731
|
+
),
|
|
1732
|
+
...entries.map(
|
|
1733
|
+
(entry) => this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
|
|
1734
|
+
)
|
|
1735
|
+
];
|
|
1736
|
+
let results;
|
|
1737
|
+
if (typeof this._db.batch === "function") {
|
|
1738
|
+
results = await this._db.batch(buildStatements());
|
|
1739
|
+
} else {
|
|
1740
|
+
results = [];
|
|
1741
|
+
for (const stmt of buildStatements()) {
|
|
1742
|
+
results.push(await stmt.run());
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
const outcomes = results.slice(cleanups.length).map((res) => res.meta.changes > 0);
|
|
1746
|
+
let upserted = 0;
|
|
1747
|
+
let skipped = 0;
|
|
1748
|
+
for (const wrote of outcomes) {
|
|
1749
|
+
if (wrote) upserted++;
|
|
1750
|
+
else skipped++;
|
|
1751
|
+
}
|
|
1752
|
+
return { upserted, skipped, outcomes };
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* All entries of one namespace (values still encrypted).
|
|
1756
|
+
*
|
|
1757
|
+
* @param {string} userId
|
|
1758
|
+
* @param {string} namespace
|
|
1759
|
+
* @returns {Promise<Array<{ namespace: string, key: string, value: string, updated_at: number }>>}
|
|
1760
|
+
*/
|
|
1761
|
+
async getClientState(userId, namespace) {
|
|
1762
|
+
const res = await this._db.prepare(
|
|
1763
|
+
`SELECT namespace, key, value, updated_at
|
|
1764
|
+
FROM client_state
|
|
1765
|
+
WHERE user_id = ? AND namespace = ?
|
|
1766
|
+
ORDER BY key ASC`
|
|
1767
|
+
).bind(userId, namespace).all();
|
|
1768
|
+
return res.results || [];
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Wipe every entry of this user.
|
|
1772
|
+
*
|
|
1773
|
+
* @param {string} userId
|
|
1774
|
+
* @returns {Promise<number>} rows deleted
|
|
1775
|
+
*/
|
|
1776
|
+
async clearClientState(userId) {
|
|
1777
|
+
const res = await this._db.prepare("DELETE FROM client_state WHERE user_id = ?").bind(userId).run();
|
|
1778
|
+
return res.meta.changes || 0;
|
|
1779
|
+
}
|
|
1413
1780
|
};
|
|
1414
1781
|
function createD1Adapter(db) {
|
|
1415
1782
|
if (!db || typeof db.prepare !== "function") {
|
|
@@ -1523,6 +1890,224 @@ function createVapidPublicKeyHandler(ctx) {
|
|
|
1523
1890
|
return { GET };
|
|
1524
1891
|
}
|
|
1525
1892
|
|
|
1893
|
+
// src/server/handlers/client-state.js
|
|
1894
|
+
var MAX_STATE_ENTRIES_PER_REQUEST = 200;
|
|
1895
|
+
var MAX_NAMESPACE_CHARS = 128;
|
|
1896
|
+
var MAX_KEY_CHARS = 256;
|
|
1897
|
+
var utf82 = new TextEncoder();
|
|
1898
|
+
function err(status, code, message, details) {
|
|
1899
|
+
const error = details === void 0 ? { code, message } : { code, message, details };
|
|
1900
|
+
return { status, body: { success: false, error } };
|
|
1901
|
+
}
|
|
1902
|
+
function requireUserId(headers) {
|
|
1903
|
+
const userId = getHeader(headers, "x-user-id");
|
|
1904
|
+
if (!userId) return { error: err(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
|
|
1905
|
+
if (!isValidUUIDv4(userId)) return { error: err(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
|
|
1906
|
+
return { userId };
|
|
1907
|
+
}
|
|
1908
|
+
function rejectEntry(entry, index, code, message, extra) {
|
|
1909
|
+
const rejection = { index, code, message, ...extra || {} };
|
|
1910
|
+
if (entry && typeof entry === "object") {
|
|
1911
|
+
if (typeof entry.namespace === "string") rejection.namespace = entry.namespace;
|
|
1912
|
+
if (typeof entry.key === "string") rejection.key = entry.key;
|
|
1913
|
+
}
|
|
1914
|
+
return rejection;
|
|
1915
|
+
}
|
|
1916
|
+
function validateEntry(entry, index, maxValueBytes) {
|
|
1917
|
+
if (!isPlainObject(entry)) {
|
|
1918
|
+
return rejectEntry(entry, index, "INVALID_STATE_ENTRY", `entries[${index}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
|
|
1919
|
+
}
|
|
1920
|
+
if (typeof entry.namespace !== "string" || !entry.namespace.trim() || entry.namespace.length > MAX_NAMESPACE_CHARS) {
|
|
1921
|
+
return rejectEntry(entry, index, "INVALID_STATE_NAMESPACE", `entries[${index}].namespace \u5FC5\u987B\u662F 1-${MAX_NAMESPACE_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
|
|
1922
|
+
}
|
|
1923
|
+
if (INTERNAL_STATE_CHAR_RE.test(entry.namespace)) {
|
|
1924
|
+
return rejectEntry(entry, index, "INVALID_STATE_NAMESPACE", `entries[${index}].namespace \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`);
|
|
1925
|
+
}
|
|
1926
|
+
if (typeof entry.key !== "string" || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
|
|
1927
|
+
return rejectEntry(entry, index, "INVALID_STATE_KEY", `entries[${index}].key \u5FC5\u987B\u662F 1-${MAX_KEY_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
|
|
1928
|
+
}
|
|
1929
|
+
if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
|
|
1930
|
+
return rejectEntry(entry, index, "INVALID_STATE_KEY", `entries[${index}].key \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`);
|
|
1931
|
+
}
|
|
1932
|
+
if (typeof entry.value !== "string") {
|
|
1933
|
+
return rejectEntry(entry, index, "INVALID_STATE_VALUE", `entries[${index}].value \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF08\u5BBF\u4E3B\u81EA\u884C\u5E8F\u5217\u5316\uFF09`);
|
|
1934
|
+
}
|
|
1935
|
+
const bytes = utf82.encode(entry.value).length;
|
|
1936
|
+
if (bytes > maxValueBytes) {
|
|
1937
|
+
return rejectEntry(entry, index, "STATE_VALUE_TOO_LARGE", `entries[${index}].value \u8D85\u8FC7\u5355\u6761\u603B\u4E0A\u9650`, { bytes, maxBytes: maxValueBytes });
|
|
1938
|
+
}
|
|
1939
|
+
if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
|
|
1940
|
+
return rejectEntry(entry, index, "INVALID_STATE_UPDATED_AT", `entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
|
|
1941
|
+
}
|
|
1942
|
+
return null;
|
|
1943
|
+
}
|
|
1944
|
+
function createClientStateHandler(ctx) {
|
|
1945
|
+
async function PUT(headers, body) {
|
|
1946
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
1947
|
+
if (!tenantResult.ok) return tenantResult.error;
|
|
1948
|
+
const { db, masterKey } = tenantResult.context;
|
|
1949
|
+
if (getHeader(headers, "x-payload-encrypted") !== "true") {
|
|
1950
|
+
return err(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
|
|
1951
|
+
}
|
|
1952
|
+
const gate = requireUserId(headers);
|
|
1953
|
+
if (gate.error) return gate.error;
|
|
1954
|
+
const { userId } = gate;
|
|
1955
|
+
if (getHeader(headers, "x-encryption-version") !== "1") {
|
|
1956
|
+
return err(400, "UNSUPPORTED_ENCRYPTION_VERSION", "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301");
|
|
1957
|
+
}
|
|
1958
|
+
const parsedBody = parseEncryptedBody(body);
|
|
1959
|
+
if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
|
|
1960
|
+
const userKey = await deriveUserEncryptionKey(userId, masterKey);
|
|
1961
|
+
let payload;
|
|
1962
|
+
try {
|
|
1963
|
+
payload = await decryptPayload(parsedBody.data, userKey);
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
if (error instanceof SyntaxError) {
|
|
1966
|
+
return err(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u4E0D\u662F\u6709\u6548 JSON");
|
|
1967
|
+
}
|
|
1968
|
+
return err(400, "DECRYPTION_FAILED", "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25");
|
|
1969
|
+
}
|
|
1970
|
+
if (!isPlainObject(payload)) return err(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
|
|
1971
|
+
const entries = payload.entries;
|
|
1972
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
1973
|
+
return err(400, "INVALID_STATE_ENTRIES", "entries \u5FC5\u987B\u662F\u975E\u7A7A\u6570\u7EC4");
|
|
1974
|
+
}
|
|
1975
|
+
if (entries.length > MAX_STATE_ENTRIES_PER_REQUEST) {
|
|
1976
|
+
return err(400, "TOO_MANY_STATE_ENTRIES", `\u5355\u6B21\u6700\u591A ${MAX_STATE_ENTRIES_PER_REQUEST} \u6761`, { count: entries.length });
|
|
1977
|
+
}
|
|
1978
|
+
const maxValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0 ? ctx.maxStateValueBytes : DEFAULT_MAX_STATE_VALUE_BYTES;
|
|
1979
|
+
const accepted = [];
|
|
1980
|
+
const rejected = [];
|
|
1981
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1982
|
+
const rejection = validateEntry(entries[i], i, maxValueBytes);
|
|
1983
|
+
if (rejection) rejected.push(rejection);
|
|
1984
|
+
else accepted.push(entries[i]);
|
|
1985
|
+
}
|
|
1986
|
+
if (typeof db.upsertClientState !== "function") {
|
|
1987
|
+
return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
|
|
1988
|
+
}
|
|
1989
|
+
const physicalRows = [];
|
|
1990
|
+
const cleanups = [];
|
|
1991
|
+
const rootRowIndexes = [];
|
|
1992
|
+
for (const entry of accepted) {
|
|
1993
|
+
cleanups.push({
|
|
1994
|
+
namespace: chunkNamespaceFor(entry.namespace),
|
|
1995
|
+
keyPrefix: chunkKeyPrefixFor(entry.key),
|
|
1996
|
+
updatedAt: entry.updatedAt
|
|
1997
|
+
});
|
|
1998
|
+
rootRowIndexes.push(physicalRows.length);
|
|
1999
|
+
if (utf82.encode(entry.value).length <= STATE_CHUNK_SLICE_BYTES) {
|
|
2000
|
+
physicalRows.push({
|
|
2001
|
+
namespace: entry.namespace,
|
|
2002
|
+
key: entry.key,
|
|
2003
|
+
value: await encryptForStorage(entry.value, userKey),
|
|
2004
|
+
updatedAt: entry.updatedAt
|
|
2005
|
+
});
|
|
2006
|
+
} else {
|
|
2007
|
+
const slices = splitStateValue(entry.value);
|
|
2008
|
+
physicalRows.push({
|
|
2009
|
+
namespace: entry.namespace,
|
|
2010
|
+
key: entry.key,
|
|
2011
|
+
value: buildChunkedRootValue(slices.length),
|
|
2012
|
+
updatedAt: entry.updatedAt
|
|
2013
|
+
});
|
|
2014
|
+
const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
|
|
2015
|
+
for (let c = 0; c < encryptedSlices.length; c++) {
|
|
2016
|
+
physicalRows.push({
|
|
2017
|
+
namespace: chunkNamespaceFor(entry.namespace),
|
|
2018
|
+
key: chunkKeyFor(entry.key, c),
|
|
2019
|
+
value: encryptedSlices[c],
|
|
2020
|
+
updatedAt: entry.updatedAt
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
let upserted = 0;
|
|
2026
|
+
let skipped = 0;
|
|
2027
|
+
if (physicalRows.length > 0) {
|
|
2028
|
+
const result = await db.upsertClientState(userId, physicalRows, cleanups);
|
|
2029
|
+
if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
|
|
2030
|
+
for (const rootIndex of rootRowIndexes) {
|
|
2031
|
+
if (result.outcomes[rootIndex]) upserted++;
|
|
2032
|
+
else skipped++;
|
|
2033
|
+
}
|
|
2034
|
+
} else {
|
|
2035
|
+
upserted = result.upserted;
|
|
2036
|
+
skipped = result.skipped;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
const data = { upserted, skipped };
|
|
2040
|
+
if (rejected.length > 0) data.rejected = rejected;
|
|
2041
|
+
return { status: 200, body: { success: true, data } };
|
|
2042
|
+
}
|
|
2043
|
+
async function GET(url, headers) {
|
|
2044
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
2045
|
+
if (!tenantResult.ok) return tenantResult.error;
|
|
2046
|
+
const { db, masterKey } = tenantResult.context;
|
|
2047
|
+
const gate = requireUserId(headers);
|
|
2048
|
+
if (gate.error) return gate.error;
|
|
2049
|
+
const { userId } = gate;
|
|
2050
|
+
const namespace = new URL(url, "https://dummy").searchParams.get("namespace") || "";
|
|
2051
|
+
if (!namespace.trim()) return err(400, "NAMESPACE_REQUIRED", "\u5FC5\u987B\u63D0\u4F9B namespace \u67E5\u8BE2\u53C2\u6570");
|
|
2052
|
+
if (INTERNAL_STATE_CHAR_RE.test(namespace)) {
|
|
2053
|
+
return err(400, "INVALID_STATE_NAMESPACE", "namespace \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09");
|
|
2054
|
+
}
|
|
2055
|
+
if (typeof db.getClientState !== "function") {
|
|
2056
|
+
return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
|
|
2057
|
+
}
|
|
2058
|
+
const userKey = await deriveUserEncryptionKey(userId, masterKey);
|
|
2059
|
+
const rows = await db.getClientState(userId, namespace);
|
|
2060
|
+
const decrypted = await resolveClientStateEntries(
|
|
2061
|
+
rows,
|
|
2062
|
+
() => db.getClientState(userId, chunkNamespaceFor(namespace)),
|
|
2063
|
+
(value) => decryptFromStorage(value, userKey)
|
|
2064
|
+
);
|
|
2065
|
+
const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
|
|
2066
|
+
return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
|
|
2067
|
+
}
|
|
2068
|
+
async function DELETE(url, headers) {
|
|
2069
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
2070
|
+
if (!tenantResult.ok) return tenantResult.error;
|
|
2071
|
+
const { db } = tenantResult.context;
|
|
2072
|
+
const gate = requireUserId(headers);
|
|
2073
|
+
if (gate.error) return gate.error;
|
|
2074
|
+
const { userId } = gate;
|
|
2075
|
+
if (typeof db.clearClientState !== "function") {
|
|
2076
|
+
return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
|
|
2077
|
+
}
|
|
2078
|
+
const deleted = await db.clearClientState(userId);
|
|
2079
|
+
return { status: 200, body: { success: true, data: { deleted } } };
|
|
2080
|
+
}
|
|
2081
|
+
return { PUT, GET, DELETE };
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// src/server/lib/version.js
|
|
2085
|
+
var SERVER_VERSION = true ? "2.6.0-next.4" : "0.0.0-dev";
|
|
2086
|
+
|
|
2087
|
+
// src/server/handlers/capabilities.js
|
|
2088
|
+
var SERVER_FEATURES = Object.freeze([
|
|
2089
|
+
"client-state",
|
|
2090
|
+
"client-state-chunking",
|
|
2091
|
+
"client-state-partial-failure",
|
|
2092
|
+
"agentic-hooks",
|
|
2093
|
+
"agentic-scratch",
|
|
2094
|
+
"vapid-public-key"
|
|
2095
|
+
]);
|
|
2096
|
+
function createCapabilitiesHandler(ctx) {
|
|
2097
|
+
async function GET(url, headers) {
|
|
2098
|
+
const effectiveHeaders = headers || url || {};
|
|
2099
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
|
|
2100
|
+
if (!tenantResult.ok) {
|
|
2101
|
+
return tenantResult.error;
|
|
2102
|
+
}
|
|
2103
|
+
return {
|
|
2104
|
+
status: 200,
|
|
2105
|
+
body: { success: true, serverVersion: SERVER_VERSION, features: [...SERVER_FEATURES] }
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
return { GET };
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1526
2111
|
// src/server/single-user.js
|
|
1527
2112
|
function createSingleUserServer(config) {
|
|
1528
2113
|
if (!config || !config.db) throw new Error("[amsg-server single-user] config.db is required");
|
|
@@ -1540,7 +2125,14 @@ function createSingleUserServer(config) {
|
|
|
1540
2125
|
privateKey: vapid.privateKey || ""
|
|
1541
2126
|
},
|
|
1542
2127
|
webpush: config.webpush || null,
|
|
1543
|
-
tenantManager
|
|
2128
|
+
tenantManager,
|
|
2129
|
+
// Fire-time hooks (optional): the in-server instant path fires through
|
|
2130
|
+
// processMessagesByUuid with this ctx, so instant-type tasks can take
|
|
2131
|
+
// the agentic path too.
|
|
2132
|
+
hooks: config.hooks || null,
|
|
2133
|
+
maxToolIterations: config.maxToolIterations,
|
|
2134
|
+
totalTimeoutMs: config.totalTimeoutMs,
|
|
2135
|
+
maxStateValueBytes: config.maxStateValueBytes
|
|
1544
2136
|
};
|
|
1545
2137
|
return {
|
|
1546
2138
|
ctx,
|
|
@@ -1551,7 +2143,9 @@ function createSingleUserServer(config) {
|
|
|
1551
2143
|
updateMessage: createUpdateMessageHandler(ctx),
|
|
1552
2144
|
cancelMessage: createCancelMessageHandler(ctx),
|
|
1553
2145
|
messages: createMessagesHandler(ctx),
|
|
1554
|
-
vapidPublicKey: createVapidPublicKeyHandler(ctx)
|
|
2146
|
+
vapidPublicKey: createVapidPublicKeyHandler(ctx),
|
|
2147
|
+
clientState: createClientStateHandler(ctx),
|
|
2148
|
+
capabilities: createCapabilitiesHandler(ctx)
|
|
1555
2149
|
}
|
|
1556
2150
|
};
|
|
1557
2151
|
}
|
|
@@ -1605,12 +2199,12 @@ async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl
|
|
|
1605
2199
|
});
|
|
1606
2200
|
if (!res.ok) {
|
|
1607
2201
|
const text = await safeReadText(res);
|
|
1608
|
-
const
|
|
2202
|
+
const err2 = new Error(
|
|
1609
2203
|
`Web Push delivery failed: ${res.status} ${res.statusText || ""}${text ? ` \u2014 ${text}` : ""}`
|
|
1610
2204
|
);
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
throw
|
|
2205
|
+
err2.code = "PUSH_SEND_FAILED";
|
|
2206
|
+
err2.statusCode = res.status;
|
|
2207
|
+
throw err2;
|
|
1614
2208
|
}
|
|
1615
2209
|
return {
|
|
1616
2210
|
statusCode: res.status,
|
|
@@ -1817,6 +2411,14 @@ function createSingleUserCloudflareWorker(buildConfig) {
|
|
|
1817
2411
|
result = await server.handlers.cancelMessage.DELETE(url, headers);
|
|
1818
2412
|
} else if (method === "GET" && pathname.endsWith("/vapid-public-key")) {
|
|
1819
2413
|
result = await server.handlers.vapidPublicKey.GET(url, headers);
|
|
2414
|
+
} else if (method === "GET" && pathname.endsWith("/capabilities")) {
|
|
2415
|
+
result = await server.handlers.capabilities.GET(url, headers);
|
|
2416
|
+
} else if (method === "PUT" && pathname.endsWith("/client-state")) {
|
|
2417
|
+
result = await server.handlers.clientState.PUT(headers, await request.text());
|
|
2418
|
+
} else if (method === "GET" && pathname.endsWith("/client-state")) {
|
|
2419
|
+
result = await server.handlers.clientState.GET(url, headers);
|
|
2420
|
+
} else if (method === "DELETE" && pathname.endsWith("/client-state")) {
|
|
2421
|
+
result = await server.handlers.clientState.DELETE(url, headers);
|
|
1820
2422
|
} else {
|
|
1821
2423
|
result = { status: 404, body: { success: false, error: { code: "NOT_FOUND", message: "Unknown route" } } };
|
|
1822
2424
|
}
|
|
@@ -1834,7 +2436,17 @@ function createSingleUserCloudflareWorker(buildConfig) {
|
|
|
1834
2436
|
return;
|
|
1835
2437
|
}
|
|
1836
2438
|
try {
|
|
1837
|
-
await runScheduledTick({
|
|
2439
|
+
await runScheduledTick({
|
|
2440
|
+
db: cfg.db,
|
|
2441
|
+
masterKey: cfg.masterKey,
|
|
2442
|
+
vapid,
|
|
2443
|
+
webpush: cfg.webpush,
|
|
2444
|
+
// Fire-time hooks (optional; see lib/agentic-fire.js). runScheduledTick
|
|
2445
|
+
// spreads its ctx into processSingleMessage, so these ride along.
|
|
2446
|
+
hooks: cfg.hooks || null,
|
|
2447
|
+
maxToolIterations: cfg.maxToolIterations,
|
|
2448
|
+
totalTimeoutMs: cfg.totalTimeoutMs
|
|
2449
|
+
});
|
|
1838
2450
|
} catch (error) {
|
|
1839
2451
|
console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
|
|
1840
2452
|
}
|