初始化模版工程
This commit is contained in:
370
components/nova-sdk/hooks/useNovaEvents.ts
Normal file
370
components/nova-sdk/hooks/useNovaEvents.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
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
|
||||
Reference in New Issue
Block a user