Files
mai-bot/dashboard/src/routes/monitor/use-maisaka-monitor.ts

238 lines
6.6 KiB
TypeScript

/**
* MaiSaka 聊天流实时监控 - React Hook
*
* 管理 WebSocket 订阅与事件流的状态。
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { MaisakaMonitorEvent } from '@/lib/maisaka-monitor-client'
import { maisakaMonitorClient } from '@/lib/maisaka-monitor-client'
/** 单条时间线事件(前端视图模型) */
export interface TimelineEntry {
/** 唯一 ID */
id: string
/** 事件类型 */
type: MaisakaMonitorEvent['type']
/** 原始事件数据 */
data: MaisakaMonitorEvent['data']
/** 事件时间戳 */
timestamp: number
/** 所属会话 ID */
sessionId: string
}
/** 会话概要信息 */
export interface SessionInfo {
sessionId: string
sessionName: string
isGroupChat?: boolean
groupId?: string | null
userId?: string | null
platform?: string
lastActivity: number
eventCount: number
}
/** 最大保留的时间线条目数 */
const MAX_TIMELINE_ENTRIES = 500
function resolveSessionDisplayName({
fallbackName,
groupId,
isGroupChat,
sessionId,
userId,
}: {
fallbackName?: string
groupId?: string | null
isGroupChat?: boolean
sessionId: string
userId?: string | null
}) {
const targetId = isGroupChat ? groupId : userId
const normalizedName = fallbackName?.trim()
if (targetId && normalizedName?.endsWith(`(${targetId})`)) {
return normalizedName
}
if (normalizedName && targetId && normalizedName !== targetId && normalizedName !== sessionId) {
return `${normalizedName}(${targetId})`
}
if (isGroupChat && groupId) {
return groupId
}
if (!isGroupChat && userId) {
return userId
}
return fallbackName || sessionId.slice(0, 8)
}
let entryCounter = 0
let cachedTimeline: TimelineEntry[] = []
let cachedSessions: Map<string, SessionInfo> = new Map()
let cachedSelectedSession: string | null = null
export function useMaisakaMonitor() {
const [timeline, setTimeline] = useState<TimelineEntry[]>(cachedTimeline)
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map(cachedSessions))
const [selectedSession, setSelectedSessionState] = useState<string | null>(cachedSelectedSession)
const [connected, setConnected] = useState(false)
const unsubRef = useRef<(() => Promise<void>) | null>(null)
const handleEvent = useCallback((event: MaisakaMonitorEvent) => {
const dataRecord = event.data as unknown as Record<string, unknown>
const sessionId = dataRecord.session_id as string
const timestamp = dataRecord.timestamp as number
const isGroupChat = typeof dataRecord.is_group_chat === 'boolean'
? dataRecord.is_group_chat
: undefined
const groupId = typeof dataRecord.group_id === 'string' ? dataRecord.group_id : null
const userId = typeof dataRecord.user_id === 'string' ? dataRecord.user_id : null
const platform = typeof dataRecord.platform === 'string' ? dataRecord.platform : undefined
const sessionName = typeof dataRecord.session_name === 'string'
? dataRecord.session_name
: undefined
const entry: TimelineEntry = {
id: `evt_${++entryCounter}_${Date.now()}`,
type: event.type,
data: event.data,
timestamp,
sessionId,
}
setTimeline((prev) => {
const next = [...prev, entry]
const trimmed = next.length > MAX_TIMELINE_ENTRIES
? next.slice(next.length - MAX_TIMELINE_ENTRIES)
: next
cachedTimeline = trimmed
return trimmed
})
// 更新会话信息
if (event.type === 'session.start') {
setSessions((prev) => {
const next = new Map(prev)
next.set(sessionId, {
sessionId,
sessionName: resolveSessionDisplayName({
fallbackName: sessionName,
groupId,
isGroupChat,
sessionId,
userId,
}),
isGroupChat,
groupId,
userId,
platform,
lastActivity: timestamp,
eventCount: (prev.get(sessionId)?.eventCount ?? 0) + 1,
})
cachedSessions = next
return next
})
} else {
setSessions((prev) => {
const existing = prev.get(sessionId)
if (!existing) {
const next = new Map(prev)
next.set(sessionId, {
sessionId,
sessionName: resolveSessionDisplayName({
fallbackName: sessionName,
groupId,
isGroupChat,
sessionId,
userId,
}),
isGroupChat,
groupId,
userId,
platform,
lastActivity: timestamp,
eventCount: 1,
})
cachedSessions = next
return next
}
const next = new Map(prev)
next.set(sessionId, {
...existing,
sessionName: resolveSessionDisplayName({
fallbackName: sessionName ?? existing.sessionName,
groupId: groupId ?? existing.groupId,
isGroupChat: isGroupChat ?? existing.isGroupChat,
sessionId,
userId: userId ?? existing.userId,
}),
isGroupChat: isGroupChat ?? existing.isGroupChat,
groupId: groupId ?? existing.groupId,
userId: userId ?? existing.userId,
platform: platform ?? existing.platform,
lastActivity: timestamp,
eventCount: existing.eventCount + 1,
})
cachedSessions = next
return next
})
}
// 自动选中第一个会话
setSelectedSessionState((current) => {
const next = current ?? sessionId
cachedSelectedSession = next
return next
})
}, [])
useEffect(() => {
let cancelled = false
maisakaMonitorClient.subscribe(handleEvent).then((unsub) => {
if (cancelled) {
void unsub()
return
}
unsubRef.current = unsub
setConnected(true)
})
return () => {
cancelled = true
if (unsubRef.current) {
void unsubRef.current()
unsubRef.current = null
}
setConnected(false)
}
}, [handleEvent])
const clearTimeline = useCallback(() => {
cachedTimeline = []
setTimeline([])
}, [])
const setSelectedSession = useCallback((sessionId: string | null) => {
cachedSelectedSession = sessionId
setSelectedSessionState(sessionId)
}, [])
/** 当前选中会话的时间线 */
const filteredTimeline = selectedSession
? timeline.filter((e) => e.sessionId === selectedSession)
: timeline
return {
timeline: filteredTimeline,
allTimeline: timeline,
sessions,
selectedSession,
setSelectedSession,
connected,
clearTimeline,
}
}