refactor: implement centralized realtime connection manager and enhance message handling logic
This commit is contained in:
@@ -5,23 +5,26 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
import { createAdminWebSocketUrl } from "@/lib/api/admin"
|
import { createAdminWebSocketUrl } from "@/lib/api/admin"
|
||||||
import { readSession } from "@/lib/auth"
|
import { readSession } from "@/lib/auth"
|
||||||
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
|
import { createRealtimeConnectionManager } from "@/lib/realtime-connection"
|
||||||
import { getNotificationBody, showNotification } from "@/lib/services/notification"
|
import { getNotificationBody, showNotification } from "@/lib/services/notification"
|
||||||
|
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
|
||||||
|
|
||||||
const RECONNECT_BASE_DELAY = 2000
|
type AgentRealtimeConnection = ReturnType<typeof createRealtimeConnectionManager>
|
||||||
const RECONNECT_MAX_DELAY = 30000
|
|
||||||
|
|
||||||
export function useAgentConversationRealtime() {
|
export function useAgentConversationRealtime() {
|
||||||
const selectedConversationId = useAgentConversationsStore((state) => state.selectedConversationId)
|
const selectedConversationId = useAgentConversationsStore(
|
||||||
const loadConversations = useAgentConversationsStore((state) => state.loadConversations)
|
(state) => state.selectedConversationId
|
||||||
const syncLatestMessages = useAgentConversationsStore(
|
|
||||||
(state) => state.syncLatestMessages,
|
|
||||||
)
|
)
|
||||||
const setRealtimeStatus = useAgentConversationsStore((state) => state.setRealtimeStatus)
|
const loadConversations = useAgentConversationsStore(
|
||||||
const websocketRef = useRef<WebSocket | null>(null)
|
(state) => state.loadConversations
|
||||||
const reconnectTimerRef = useRef<number | null>(null)
|
)
|
||||||
const pingTimerRef = useRef<number | null>(null)
|
const syncLatestMessages = useAgentConversationsStore(
|
||||||
const reconnectAttemptRef = useRef(0)
|
(state) => state.syncLatestMessages
|
||||||
|
)
|
||||||
|
const setRealtimeStatus = useAgentConversationsStore(
|
||||||
|
(state) => state.setRealtimeStatus
|
||||||
|
)
|
||||||
|
const realtimeRef = useRef<AgentRealtimeConnection | null>(null)
|
||||||
const subscribedConversationIdRef = useRef<number | null>(null)
|
const subscribedConversationIdRef = useRef<number | null>(null)
|
||||||
const selectedConversationIdRef = useRef<number | null>(selectedConversationId)
|
const selectedConversationIdRef = useRef<number | null>(selectedConversationId)
|
||||||
const currentUserIdRef = useRef<number>(readSession()?.user.id ?? 0)
|
const currentUserIdRef = useRef<number>(readSession()?.user.id ?? 0)
|
||||||
@@ -31,68 +34,13 @@ export function useAgentConversationRealtime() {
|
|||||||
}, [selectedConversationId])
|
}, [selectedConversationId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
const realtime = createRealtimeConnectionManager({
|
||||||
|
createSocket: () => new WebSocket(createAdminWebSocketUrl()),
|
||||||
const clearTimers = () => {
|
onStatusChange: setRealtimeStatus,
|
||||||
if (reconnectTimerRef.current) {
|
onOpen: (socket) => {
|
||||||
window.clearTimeout(reconnectTimerRef.current)
|
|
||||||
reconnectTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (pingTimerRef.current) {
|
|
||||||
window.clearInterval(pingTimerRef.current)
|
|
||||||
pingTimerRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
|
||||||
if (cancelled || reconnectTimerRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const delay = Math.min(
|
|
||||||
RECONNECT_BASE_DELAY * 2 ** reconnectAttemptRef.current,
|
|
||||||
RECONNECT_MAX_DELAY
|
|
||||||
)
|
|
||||||
reconnectTimerRef.current = window.setTimeout(() => {
|
|
||||||
reconnectTimerRef.current = null
|
|
||||||
reconnectAttemptRef.current += 1
|
|
||||||
if (!cancelled) {
|
|
||||||
connect()
|
|
||||||
}
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
if (cancelled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let socket: WebSocket
|
|
||||||
try {
|
|
||||||
setRealtimeStatus("connecting")
|
|
||||||
socket = new WebSocket(createAdminWebSocketUrl())
|
|
||||||
} catch (error) {
|
|
||||||
setRealtimeStatus("disconnected")
|
|
||||||
toast.error(error instanceof Error ? error.message : "连接实时服务失败")
|
|
||||||
scheduleReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
websocketRef.current = socket
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
console.info("[agent-realtime] websocket connected", {
|
console.info("[agent-realtime] websocket connected", {
|
||||||
url: socket.url,
|
url: socket.url,
|
||||||
})
|
})
|
||||||
setRealtimeStatus("connected")
|
|
||||||
reconnectAttemptRef.current = 0
|
|
||||||
if (pingTimerRef.current) {
|
|
||||||
window.clearInterval(pingTimerRef.current)
|
|
||||||
}
|
|
||||||
pingTimerRef.current = window.setInterval(() => {
|
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: "ping" }))
|
|
||||||
}
|
|
||||||
}, 20000)
|
|
||||||
|
|
||||||
const conversationId = selectedConversationIdRef.current
|
const conversationId = selectedConversationIdRef.current
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
@@ -106,9 +54,8 @@ export function useAgentConversationRealtime() {
|
|||||||
} else {
|
} else {
|
||||||
subscribedConversationIdRef.current = null
|
subscribedConversationIdRef.current = null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onMessage: (event, socket) => {
|
||||||
socket.onmessage = (event) => {
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data) as {
|
const payload = JSON.parse(event.data) as {
|
||||||
eventId?: string
|
eventId?: string
|
||||||
@@ -146,7 +93,8 @@ export function useAgentConversationRealtime() {
|
|||||||
const status = payload.data?.status ?? 0
|
const status = payload.data?.status ?? 0
|
||||||
const currentAssigneeId = payload.data?.currentAssigneeId ?? 0
|
const currentAssigneeId = payload.data?.currentAssigneeId ?? 0
|
||||||
|
|
||||||
void loadConversations().then(() => {
|
void loadConversations()
|
||||||
|
.then(() => {
|
||||||
const store = useAgentConversationsStore.getState()
|
const store = useAgentConversationsStore.getState()
|
||||||
const shouldNotify =
|
const shouldNotify =
|
||||||
senderType === "customer" &&
|
senderType === "customer" &&
|
||||||
@@ -170,7 +118,8 @@ export function useAgentConversationRealtime() {
|
|||||||
void store.selectConversation(conversationId)
|
void store.selectConversation(conversationId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}).catch((error) => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
toast.error(error instanceof Error ? error.message : "加载消息失败")
|
toast.error(error instanceof Error ? error.message : "加载消息失败")
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -179,15 +128,17 @@ export function useAgentConversationRealtime() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conversationId > 0 && selectedConversationIdRef.current === conversationId) {
|
if (
|
||||||
|
conversationId > 0 &&
|
||||||
|
selectedConversationIdRef.current === conversationId
|
||||||
|
) {
|
||||||
void syncLatestMessages(conversationId)
|
void syncLatestMessages(conversationId)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore invalid ws payload
|
// ignore invalid ws payload
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onClose: (event, socket) => {
|
||||||
socket.onclose = (event) => {
|
|
||||||
console.log("[agent-realtime] websocket closed", {
|
console.log("[agent-realtime] websocket closed", {
|
||||||
url: socket.url,
|
url: socket.url,
|
||||||
readyState: socket.readyState,
|
readyState: socket.readyState,
|
||||||
@@ -195,46 +146,31 @@ export function useAgentConversationRealtime() {
|
|||||||
reason: event.reason,
|
reason: event.reason,
|
||||||
wasClean: event.wasClean,
|
wasClean: event.wasClean,
|
||||||
})
|
})
|
||||||
setRealtimeStatus("disconnected")
|
|
||||||
if (pingTimerRef.current) {
|
|
||||||
window.clearInterval(pingTimerRef.current)
|
|
||||||
pingTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (websocketRef.current === socket) {
|
|
||||||
websocketRef.current = null
|
|
||||||
}
|
|
||||||
subscribedConversationIdRef.current = null
|
subscribedConversationIdRef.current = null
|
||||||
scheduleReconnect()
|
},
|
||||||
}
|
onError: (_event, socket) => {
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
console.log("[agent-realtime] websocket error", {
|
console.log("[agent-realtime] websocket error", {
|
||||||
url: socket.url,
|
url: socket.url,
|
||||||
readyState: socket.readyState,
|
readyState: socket.readyState,
|
||||||
})
|
})
|
||||||
setRealtimeStatus("disconnected")
|
},
|
||||||
scheduleReconnect()
|
onConnectError: (error) => {
|
||||||
}
|
toast.error(error instanceof Error ? error.message : "连接实时服务失败")
|
||||||
}
|
},
|
||||||
|
})
|
||||||
|
|
||||||
connect()
|
realtimeRef.current = realtime
|
||||||
|
realtime.connect()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
realtimeRef.current = null
|
||||||
clearTimers()
|
realtime.disconnect()
|
||||||
reconnectAttemptRef.current = 0
|
|
||||||
const socket = websocketRef.current
|
|
||||||
websocketRef.current = null
|
|
||||||
if (socket) {
|
|
||||||
socket.close()
|
|
||||||
}
|
|
||||||
setRealtimeStatus("disconnected")
|
|
||||||
subscribedConversationIdRef.current = null
|
subscribedConversationIdRef.current = null
|
||||||
}
|
}
|
||||||
}, [loadConversations, setRealtimeStatus, syncLatestMessages])
|
}, [loadConversations, setRealtimeStatus, syncLatestMessages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = websocketRef.current
|
const socket = realtimeRef.current?.getSocket()
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,51 @@ export function mergeImMessagesByIdAsc<T extends MergeableImMessage>(
|
|||||||
return Array.from(byId.values()).sort((x, y) => x.id - y.id)
|
return Array.from(byId.values()).sort((x, y) => x.id - y.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseImMessageCursorId(cursor: string): number {
|
||||||
|
const value = Number.parseInt(cursor, 10)
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cursorFromLoadedImMessages<T extends Pick<MergeableImMessage, "id">>(
|
||||||
|
messages: T[]
|
||||||
|
): string {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return String(Math.min(...messages.map((message) => message.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMoreAfterLatestImMessageMerge<
|
||||||
|
T extends Pick<MergeableImMessage, "id">,
|
||||||
|
>(args: {
|
||||||
|
previousMessages: T[]
|
||||||
|
previousHasMore: boolean
|
||||||
|
merged: T[]
|
||||||
|
apiHasMore: boolean
|
||||||
|
}): boolean {
|
||||||
|
const prevMin = minImMessageId(args.previousMessages)
|
||||||
|
const mergedMin = minImMessageId(args.merged)
|
||||||
|
|
||||||
|
if (mergedMin === null) {
|
||||||
|
return Boolean(args.apiHasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.previousHasMore && prevMin !== null && mergedMin >= prevMin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return args.previousHasMore || Boolean(args.apiHasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
function minImMessageId<T extends Pick<MergeableImMessage, "id">>(
|
||||||
|
messages: T[]
|
||||||
|
): number | null {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Math.min(...messages.map((message) => message.id))
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeImMessage<T extends MergeableImMessage>(
|
export function mergeImMessage<T extends MergeableImMessage>(
|
||||||
existing: T,
|
existing: T,
|
||||||
incoming: T
|
incoming: T
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
|
||||||
|
|
||||||
|
type RealtimeConnectionManagerOptions = {
|
||||||
|
createSocket: () => WebSocket
|
||||||
|
canReconnect?: () => boolean
|
||||||
|
onStatusChange?: (status: RealtimeConnectionStatus) => void
|
||||||
|
onSocketChange?: (socket: WebSocket | null) => void
|
||||||
|
onOpen?: (socket: WebSocket) => void
|
||||||
|
onMessage?: (event: MessageEvent, socket: WebSocket) => void
|
||||||
|
onClose?: (event: CloseEvent, socket: WebSocket) => void
|
||||||
|
onError?: (event: Event, socket: WebSocket) => void
|
||||||
|
onConnectError?: (error: unknown) => void
|
||||||
|
buildPingMessage?: () => string
|
||||||
|
pingIntervalMs?: number
|
||||||
|
reconnectBaseDelayMs?: number
|
||||||
|
reconnectMaxDelayMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisconnectOptions = {
|
||||||
|
reconnect?: boolean
|
||||||
|
updateStatus?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PING_INTERVAL_MS = 20000
|
||||||
|
const DEFAULT_RECONNECT_BASE_DELAY_MS = 2000
|
||||||
|
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
|
||||||
|
|
||||||
|
export function createRealtimeConnectionManager(
|
||||||
|
options: RealtimeConnectionManagerOptions
|
||||||
|
) {
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let reconnectTimer: number | null = null
|
||||||
|
let pingTimer: number | null = null
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
let reconnectEnabled = false
|
||||||
|
|
||||||
|
const clearReconnectTimer = () => {
|
||||||
|
if (reconnectTimer !== null) {
|
||||||
|
window.clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPingTimer = () => {
|
||||||
|
if (pingTimer !== null) {
|
||||||
|
window.clearInterval(pingTimer)
|
||||||
|
pingTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
clearReconnectTimer()
|
||||||
|
clearPingTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSocket = (nextSocket: WebSocket | null) => {
|
||||||
|
socket = nextSocket
|
||||||
|
options.onSocketChange?.(nextSocket)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canReconnect = () => {
|
||||||
|
return reconnectEnabled && (options.canReconnect?.() ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (!canReconnect() || reconnectTimer !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
(options.reconnectBaseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
|
||||||
|
2 ** reconnectAttempt,
|
||||||
|
options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
|
||||||
|
)
|
||||||
|
options.onStatusChange?.("connecting")
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
reconnectTimer = null
|
||||||
|
reconnectAttempt += 1
|
||||||
|
if (canReconnect()) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
reconnectEnabled = true
|
||||||
|
if (!(options.canReconnect?.() ?? true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect({ reconnect: true, updateStatus: false })
|
||||||
|
reconnectEnabled = true
|
||||||
|
options.onStatusChange?.("connecting")
|
||||||
|
|
||||||
|
let nextSocket: WebSocket
|
||||||
|
try {
|
||||||
|
nextSocket = options.createSocket()
|
||||||
|
} catch (error) {
|
||||||
|
options.onStatusChange?.("disconnected")
|
||||||
|
options.onConnectError?.(error)
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSocket(nextSocket)
|
||||||
|
|
||||||
|
nextSocket.addEventListener("open", () => {
|
||||||
|
if (socket !== nextSocket) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearReconnectTimer()
|
||||||
|
clearPingTimer()
|
||||||
|
reconnectAttempt = 0
|
||||||
|
options.onStatusChange?.("connected")
|
||||||
|
const buildPingMessage =
|
||||||
|
options.buildPingMessage ?? (() => JSON.stringify({ type: "ping" }))
|
||||||
|
pingTimer = window.setInterval(() => {
|
||||||
|
if (nextSocket.readyState === WebSocket.OPEN) {
|
||||||
|
nextSocket.send(buildPingMessage())
|
||||||
|
}
|
||||||
|
}, options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS)
|
||||||
|
options.onOpen?.(nextSocket)
|
||||||
|
})
|
||||||
|
|
||||||
|
nextSocket.addEventListener("message", (event) => {
|
||||||
|
if (socket === nextSocket) {
|
||||||
|
options.onMessage?.(event, nextSocket)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nextSocket.addEventListener("close", (event) => {
|
||||||
|
clearPingTimer()
|
||||||
|
const isCurrentSocket = socket === nextSocket
|
||||||
|
if (isCurrentSocket) {
|
||||||
|
setSocket(null)
|
||||||
|
}
|
||||||
|
options.onClose?.(event, nextSocket)
|
||||||
|
if (!isCurrentSocket) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (canReconnect()) {
|
||||||
|
scheduleReconnect()
|
||||||
|
} else {
|
||||||
|
options.onStatusChange?.("disconnected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nextSocket.addEventListener("error", (event) => {
|
||||||
|
if (socket !== nextSocket) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.onError?.(event, nextSocket)
|
||||||
|
options.onStatusChange?.("disconnected")
|
||||||
|
scheduleReconnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnect = (disconnectOptions?: DisconnectOptions) => {
|
||||||
|
reconnectEnabled = disconnectOptions?.reconnect ?? false
|
||||||
|
clearTimers()
|
||||||
|
if (!reconnectEnabled) {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSocket = socket
|
||||||
|
setSocket(null)
|
||||||
|
if (
|
||||||
|
currentSocket &&
|
||||||
|
(currentSocket.readyState === WebSocket.OPEN ||
|
||||||
|
currentSocket.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
currentSocket.close()
|
||||||
|
}
|
||||||
|
if (disconnectOptions?.updateStatus ?? !reconnectEnabled) {
|
||||||
|
options.onStatusChange?.("disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
reconnect: () => {
|
||||||
|
reconnectEnabled = true
|
||||||
|
connect()
|
||||||
|
},
|
||||||
|
getSocket: () => socket,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,12 @@ import {
|
|||||||
type AgentMessage,
|
type AgentMessage,
|
||||||
} from "@/lib/api/agent"
|
} from "@/lib/api/agent"
|
||||||
import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status"
|
import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status"
|
||||||
import { mergeImMessagesByIdAsc } from "@/lib/im-message-merge"
|
import {
|
||||||
|
cursorFromLoadedImMessages,
|
||||||
|
hasMoreAfterLatestImMessageMerge,
|
||||||
|
mergeImMessagesByIdAsc,
|
||||||
|
parseImMessageCursorId,
|
||||||
|
} from "@/lib/im-message-merge"
|
||||||
import { summarizeIMMessage } from "@/lib/im-message"
|
import { summarizeIMMessage } from "@/lib/im-message"
|
||||||
import { generateUUID } from "@/lib/utils"
|
import { generateUUID } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -49,54 +54,6 @@ function ensureArray<T>(value: T[] | null | undefined): T[] {
|
|||||||
return Array.isArray(value) ? value : []
|
return Array.isArray(value) ? value : []
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCursorId(cursor: string): number {
|
|
||||||
const n = Number.parseInt(cursor, 10)
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下一页「更旧」请求应传入的游标:当前已加载列表中的最小 message id(后端用 id < cursor) */
|
|
||||||
function cursorFromLoadedMessages(messages: AgentMessage[]): string {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return String(Math.min(...messages.map((m) => m.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function minMessageId(messages: AgentMessage[]): number | null {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return Math.min(...messages.map((m) => m.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉「最新一页」做增量合并后,是否仍显示「还有更旧」。
|
|
||||||
* 若本地已确认没有更旧,且合并后最早一条 id 没有变小,则不能用接口对「最新一页」的 hasMore 再次打开(满页会误报)。
|
|
||||||
*/
|
|
||||||
function hasMoreAfterLatestSyncMerge(args: {
|
|
||||||
previousMessages: AgentMessage[]
|
|
||||||
previousHasMore: boolean
|
|
||||||
merged: AgentMessage[]
|
|
||||||
apiHasMore: boolean
|
|
||||||
}): boolean {
|
|
||||||
const prevMin = minMessageId(args.previousMessages)
|
|
||||||
const mergedMin = minMessageId(args.merged)
|
|
||||||
|
|
||||||
if (mergedMin === null) {
|
|
||||||
return Boolean(args.apiHasMore)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!args.previousHasMore &&
|
|
||||||
prevMin !== null &&
|
|
||||||
mergedMin >= prevMin
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return args.previousHasMore || Boolean(args.apiHasMore)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentConversationsStore = {
|
type AgentConversationsStore = {
|
||||||
searchKeyword: string
|
searchKeyword: string
|
||||||
conversationFilter: AgentConversationFilterKey
|
conversationFilter: AgentConversationFilterKey
|
||||||
@@ -298,7 +255,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
|||||||
messagesLoading: false,
|
messagesLoading: false,
|
||||||
messagesLoadedConversationId: conversationId,
|
messagesLoadedConversationId: conversationId,
|
||||||
messagesCursor:
|
messagesCursor:
|
||||||
cursorFromLoadedMessages(list) || (data.cursor ?? ""),
|
cursorFromLoadedImMessages(list) || (data.cursor ?? ""),
|
||||||
messagesHasMore: Boolean(data.hasMore),
|
messagesHasMore: Boolean(data.hasMore),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -314,7 +271,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
|||||||
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
|
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cursorId = parseCursorId(get().messagesCursor)
|
const cursorId = parseImMessageCursorId(get().messagesCursor)
|
||||||
if (cursorId <= 0) {
|
if (cursorId <= 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -335,7 +292,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
|||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor:
|
messagesCursor:
|
||||||
cursorFromLoadedMessages(merged) ||
|
cursorFromLoadedImMessages(merged) ||
|
||||||
(data.cursor ?? state.messagesCursor),
|
(data.cursor ?? state.messagesCursor),
|
||||||
messagesHasMore: Boolean(data.hasMore),
|
messagesHasMore: Boolean(data.hasMore),
|
||||||
messagesLoadingMore: false,
|
messagesLoadingMore: false,
|
||||||
@@ -368,9 +325,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
|||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor:
|
messagesCursor:
|
||||||
cursorFromLoadedMessages(merged) ||
|
cursorFromLoadedImMessages(merged) ||
|
||||||
(data.cursor ?? state.messagesCursor),
|
(data.cursor ?? state.messagesCursor),
|
||||||
messagesHasMore: hasMoreAfterLatestSyncMerge({
|
messagesHasMore: hasMoreAfterLatestImMessageMerge({
|
||||||
previousMessages: state.messages,
|
previousMessages: state.messages,
|
||||||
previousHasMore: state.messagesHasMore,
|
previousHasMore: state.messagesHasMore,
|
||||||
merged,
|
merged,
|
||||||
|
|||||||
+38
-149
@@ -20,14 +20,18 @@ import {
|
|||||||
createImRealtimeConnection,
|
createImRealtimeConnection,
|
||||||
type ImRealtimeEnvelope,
|
type ImRealtimeEnvelope,
|
||||||
} from "@/lib/im-realtime"
|
} from "@/lib/im-realtime"
|
||||||
import { mergeImMessagesByIdAsc } from "@/lib/im-message-merge"
|
import {
|
||||||
|
cursorFromLoadedImMessages,
|
||||||
|
hasMoreAfterLatestImMessageMerge,
|
||||||
|
mergeImMessagesByIdAsc,
|
||||||
|
parseImMessageCursorId,
|
||||||
|
} from "@/lib/im-message-merge"
|
||||||
import { summarizeIMMessage } from "@/lib/im-message"
|
import { summarizeIMMessage } from "@/lib/im-message"
|
||||||
|
import { createRealtimeConnectionManager } from "@/lib/realtime-connection"
|
||||||
import { generateUUID } from "@/lib/utils"
|
import { generateUUID } from "@/lib/utils"
|
||||||
|
|
||||||
type ChatStatus = "connecting" | "connected" | "disconnected"
|
type ChatStatus = "connecting" | "connected" | "disconnected"
|
||||||
|
|
||||||
const RECONNECT_BASE_DELAY = 2000
|
|
||||||
const RECONNECT_MAX_DELAY = 30000
|
|
||||||
const DEFAULT_PAGE_LIMIT = 50
|
const DEFAULT_PAGE_LIMIT = 50
|
||||||
|
|
||||||
function getNotificationBody(message: ImMessage): string {
|
function getNotificationBody(message: ImMessage): string {
|
||||||
@@ -66,45 +70,6 @@ function ensureMessageList(value: ImMessage[] | null | undefined): ImMessage[] {
|
|||||||
return Array.isArray(value) ? value : []
|
return Array.isArray(value) ? value : []
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCursorId(cursor: string): number {
|
|
||||||
const value = Number.parseInt(cursor, 10)
|
|
||||||
return Number.isFinite(value) && value > 0 ? value : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function cursorFromLoadedMessages(messages: ImMessage[]): string {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return String(Math.min(...messages.map((message) => message.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function minMessageId(messages: ImMessage[]): number | null {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return Math.min(...messages.map((message) => message.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasMoreAfterLatestSyncMerge(args: {
|
|
||||||
previousMessages: ImMessage[]
|
|
||||||
previousHasMore: boolean
|
|
||||||
merged: ImMessage[]
|
|
||||||
apiHasMore: boolean
|
|
||||||
}): boolean {
|
|
||||||
const prevMin = minMessageId(args.previousMessages)
|
|
||||||
const mergedMin = minMessageId(args.merged)
|
|
||||||
|
|
||||||
if (mergedMin === null) {
|
|
||||||
return Boolean(args.apiHasMore)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.previousHasMore && prevMin !== null && mergedMin >= prevMin) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return args.previousHasMore || Boolean(args.apiHasMore)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KefuChatStore = {
|
export type KefuChatStore = {
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
@@ -144,74 +109,29 @@ export type KefuChatStore = {
|
|||||||
let bootstrapToken = 0
|
let bootstrapToken = 0
|
||||||
|
|
||||||
export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||||
let reconnectTimer: number | null = null
|
const realtime = createRealtimeConnectionManager({
|
||||||
let pingTimer: number | null = null
|
createSocket: createImRealtimeConnection,
|
||||||
let reconnectAttempt = 0
|
canReconnect: () => Boolean(get().isOpen && get().conversation?.id),
|
||||||
let shouldReconnect = false
|
onStatusChange: (status) => {
|
||||||
|
if (get().isOpen || status === "disconnected") {
|
||||||
const clearRealtimeTimers = () => {
|
set({ status })
|
||||||
if (reconnectTimer !== null) {
|
|
||||||
window.clearTimeout(reconnectTimer)
|
|
||||||
reconnectTimer = null
|
|
||||||
}
|
}
|
||||||
if (pingTimer !== null) {
|
},
|
||||||
window.clearInterval(pingTimer)
|
onSocketChange: (socket) => {
|
||||||
pingTimer = null
|
set({ socket })
|
||||||
}
|
},
|
||||||
}
|
onMessage: (messageEvent) => {
|
||||||
|
let event: ImRealtimeEnvelope
|
||||||
const scheduleReconnect = () => {
|
try {
|
||||||
if (!shouldReconnect || reconnectTimer !== null) {
|
event = JSON.parse(messageEvent.data) as ImRealtimeEnvelope
|
||||||
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = Math.min(
|
|
||||||
RECONNECT_BASE_DELAY * 2 ** reconnectAttempt,
|
|
||||||
RECONNECT_MAX_DELAY
|
|
||||||
)
|
|
||||||
set({ status: "connecting" })
|
|
||||||
reconnectTimer = window.setTimeout(() => {
|
|
||||||
reconnectTimer = null
|
|
||||||
reconnectAttempt += 1
|
|
||||||
if (!shouldReconnect || !get().isOpen) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
connectSocket()
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSocket = (options?: { reconnect?: boolean }) => {
|
|
||||||
shouldReconnect = options?.reconnect ?? false
|
|
||||||
clearRealtimeTimers()
|
|
||||||
if (!shouldReconnect) {
|
|
||||||
reconnectAttempt = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const socket = get().socket
|
|
||||||
if (
|
|
||||||
socket &&
|
|
||||||
(socket.readyState === WebSocket.OPEN ||
|
|
||||||
socket.readyState === WebSocket.CONNECTING)
|
|
||||||
) {
|
|
||||||
socket.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ socket: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectSocket = () => {
|
|
||||||
const conversationId = get().conversation?.id
|
const conversationId = get().conversation?.id
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
closeSocket({ reconnect: false })
|
|
||||||
shouldReconnect = true
|
|
||||||
|
|
||||||
const socket = createImRealtimeConnection()
|
|
||||||
set({ socket })
|
|
||||||
|
|
||||||
const handleRealtimeEvent = (event: ImRealtimeEnvelope) => {
|
|
||||||
const payload = event.data ?? event.payload
|
const payload = event.data ?? event.payload
|
||||||
const needsRefresh =
|
const needsRefresh =
|
||||||
event.type === "message.created" ||
|
event.type === "message.created" ||
|
||||||
@@ -239,51 +159,21 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeSocket = (options?: { reconnect?: boolean }) => {
|
||||||
|
realtime.disconnect({
|
||||||
|
reconnect: options?.reconnect ?? false,
|
||||||
|
updateStatus: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.addEventListener("message", (event) => {
|
const connectSocket = () => {
|
||||||
try {
|
if (!get().conversation?.id) {
|
||||||
handleRealtimeEvent(JSON.parse(event.data) as ImRealtimeEnvelope)
|
|
||||||
} catch {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
realtime.connect()
|
||||||
|
|
||||||
socket.addEventListener("open", () => {
|
|
||||||
clearRealtimeTimers()
|
|
||||||
reconnectAttempt = 0
|
|
||||||
pingTimer = window.setInterval(() => {
|
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: "ping" }))
|
|
||||||
}
|
|
||||||
}, 20000)
|
|
||||||
if (get().isOpen && get().socket === socket) {
|
|
||||||
set({ status: "connected" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.addEventListener("error", () => {
|
|
||||||
if (get().socket === socket) {
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.addEventListener("close", () => {
|
|
||||||
if (pingTimer !== null) {
|
|
||||||
window.clearInterval(pingTimer)
|
|
||||||
pingTimer = null
|
|
||||||
}
|
|
||||||
if (get().socket === socket) {
|
|
||||||
set({ socket: null })
|
|
||||||
}
|
|
||||||
if (get().isOpen) {
|
|
||||||
if (shouldReconnect) {
|
|
||||||
scheduleReconnect()
|
|
||||||
} else {
|
|
||||||
set({ status: "disconnected" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -388,7 +278,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
const results = ensureMessageList(page.results)
|
const results = ensureMessageList(page.results)
|
||||||
set({
|
set({
|
||||||
messages: results,
|
messages: results,
|
||||||
messagesCursor: cursorFromLoadedMessages(results) || page.cursor || "",
|
messagesCursor: cursorFromLoadedImMessages(results) || page.cursor || "",
|
||||||
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
|
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -418,8 +308,8 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
const merged = mergeImMessagesByIdAsc(state.messages, batch)
|
const merged = mergeImMessagesByIdAsc(state.messages, batch)
|
||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
|
messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "",
|
||||||
messagesHasMore: hasMoreAfterLatestSyncMerge({
|
messagesHasMore: hasMoreAfterLatestImMessageMerge({
|
||||||
previousMessages: state.messages,
|
previousMessages: state.messages,
|
||||||
previousHasMore: state.messagesHasMore,
|
previousHasMore: state.messagesHasMore,
|
||||||
merged,
|
merged,
|
||||||
@@ -444,7 +334,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursorId = parseCursorId(get().messagesCursor)
|
const cursorId = parseImMessageCursorId(get().messagesCursor)
|
||||||
if (cursorId <= 0) {
|
if (cursorId <= 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -464,7 +354,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
|
messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "",
|
||||||
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
|
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
|
||||||
messagesLoadingMore: false,
|
messagesLoadingMore: false,
|
||||||
}
|
}
|
||||||
@@ -667,7 +557,6 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
try {
|
try {
|
||||||
await get().refreshMessages()
|
await get().refreshMessages()
|
||||||
if (get().isOpen) {
|
if (get().isOpen) {
|
||||||
shouldReconnect = true
|
|
||||||
connectSocket()
|
connectSocket()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user