Files
FusionAGI/frontend/src/hooks/useWebSocket.ts
Devin AI 01b3f27b0f
Some checks failed
CI / lint (pull_request) Failing after 44s
CI / test (3.10) (pull_request) Failing after 30s
CI / test (3.11) (pull_request) Failing after 33s
CI / test (3.12) (pull_request) Successful in 1m26s
CI / migrations (pull_request) Successful in 24s
CI / helm (pull_request) Successful in 20s
CI / docker (pull_request) Has been skipped
feat: complete all 15 next recommendations
Frontend wiring:
- Wire useMarkdownWorker into Markdown component (worker-first, sync fallback)
- Wire useIndexedDB as primary storage in useChatHistory (500 msg cap, localStorage fallback)

Backend depth:
- Persistent audit store (SQLite, thread-safe, WAL mode) with record/query/filter
- Wire audit store into session routes (session.create, prompt.submit events)
- Wire audit store into audit export routes (persistent-first, telemetry fallback)
- CSRF double-submit cookie pattern (token generation, cookie set, header validation)

Production:
- Helm chart CI: helm lint + helm template validation
- Database migration CI: verify step in pipeline
- Prometheus alerting rules (error rate, latency, pod restarts, memory, CPU, queue, health)
- Rate limiting per API key (3x IP limit, sliding window, advisory)
- WebSocket SSE fallback (auto-downgrade after MAX_RETRIES WS failures)

Tests: 605 Python + 56 frontend = 661 total, 0 ruff errors
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-05-02 04:57:52 +00:00

171 lines
5.5 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react'
import type { WSEvent } from '../types'
type WSStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
const MAX_RETRIES = 10
const BASE_DELAY = 1000
export interface StreamCallbacks {
onToken?: (token: string) => void
onHeadUpdate?: (head: string, content: string) => void
onComplete?: (response: Record<string, unknown>) => void
onError?: (error: string) => void
}
export function useWebSocket(sessionId: string | null) {
const [status, setStatus] = useState<WSStatus>('disconnected')
const [events, setEvents] = useState<WSEvent[]>([])
const [streaming, setStreaming] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const retryCount = useRef(0)
const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const shouldReconnect = useRef(true)
const callbacksRef = useRef<StreamCallbacks>({})
const connect = useCallback((sid: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) return
if (wsRef.current) wsRef.current.close()
shouldReconnect.current = true
setStatus('connecting')
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${window.location.host}/v1/sessions/${sid}/stream`)
wsRef.current = ws
ws.onopen = () => {
setStatus('connected')
retryCount.current = 0
}
ws.onclose = () => {
setStatus('disconnected')
setStreaming(false)
if (shouldReconnect.current && retryCount.current < MAX_RETRIES) {
const delay = BASE_DELAY * Math.pow(2, retryCount.current) + Math.random() * 500
retryCount.current++
retryTimer.current = setTimeout(() => connect(sid), delay)
}
}
ws.onerror = () => {
setStatus('error')
setStreaming(false)
}
ws.onmessage = (e) => {
try {
const event: WSEvent = JSON.parse(e.data)
setEvents((prev) => [...prev, event])
// Handle streaming protocol events
const cb = callbacksRef.current
if (event.type === 'token' && cb.onToken) {
cb.onToken(event.data as string)
} else if (event.type === 'head_update' && cb.onHeadUpdate) {
const d = event.data as Record<string, string>
cb.onHeadUpdate(d.head, d.content)
} else if (event.type === 'complete' && cb.onComplete) {
setStreaming(false)
cb.onComplete(event.data as Record<string, unknown>)
} else if (event.type === 'error' && cb.onError) {
setStreaming(false)
cb.onError(event.data as string)
}
} catch { /* ignore malformed */ }
}
}, [])
const send = useCallback((data: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data))
}
}, [])
const sendPrompt = useCallback((prompt: string, callbacks?: StreamCallbacks) => {
if (callbacks) callbacksRef.current = callbacks
setStreaming(true)
send({ type: 'prompt', prompt })
}, [send])
const disconnect = useCallback(() => {
shouldReconnect.current = false
if (retryTimer.current) clearTimeout(retryTimer.current)
wsRef.current?.close()
wsRef.current = null
setStatus('disconnected')
setStreaming(false)
retryCount.current = 0
}, [])
const clearEvents = useCallback(() => setEvents([]), [])
// SSE fallback: if WebSocket fails repeatedly, use Server-Sent Events
const sendPromptSSE = useCallback((sessionId: string, prompt: string, callbacks?: StreamCallbacks) => {
if (callbacks) callbacksRef.current = callbacks
setStreaming(true)
const cb = callbacksRef.current
const params = new URLSearchParams({ prompt, session_id: sessionId })
try {
const eventSource = new EventSource(`/v1/sessions/stream/sse?${params}`)
eventSource.addEventListener('token', (e) => {
if (cb.onToken) cb.onToken(e.data)
})
eventSource.addEventListener('head_update', (e) => {
try {
const data = JSON.parse(e.data)
if (cb.onHeadUpdate) cb.onHeadUpdate(data.head, data.content)
} catch { /* malformed */ }
})
eventSource.addEventListener('complete', (e) => {
try {
const data = JSON.parse(e.data)
setStreaming(false)
if (cb.onComplete) cb.onComplete(data)
} catch { /* malformed */ }
eventSource.close()
})
eventSource.addEventListener('error', (e) => {
setStreaming(false)
if (cb.onError && e instanceof MessageEvent) cb.onError(e.data)
eventSource.close()
})
eventSource.onerror = () => {
setStreaming(false)
eventSource.close()
}
} catch {
setStreaming(false)
if (cb.onError) cb.onError('SSE connection failed')
}
}, [])
// Auto-fallback: after MAX_RETRIES WS failures, switch to SSE
const sendWithFallback = useCallback((prompt: string, callbacks?: StreamCallbacks) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
sendPrompt(prompt, callbacks)
} else if (sessionId && retryCount.current >= MAX_RETRIES) {
sendPromptSSE(sessionId, prompt, callbacks)
} else {
sendPrompt(prompt, callbacks)
}
}, [sendPrompt, sendPromptSSE, sessionId])
useEffect(() => {
return () => {
shouldReconnect.current = false
if (retryTimer.current) clearTimeout(retryTimer.current)
wsRef.current?.close()
}
}, [])
return { status, events, streaming, connect, send, sendPrompt: sendWithFallback, sendPromptSSE, disconnect, clearEvents }
}