Files
aetheel-2/src/response-formatter.ts

126 lines
3.7 KiB
TypeScript

/**
* ResponseFormatter — splits long response text into Discord-safe chunks.
*
* Splits text into chunks of at most `maxLength` characters (default 2000).
* Prefers splitting at line boundaries. Tracks open code blocks: if a split
* occurs inside a code block, the chunk is closed with ``` and the next chunk
* reopens with ``` (preserving the language tag).
*/
const DEFAULT_MAX_LENGTH = 2000;
const CODE_FENCE = "```";
const CLOSING_SUFFIX = "\n" + CODE_FENCE;
/**
* Split `text` into an array of chunks, each at most `maxLength` characters.
*
* - Prefers splitting at newline boundaries.
* - If a code block spans a split boundary, the current chunk is closed with
* ``` and the next chunk reopens with ``` (including the original language tag).
* - Empty text returns an empty array.
*/
export function splitMessage(
text: string,
maxLength: number = DEFAULT_MAX_LENGTH,
): string[] {
if (text.length === 0) {
return [];
}
if (text.length <= maxLength) {
return [text];
}
const chunks: string[] = [];
let remaining = text;
let openFenceTag: string | null = null;
while (remaining.length > 0) {
// If we're continuing inside a code block, prepend the fence opener.
const prefix = openFenceTag !== null ? openFenceTag + "\n" : "";
// If the remaining text (with prefix) fits, emit the final chunk.
if (prefix.length + remaining.length <= maxLength) {
chunks.push(prefix + remaining);
break;
}
// Always reserve space for a potential closing fence suffix when splitting.
// This guarantees the chunk stays within maxLength even if we need to close
// a code block that was opened (or continued) in this chunk.
const budget = maxLength - prefix.length - CLOSING_SUFFIX.length;
if (budget <= 0) {
// Degenerate case: maxLength too small for overhead. Force progress.
const take = Math.max(1, maxLength - prefix.length);
chunks.push(prefix + remaining.slice(0, take));
remaining = remaining.slice(take);
continue;
}
// Find a good split point within the budget.
const splitIndex = findSplitPoint(remaining, budget);
const chunk = remaining.slice(0, splitIndex);
remaining = remaining.slice(splitIndex);
// Determine code-block state at the end of this chunk.
const fenceState = computeFenceState(chunk, openFenceTag);
if (fenceState.insideCodeBlock) {
chunks.push(prefix + chunk + CLOSING_SUFFIX);
openFenceTag = fenceState.fenceTag;
} else {
chunks.push(prefix + chunk);
openFenceTag = null;
}
}
return chunks;
}
/**
* Find the best index to split `text` at, within `budget` characters.
* Prefers the last newline boundary. Falls back to `budget` if no newline found.
*/
function findSplitPoint(text: string, budget: number): number {
const region = text.slice(0, budget);
const lastNewline = region.lastIndexOf("\n");
if (lastNewline > 0) {
return lastNewline + 1;
}
return budget;
}
/**
* Scan `chunk` for code fence toggles and determine whether we end inside
* an open code block.
*/
function computeFenceState(
chunk: string,
initialFenceTag: string | null,
): { insideCodeBlock: boolean; fenceTag: string | null } {
let inside = initialFenceTag !== null;
let fenceTag = initialFenceTag;
const fenceRegex = /^(`{3,})(.*)?$/gm;
let match: RegExpExecArray | null;
while ((match = fenceRegex.exec(chunk)) !== null) {
const backticks = match[1];
const langTag = (match[2] ?? "").trim();
if (!inside) {
inside = true;
fenceTag = langTag ? backticks + langTag : backticks;
} else {
inside = false;
fenceTag = null;
}
}
return { insideCodeBlock: inside, fenceTag: fenceTag };
}