Files
2026-07-13 13:39:12 +08:00

90 lines
2.7 KiB
TypeScript

export interface TextBlock {
type?: string;
text?: string;
[key: string]: unknown;
}
export interface ChatMessageLike {
role: string;
content?: string | TextBlock[] | unknown[];
[key: string]: unknown;
}
export function isTextBlock(value: unknown): value is TextBlock {
return (
!!value &&
typeof value === "object" &&
"text" in value &&
typeof (value as TextBlock).text === "string" &&
((value as TextBlock).type === undefined ||
(value as TextBlock).type === "text" ||
(value as TextBlock).type === "input_text")
);
}
export function extractTextContent(content: ChatMessageLike["content"]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const textParts: string[] = [];
for (const part of content) {
if (isTextBlock(part) && part.text) {
textParts.push(part.text);
}
}
return textParts.join("\n");
}
export function mapTextContent(
msg: ChatMessageLike,
transform: (text: string, index: number) => string
): ChatMessageLike {
if (typeof msg.content === "string") {
return { ...msg, content: transform(msg.content, 0) };
}
if (!Array.isArray(msg.content)) return msg;
let textIndex = 0;
let changed = false;
const content = msg.content.map((part) => {
if (!isTextBlock(part)) return part;
const nextText = transform(part.text ?? "", textIndex);
textIndex++;
if (nextText === part.text) return part;
changed = true;
return { ...part, text: nextText };
});
return changed ? { ...msg, content } : msg;
}
export function replaceTextContent(msg: ChatMessageLike, newText: string): ChatMessageLike {
if (typeof msg.content === "string" || !Array.isArray(msg.content)) {
return { ...msg, content: newText };
}
// The first text block receives `newText`. Trailing text blocks are normally
// already subsumed by `newText` (callers build it from the JOIN of all text
// blocks via extractTextContent), so we drop them to avoid duplicating content.
// But if a caller's `newText` does NOT contain a trailing block's text, dropping
// it would silently lose content the model can no longer see (B-AGG-TEXTDROP) —
// so in that case we keep the trailing block instead of returning [].
let replaced = false;
const content = msg.content.flatMap((part) => {
if (!isTextBlock(part)) return [part];
if (!replaced) {
replaced = true;
return [{ ...part, text: newText }];
}
const partText = part.text ?? "";
if (partText && !newText.includes(partText)) return [part];
return [];
});
if (!replaced) {
return { ...msg, content: [{ type: "text", text: newText }, ...msg.content] };
}
return { ...msg, content };
}