/** * 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 || '…') }