fix: flush block streaming on paragraph boundaries for chunkMode=newline (#7014)
* feat: Implement paragraph boundary flushing in block streaming - Added `flushOnParagraph` option to `BlockReplyChunking` for immediate flushing on paragraph breaks. - Updated `EmbeddedBlockChunker` to handle paragraph boundaries during chunking. - Enhanced `createBlockReplyCoalescer` to support flushing on enqueue. - Added tests to verify behavior of flushing with and without `flushOnEnqueue` set. - Updated relevant types and interfaces to include `flushOnParagraph` and `flushOnEnqueue` options. * fix: Improve streaming behavior and enhance block chunking logic - Resolved issue with stuck typing indicator after streamed BlueBubbles replies. - Refactored `EmbeddedBlockChunker` to streamline fence-split handling and ensure maxChars fallback for newline chunking. - Added tests to validate new chunking behavior, including handling of paragraph breaks and fence scenarios. - Updated changelog to reflect these changes. * test: Add test for clamping long paragraphs in EmbeddedBlockChunker - Introduced a new test case to verify that long paragraphs are correctly clamped to maxChars when flushOnParagraph is enabled. - Updated logic in EmbeddedBlockChunker to handle cases where the next paragraph break exceeds maxChars, ensuring proper chunking behavior. * refactor: streamline logging and improve error handling in message processing - Removed verbose logging statements from the `processMessage` function to reduce clutter. - Enhanced error handling by using `runtime.error` for typing restart failures. - Updated the `applySystemPromptOverrideToSession` function to accept a string directly instead of a function, simplifying the prompt application process. - Adjusted the `runEmbeddedAttempt` function to directly use the system prompt override without invoking it as a function.
This commit is contained in:
@@ -2145,12 +2145,40 @@ async function processMessage(
|
||||
};
|
||||
|
||||
let sentMessage = false;
|
||||
let streamingActive = false;
|
||||
let typingRestartTimer: NodeJS.Timeout | undefined;
|
||||
const typingRestartDelayMs = 150;
|
||||
const clearTypingRestartTimer = () => {
|
||||
if (typingRestartTimer) {
|
||||
clearTimeout(typingRestartTimer);
|
||||
typingRestartTimer = undefined;
|
||||
}
|
||||
};
|
||||
const restartTypingSoon = () => {
|
||||
if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
clearTypingRestartTimer();
|
||||
typingRestartTimer = setTimeout(() => {
|
||||
typingRestartTimer = undefined;
|
||||
if (!streamingActive) {
|
||||
return;
|
||||
}
|
||||
sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
|
||||
});
|
||||
}, typingRestartDelayMs);
|
||||
};
|
||||
try {
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
deliver: async (payload, info) => {
|
||||
const rawReplyToId =
|
||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||
// Resolve short ID (e.g., "5") to full UUID
|
||||
@@ -2185,6 +2213,9 @@ async function processMessage(
|
||||
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
if (info.kind === "block") {
|
||||
restartTypingSoon();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2220,16 +2251,8 @@ async function processMessage(
|
||||
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
// In newline mode, restart typing after each chunk if more chunks remain
|
||||
// Small delay allows the Apple API to finish clearing the typing state from message send
|
||||
if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
}).catch(() => {
|
||||
// Ignore typing errors
|
||||
});
|
||||
if (info.kind === "block") {
|
||||
restartTypingSoon();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2240,7 +2263,8 @@ async function processMessage(
|
||||
if (!baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`);
|
||||
streamingActive = true;
|
||||
clearTypingRestartTimer();
|
||||
try {
|
||||
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
@@ -2257,14 +2281,8 @@ async function processMessage(
|
||||
if (!baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendBlueBubblesTyping(chatGuidForActions, false, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
|
||||
}
|
||||
// Intentionally no-op for block streaming. We stop typing in finally
|
||||
// after the run completes to avoid flicker between paragraph blocks.
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
||||
@@ -2278,6 +2296,10 @@ async function processMessage(
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
const shouldStopTyping =
|
||||
Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
|
||||
streamingActive = false;
|
||||
clearTypingRestartTimer();
|
||||
if (sentMessage && chatGuidForActions && ackMessageId) {
|
||||
core.channel.reactions.removeAckReactionAfterReply({
|
||||
removeAfterReply: removeAckAfterReply,
|
||||
@@ -2301,8 +2323,8 @@ async function processMessage(
|
||||
},
|
||||
});
|
||||
}
|
||||
if (chatGuidForActions && baseUrl && password && !sentMessage) {
|
||||
// Stop typing indicator when no message was sent (e.g., NO_REPLY)
|
||||
if (shouldStopTyping) {
|
||||
// Stop typing after streaming completes to avoid a stuck indicator.
|
||||
sendBlueBubblesTyping(chatGuidForActions, false, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
|
||||
Reference in New Issue
Block a user