Files
test1/components/nova-sdk/hooks/useNovaEvents.ts
2026-03-20 07:33:46 +00:00

371 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react'
import { createWebSocketClient, ReadyState, type Result as WebSocketResult } from '@/app/api/websocket'
// import { createRequestClient, type RequestClient } from '@/api/request'
import { request } from '@/http/request'
import type { ApiEvent } from '../types'
export type { ApiEvent }
export interface PlatformConfig {
wssUrl: string
apiBaseUrl: string
agentId: string
agentName: string
}
interface UseNovaEventsOptions {
mode: 'chat' | 'share'
conversationId?: string
platformConfig: PlatformConfig
/** WebSocket 重连次数限制,默认 3 */
reconnectLimit?: number
/** WebSocket 重连间隔(毫秒),默认 3000 */
reconnectInterval?: number
/** 获取认证 Token */
getToken?: () => string | undefined
/** 获取租户 ID */
getTenantId?: () => string | undefined
/** 新事件回调 */
onEvent?: (event: ApiEvent) => void
/** 连接状态变化回调 */
onConnectionChange?: (connected: boolean) => void
/** 错误回调 */
onError?: (error: Error) => void
}
interface UseNovaEventsResult {
/** 原始事件列表 */
rawEvents: ApiEvent[]
/** 是否正在加载历史记录 */
loading: boolean
/** WebSocket 是否已连接 */
connected: boolean
/** WebSocket 连接状态 */
readyState: ReadyState
/** 错误信息 */
error: Error | null
/** 手动刷新历史记录 */
refresh: () => Promise<void>
/** 手动重连 WebSocket */
reconnect: () => void
/** 清空事件列表 */
clear: () => void
/** 切换会话 */
switchConversation: (conversationId: string) => void
/** 发送消息 */
sendMessage: WebSocket['send']
}
function isApiEventLike(value: unknown): value is ApiEvent {
return !!value && typeof value === 'object' && 'event_id' in value
}
function normalizeIncomingEvent(payload: unknown): ApiEvent | null {
if (isApiEventLike(payload)) {
return payload
}
if (
payload &&
typeof payload === 'object' &&
'data' in payload &&
isApiEventLike((payload as { data?: unknown }).data)
) {
return (payload as { data: ApiEvent }).data
}
return null
}
/**
* Nova 事件管理 Hook
*
* 负责:
* 1. 建立 WebSocket 连接接收实时事件
* 2. 请求 event_list 接口获取历史记录
* 3. 合并和管理事件列表
*/
export function useNovaEvents({
mode,
conversationId,
platformConfig,
reconnectLimit = 3,
reconnectInterval = 3000,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
}: UseNovaEventsOptions): UseNovaEventsResult {
// 状态
const [rawEvents, setRawEvents] = useState<ApiEvent[]>([])
const [loading, setLoading] = useState(false)
const [connected, setConnected] = useState(false)
const [readyState, setReadyState] = useState<ReadyState>(ReadyState.Closed)
const [error, setError] = useState<Error | null>(null)
// Refs
const wsClientRef = useRef<WebSocketResult | null>(null)
const isUnmountedRef = useRef(false)
const conversationIdRef = useRef(conversationId)
const mountCountRef = useRef(0)
// 保持 conversationId ref 同步
conversationIdRef.current = conversationId
// 回调 refs避免重新创建 WebSocket
const onEventRef = useRef(onEvent)
const onConnectionChangeRef = useRef(onConnectionChange)
const onErrorRef = useRef(onError)
onEventRef.current = onEvent
onConnectionChangeRef.current = onConnectionChange
onErrorRef.current = onError
// 更新连接状态
const updateConnected = useCallback((value: boolean) => {
setConnected(value)
onConnectionChangeRef.current?.(value)
}, [])
// 更新错误状态
const updateError = useCallback((err: Error | null) => {
setError(err)
if (err) {
onErrorRef.current?.(err)
}
}, [])
// 合并事件到列表(按 event_id 去重,新事件替换旧事件)
const mergeEvent = useCallback((newEvent: ApiEvent) => {
setRawEvents(prev => {
const index = prev.findIndex(e => e.event_id === newEvent.event_id)
if (index === -1) {
// 新事件,追加到末尾
return [...prev, newEvent]
}
// 已存在,替换
const updated = [...prev]
updated[index] = newEvent
return updated
})
}, [])
// 批量合并事件(用于历史记录)
const mergeEvents = useCallback((newEvents: ApiEvent[]) => {
setRawEvents((prev) => {
const eventsMap = new Map<string, ApiEvent>()
// 先放入现有事件
for (const event of prev) {
eventsMap.set(event.event_id, event)
}
// 合并新事件(会覆盖同 id 的旧事件)
for (const event of newEvents) {
eventsMap.set(event.event_id, event)
}
return Array.from(eventsMap.values())
})
}, [])
// 获取历史记录
const fetchEventList = useCallback(async () => {
if (!conversationIdRef.current) return
setLoading(true)
updateError(null)
try {
const payload = {
conversation_id: conversationIdRef.current,
page_no: 1,
page_size: 3000,
}
const response = mode === 'share'
? await request.post<{
data: {
chat_event_list: ApiEvent[]
}
}>('/v1/super_agent/chat/event_list_share', payload)
: await request.get<{
data: {
chat_event_list: ApiEvent[]
}
}>('/chat/event', payload)
// 提取 data.data.chat_event_list 字段
const events = response?.data?.chat_event_list || []
if (!isUnmountedRef.current) {
// 使用 mergeEvents 而不是直接 setRawEvents以保留实时推送的新事件
mergeEvents(events)
}
} catch (err) {
if (!isUnmountedRef.current) {
const error = err instanceof Error ? err : new Error('Failed to fetch event list')
updateError(error)
}
} finally {
if (!isUnmountedRef.current) {
setLoading(false)
}
}
}, [mergeEvents, updateError, mode])
// 创建 WebSocket 连接
const createWebSocket = useCallback(() => {
if (!conversationIdRef.current) return
// 已有连接,不重复创建
if (wsClientRef.current) return
// 构建 WebSocket URL
const wsUrl = new URL(platformConfig.wssUrl)
wsUrl.searchParams.set('conversation_id', conversationIdRef.current)
const wsClient = createWebSocketClient(wsUrl.toString(), {
reconnectLimit,
reconnectInterval,
manual: true, // 手动连接,避免 React Strict Mode 下的问题
getToken,
getTenantId,
onOpen: () => {
if (isUnmountedRef.current) return
setReadyState(ReadyState.Open)
updateConnected(true)
updateError(null)
// 连接成功后切换到当前会话
if (conversationIdRef.current && wsClientRef.current) {
wsClientRef.current.switchConversation(conversationIdRef.current)
}
},
onClose: () => {
if (isUnmountedRef.current) return
setReadyState(ReadyState.Closed)
updateConnected(false)
},
onError: () => {
if (isUnmountedRef.current) return
updateError(new Error('WebSocket connection error'))
},
onMessage: (message: ApiEvent) => {
if (isUnmountedRef.current) return
const normalizedEvent = normalizeIncomingEvent(message)
if (!normalizedEvent) return
mergeEvent(normalizedEvent)
onEventRef.current?.(normalizedEvent)
},
})
wsClientRef.current = wsClient
// 手动触发连接
wsClient.connect()
}, [
platformConfig.wssUrl,
reconnectLimit,
reconnectInterval,
getToken,
getTenantId,
mergeEvent,
updateConnected,
updateError,
])
// 手动重连
const reconnect = useCallback(() => {
if (wsClientRef.current) {
wsClientRef.current.connect()
} else {
createWebSocket()
}
}, [createWebSocket])
// 清空事件列表
const clear = useCallback(() => {
setRawEvents([])
}, [])
// 切换会话
const switchConversation = useCallback((newConversationId: string) => {
if (wsClientRef.current) {
wsClientRef.current.switchConversation(newConversationId)
}
}, [])
// 发送消息
const sendMessage = useCallback<WebSocket['send']>((data) => {
if (wsClientRef.current) {
wsClientRef.current.sendMessage(data)
} else {
throw new Error('WebSocket not connected')
}
}, [])
// 主 effect当 conversationId 或 platformConfig 变化时处理
useEffect(() => {
isUnmountedRef.current = false
console.log('conversationId', conversationId)
// 无会话 ID 时清空
if (!conversationId) {
setRawEvents([])
setConnected(false)
setReadyState(ReadyState.Closed)
return
}
// conversationId 存在,获取历史记录
fetchEventList()
// 如果已有连接且已打开,直接切换会话;否则创建新连接
if (wsClientRef.current && wsClientRef.current.readyState === ReadyState.Open) {
wsClientRef.current.switchConversation(conversationId)
} else {
createWebSocket()
}
// 清理函数
return () => {
isUnmountedRef.current = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, platformConfig.wssUrl, platformConfig.apiBaseUrl])
// 组件卸载时清理
useEffect(() => {
mountCountRef.current += 1
const currentMount = mountCountRef.current
return () => {
// 延迟执行,如果 mountCount 变了说明重新 mount 了Strict Mode不清理
setTimeout(() => {
if (currentMount === mountCountRef.current && wsClientRef.current) {
wsClientRef.current.cleanup()
wsClientRef.current = null
}
}, 0)
}
}, [])
return {
rawEvents,
loading,
connected,
readyState,
error,
refresh: fetchEventList,
reconnect,
clear,
switchConversation,
sendMessage,
}
}
export { ReadyState }
export default useNovaEvents