feat: collapsible think block in chat, separate reasoning from content
This commit is contained in:
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user