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>
190 lines
5.7 KiB
TypeScript
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>
|
|
)
|
|
}
|