feat: collapsible think block in chat, separate reasoning from content

This commit is contained in:
2026-06-29 13:18:02 +02:00
parent 057903f350
commit a267cc28a5

View File

@@ -5,13 +5,14 @@ interface Stats {
promptTokens: number
completionTokens: number
totalTokens: number
tps: number // completion tokens / elapsed seconds
tps: number
elapsedMs: number
}
interface Message {
role: 'user' | 'assistant'
content: string
reasoning?: string // thinking tokens, separate from the final answer
pending?: boolean
stats?: Stats
}
@@ -81,6 +82,7 @@ export default function Chat() {
const decoder = new TextDecoder()
let buf = ''
let reply = ''
let reasoning = ''
let usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null = null
while (true) {
@@ -95,13 +97,14 @@ export default function Chat() {
if (data === '[DONE]') continue
try {
const chunk = JSON.parse(data)
// Capture usage from the final chunk (stream_options.include_usage)
if (chunk.usage) usage = chunk.usage
const delta = chunk.choices?.[0]?.delta?.content ?? chunk.choices?.[0]?.delta?.reasoning_content ?? ''
if (delta) {
const reasoningDelta: string = chunk.choices?.[0]?.delta?.reasoning_content ?? ''
const contentDelta: string = chunk.choices?.[0]?.delta?.content ?? ''
if (reasoningDelta || contentDelta) {
if (firstTokenMs === null) firstTokenMs = performance.now()
reply += delta
setMessages([...next, { role: 'assistant', content: reply }])
reasoning += reasoningDelta
reply += contentDelta
setMessages([...next, { role: 'assistant', content: reply, reasoning: reasoning || undefined }])
}
} catch {}
}
@@ -110,7 +113,7 @@ export default function Chat() {
const endMs = performance.now()
const elapsedMs = endMs - (firstTokenMs ?? startMs)
const completionTokens = usage?.completion_tokens ?? 0
const tps = elapsedMs > 0 && completionTokens > 0 ? (completionTokens / (elapsedMs / 1000)) : 0
const tps = elapsedMs > 0 && completionTokens > 0 ? completionTokens / (elapsedMs / 1000) : 0
const stats: Stats | undefined = usage ? {
promptTokens: usage.prompt_tokens,
@@ -120,7 +123,12 @@ export default function Chat() {
elapsedMs: endMs - startMs,
} : undefined
setMessages([...next, { role: 'assistant', content: reply || '(empty response)', stats }])
setMessages([...next, {
role: 'assistant',
content: reply || '(empty response)',
reasoning: reasoning || undefined,
stats,
}])
} catch (e: any) {
if (e.name !== 'AbortError') { setError(e.message); setMessages(next) }
else setMessages(prev => prev.map((m, i) => i === prev.length - 1 ? { ...m, pending: false } : m))
@@ -174,7 +182,9 @@ export default function Chat() {
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 600, color: 'rgba(237,237,240,0.7)',
}}>{m.role === 'user' ? 'U' : 'AI'}</div>
<div style={{ maxWidth: '80%', display: 'flex', flexDirection: 'column', gap: 5 }}>
{m.reasoning && <ThinkBlock text={m.reasoning} streaming={streaming && i === messages.length - 1} />}
<div style={{
background: m.role === 'user' ? 'rgba(124,58,237,0.15)' : '#17171c',
border: `1px solid ${m.role === 'user' ? 'rgba(124,58,237,0.25)' : 'rgba(255,255,255,0.07)'}`,
@@ -182,7 +192,7 @@ export default function Chat() {
padding: '10px 14px', fontSize: 13.5, lineHeight: 1.6, color: '#ededf0',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{m.content || (m.pending
{m.content || (m.pending && !m.reasoning
? <span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
{[0,1,2].map(j => <span key={j} style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(237,237,240,0.3)', display: 'inline-block' }}/>)}
</span>
@@ -233,6 +243,65 @@ export default function Chat() {
)
}
function ThinkBlock({ text, streaming }: { text: string; streaming: boolean }) {
const [open, setOpen] = useState(true)
// Auto-collapse when streaming finishes
useEffect(() => {
if (!streaming) setOpen(false)
}, [streaming])
const wordCount = text.trim().split(/\s+/).length
return (
<div style={{
border: '1px solid rgba(251,191,36,0.2)',
borderRadius: 8,
background: 'rgba(251,191,36,0.04)',
overflow: 'hidden',
}}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 7,
padding: '7px 12px', background: 'none', border: 'none', cursor: 'pointer',
textAlign: 'left',
}}
>
<svg
width="11" height="11" viewBox="0 0 10 10" fill="none"
stroke="rgba(251,191,36,0.6)" strokeWidth="1.6" strokeLinecap="round"
style={{ flexShrink: 0, transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}
>
<polyline points="3,1 7,5 3,9"/>
</svg>
<span style={{ fontSize: 11.5, fontWeight: 500, color: 'rgba(251,191,36,0.7)', flex: 1 }}>
{streaming ? 'Thinking…' : `Thought for ${wordCount} words`}
</span>
{streaming && (
<span style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
{[0,1,2].map(j => (
<span key={j} style={{ width: 4, height: 4, borderRadius: '50%', background: 'rgba(251,191,36,0.5)', display: 'inline-block' }}/>
))}
</span>
)}
</button>
{open && (
<div style={{
padding: '0 12px 10px',
fontSize: 12.5, lineHeight: 1.65,
color: 'rgba(251,191,36,0.55)',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
maxHeight: 320, overflowY: 'auto',
borderTop: '1px solid rgba(251,191,36,0.1)',
}}>
<div style={{ paddingTop: 10 }}>{text}</div>
</div>
)}
</div>
)
}
function StatsBar({ stats }: { stats: Stats }) {
const c = 'rgba(237,237,240,0.3)'
const items = [