PicoBot/web/src/hooks/useWebSocket.ts
oudecheng 624d8e8943 feat: 添加 React Web UI 前端界面
- 使用 React 18 + TypeScript + Vite + Tailwind CSS 构建前端
- 实现 WebSocket 实时通信(useWebSocket hook)
- 添加聊天界面组件(MessageList, MessageBubble, MessageInput)
- 集成 Topic 管理(新建、列出、切换)
- 支持 Markdown 渲染(react-markdown + remark-gfm)
- 添加工具调用展示面板
- 实现深色科技主题(Tech Dark)
- 后端集成静态文件服务(tower-http)
- 添加 Makefile 和 build.sh 构建脚本
- 更新 .gitignore 忽略前端构建产物

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:43:15 +08:00

128 lines
3.1 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react'
import type { WsInbound, WsOutbound, ConnectionStatus } from '../types/protocol'
interface UseWebSocketOptions {
url: string
onMessage?: (message: WsOutbound) => void
onConnect?: () => void
onDisconnect?: () => void
onError?: (error: Event) => void
reconnectInterval?: number
maxReconnectAttempts?: number
}
interface UseWebSocketReturn {
status: ConnectionStatus
sendMessage: (message: WsInbound) => boolean
connect: () => void
disconnect: () => void
}
export function useWebSocket({
url,
onMessage,
onConnect,
onDisconnect,
onError,
reconnectInterval = 3000,
maxReconnectAttempts = 5,
}: UseWebSocketOptions): UseWebSocketReturn {
const [status, setStatus] = useState<ConnectionStatus>('disconnected')
const wsRef = useRef<WebSocket | null>(null)
const reconnectAttemptsRef = useRef(0)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isManualDisconnectRef = useRef(false)
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return
}
isManualDisconnectRef.current = false
setStatus('connecting')
try {
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
setStatus('connected')
reconnectAttemptsRef.current = 0
onConnect?.()
}
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WsOutbound
onMessage?.(message)
} catch (error) {
console.error('Failed to parse message:', error)
}
}
ws.onerror = (error) => {
setStatus('error')
onError?.(error)
}
ws.onclose = () => {
setStatus('disconnected')
onDisconnect?.()
// Auto reconnect if not manually disconnected
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current += 1
reconnectTimerRef.current = setTimeout(() => {
connect()
}, reconnectInterval)
}
}
} catch (error) {
setStatus('error')
console.error('WebSocket connection error:', error)
}
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts])
const disconnect = useCallback(() => {
isManualDisconnectRef.current = true
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setStatus('disconnected')
}, [])
const sendMessage = useCallback((message: WsInbound): boolean => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message))
return true
}
console.warn('WebSocket is not connected')
return false
}, [])
// Auto connect on mount
useEffect(() => {
connect()
// Cleanup on unmount
return () => {
disconnect()
}
}, [connect, disconnect])
return {
status,
sendMessage,
connect,
disconnect,
}
}