Files
awoooi/apps/web/src/components/thinking-stream-test.tsx
OG T 4d46e6b9a7
Some checks failed
E2E Health Check / e2e-health (push) Successful in 17s
CD Pipeline / build-and-deploy (push) Has been cancelled
style(web): 全站 font-mono → font-body (DM Mono 設計系統套用)
45 個 component + 6 個 page 統一從舊 font-mono 遷移到
font-body (DM Mono),確保設計系統一致性。

font-body = DM Mono (等寬),視覺效果相同但走新設計 token。
保留: font-heading (Syne)、font-dot-matrix (VT323/DSEG7)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:37:03 +08:00

190 lines
5.7 KiB
TypeScript

'use client'
/**
* ThinkingStreamTest - Tracer Bullet 測試元件 (企業級強化版)
*
* 修復項目:
* 1. Buffer 累積 - 防止 TCP 封包切斷 JSON
* 2. AbortController - 防止 Memory Leak
* 3. Zustand 整合 - 符合 ADR-004
* 4. Nothing.tech 美學 - 符合 ADR-002
*/
import { useCallback, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { useAgentStore } from '@/stores/agent.store'
interface StreamThinkingStep {
type: 'thinking' | 'result'
content: string
}
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/v1'
export function ThinkingStreamTest() {
// ADR-004: 使用 Zustand 全域狀態
const {
status,
thinkingStream,
setStatus,
appendThinking,
clearThinking,
} = useAgentStore()
const isStreaming = status === 'thinking'
// 防線 1: AbortController 用於中斷連線
const abortControllerRef = useRef<AbortController | null>(null)
// 防線 2: 組件卸載時自動清理
useEffect(() => {
return () => {
abortControllerRef.current?.abort()
}
}, [])
const startStream = useCallback(async () => {
// 中斷前一次未完成的請求
abortControllerRef.current?.abort()
abortControllerRef.current = new AbortController()
// 重置狀態 (透過 Zustand)
clearThinking()
setStatus('thinking')
try {
const response = await fetch(`${API_BASE_URL}/agent/thinking`, {
signal: abortControllerRef.current.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('無法建立串流通道')
}
const decoder = new TextDecoder()
let buffer = '' // 防線 3: 字串緩衝區,防止 JSON 被切斷
// eslint-disable-next-line no-constant-condition -- SSE stream read loop
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// SSE 規範: 事件之間以 \n\n 分隔
const events = buffer.split('\n\n')
// 保留最後一個可能不完整的片段
buffer = events.pop() || ''
for (const event of events) {
if (event.startsWith('data: ')) {
const data = event.slice(6).trim()
// 結束標記
if (data === '[DONE]') {
setStatus('idle')
return
}
// 安全解析 JSON
try {
const step = JSON.parse(data) as StreamThinkingStep
// 派發到 Zustand (ADR-004)
appendThinking({
type: step.type === 'result' ? 'result' : 'thinking',
content: step.content,
timestamp: new Date(),
})
} catch (e) {
console.warn('JSON 解析錯誤,跳過片段:', data)
}
}
}
}
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') {
console.log('串流已手動中斷')
} else {
const message = err instanceof Error ? err.message : '未知錯誤'
appendThinking({
type: 'error',
content: message,
timestamp: new Date(),
})
}
} finally {
setStatus('idle')
}
}, [setStatus, appendThinking, clearThinking])
return (
<div className="glass-panel p-6 max-w-2xl w-full space-y-4">
{/* Header - Nothing.tech 風格 */}
<div className="flex items-center justify-between border-b border-nothing-gray-800 pb-4">
<h2 className="font-display text-display-sm">AWOOOI Terminal</h2>
<span className="font-body text-xs text-nothing-gray-500">
v0.1.0 | SSE
</span>
</div>
{/* Control Button */}
<button
onClick={startStream}
disabled={isStreaming}
className={cn(
'w-full py-3 px-4 rounded-button font-body text-sm transition-all border',
isStreaming
? 'bg-nothing-gray-800 text-nothing-gray-500 border-nothing-gray-700 cursor-not-allowed'
: 'bg-nothing-white text-nothing-black border-transparent hover:bg-nothing-gray-200'
)}
>
{isStreaming ? '>_ EXECUTING...' : 'INITIATE SYNC'}
</button>
{/* Terminal Output */}
<div className="bg-nothing-gray-900 rounded-card p-4 min-h-[200px] max-h-[300px] overflow-y-auto">
{thinkingStream.length === 0 && !isStreaming && (
<div className="font-body text-sm text-nothing-gray-600">
{'>'} Waiting for command...
</div>
)}
{thinkingStream.map((step, index) => (
<div
key={index}
className={cn(
'font-body text-sm py-0.5 animate-fade-in',
step.type === 'result' && 'text-status-healthy',
step.type === 'thinking' && 'text-nothing-gray-500',
step.type === 'error' && 'text-status-critical'
)}
>
[{step.type.toUpperCase()}] {step.content}
</div>
))}
{/* Cursor 動畫 */}
{isStreaming && (
<div className="flex items-center gap-1 mt-1">
<span className="font-body text-sm text-nothing-gray-500">{'>'}</span>
<span className="w-2 h-4 bg-nothing-white animate-pulse" />
</div>
)}
</div>
{/* Connection Info */}
<div className="pt-2 border-t border-nothing-gray-800">
<p className="font-body text-xs text-nothing-gray-600">
ENDPOINT: {API_BASE_URL}/agent/thinking
</p>
</div>
</div>
)
}