import type {
AssistantMessage,
AssistantMessageEventStream,
TextContent,
ThinkingContent,
} from "@earendil-works/pi-ai";
const THINKING_TAG_VARIANTS: Array<{ open: string; close: string }> = [
{ open: "", close: "" },
{ open: "", close: "" },
{ open: "", close: "" },
{ open: "", close: "" },
];
function getTrailingPossibleTagPrefixLength(text: string, tag: string): number {
const maxPrefixLength = Math.min(text.length, tag.length - 1);
for (let len = maxPrefixLength; len > 0; len--) {
if (text.endsWith(tag.slice(0, len))) return len;
}
return 0;
}
function getMaxTrailingPossibleTagPrefixLength(text: string, tags: string[]): number {
let maxLength = 0;
for (const tag of tags) {
maxLength = Math.max(maxLength, getTrailingPossibleTagPrefixLength(text, tag));
}
return maxLength;
}
export class ThinkingTagParser {
private textBuffer = "";
private inThinking = false;
private thinkingExtracted = false;
private thinkingBlockIndex: number | null = null;
private textBlockIndex: number | null = null;
private lastTextBlockIndex: number | null = null;
private activeEndTag: string = THINKING_TAG_VARIANTS[0].close;
constructor(
private output: AssistantMessage,
private stream: AssistantMessageEventStream,
) {}
processChunk(chunk: string): void {
this.textBuffer += chunk;
while (this.textBuffer.length > 0) {
const prevLength = this.textBuffer.length;
if (!this.inThinking && !this.thinkingExtracted) {
this.processBeforeThinking();
if (this.textBuffer.length === 0) break;
}
if (this.inThinking) {
this.processInsideThinking();
if (this.textBuffer.length === 0) break;
}
if (this.thinkingExtracted) {
this.processAfterThinking();
break;
}
if (this.textBuffer.length >= prevLength) break;
}
}
finalize(): void {
if (this.textBuffer.length === 0) return;
if (this.inThinking && this.thinkingBlockIndex !== null) {
const block = this.output.content[this.thinkingBlockIndex] as ThinkingContent;
block.thinking += this.textBuffer;
this.stream.push({
type: "thinking_delta",
contentIndex: this.thinkingBlockIndex,
delta: this.textBuffer,
partial: this.output,
});
this.stream.push({
type: "thinking_end",
contentIndex: this.thinkingBlockIndex,
content: block.thinking,
partial: this.output,
});
} else {
this.emitText(this.textBuffer);
}
this.textBuffer = "";
}
getTextBlockIndex(): number | null {
return this.textBlockIndex ?? this.lastTextBlockIndex;
}
private processBeforeThinking(): void {
let bestPos = -1;
let bestVariant: (typeof THINKING_TAG_VARIANTS)[number] | null = null;
for (const variant of THINKING_TAG_VARIANTS) {
const pos = this.textBuffer.indexOf(variant.open);
if (pos !== -1 && (bestPos === -1 || pos < bestPos)) {
bestPos = pos;
bestVariant = variant;
}
}
if (bestPos !== -1 && bestVariant) {
if (bestPos > 0) this.emitText(this.textBuffer.slice(0, bestPos));
this.textBuffer = this.textBuffer.slice(bestPos + bestVariant.open.length);
this.activeEndTag = bestVariant.close;
this.inThinking = true;
return;
}
const trailingPrefixLength = getMaxTrailingPossibleTagPrefixLength(
this.textBuffer,
THINKING_TAG_VARIANTS.map((variant) => variant.open),
);
const safeLen = this.textBuffer.length - trailingPrefixLength;
if (safeLen > 0) {
this.emitText(this.textBuffer.slice(0, safeLen));
this.textBuffer = this.textBuffer.slice(safeLen);
}
}
private processInsideThinking(): void {
const endPos = this.textBuffer.indexOf(this.activeEndTag);
if (endPos !== -1) {
if (endPos > 0) this.emitThinking(this.textBuffer.slice(0, endPos));
if (this.thinkingBlockIndex !== null) {
const block = this.output.content[this.thinkingBlockIndex] as ThinkingContent;
this.stream.push({
type: "thinking_end",
contentIndex: this.thinkingBlockIndex,
content: block.thinking,
partial: this.output,
});
}
this.textBuffer = this.textBuffer.slice(endPos + this.activeEndTag.length);
this.inThinking = false;
this.thinkingExtracted = true;
this.lastTextBlockIndex = this.textBlockIndex;
this.textBlockIndex = null;
if (this.textBuffer.startsWith("\n\n")) this.textBuffer = this.textBuffer.slice(2);
return;
}
const trailingPrefixLength = getTrailingPossibleTagPrefixLength(this.textBuffer, this.activeEndTag);
const safeLen = this.textBuffer.length - trailingPrefixLength;
if (safeLen > 0) {
this.emitThinking(this.textBuffer.slice(0, safeLen));
this.textBuffer = this.textBuffer.slice(safeLen);
}
}
private processAfterThinking(): void {
this.emitText(this.textBuffer);
this.textBuffer = "";
}
private emitText(text: string): void {
if (!text) return;
if (this.textBlockIndex === null) {
this.textBlockIndex = this.output.content.length;
this.output.content.push({ type: "text", text: "" });
this.stream.push({ type: "text_start", contentIndex: this.textBlockIndex, partial: this.output });
}
const block = this.output.content[this.textBlockIndex] as TextContent;
block.text += text;
this.stream.push({ type: "text_delta", contentIndex: this.textBlockIndex, delta: text, partial: this.output });
}
private emitThinking(thinking: string): void {
if (!thinking) return;
if (this.thinkingBlockIndex === null) {
if (this.textBlockIndex !== null) {
this.thinkingBlockIndex = this.textBlockIndex;
this.output.content.splice(this.thinkingBlockIndex, 0, { type: "thinking", thinking: "" });
this.textBlockIndex = this.textBlockIndex + 1;
} else {
this.thinkingBlockIndex = this.output.content.length;
this.output.content.push({ type: "thinking", thinking: "" });
}
this.stream.push({ type: "thinking_start", contentIndex: this.thinkingBlockIndex, partial: this.output });
}
const block = this.output.content[this.thinkingBlockIndex] as ThinkingContent;
block.thinking += thinking;
this.stream.push({
type: "thinking_delta",
contentIndex: this.thinkingBlockIndex,
delta: thinking,
partial: this.output,
});
}
}