/**
* Stateful splitter for inline `...` reasoning tags.
*
* Ollama's native `/api/chat` and its `/v1` shim both surface reasoning as a
* dedicated field (`message.thinking` / `delta.reasoning`), but other
* OpenAI-compatible backends (LM Studio, llama.cpp, plain GGUF servers) emit
* the tags inline in the content stream. This splits them back out so the UI
* gets a clean `content` channel and a separate `thinking` channel regardless
* of which backend answered.
*
* Lives here rather than inside OllamaService because both transports need it
* and because it is the only real logic in the streaming path — as a pure
* class it can be exercised under bare `node --test` with no Ollama, the same
* reason `rag_prompt.ts` is shaped this way.
*
* The hard part is that a tag can be split across chunk boundaries: a chunk may
* end with `"= 1; len--) {
if (text.endsWith(tag.slice(0, len))) return len
}
return 0
}
export type ThinkSplit = { content: string; thinking: string }
export class ThinkTagSplitter {
private buffer = ''
private inThink = false
/**
* Feed the next slice of raw model output. Returns only what could be
* resolved from this call; text that might be a partial tag stays buffered.
*/
push(raw: string): ThinkSplit {
this.buffer += raw
let content = ''
let thinking = ''
while (this.buffer.length > 0) {
if (this.inThink) {
const closeIdx = this.buffer.indexOf(CLOSE_TAG)
if (closeIdx !== -1) {
thinking += this.buffer.slice(0, closeIdx)
this.buffer = this.buffer.slice(closeIdx + CLOSE_TAG.length)
this.inThink = false
} else {
const hold = partialTagSuffix(CLOSE_TAG, this.buffer)
thinking += this.buffer.slice(0, this.buffer.length - hold)
this.buffer = this.buffer.slice(this.buffer.length - hold)
break
}
} else {
const openIdx = this.buffer.indexOf(OPEN_TAG)
if (openIdx !== -1) {
content += this.buffer.slice(0, openIdx)
this.buffer = this.buffer.slice(openIdx + OPEN_TAG.length)
this.inThink = true
} else {
const hold = partialTagSuffix(OPEN_TAG, this.buffer)
content += this.buffer.slice(0, this.buffer.length - hold)
this.buffer = this.buffer.slice(this.buffer.length - hold)
break
}
}
}
return { content, thinking }
}
/**
* Flush whatever is still buffered at end of stream. A held-back partial tag
* that never completed was never a tag, so it belongs to whichever channel
* we were in when the stream ended — losing it would silently truncate the
* answer.
*/
flush(): ThinkSplit {
const pending = this.buffer
this.buffer = ''
return this.inThink ? { content: '', thinking: pending } : { content: pending, thinking: '' }
}
}
/**
* One-shot convenience for non-streaming responses.
*/
export function splitThinkTags(raw: string): ThinkSplit {
const splitter = new ThinkTagSplitter()
const streamed = splitter.push(raw)
const tail = splitter.flush()
return {
content: streamed.content + tail.content,
thinking: streamed.thinking + tail.thinking,
}
}
/**
* Non-streaming counterpart to the merge the streaming normalizers do inline.
*
* A backend may report reasoning structurally (native `message.thinking`, or
* `reasoning` on the /v1 shim), inline as `` tags, or both at once —
* and the ancillary calls (title, suggestions, query rewrite) all go through
* the non-streaming path. Without this the tags reached the sidebar title and,
* worse, the string that gets embedded and sent to Qdrant.
*/
export function normalizeNonStreamed(rawContent: string, nativeThinking?: string): ThinkSplit {
const split = splitThinkTags(rawContent ?? '')
return {
content: split.content,
thinking: (nativeThinking ?? '') + split.thinking,
}
}