/**
* Split Qwen-style thinking blocks from assistant text for UI.
* Supports complete and in-progress streams.
*
* Common tags: …, …, …
*/
const TAG = String.raw`think|thinking|redacted[_-]?thinking|redacted[_-]?reasoning`
const THINK_OPEN = new RegExp(`<\\s*(?:${TAG})\\s*>`, 'i')
const THINK_PAIR = new RegExp(
`<\\s*(?:${TAG})\\s*>([\\s\\S]*?)<\\s*/\\s*(?:${TAG})\\s*>`,
'gi'
)
/**
* @param {string} text
* @returns {{ thinking: string, answer: string, thinkingOpen: boolean }}
*/
export function partitionThink(text) {
const full = String(text || '')
/** @type {string[]} */
const blocks = []
let answer = full.replace(THINK_PAIR, (_, body) => {
const t = String(body || '').trim()
if (t) blocks.push(t)
return ''
})
// Incomplete open block at end of stream
let thinkingOpen = false
const openMatch = answer.match(THINK_OPEN)
if (openMatch && openMatch.index != null) {
thinkingOpen = true
const openIdx = openMatch.index
const after = answer.slice(openIdx + openMatch[0].length)
if (after.trim()) blocks.push(after.trim())
answer = answer.slice(0, openIdx)
}
return {
thinking: blocks.join('\n\n').trim(),
answer: answer.replace(/^\s+/, '').replace(/\s+$/, ''),
thinkingOpen,
}
}
/**
* Render assistant body HTML: optional collapsible think + answer.
* @param {string} raw
* @param {(s: string) => string} formatBody
* @param {{ openThink?: boolean }} [opts]
*/
export function renderAssistantHtml(raw, formatBody, opts = {}) {
const { thinking, answer, thinkingOpen } = partitionThink(raw)
const open = opts.openThink !== false && (thinkingOpen || Boolean(thinking))
let html = ''
if (thinking) {
html += `
◐
Thinking
${thinkingOpen ? 'live' : ''}
${formatBody(thinking)}
`
}
const ans = answer || (!thinking ? '…' : '')
if (ans) {
html += `${formatBody(ans)}
`
} else if (thinkingOpen) {
html += `Working…
`
}
return html || formatBody(raw || '…')
}