UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

363 lines (362 loc) 13.3 kB
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 };