371 lines
9.9 KiB
TypeScript
371 lines
9.9 KiB
TypeScript
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
|