openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
363 lines (362 loc) • 13.3 kB
JavaScript
import { r as logVerbose } from "./globals-GTrXU4s9.js";
import { i as getReplyPayloadMetadata, r as copyReplyPayloadMetadata, s as isReplyPayloadStatusNotice } from "./reply-payload-CZJ2cgZc.js";
import { m as resolveSendableOutboundReplyParts, s as hasOutboundReplyContent } from "./reply-payload-D-VMfpYI.js";
//#region src/auto-reply/reply/block-reply-coalescer.ts
/** Creates a text coalescer with idle and size-based flush behavior. */
function createBlockReplyCoalescer(params) {
const { config, shouldAbort, onFlush } = params;
const minChars = Math.max(1, Math.floor(config.minChars));
const maxChars = Math.max(minChars, Math.floor(config.maxChars));
const idleMs = Math.max(0, Math.floor(config.idleMs));
const joiner = config.joiner ?? "";
const flushOnEnqueue = config.flushOnEnqueue === true;
let bufferText = "";
let bufferReplyToId;
let bufferAudioAsVoice;
let bufferIsReasoning;
let bufferIsCompactionNotice;
let bufferIsFallbackNotice;
let bufferIsStatusNotice;
let bufferMetadataSource;
let idleTimer;
const clearIdleTimer = () => {
if (!idleTimer) return;
clearTimeout(idleTimer);
idleTimer = void 0;
};
const resetBuffer = () => {
bufferText = "";
bufferReplyToId = void 0;
bufferAudioAsVoice = void 0;
bufferIsReasoning = void 0;
bufferIsCompactionNotice = void 0;
bufferIsFallbackNotice = void 0;
bufferIsStatusNotice = void 0;
bufferMetadataSource = void 0;
};
const startBufferFromPayload = (payload) => {
bufferReplyToId = payload.replyToId;
bufferAudioAsVoice = payload.audioAsVoice;
bufferIsReasoning = payload.isReasoning;
bufferIsCompactionNotice = payload.isCompactionNotice;
bufferIsFallbackNotice = payload.isFallbackNotice;
bufferIsStatusNotice = payload.isStatusNotice;
bufferMetadataSource = payload;
};
const scheduleIdleFlush = () => {
if (idleMs <= 0) return;
clearIdleTimer();
idleTimer = setTimeout(() => {
flush({ force: false });
}, idleMs);
};
const flush = async (options) => {
clearIdleTimer();
if (shouldAbort()) {
resetBuffer();
return;
}
if (!bufferText) return;
if (!options?.force && !flushOnEnqueue && bufferText.length < minChars) {
scheduleIdleFlush();
return;
}
const payload = {
text: bufferText,
replyToId: bufferReplyToId,
audioAsVoice: bufferAudioAsVoice,
isReasoning: bufferIsReasoning,
isCompactionNotice: bufferIsCompactionNotice,
isFallbackNotice: bufferIsFallbackNotice,
isStatusNotice: bufferIsStatusNotice
};
const payloadWithMetadata = copyReplyPayloadMetadata(bufferMetadataSource ?? payload, payload);
resetBuffer();
await onFlush(payloadWithMetadata);
};
const canMergeBufferedTextWithMedia = (payload) => Boolean(bufferText) && !flushOnEnqueue && !bufferAudioAsVoice && !payload.audioAsVoice && !payload.isReasoning && !isReplyPayloadStatusNotice(payload) && !bufferIsReasoning && !isReplyPayloadStatusNotice({
isCompactionNotice: bufferIsCompactionNotice,
isFallbackNotice: bufferIsFallbackNotice,
isStatusNotice: bufferIsStatusNotice
}) && (!payload.replyToId || bufferReplyToId === payload.replyToId);
/** Merges buffered text into a media payload without changing media metadata. */
const mergeBufferedTextWithMedia = (payload, text) => {
const mergedText = text ? `${bufferText}${joiner}${text}` : bufferText;
const mergedPayload = {
...payload,
text: mergedText,
replyToId: payload.replyToId ?? bufferReplyToId
};
const metadataMergedPayload = copyReplyPayloadMetadata(bufferMetadataSource ?? mergedPayload, mergedPayload);
resetBuffer();
return copyReplyPayloadMetadata(payload, metadataMergedPayload);
};
const enqueue = (payload) => {
if (shouldAbort()) return;
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
const text = reply.text;
const hasText = reply.hasText;
if (hasMedia) {
if (canMergeBufferedTextWithMedia(payload)) {
onFlush(mergeBufferedTextWithMedia(payload, text));
return;
}
flush({ force: true });
onFlush(payload);
return;
}
if (!hasText) return;
if (flushOnEnqueue) {
if (bufferText) flush({ force: true });
startBufferFromPayload(payload);
bufferText = text;
flush({ force: true });
return;
}
const replyToConflict = Boolean(bufferText && payload.replyToId && (!bufferReplyToId || bufferReplyToId !== payload.replyToId));
const visibilityConflict = bufferText && (bufferIsReasoning !== payload.isReasoning || bufferIsCompactionNotice !== payload.isCompactionNotice || bufferIsFallbackNotice !== payload.isFallbackNotice || isReplyPayloadStatusNotice({
isCompactionNotice: bufferIsCompactionNotice,
isFallbackNotice: bufferIsFallbackNotice,
isStatusNotice: bufferIsStatusNotice
}) !== isReplyPayloadStatusNotice(payload));
if (bufferText && (replyToConflict || bufferAudioAsVoice !== payload.audioAsVoice || visibilityConflict)) flush({ force: true });
if (!bufferText) startBufferFromPayload(payload);
const nextText = bufferText ? `${bufferText}${joiner}${text}` : text;
if (nextText.length > maxChars) {
if (bufferText) {
flush({ force: true });
startBufferFromPayload(payload);
if (text.length >= maxChars) {
onFlush(payload);
return;
}
bufferText = text;
scheduleIdleFlush();
return;
}
onFlush(payload);
return;
}
bufferText = nextText;
if (bufferText.length >= maxChars) {
flush({ force: true });
return;
}
scheduleIdleFlush();
};
return {
enqueue,
flush,
hasBuffered: () => Boolean(bufferText),
stop: () => clearIdleTimer()
};
}
//#endregion
//#region src/auto-reply/reply/block-reply-pipeline.ts
/** Buffers audio payloads so final delivery can preserve voice presentation. */
function createAudioAsVoiceBuffer(params) {
let seenAudioAsVoice = false;
return {
onEnqueue: (payload) => {
if (payload.audioAsVoice) seenAudioAsVoice = true;
},
shouldBuffer: (payload) => params.isAudioPayload(payload),
finalize: (payload) => seenAudioAsVoice ? {
...payload,
audioAsVoice: true
} : payload
};
}
/** Creates a stable duplicate key for a complete outbound payload. */
function createBlockReplyPayloadKey(payload) {
const reply = resolveSendableOutboundReplyParts(payload);
return JSON.stringify({
statusNotice: isReplyPayloadStatusNotice(payload),
text: reply.trimmedText,
mediaList: reply.mediaUrls,
presentation: payload.presentation ?? null,
interactive: payload.interactive ?? null,
channelData: payload.channelData ?? null,
replyToId: payload.replyToId ?? null
});
}
/** Creates a duplicate key that ignores reply target for final suppression. */
function createBlockReplyContentKey(payload) {
const reply = resolveSendableOutboundReplyParts(payload);
return JSON.stringify({
text: reply.trimmedText,
mediaList: reply.mediaUrls,
presentation: payload.presentation ?? null,
interactive: payload.interactive ?? null,
channelData: payload.channelData ?? null
});
}
const withTimeout = async (promise, timeoutMs, timeoutError) => {
if (!timeoutMs || timeoutMs <= 0) return promise;
let timer;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => reject(timeoutError), timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timer) clearTimeout(timer);
}
};
/** Creates the ordered block reply delivery pipeline for streamed payloads. */
function createBlockReplyPipeline(params) {
const { onBlockReply, timeoutMs, coalescing, buffer } = params;
const sentKeys = /* @__PURE__ */ new Set();
const sentContentKeys = /* @__PURE__ */ new Set();
const sentMediaUrls = /* @__PURE__ */ new Set();
const pendingKeys = /* @__PURE__ */ new Set();
const seenKeys = /* @__PURE__ */ new Set();
const bufferedKeys = /* @__PURE__ */ new Set();
const bufferedPayloadKeys = /* @__PURE__ */ new Set();
const bufferedPayloads = [];
const streamedTextFragments = [];
let bufferedAssistantMessageIndex;
let sendChain = Promise.resolve();
let aborted = false;
let didStream = false;
let didLogTimeout = false;
const hasSeenOrQueuedPayloadKey = (payloadKey) => seenKeys.has(payloadKey) || sentKeys.has(payloadKey) || pendingKeys.has(payloadKey);
const flushBufferedAssistantBlock = () => {
bufferedAssistantMessageIndex = void 0;
coalescer?.flush({ force: true });
};
const sendPayload = (payload, bypassSeenCheck = false) => {
if (aborted) return;
const payloadKey = createBlockReplyPayloadKey(payload);
const contentKey = createBlockReplyContentKey(payload);
if (!bypassSeenCheck) {
if (seenKeys.has(payloadKey)) return;
seenKeys.add(payloadKey);
}
if (sentKeys.has(payloadKey) || pendingKeys.has(payloadKey)) return;
pendingKeys.add(payloadKey);
const timeoutError = /* @__PURE__ */ new Error(`block reply delivery timed out after ${timeoutMs}ms`);
const abortController = new AbortController();
sendChain = sendChain.then(async () => {
if (aborted) return false;
await withTimeout(Promise.resolve(onBlockReply(payload, {
abortSignal: abortController.signal,
timeoutMs
})), timeoutMs, timeoutError);
return true;
}).then((didSend) => {
if (!didSend) return;
sentKeys.add(payloadKey);
const isStatusNotice = isReplyPayloadStatusNotice(payload);
if (!isStatusNotice) sentContentKeys.add(contentKey);
const reply = resolveSendableOutboundReplyParts(payload);
for (const mediaUrl of reply.mediaUrls) sentMediaUrls.add(mediaUrl);
if (!isStatusNotice && reply.trimmedText) streamedTextFragments.push(reply.trimmedText);
if (!isStatusNotice) didStream = true;
}).catch((err) => {
if (err === timeoutError) {
abortController.abort();
aborted = true;
if (!didLogTimeout) {
didLogTimeout = true;
logVerbose(`block reply delivery timed out after ${timeoutMs}ms; skipping remaining block replies to preserve ordering`);
}
return;
}
logVerbose(`block reply delivery failed: ${String(err)}`);
}).finally(() => {
pendingKeys.delete(payloadKey);
});
};
const coalescer = coalescing ? createBlockReplyCoalescer({
config: coalescing,
shouldAbort: () => aborted,
onFlush: (payload) => {
bufferedAssistantMessageIndex = void 0;
bufferedKeys.clear();
sendPayload(payload, true);
}
}) : null;
const bufferPayload = (payload) => {
buffer?.onEnqueue?.(payload);
if (!buffer?.shouldBuffer(payload)) return false;
const payloadKey = createBlockReplyPayloadKey(payload);
if (hasSeenOrQueuedPayloadKey(payloadKey) || bufferedPayloadKeys.has(payloadKey)) return true;
seenKeys.add(payloadKey);
bufferedPayloadKeys.add(payloadKey);
bufferedPayloads.push(payload);
return true;
};
const flushBuffered = () => {
if (!bufferedPayloads.length) return;
for (const payload of bufferedPayloads) sendPayload(buffer?.finalize?.(payload) ?? payload, true);
bufferedPayloads.length = 0;
bufferedPayloadKeys.clear();
};
const enqueueCoalescedPayload = (payload) => {
if (!coalescer) return;
const assistantMessageIndex = getReplyPayloadMetadata(payload)?.assistantMessageIndex;
if (assistantMessageIndex !== void 0 && bufferedAssistantMessageIndex !== void 0 && assistantMessageIndex !== bufferedAssistantMessageIndex && coalescer.hasBuffered()) flushBufferedAssistantBlock();
const payloadKey = createBlockReplyPayloadKey(payload);
if (hasSeenOrQueuedPayloadKey(payloadKey) || bufferedKeys.has(payloadKey)) return;
seenKeys.add(payloadKey);
bufferedKeys.add(payloadKey);
bufferedAssistantMessageIndex = assistantMessageIndex;
coalescer.enqueue(payload);
};
const enqueue = (payload) => {
if (aborted) return;
if (bufferPayload(payload)) return;
const reply = resolveSendableOutboundReplyParts(payload);
const hasNonTextContent = hasOutboundReplyContent({
...payload,
text: void 0,
mediaUrl: void 0,
mediaUrls: void 0
}, { trimText: true });
if (reply.hasMedia && coalescer && !hasNonTextContent) {
enqueueCoalescedPayload(payload);
return;
}
if (reply.hasMedia || hasNonTextContent) {
coalescer?.flush({ force: true });
sendPayload(payload, false);
return;
}
if (coalescer) {
enqueueCoalescedPayload(payload);
return;
}
sendPayload(payload, false);
};
const flush = async (options) => {
await coalescer?.flush(options);
bufferedAssistantMessageIndex = void 0;
flushBuffered();
await sendChain;
};
const stop = () => {
coalescer?.stop();
};
return {
enqueue,
flush,
stop,
hasBuffered: () => coalescer?.hasBuffered() || bufferedPayloads.length > 0,
didStream: () => didStream,
isAborted: () => aborted,
hasSentPayload: (payload) => {
const payloadKey = createBlockReplyContentKey(payload);
if (sentContentKeys.has(payloadKey)) return true;
if (!didStream || streamedTextFragments.length === 0) return false;
const reply = resolveSendableOutboundReplyParts(payload);
if (reply.hasMedia || !reply.trimmedText) return false;
const normalize = (text) => text.replace(/\s+/g, "");
return normalize(streamedTextFragments.join("")) === normalize(reply.trimmedText);
},
getSentMediaUrls: () => Array.from(sentMediaUrls)
};
}
//#endregion
export { createBlockReplyContentKey as n, createBlockReplyPipeline as r, createAudioAsVoiceBuffer as t };