mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
@@ -37,6 +37,7 @@ export interface ChatSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
messageKey: string
|
||||||
localId: number
|
localId: number
|
||||||
serverId: number
|
serverId: number
|
||||||
localType: number
|
localType: number
|
||||||
@@ -1433,7 +1434,7 @@ class ChatService {
|
|||||||
startTime: number = 0,
|
startTime: number = 0,
|
||||||
endTime: number = 0,
|
endTime: number = 0,
|
||||||
ascending: boolean = false
|
ascending: boolean = false
|
||||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> {
|
||||||
let releaseMessageCursorMutex: (() => void) | null = null
|
let releaseMessageCursorMutex: (() => void) | null = null
|
||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
@@ -1492,7 +1493,6 @@ class ChatService {
|
|||||||
|
|
||||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||||
this.messageCursors.set(sessionId, state)
|
this.messageCursors.set(sessionId, state)
|
||||||
releaseMessageCursorMutex?.()
|
|
||||||
|
|
||||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||||
// 注意:仅在 offset === 0 时重建游标最安全;
|
// 注意:仅在 offset === 0 时重建游标最安全;
|
||||||
@@ -1512,7 +1512,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
if (!skipBatch.rows || skipBatch.rows.length === 0) {
|
if (!skipBatch.rows || skipBatch.rows.length === 0) {
|
||||||
console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`)
|
console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`)
|
||||||
return { success: true, messages: [], hasMore: false }
|
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = skipBatch.rows.length
|
const count = skipBatch.rows.length
|
||||||
@@ -1531,7 +1531,7 @@ class ChatService {
|
|||||||
|
|
||||||
if (!skipBatch.hasMore) {
|
if (!skipBatch.hasMore) {
|
||||||
console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`)
|
console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`)
|
||||||
return { success: true, messages: [], hasMore: false }
|
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (attempts >= maxSkipAttempts) {
|
if (attempts >= maxSkipAttempts) {
|
||||||
@@ -1548,91 +1548,28 @@ class ChatService {
|
|||||||
return { success: false, error: '游标状态未初始化' }
|
return { success: false, error: '游标状态未初始化' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前批次的消息
|
const collected = await this.collectVisibleMessagesFromCursor(
|
||||||
// Use buffered rows from skip logic if available
|
sessionId,
|
||||||
let rows: any[] = state.bufferedMessages || []
|
state.cursor,
|
||||||
state.bufferedMessages = undefined // Clear buffer after use
|
limit,
|
||||||
|
state.bufferedMessages as Record<string, any>[] | undefined
|
||||||
// Track actual hasMore status from C++ layer
|
)
|
||||||
// If we have buffered messages, we need to check if there's more data
|
state.bufferedMessages = collected.bufferedRows
|
||||||
let actualHasMore = rows.length > 0 // If buffer exists, assume there might be more
|
if (!collected.success) {
|
||||||
|
return { success: false, error: collected.error || '获取消息失败' }
|
||||||
// If buffer is not enough to fill a batch, try to fetch more
|
|
||||||
// Or if buffer is empty, fetch a batch
|
|
||||||
if (rows.length < batchSize) {
|
|
||||||
const nextBatch = await wcdbService.fetchMessageBatch(state.cursor)
|
|
||||||
if (nextBatch.success && nextBatch.rows) {
|
|
||||||
rows = rows.concat(nextBatch.rows)
|
|
||||||
actualHasMore = nextBatch.hasMore === true
|
|
||||||
} else if (!nextBatch.success) {
|
|
||||||
console.error('[ChatService] 获取消息批次失败:', nextBatch.error)
|
|
||||||
// If we have some buffered rows, we can still return them?
|
|
||||||
// Or fail? Let's return what we have if any, otherwise fail.
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return { success: false, error: nextBatch.error || '获取消息失败' }
|
|
||||||
}
|
|
||||||
actualHasMore = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have more than limit (due to buffer + full batch), slice it
|
const rawRowsConsumed = collected.rawRowsConsumed || 0
|
||||||
if (rows.length > limit) {
|
const filtered = collected.messages || []
|
||||||
rows = rows.slice(0, limit)
|
const hasMore = collected.hasMore === true
|
||||||
// Note: We don't adjust state.fetched here because it tracks cursor position.
|
state.fetched += rawRowsConsumed
|
||||||
// Next time offset will catch up or mismatch trigger reset.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use actual hasMore from C++ layer, not simplified row count check
|
|
||||||
const hasMore = actualHasMore
|
|
||||||
|
|
||||||
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows))
|
|
||||||
|
|
||||||
// 🔒 安全验证:过滤掉不属于当前 sessionId 的消息(防止 C++ 层或缓存错误)
|
|
||||||
const filtered = normalized.filter(msg => {
|
|
||||||
// 检查消息的 senderUsername 或 rawContent 中的 talker
|
|
||||||
// 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文
|
|
||||||
// 单聊消息:senderUsername 应该是 sessionId 或自己
|
|
||||||
const isGroupChat = sessionId.includes('@chatroom')
|
|
||||||
|
|
||||||
if (isGroupChat) {
|
|
||||||
// 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId)
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
// 单聊消息:senderUsername 应该是 sessionId(对方)或为空/null(自己)
|
|
||||||
if (!msg.senderUsername || msg.senderUsername === sessionId) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 如果 isSend 为 1,说明是自己发的,允许通过
|
|
||||||
if (msg.isSend === 1) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 其他情况:可能是错误的消息
|
|
||||||
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filtered.length < normalized.length) {
|
|
||||||
console.warn(`[ChatService] 过滤了 ${normalized.length - filtered.length} 条异常消息`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 并发检查并修复缺失 CDN URL 的表情包
|
|
||||||
const fixPromises: Promise<void>[] = []
|
|
||||||
for (const msg of filtered) {
|
|
||||||
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
|
||||||
fixPromises.push(this.fallbackEmoticon(msg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fixPromises.length > 0) {
|
|
||||||
await Promise.allSettled(fixPromises)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.fetched += rows.length
|
|
||||||
releaseMessageCursorMutex?.()
|
releaseMessageCursorMutex?.()
|
||||||
|
|
||||||
this.messageCacheService.set(sessionId, filtered)
|
this.messageCacheService.set(sessionId, filtered)
|
||||||
return { success: true, messages: filtered, hasMore }
|
console.log(
|
||||||
|
`[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}`
|
||||||
|
)
|
||||||
|
return { success: true, messages: filtered, hasMore, nextOffset: state.fetched }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取消息失败:', e)
|
console.error('ChatService: 获取消息失败:', e)
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
@@ -1732,7 +1669,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
if (!connectResult.success) {
|
if (!connectResult.success) {
|
||||||
@@ -1746,24 +1683,19 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursorResult.cursor, limit)
|
||||||
if (!batch.success || !batch.rows) {
|
if (!collected.success) {
|
||||||
return { success: false, error: batch.error || '获取消息失败' }
|
return { success: false, error: collected.error || '获取消息失败' }
|
||||||
}
|
}
|
||||||
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(batch.rows as Record<string, any>[]))
|
console.log(
|
||||||
|
`[ChatService] getLatestMessages session=${sessionId} rawRowsConsumed=${collected.rawRowsConsumed || 0} visibleMessagesReturned=${collected.messages?.length || 0} filteredOut=${collected.filteredOut || 0} nextOffset=${collected.rawRowsConsumed || 0} hasMore=${collected.hasMore === true}`
|
||||||
// 并发检查并修复缺失 CDN URL 的表情包
|
)
|
||||||
const fixPromises: Promise<void>[] = []
|
return {
|
||||||
for (const msg of normalized) {
|
success: true,
|
||||||
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
messages: collected.messages,
|
||||||
fixPromises.push(this.fallbackEmoticon(msg))
|
hasMore: collected.hasMore,
|
||||||
}
|
nextOffset: collected.rawRowsConsumed || 0
|
||||||
}
|
}
|
||||||
if (fixPromises.length > 0) {
|
|
||||||
await Promise.allSettled(fixPromises)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, messages: normalized, hasMore: batch.hasMore === true }
|
|
||||||
} finally {
|
} finally {
|
||||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||||
}
|
}
|
||||||
@@ -1819,6 +1751,174 @@ class ChatService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private encodeMessageKeySegment(value: unknown): string {
|
||||||
|
const normalized = String(value ?? '').trim()
|
||||||
|
return encodeURIComponent(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessageSourceInfo(row: Record<string, any>): { dbName?: string; tableName?: string; dbPath?: string } {
|
||||||
|
const dbPath = String(
|
||||||
|
this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path'])
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const explicitDbName = String(
|
||||||
|
this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db'])
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const tableName = String(
|
||||||
|
this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable'])
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '')
|
||||||
|
return {
|
||||||
|
dbName: dbName || undefined,
|
||||||
|
tableName: tableName || undefined,
|
||||||
|
dbPath: dbPath || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMessageKey(input: {
|
||||||
|
localId: number
|
||||||
|
serverId: number
|
||||||
|
createTime: number
|
||||||
|
sortSeq: number
|
||||||
|
senderUsername?: string | null
|
||||||
|
localType: number
|
||||||
|
dbName?: string
|
||||||
|
tableName?: string
|
||||||
|
dbPath?: string
|
||||||
|
}): string {
|
||||||
|
const localId = Number.isFinite(input.localId) ? Math.max(0, Math.floor(input.localId)) : 0
|
||||||
|
const serverId = Number.isFinite(input.serverId) ? Math.max(0, Math.floor(input.serverId)) : 0
|
||||||
|
const createTime = Number.isFinite(input.createTime) ? Math.max(0, Math.floor(input.createTime)) : 0
|
||||||
|
const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0
|
||||||
|
const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0
|
||||||
|
const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '')
|
||||||
|
const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '')
|
||||||
|
const tableName = String(input.tableName || '').trim()
|
||||||
|
|
||||||
|
if (localId > 0 && dbName && tableName) {
|
||||||
|
return `${this.encodeMessageKeySegment(dbName)}:${this.encodeMessageKeySegment(tableName)}:${localId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverId > 0) {
|
||||||
|
return `server:${serverId}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `fallback:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMessageVisibleForSession(sessionId: string, msg: Message): boolean {
|
||||||
|
const isGroupChat = sessionId.includes('@chatroom')
|
||||||
|
if (isGroupChat) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!msg.senderUsername || msg.senderUsername === sessionId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (msg.isSend === 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private async repairEmojiMessages(messages: Message[]): Promise<void> {
|
||||||
|
const fixPromises: Promise<void>[] = []
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
||||||
|
fixPromises.push(this.fallbackEmoticon(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fixPromises.length > 0) {
|
||||||
|
await Promise.allSettled(fixPromises)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectVisibleMessagesFromCursor(
|
||||||
|
sessionId: string,
|
||||||
|
cursor: number,
|
||||||
|
limit: number,
|
||||||
|
initialRows: Record<string, any>[] = []
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
messages?: Message[]
|
||||||
|
hasMore?: boolean
|
||||||
|
error?: string
|
||||||
|
rawRowsConsumed?: number
|
||||||
|
filteredOut?: number
|
||||||
|
bufferedRows?: Record<string, any>[]
|
||||||
|
}> {
|
||||||
|
const visibleMessages: Message[] = []
|
||||||
|
let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : []
|
||||||
|
let rawRowsConsumed = 0
|
||||||
|
let filteredOut = 0
|
||||||
|
let cursorMayHaveMore = queuedRows.length > 0
|
||||||
|
|
||||||
|
while (visibleMessages.length < limit) {
|
||||||
|
if (queuedRows.length === 0) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||||
|
if (!batch.success) {
|
||||||
|
console.error('[ChatService] 获取消息批次失败:', batch.error)
|
||||||
|
if (visibleMessages.length === 0) {
|
||||||
|
return { success: false, error: batch.error || '获取消息失败' }
|
||||||
|
}
|
||||||
|
cursorMayHaveMore = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchRows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
|
cursorMayHaveMore = batch.hasMore === true
|
||||||
|
if (batchRows.length === 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
queuedRows = batchRows
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsToProcess = queuedRows
|
||||||
|
queuedRows = []
|
||||||
|
const mappedMessages = this.mapRowsToMessages(rowsToProcess)
|
||||||
|
for (let index = 0; index < mappedMessages.length; index += 1) {
|
||||||
|
const msg = mappedMessages[index]
|
||||||
|
rawRowsConsumed += 1
|
||||||
|
if (this.isMessageVisibleForSession(sessionId, msg)) {
|
||||||
|
visibleMessages.push(msg)
|
||||||
|
if (visibleMessages.length >= limit) {
|
||||||
|
if (index + 1 < rowsToProcess.length) {
|
||||||
|
queuedRows = rowsToProcess.slice(index + 1)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filteredOut += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleMessages.length >= limit) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cursorMayHaveMore) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredOut > 0) {
|
||||||
|
console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.normalizeMessageOrder(visibleMessages)
|
||||||
|
await this.repairEmojiMessages(normalized)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: normalized,
|
||||||
|
hasMore: queuedRows.length > 0 || cursorMayHaveMore,
|
||||||
|
rawRowsConsumed,
|
||||||
|
filteredOut,
|
||||||
|
bufferedRows: queuedRows.length > 0 ? queuedRows : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getRowField(row: Record<string, any>, keys: string[]): any {
|
private getRowField(row: Record<string, any>, keys: string[]): any {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (row[key] !== undefined && row[key] !== null) return row[key]
|
if (row[key] !== undefined && row[key] !== null) return row[key]
|
||||||
@@ -2954,6 +3054,7 @@ class ChatService {
|
|||||||
|
|
||||||
const messages: Message[] = []
|
const messages: Message[] = []
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
const sourceInfo = this.getMessageSourceInfo(row)
|
||||||
const rawMessageContent = this.getRowField(row, [
|
const rawMessageContent = this.getRowField(row, [
|
||||||
'message_content',
|
'message_content',
|
||||||
'messageContent',
|
'messageContent',
|
||||||
@@ -3160,12 +3261,25 @@ class ChatService {
|
|||||||
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
||||||
|
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
|
||||||
|
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
|
messageKey: this.buildMessageKey({
|
||||||
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
|
localId,
|
||||||
|
serverId,
|
||||||
|
createTime,
|
||||||
|
sortSeq,
|
||||||
|
senderUsername,
|
||||||
|
localType,
|
||||||
|
...sourceInfo
|
||||||
|
}),
|
||||||
|
localId,
|
||||||
|
serverId,
|
||||||
localType,
|
localType,
|
||||||
createTime,
|
createTime,
|
||||||
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime),
|
sortSeq,
|
||||||
isSend,
|
isSend,
|
||||||
senderUsername,
|
senderUsername,
|
||||||
parsedContent: this.parseMessageContent(content, localType),
|
parsedContent: this.parseMessageContent(content, localType),
|
||||||
@@ -3217,7 +3331,8 @@ class ChatService {
|
|||||||
transferPayerUsername,
|
transferPayerUsername,
|
||||||
transferReceiverUsername,
|
transferReceiverUsername,
|
||||||
chatRecordTitle,
|
chatRecordTitle,
|
||||||
chatRecordList
|
chatRecordList,
|
||||||
|
_db_path: sourceInfo.dbPath
|
||||||
})
|
})
|
||||||
const last = messages[messages.length - 1]
|
const last = messages[messages.length - 1]
|
||||||
if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) {
|
if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) {
|
||||||
@@ -6564,7 +6679,11 @@ class ChatService {
|
|||||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||||
|
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
const row = result.rows[0]
|
const row = {
|
||||||
|
...(result.rows[0] as Record<string, any>),
|
||||||
|
db_path: dbPath,
|
||||||
|
table_name: tableName
|
||||||
|
}
|
||||||
const message = this.parseMessage(row)
|
const message = this.parseMessage(row)
|
||||||
|
|
||||||
if (message.localId !== 0) {
|
if (message.localId !== 0) {
|
||||||
@@ -6595,6 +6714,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseMessage(row: any): Message {
|
private parseMessage(row: any): Message {
|
||||||
|
const sourceInfo = this.getMessageSourceInfo(row)
|
||||||
const rawContent = this.decodeMessageContent(
|
const rawContent = this.decodeMessageContent(
|
||||||
this.getRowField(row, [
|
this.getRowField(row, [
|
||||||
'message_content',
|
'message_content',
|
||||||
@@ -6615,19 +6735,35 @@ class ChatService {
|
|||||||
)
|
)
|
||||||
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
||||||
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
|
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
|
||||||
|
const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
||||||
|
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
||||||
|
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
||||||
|
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
||||||
|
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||||
|
|| this.extractSenderUsernameFromContent(rawContent)
|
||||||
|
|| null
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
|
messageKey: this.buildMessageKey({
|
||||||
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
|
localId,
|
||||||
localType: this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0),
|
serverId,
|
||||||
createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0),
|
createTime,
|
||||||
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)),
|
sortSeq,
|
||||||
|
senderUsername,
|
||||||
|
localType,
|
||||||
|
...sourceInfo
|
||||||
|
}),
|
||||||
|
localId,
|
||||||
|
serverId,
|
||||||
|
localType,
|
||||||
|
createTime,
|
||||||
|
sortSeq,
|
||||||
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
||||||
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
senderUsername,
|
||||||
|| this.extractSenderUsernameFromContent(rawContent)
|
|
||||||
|| null,
|
|
||||||
rawContent: rawContent,
|
rawContent: rawContent,
|
||||||
content: rawContent, // 添加原始内容供视频MD5解析使用
|
content: rawContent, // 添加原始内容供视频MD5解析使用
|
||||||
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
|
parsedContent: this.parseMessageContent(rawContent, localType),
|
||||||
|
_db_path: sourceInfo.dbPath
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.localId === 0 || msg.createTime === 0) {
|
if (msg.localId === 0 || msg.createTime === 0) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import type { ChatSession } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
export function GlobalSessionMonitor() {
|
export function GlobalSessionMonitor() {
|
||||||
@@ -20,9 +20,9 @@ export function GlobalSessionMonitor() {
|
|||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
// 去重辅助函数:获取消息 key
|
// 去重辅助函数:获取消息 key
|
||||||
const getMessageKey = (msg: any) => {
|
const getMessageKey = (msg: Message) => {
|
||||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理数据库变更
|
// 处理数据库变更
|
||||||
@@ -96,8 +96,8 @@ export function GlobalSessionMonitor() {
|
|||||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||||
// 这是新消息事件
|
// 这是新消息事件
|
||||||
|
|
||||||
// 折叠群、折叠入口不弹通知
|
// 免打扰、折叠群、折叠入口不弹通知
|
||||||
if (newSession.isFolded) continue
|
if (newSession.isMuted || newSession.isFolded) continue
|
||||||
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||||
|
|
||||||
// 1. 群聊过滤自己发送的消息
|
// 1. 群聊过滤自己发送的消息
|
||||||
@@ -267,7 +267,12 @@ export function GlobalSessionMonitor() {
|
|||||||
try {
|
try {
|
||||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||||
if (result.success && result.messages && result.messages.length > 0) {
|
if (result.success && result.messages && result.messages.length > 0) {
|
||||||
appendMessages(result.messages, false) // 追加到末尾
|
const latestMessages = useChatStore.getState().messages || []
|
||||||
|
const existingKeys = new Set(latestMessages.map(getMessageKey))
|
||||||
|
const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg)))
|
||||||
|
if (newMessages.length > 0) {
|
||||||
|
appendMessages(newMessages, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('后台活跃会话刷新失败:', e)
|
console.warn('后台活跃会话刷新失败:', e)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
@@ -377,7 +377,7 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`session-item ${isActive ? 'active' : ''}`}
|
className={`session-item ${isActive ? 'active' : ''} ${session.isMuted ? 'muted' : ''}`}
|
||||||
onClick={() => onSelect(session)}
|
onClick={() => onSelect(session)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -394,8 +394,9 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
<div className="session-bottom">
|
<div className="session-bottom">
|
||||||
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
||||||
<div className="session-badges">
|
<div className="session-badges">
|
||||||
|
{session.isMuted && <BellOff size={12} className="mute-icon" />}
|
||||||
{session.unreadCount > 0 && (
|
{session.unreadCount > 0 && (
|
||||||
<span className="unread-badge">
|
<span className={`unread-badge ${session.isMuted ? 'muted' : ''}`}>
|
||||||
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -413,6 +414,7 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
prevProps.session.unreadCount === nextProps.session.unreadCount &&
|
prevProps.session.unreadCount === nextProps.session.unreadCount &&
|
||||||
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
|
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
|
||||||
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
|
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
|
||||||
|
prevProps.session.isMuted === nextProps.session.isMuted &&
|
||||||
prevProps.isActive === nextProps.isActive
|
prevProps.isActive === nextProps.isActive
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -471,8 +473,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const getMessageKey = useCallback((msg: Message): string => {
|
const getMessageKey = useCallback((msg: Message): string => {
|
||||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||||
}, [])
|
}, [])
|
||||||
const initialRevealTimerRef = useRef<number | null>(null)
|
const initialRevealTimerRef = useRef<number | null>(null)
|
||||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -537,7 +539,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
// 多选模式
|
// 多选模式
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||||
const [selectedMessages, setSelectedMessages] = useState<Set<number>>(new Set())
|
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
// 编辑消息额外状态
|
// 编辑消息额外状态
|
||||||
const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw')
|
const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw')
|
||||||
@@ -1896,14 +1898,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (!status) return session
|
if (!status) return session
|
||||||
|
|
||||||
const nextIsFolded = status.isFolded ?? session.isFolded
|
const nextIsFolded = status.isFolded ?? session.isFolded
|
||||||
if (nextIsFolded === session.isFolded) {
|
const nextIsMuted = status.isMuted ?? session.isMuted
|
||||||
|
if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) {
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
hasChanges = true
|
hasChanges = true
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
isFolded: nextIsFolded
|
isFolded: nextIsFolded,
|
||||||
|
isMuted: nextIsMuted
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2353,6 +2357,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
|
nextOffset?: number;
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) {
|
if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) {
|
||||||
@@ -2429,7 +2434,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCurrentOffset(offset + result.messages.length)
|
const nextOffset = typeof result.nextOffset === 'number'
|
||||||
|
? result.nextOffset
|
||||||
|
: offset + result.messages.length
|
||||||
|
setCurrentOffset(nextOffset)
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
setNoMessageTable(true)
|
setNoMessageTable(true)
|
||||||
setHasMoreMessages(false)
|
setHasMoreMessages(false)
|
||||||
@@ -3567,38 +3575,38 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates])
|
const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates])
|
||||||
const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), [])
|
const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), [])
|
||||||
|
|
||||||
const lastSelectedIdRef = useRef<number | null>(null)
|
const lastSelectedKeyRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
|
const handleToggleSelection = useCallback((messageKey: string, isShiftKey: boolean = false) => {
|
||||||
setSelectedMessages(prev => {
|
setSelectedMessages(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
|
|
||||||
// Range selection with Shift key
|
// Range selection with Shift key
|
||||||
if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) {
|
if (isShiftKey && lastSelectedKeyRef.current !== null && lastSelectedKeyRef.current !== messageKey) {
|
||||||
const currentMsgs = useChatStore.getState().messages || []
|
const currentMsgs = useChatStore.getState().messages || []
|
||||||
const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current)
|
const idx1 = currentMsgs.findIndex(m => getMessageKey(m) === lastSelectedKeyRef.current)
|
||||||
const idx2 = currentMsgs.findIndex(m => m.localId === localId)
|
const idx2 = currentMsgs.findIndex(m => getMessageKey(m) === messageKey)
|
||||||
|
|
||||||
if (idx1 !== -1 && idx2 !== -1) {
|
if (idx1 !== -1 && idx2 !== -1) {
|
||||||
const start = Math.min(idx1, idx2)
|
const start = Math.min(idx1, idx2)
|
||||||
const end = Math.max(idx1, idx2)
|
const end = Math.max(idx1, idx2)
|
||||||
for (let i = start; i <= end; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
next.add(currentMsgs[i].localId)
|
next.add(getMessageKey(currentMsgs[i]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal toggle
|
// Normal toggle
|
||||||
if (next.has(localId)) {
|
if (next.has(messageKey)) {
|
||||||
next.delete(localId)
|
next.delete(messageKey)
|
||||||
lastSelectedIdRef.current = null // Reset last selection on uncheck? Or keep? Usually keep last interaction.
|
lastSelectedKeyRef.current = null
|
||||||
} else {
|
} else {
|
||||||
next.add(localId)
|
next.add(messageKey)
|
||||||
lastSelectedIdRef.current = localId
|
lastSelectedKeyRef.current = messageKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [getMessageKey])
|
||||||
|
|
||||||
const formatBatchDateLabel = useCallback((dateStr: string) => {
|
const formatBatchDateLabel = useCallback((dateStr: string) => {
|
||||||
const [y, m, d] = dateStr.split('-').map(Number)
|
const [y, m, d] = dateStr.split('-').map(Number)
|
||||||
@@ -3642,11 +3650,12 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 执行单条删除动作
|
// 执行单条删除动作
|
||||||
const performSingleDelete = async (msg: Message) => {
|
const performSingleDelete = async (msg: Message) => {
|
||||||
try {
|
try {
|
||||||
const dbPathHint = (msg as any)._db_path
|
const targetMessageKey = getMessageKey(msg)
|
||||||
|
const dbPathHint = msg._db_path
|
||||||
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint)
|
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const currentMessages = useChatStore.getState().messages || []
|
const currentMessages = useChatStore.getState().messages || []
|
||||||
const newMessages = currentMessages.filter(m => m.localId !== msg.localId)
|
const newMessages = currentMessages.filter(m => getMessageKey(m) !== targetMessageKey)
|
||||||
useChatStore.getState().setMessages(newMessages)
|
useChatStore.getState().setMessages(newMessages)
|
||||||
} else {
|
} else {
|
||||||
alert('删除失败: ' + (result.error || '原因未知'))
|
alert('删除失败: ' + (result.error || '原因未知'))
|
||||||
@@ -3708,7 +3717,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const currentMessages = useChatStore.getState().messages || []
|
const currentMessages = useChatStore.getState().messages || []
|
||||||
const newMessages = currentMessages.map(m => {
|
const newMessages = currentMessages.map(m => {
|
||||||
if (m.localId === editingMessage.message.localId) {
|
if (getMessageKey(m) === getMessageKey(editingMessage.message)) {
|
||||||
return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent }
|
return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent }
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
@@ -3749,37 +3758,44 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentMessages = useChatStore.getState().messages || []
|
const currentMessages = useChatStore.getState().messages || []
|
||||||
const selectedIds = Array.from(selectedMessages)
|
const selectedKeys = Array.from(selectedMessages)
|
||||||
const deletedIds = new Set<number>()
|
const deletedKeys = new Set<string>()
|
||||||
|
|
||||||
for (let i = 0; i < selectedIds.length; i++) {
|
for (let i = 0; i < selectedKeys.length; i++) {
|
||||||
if (cancelDeleteRef.current) break
|
if (cancelDeleteRef.current) break
|
||||||
|
|
||||||
const id = selectedIds[i]
|
const key = selectedKeys[i]
|
||||||
const msgObj = currentMessages.find(m => m.localId === id)
|
const msgObj = currentMessages.find(m => getMessageKey(m) === key)
|
||||||
const dbPathHint = (msgObj as any)?._db_path
|
const dbPathHint = msgObj?._db_path
|
||||||
const createTime = msgObj?.createTime || 0
|
const createTime = msgObj?.createTime || 0
|
||||||
|
const localId = msgObj?.localId || 0
|
||||||
|
|
||||||
try {
|
if (!msgObj) {
|
||||||
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, id, createTime, dbPathHint)
|
setDeleteProgress({ current: i + 1, total: selectedKeys.length })
|
||||||
if (result.success) {
|
continue
|
||||||
deletedIds.add(id)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`删除消息 ${id} 失败:`, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeleteProgress({ current: i + 1, total: selectedIds.length })
|
try {
|
||||||
|
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, localId, createTime, dbPathHint)
|
||||||
|
if (result.success) {
|
||||||
|
deletedKeys.add(key)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`删除消息 ${localId} 失败:`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteProgress({ current: i + 1, total: selectedKeys.length })
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId))
|
const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedKeys.has(getMessageKey(m)))
|
||||||
useChatStore.getState().setMessages(finalMessages)
|
useChatStore.getState().setMessages(finalMessages)
|
||||||
|
|
||||||
setIsSelectionMode(false)
|
setIsSelectionMode(false)
|
||||||
setSelectedMessages(new Set())
|
setSelectedMessages(new Set<string>())
|
||||||
|
lastSelectedKeyRef.current = null
|
||||||
|
|
||||||
if (cancelDeleteRef.current) {
|
if (cancelDeleteRef.current) {
|
||||||
alert(`操作已中止。已删除 ${deletedIds.size} 条,剩余记录保留。`)
|
alert(`操作已中止。已删除 ${deletedKeys.size} 条,剩余记录保留。`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('批量删除出现错误: ' + String(e))
|
alert('批量删除出现错误: ' + String(e))
|
||||||
@@ -4234,7 +4250,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
onRequireModelDownload={handleRequireModelDownload}
|
onRequireModelDownload={handleRequireModelDownload}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
isSelected={selectedMessages.has(msg.localId)}
|
messageKey={messageKey}
|
||||||
|
isSelected={selectedMessages.has(messageKey)}
|
||||||
onToggleSelection={handleToggleSelection}
|
onToggleSelection={handleToggleSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -4809,7 +4826,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="menu-item" onClick={() => {
|
<div className="menu-item" onClick={() => {
|
||||||
setIsSelectionMode(true)
|
setIsSelectionMode(true)
|
||||||
setSelectedMessages(new Set([contextMenu.message.localId]))
|
setSelectedMessages(new Set<string>([getMessageKey(contextMenu.message)]))
|
||||||
|
lastSelectedKeyRef.current = getMessageKey(contextMenu.message)
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
}}>
|
}}>
|
||||||
<CheckSquare size={16} />
|
<CheckSquare size={16} />
|
||||||
@@ -5085,7 +5103,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
className="btn-secondary"
|
className="btn-secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsSelectionMode(false)
|
setIsSelectionMode(false)
|
||||||
setSelectedMessages(new Set())
|
setSelectedMessages(new Set<string>())
|
||||||
|
lastSelectedKeyRef.current = null
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 16px',
|
padding: '6px 16px',
|
||||||
@@ -5163,6 +5182,7 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
|||||||
// 消息气泡组件
|
// 消息气泡组件
|
||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
|
messageKey,
|
||||||
session,
|
session,
|
||||||
showTime,
|
showTime,
|
||||||
myAvatarUrl,
|
myAvatarUrl,
|
||||||
@@ -5174,6 +5194,7 @@ function MessageBubble({
|
|||||||
onToggleSelection
|
onToggleSelection
|
||||||
}: {
|
}: {
|
||||||
message: Message;
|
message: Message;
|
||||||
|
messageKey: string;
|
||||||
session: ChatSession;
|
session: ChatSession;
|
||||||
showTime?: boolean;
|
showTime?: boolean;
|
||||||
myAvatarUrl?: string;
|
myAvatarUrl?: string;
|
||||||
@@ -5182,7 +5203,7 @@ function MessageBubble({
|
|||||||
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
|
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onToggleSelection?: (localId: number, isShiftKey?: boolean) => void;
|
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const isSystem = isSystemMessage(message.localType)
|
const isSystem = isSystemMessage(message.localType)
|
||||||
const isEmoji = message.localType === 47
|
const isEmoji = message.localType === 47
|
||||||
@@ -5960,7 +5981,7 @@ function MessageBubble({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggleSelection?.(message.localId, e.shiftKey)
|
onToggleSelection?.(messageKey, e.shiftKey)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -7121,7 +7142,7 @@ function MessageBubble({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggleSelection?.(message.localId, e.shiftKey)
|
onToggleSelection?.(messageKey, e.shiftKey)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -81,10 +81,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setMessages: (messages) => set({ messages }),
|
setMessages: (messages) => set({ messages }),
|
||||||
|
|
||||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||||
// 强制去重逻辑
|
|
||||||
const getMsgKey = (m: Message) => {
|
const getMsgKey = (m: Message) => {
|
||||||
if (m.localId && m.localId > 0) return `l:${m.localId}`
|
if (m.messageKey) return m.messageKey
|
||||||
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
|
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
||||||
}
|
}
|
||||||
const currentMessages = state.messages || []
|
const currentMessages = state.messages || []
|
||||||
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -183,12 +183,14 @@ export interface ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
|
nextOffset?: number;
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
hasMore?: boolean
|
hasMore?: boolean
|
||||||
|
nextOffset?: number
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface ContactInfo {
|
|||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
messageKey: string
|
||||||
localId: number
|
localId: number
|
||||||
serverId: number
|
serverId: number
|
||||||
localType: number
|
localType: number
|
||||||
@@ -105,6 +106,7 @@ export interface Message {
|
|||||||
// 聊天记录
|
// 聊天记录
|
||||||
chatRecordTitle?: string // 聊天记录标题
|
chatRecordTitle?: string // 聊天记录标题
|
||||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
||||||
|
_db_path?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聊天记录项
|
// 聊天记录项
|
||||||
|
|||||||
Reference in New Issue
Block a user