126 lines
3.7 KiB
TypeScript
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 };
|
|
}
|