feat(chat): 前端增加实时开关并自动刷新会话/消息

- 聊天页新增实时开关,自动探测 realtime 可用性

- 监听 /api/chat/realtime/stream,变更时触发增量同步并刷新会话/消息

- useApi 增加 realtime 接口,sessions/messages 支持 source 参数
This commit is contained in:
2977094657
2026-01-01 23:24:15 +08:00
parent 57ffcd3aa0
commit b422b3c55c
2 changed files with 400 additions and 9 deletions

View File

@@ -69,6 +69,7 @@ export const useApi = () => {
if (params && params.limit != null) query.set('limit', String(params.limit)) if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden)) if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official)) if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
if (params && params.source) query.set('source', params.source)
const url = '/chat/sessions' + (query.toString() ? `?${query.toString()}` : '') const url = '/chat/sessions' + (query.toString() ? `?${query.toString()}` : '')
return await request(url) return await request(url)
} }
@@ -81,10 +82,39 @@ export const useApi = () => {
if (params && params.offset != null) query.set('offset', String(params.offset)) if (params && params.offset != null) query.set('offset', String(params.offset))
if (params && params.order) query.set('order', params.order) if (params && params.order) query.set('order', params.order)
if (params && params.render_types) query.set('render_types', params.render_types) if (params && params.render_types) query.set('render_types', params.render_types)
if (params && params.source) query.set('source', params.source)
const url = '/chat/messages' + (query.toString() ? `?${query.toString()}` : '') const url = '/chat/messages' + (query.toString() ? `?${query.toString()}` : '')
return await request(url) return await request(url)
} }
const getChatRealtimeStatus = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
const url = '/chat/realtime/status' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const syncChatRealtimeMessages = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.max_scan != null) query.set('max_scan', String(params.max_scan))
const url = '/chat/realtime/sync' + (query.toString() ? `?${query.toString()}` : '')
return await request(url, { method: 'POST' })
}
const syncChatRealtimeAll = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.max_scan != null) query.set('max_scan', String(params.max_scan))
if (params && params.priority_username) query.set('priority_username', params.priority_username)
if (params && params.priority_max_scan != null) query.set('priority_max_scan', String(params.priority_max_scan))
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
const url = '/chat/realtime/sync_all' + (query.toString() ? `?${query.toString()}` : '')
return await request(url, { method: 'POST' })
}
const searchChatMessages = async (params = {}) => { const searchChatMessages = async (params = {}) => {
const query = new URLSearchParams() const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account) if (params && params.account) query.set('account', params.account)
@@ -250,6 +280,9 @@ export const useApi = () => {
listChatAccounts, listChatAccounts,
listChatSessions, listChatSessions,
listChatMessages, listChatMessages,
getChatRealtimeStatus,
syncChatRealtimeMessages,
syncChatRealtimeAll,
searchChatMessages, searchChatMessages,
getChatSearchIndexStatus, getChatSearchIndexStatus,
buildChatSearchIndex, buildChatSearchIndex,

View File

@@ -150,6 +150,18 @@
</svg> </svg>
<span>刷新</span> <span>刷新</span>
</button> </button>
<button
class="header-btn"
:class="realtimeEnabled ? 'bg-emerald-100 border-emerald-200' : ''"
@click="toggleRealtime"
:disabled="realtimeChecking"
:title="realtimeEnabled ? '关闭实时更新' : (realtimeAvailable ? '开启实时更新' : (realtimeStatusError || '实时模式不可用'))"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{{ realtimeEnabled ? '实时开' : '实时关' }}</span>
</button>
<button <button
class="header-btn" class="header-btn"
@click="openExportModal" @click="openExportModal"
@@ -1602,6 +1614,21 @@ const selectedAccount = ref(null)
const availableAccounts = ref([]) const availableAccounts = ref([])
// 实时更新WCDB DLL + db_storage watcher
const realtimeEnabled = ref(false)
const realtimeAvailable = ref(false)
const realtimeChecking = ref(false)
const realtimeStatusInfo = ref(null)
const realtimeStatusError = ref('')
let realtimeEventSource = null
let realtimeRefreshFuture = null
let realtimeRefreshQueued = false
let realtimeSessionsRefreshFuture = null
let realtimeSessionsRefreshQueued = false
let realtimeFullSyncFuture = null
let realtimeFullSyncQueued = false
let realtimeFullSyncPriority = ''
const allMessages = ref({}) const allMessages = ref({})
const messagesMeta = ref({}) const messagesMeta = ref({})
@@ -3416,12 +3443,28 @@ const loadSessionsForSelectedAccount = async () => {
return return
} }
const sessionsResp = await api.listChatSessions({ const fetchSessions = async (source) => {
account: selectedAccount.value, const params = {
limit: 400, account: selectedAccount.value,
include_hidden: false, limit: 400,
include_official: false include_hidden: false,
}) include_official: false
}
if (source) params.source = source
return await api.listChatSessions(params)
}
let sessionsResp = null
if (realtimeEnabled.value) {
try {
sessionsResp = await fetchSessions('realtime')
} catch {
sessionsResp = null
}
}
if (!sessionsResp) {
sessionsResp = await fetchSessions('')
}
const sessions = sessionsResp?.sessions || [] const sessions = sessionsResp?.sessions || []
contacts.value = sessions.map((s) => ({ contacts.value = sessions.map((s) => ({
@@ -3461,6 +3504,121 @@ const loadSessionsForSelectedAccount = async () => {
await applyRouteSelection() await applyRouteSelection()
} }
const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
if (!process.client || typeof window === 'undefined') return
if (!selectedAccount.value) return
if (isLoadingContacts.value) return
const api = useApi()
const prevSelected = selectedContact.value?.username || ''
const desiredSource = (sourceOverride != null)
? String(sourceOverride || '').trim()
: (realtimeEnabled.value ? 'realtime' : '')
const params = {
account: selectedAccount.value,
limit: 400,
include_hidden: false,
include_official: false
}
let sessionsResp = null
if (desiredSource) {
try {
sessionsResp = await api.listChatSessions({ ...params, source: desiredSource })
} catch {
sessionsResp = null
}
}
if (!sessionsResp) {
try {
sessionsResp = await api.listChatSessions(params)
} catch {
return
}
}
const sessions = sessionsResp?.sessions || []
const nextContacts = sessions.map((s) => ({
id: s.id,
name: s.name || s.username || s.id,
avatar: s.avatar || null,
lastMessage: s.lastMessage || '',
lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup,
username: s.username
}))
contacts.value = nextContacts
if (prevSelected) {
const matched = nextContacts.find((c) => c.username === prevSelected)
if (matched) {
selectedContact.value = matched
}
}
}
const queueRealtimeSessionsRefresh = () => {
if (realtimeSessionsRefreshFuture) {
realtimeSessionsRefreshQueued = true
return
}
realtimeSessionsRefreshFuture = refreshSessionsForSelectedAccount({ sourceOverride: 'realtime' }).finally(() => {
realtimeSessionsRefreshFuture = null
if (realtimeSessionsRefreshQueued) {
realtimeSessionsRefreshQueued = false
queueRealtimeSessionsRefresh()
}
})
}
const runRealtimeFullSync = async (priorityUsername) => {
if (!realtimeEnabled.value) return null
if (!process.client || typeof window === 'undefined') return null
if (!selectedAccount.value) return null
try {
const api = useApi()
return await api.syncChatRealtimeAll({
account: selectedAccount.value,
max_scan: 200,
priority_username: String(priorityUsername || '').trim(),
priority_max_scan: 600,
include_hidden: true,
include_official: true
})
} catch {
return null
}
}
const queueRealtimeFullSync = (priorityUsername) => {
const u = String(priorityUsername || '').trim()
if (u) realtimeFullSyncPriority = u
if (realtimeFullSyncFuture) {
realtimeFullSyncQueued = true
return realtimeFullSyncFuture
}
const priority = realtimeFullSyncPriority
realtimeFullSyncPriority = ''
realtimeFullSyncFuture = runRealtimeFullSync(priority).finally(() => {
realtimeFullSyncFuture = null
if (realtimeFullSyncQueued) {
realtimeFullSyncQueued = false
queueRealtimeFullSync(realtimeFullSyncPriority)
}
})
return realtimeFullSyncFuture
}
const onAccountChange = async () => { const onAccountChange = async () => {
try { try {
isLoadingContacts.value = true isLoadingContacts.value = true
@@ -4103,8 +4261,26 @@ onUnmounted(() => {
highlightMessageTimer = null highlightMessageTimer = null
stopMessageSearchIndexPolling() stopMessageSearchIndexPolling()
stopExportPolling() stopExportPolling()
stopRealtimeStream()
}) })
const dedupeMessagesById = (list) => {
const arr = Array.isArray(list) ? list : []
const seen = new Set()
const out = []
for (const m of arr) {
const id = String(m?.id || '')
if (!id) {
out.push(m)
continue
}
if (seen.has(id)) continue
seen.add(id)
out.push(m)
}
return out
}
const loadMessages = async ({ username, reset }) => { const loadMessages = async ({ username, reset }) => {
if (!username) return if (!username) return
if (!selectedAccount.value) return if (!selectedAccount.value) return
@@ -4126,16 +4302,19 @@ const loadMessages = async ({ username, reset }) => {
username, username,
limit: messagePageSize, limit: messagePageSize,
offset, offset,
order: 'asc' order: 'asc',
} }
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') { if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value params.render_types = messageTypeFilter.value
} }
if (reset) {
await queueRealtimeFullSync(username)
}
const resp = await api.listChatMessages(params) const resp = await api.listChatMessages(params)
const raw = resp?.messages || [] const raw = resp?.messages || []
const mapped = raw.map(normalizeMessage) const mapped = dedupeMessagesById(raw.map(normalizeMessage))
if (activeMessagesFor.value !== username) { if (activeMessagesFor.value !== username) {
return return
@@ -4147,9 +4326,17 @@ const loadMessages = async ({ username, reset }) => {
[username]: mapped [username]: mapped
} }
} else { } else {
const existingIds = new Set(existing.map((m) => String(m?.id || '')))
const older = mapped.filter((m) => {
const id = String(m?.id || '')
if (!id) return true
if (existingIds.has(id)) return false
existingIds.add(id)
return true
})
allMessages.value = { allMessages.value = {
...allMessages.value, ...allMessages.value,
[username]: [...mapped, ...existing] [username]: [...older, ...existing]
} }
} }
@@ -4191,6 +4378,177 @@ const refreshSelectedMessages = async () => {
await loadMessages({ username: selectedContact.value.username, reset: true }) await loadMessages({ username: selectedContact.value.username, reset: true })
} }
const fetchRealtimeStatus = async () => {
if (!process.client) return
if (!selectedAccount.value) {
realtimeAvailable.value = false
realtimeStatusInfo.value = null
realtimeStatusError.value = ''
return
}
const api = useApi()
realtimeChecking.value = true
try {
const resp = await api.getChatRealtimeStatus({ account: selectedAccount.value })
realtimeAvailable.value = !!resp?.available
realtimeStatusInfo.value = resp?.realtime || null
realtimeStatusError.value = ''
} catch (e) {
realtimeAvailable.value = false
realtimeStatusInfo.value = null
realtimeStatusError.value = e?.message || '实时状态获取失败'
} finally {
realtimeChecking.value = false
}
}
const stopRealtimeStream = () => {
if (realtimeEventSource) {
try {
realtimeEventSource.close()
} catch {}
realtimeEventSource = null
}
}
const refreshRealtimeIncremental = async () => {
if (!realtimeEnabled.value) return
if (!selectedAccount.value) return
if (!selectedContact.value?.username) return
if (searchContext.value?.active) return
if (isLoadingMessages.value) return
const username = selectedContact.value.username
const existing = allMessages.value[username] || []
if (!existing.length) return
const container = messageContainerRef.value
const atBottom = !!container && (container.scrollHeight - container.scrollTop - container.clientHeight) < 80
const api = useApi()
const params = {
account: selectedAccount.value,
username,
limit: 30,
offset: 0,
order: 'asc',
}
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
await queueRealtimeFullSync(username)
const resp = await api.listChatMessages(params)
if (selectedContact.value?.username !== username) return
const raw = resp?.messages || []
const latest = raw.map(normalizeMessage)
const seenIds = new Set(existing.map((m) => String(m?.id || '')))
const newOnes = []
for (const m of latest) {
const id = String(m?.id || '')
if (!id) continue
if (seenIds.has(id)) continue
seenIds.add(id)
newOnes.push(m)
}
if (!newOnes.length) return
allMessages.value = {
...allMessages.value,
[username]: [...existing, ...newOnes]
}
await nextTick()
const c = messageContainerRef.value
if (c && atBottom) {
c.scrollTop = c.scrollHeight
}
updateJumpToBottomState()
}
const queueRealtimeRefresh = () => {
if (realtimeRefreshFuture) {
realtimeRefreshQueued = true
return
}
realtimeRefreshFuture = refreshRealtimeIncremental().finally(() => {
realtimeRefreshFuture = null
if (realtimeRefreshQueued) {
realtimeRefreshQueued = false
queueRealtimeRefresh()
}
})
}
const startRealtimeStream = () => {
stopRealtimeStream()
if (!process.client || typeof window === 'undefined') return
if (!realtimeEnabled.value) return
if (!selectedAccount.value) return
if (typeof EventSource === 'undefined') return
const base = 'http://localhost:8000'
const url = `${base}/api/chat/realtime/stream?account=${encodeURIComponent(String(selectedAccount.value))}`
try {
realtimeEventSource = new EventSource(url)
} catch (e) {
realtimeEventSource = null
return
}
realtimeEventSource.onmessage = (ev) => {
try {
const data = JSON.parse(String(ev.data || '{}'))
if (String(data?.type || '') === 'change') {
queueRealtimeFullSync(selectedContact.value?.username || '')
queueRealtimeRefresh()
queueRealtimeSessionsRefresh()
}
} catch {}
}
realtimeEventSource.onerror = () => {
stopRealtimeStream()
}
}
const toggleRealtime = async () => {
if (!process.client || typeof window === 'undefined') return
if (!selectedAccount.value) return
if (!realtimeEnabled.value) {
await fetchRealtimeStatus()
if (!realtimeAvailable.value) {
window.alert(realtimeStatusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
return
}
realtimeEnabled.value = true
startRealtimeStream()
queueRealtimeSessionsRefresh()
if (selectedContact.value?.username) {
await refreshSelectedMessages()
}
return
}
realtimeEnabled.value = false
stopRealtimeStream()
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
if (selectedContact.value?.username) {
await refreshSelectedMessages()
}
}
watch(selectedAccount, async () => {
await fetchRealtimeStatus()
if (realtimeEnabled.value) {
startRealtimeStream()
}
})
watch(messageTypeFilter, async (next, prev) => { watch(messageTypeFilter, async (next, prev) => {
if (String(next || '') === String(prev || '')) return if (String(next || '') === String(prev || '')) return
if (!selectedContact.value?.username) return if (!selectedContact.value?.username) return