forked from EduCraft/curriculum-project-hub
a0df8b6bcf
Two bugs were introduced when sendTextMessage was changed to auto-detect message format (text/post/interactive): 1. Message type mismatch: sendTextMessage could create a text or post message, but patchTextMessage only works on interactive cards. Fix: add sendInteractiveCardMessage for streaming create (always interactive, always patchable). 2. flushTextToSink state corruption: the text management after flush had a flawed appendedDuringFlush heuristic that could lose text or duplicate it. Fix: properly track sent vs unsent text by removing the sent prefix length, keeping only the last chunk for future patches. Also fix finish() to flush pending text regardless of currentMessageId.
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
export interface PatchableTextSink {
|
|
readonly create: (text: string) => Promise<string | null>;
|
|
readonly patch: (messageId: string, text: string) => Promise<void>;
|
|
}
|
|
|
|
export interface PatchableTextStreamOptions {
|
|
readonly patchIntervalMs?: number;
|
|
readonly maxMessageLength?: number;
|
|
}
|
|
|
|
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
|
export const DEFAULT_MAX_MESSAGE_LENGTH = 8000;
|
|
|
|
interface CodeBlockRange {
|
|
readonly start: number;
|
|
readonly end: number;
|
|
}
|
|
|
|
export function splitAtBoundary(text: string, maxLength: number): string[] {
|
|
if (!Number.isInteger(maxLength) || maxLength <= 0) {
|
|
throw new RangeError("maxLength must be a positive integer");
|
|
}
|
|
if (text.length <= maxLength) return [text];
|
|
|
|
const chunks: string[] = [];
|
|
let remaining = text;
|
|
while (remaining.length > maxLength) {
|
|
const splitPoint = chooseSplitPoint(remaining, maxLength);
|
|
if (splitPoint <= 0) {
|
|
const codeBlockEnd = codeBlockAtStartEnd(remaining);
|
|
const forcedSplitPoint = codeBlockEnd ?? maxLength;
|
|
chunks.push(remaining.slice(0, forcedSplitPoint));
|
|
remaining = remaining.slice(forcedSplitPoint);
|
|
continue;
|
|
}
|
|
|
|
chunks.push(remaining.slice(0, splitPoint));
|
|
remaining = remaining.slice(splitPoint);
|
|
}
|
|
if (remaining.length > 0) {
|
|
chunks.push(remaining);
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function chooseSplitPoint(text: string, maxLength: number): number {
|
|
const codeBlocks = findFencedCodeBlocks(text);
|
|
const splitCodeBlock = codeBlocks.find((range) => maxLength > range.start && maxLength < range.end);
|
|
if (splitCodeBlock !== undefined) {
|
|
return splitCodeBlock.start;
|
|
}
|
|
|
|
return (
|
|
lastBoundary(text, maxLength, /\r?\n\r?\n/g, codeBlocks) ??
|
|
lastBoundary(text, maxLength, /\r?\n/g, codeBlocks) ??
|
|
lastBoundary(text, maxLength, / /g, codeBlocks) ??
|
|
maxLength
|
|
);
|
|
}
|
|
|
|
function lastBoundary(text: string, maxLength: number, pattern: RegExp, codeBlocks: ReadonlyArray<CodeBlockRange>): number | undefined {
|
|
let boundary: number | undefined;
|
|
let match: RegExpExecArray | null;
|
|
pattern.lastIndex = 0;
|
|
while ((match = pattern.exec(text)) !== null) {
|
|
const splitPoint = match.index + match[0].length;
|
|
if (splitPoint > maxLength) break;
|
|
if (splitPoint > 0 && !isInsideCodeBlock(splitPoint, codeBlocks)) {
|
|
boundary = splitPoint;
|
|
}
|
|
}
|
|
return boundary;
|
|
}
|
|
|
|
function isInsideCodeBlock(splitPoint: number, codeBlocks: ReadonlyArray<CodeBlockRange>): boolean {
|
|
return codeBlocks.some((range) => splitPoint > range.start && splitPoint < range.end);
|
|
}
|
|
|
|
function codeBlockAtStartEnd(text: string): number | undefined {
|
|
const firstCodeBlock = findFencedCodeBlocks(text)[0];
|
|
return firstCodeBlock?.start === 0 ? firstCodeBlock.end : undefined;
|
|
}
|
|
|
|
function findFencedCodeBlocks(text: string): CodeBlockRange[] {
|
|
const ranges: CodeBlockRange[] = [];
|
|
let openStart: number | null = null;
|
|
let lineStart = 0;
|
|
|
|
while (lineStart < text.length) {
|
|
const newlineIndex = text.indexOf("\n", lineStart);
|
|
const lineEnd = newlineIndex === -1 ? text.length : newlineIndex;
|
|
const nextLineStart = newlineIndex === -1 ? text.length : newlineIndex + 1;
|
|
const line = text.slice(lineStart, lineEnd);
|
|
|
|
if (/^ {0,3}```/.test(line)) {
|
|
if (openStart === null) {
|
|
openStart = lineStart;
|
|
} else {
|
|
ranges.push({ start: openStart, end: nextLineStart });
|
|
openStart = null;
|
|
}
|
|
}
|
|
|
|
lineStart = nextLineStart;
|
|
}
|
|
|
|
if (openStart !== null) {
|
|
ranges.push({ start: openStart, end: text.length });
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
/**
|
|
* Serializes text-card creation/patching for streaming output.
|
|
*
|
|
* Feishu returns the message id asynchronously. Without this queue, multiple
|
|
* early text deltas can all observe "no message yet" and create duplicate
|
|
* prefix messages before the first create call resolves.
|
|
*/
|
|
export class PatchableTextStream {
|
|
private currentMessageId: string | null = null;
|
|
private text = "";
|
|
private lastPatchAt = 0;
|
|
private flushChain: Promise<void> = Promise.resolve();
|
|
private flushScheduled = false;
|
|
|
|
private readonly patchIntervalMs: number;
|
|
private readonly maxMessageLength: number;
|
|
|
|
constructor(
|
|
private readonly sink: PatchableTextSink,
|
|
options: PatchableTextStreamOptions = {},
|
|
) {
|
|
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
|
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
|
}
|
|
|
|
append(delta: string): void {
|
|
if (delta === "") return;
|
|
this.text += delta;
|
|
this.scheduleFlush();
|
|
}
|
|
|
|
async finish(fallbackText: string): Promise<void> {
|
|
await this.flushChain;
|
|
// If there's pending text to flush, do it now.
|
|
if (this.text.length > 0) {
|
|
await this.flushTextToSink();
|
|
return;
|
|
}
|
|
// No pending text. If we never sent anything, send the fallback.
|
|
if (this.currentMessageId === null && fallbackText.length > 0) {
|
|
this.text = fallbackText;
|
|
await this.flushTextToSink();
|
|
}
|
|
}
|
|
|
|
private scheduleFlush(): void {
|
|
if (this.flushScheduled) return;
|
|
this.flushScheduled = true;
|
|
this.flushChain = this.flushChain.then(async () => {
|
|
this.flushScheduled = false;
|
|
await this.flush();
|
|
});
|
|
}
|
|
|
|
private async flush(): Promise<void> {
|
|
if (this.currentMessageId === null) {
|
|
await this.flushTextToSink();
|
|
this.lastPatchAt = Date.now();
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - this.lastPatchAt < this.patchIntervalMs) return;
|
|
this.lastPatchAt = now;
|
|
|
|
await this.flushTextToSink();
|
|
}
|
|
|
|
private async flushTextToSink(): Promise<void> {
|
|
const textToFlush = this.text;
|
|
const chunks = splitAtBoundary(textToFlush, this.maxMessageLength);
|
|
const firstChunk = chunks[0];
|
|
if (firstChunk === undefined) return;
|
|
|
|
if (this.currentMessageId === null) {
|
|
// First message: create it with the first chunk, then send overflow
|
|
// chunks as new messages.
|
|
this.currentMessageId = await this.sink.create(firstChunk);
|
|
for (const chunk of chunks.slice(1)) {
|
|
this.currentMessageId = await this.sink.create(chunk);
|
|
}
|
|
// Keep only the last chunk for future patches; remove already-sent prefix.
|
|
const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0);
|
|
this.text = this.text.slice(sentLength);
|
|
} else {
|
|
// Patch the current message with the first chunk, then create new
|
|
// messages for overflow chunks.
|
|
await this.sink.patch(this.currentMessageId, firstChunk);
|
|
for (const chunk of chunks.slice(1)) {
|
|
this.currentMessageId = await this.sink.create(chunk);
|
|
}
|
|
// Keep only the last chunk for future patches; remove already-sent prefix.
|
|
const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0);
|
|
this.text = this.text.slice(sentLength);
|
|
}
|
|
}
|
|
}
|