mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
计划优化 P1/5
This commit is contained in:
17
.github/ISSUE_TEMPLATE/bug.yml
vendored
17
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -19,6 +19,17 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: 我已阅读过相关文档
|
- label: 我已阅读过相关文档
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: 使用平台
|
||||||
|
description: 选择出现问题的平台
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- macOS
|
||||||
|
- Linux
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: severity
|
id: severity
|
||||||
attributes:
|
attributes:
|
||||||
@@ -76,9 +87,9 @@ body:
|
|||||||
- type: input
|
- type: input
|
||||||
id: os
|
id: os
|
||||||
attributes:
|
attributes:
|
||||||
label: 操作系统
|
label: 操作系统版本
|
||||||
description: 例如:Windows 11、macOS 14.2、Ubuntu 22.04
|
description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04
|
||||||
placeholder: Windows 11
|
placeholder: Windows 11 24H2
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
|
|||||||
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Issue Auto Assign
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, edited, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
assign-by-platform:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Assign issue by selected platform
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }}
|
||||||
|
ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }}
|
||||||
|
ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issue = context.payload.issue;
|
||||||
|
if (!issue) {
|
||||||
|
core.info("No issue payload.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = (issue.labels || []).map((l) => l.name);
|
||||||
|
if (!labels.includes("type: bug")) {
|
||||||
|
core.info("Skip non-bug issue.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = issue.body || "";
|
||||||
|
const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i);
|
||||||
|
if (!match) {
|
||||||
|
core.info("No platform field found in issue body.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPlatform = match[1].trim().toLowerCase();
|
||||||
|
let platformKey = null;
|
||||||
|
if (rawPlatform.includes("windows")) platformKey = "windows";
|
||||||
|
if (rawPlatform.includes("macos")) platformKey = "macos";
|
||||||
|
if (rawPlatform.includes("linux")) platformKey = "linux";
|
||||||
|
|
||||||
|
if (!platformKey) {
|
||||||
|
core.info(`Unrecognized platform value: ${rawPlatform}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAssignees = (value) =>
|
||||||
|
(value || "")
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const assigneeMap = {
|
||||||
|
windows: parseAssignees(process.env.ASSIGNEE_WINDOWS),
|
||||||
|
macos: parseAssignees(process.env.ASSIGNEE_MACOS),
|
||||||
|
linux: parseAssignees(process.env.ASSIGNEE_LINUX),
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidates = assigneeMap[platformKey] || [];
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
core.info(`No assignee configured for platform: ${platformKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = new Set((issue.assignees || []).map((a) => a.login));
|
||||||
|
const toAdd = candidates.filter((u) => !existing.has(u));
|
||||||
|
if (toAdd.length === 0) {
|
||||||
|
core.info("All configured assignees already assigned.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.addAssignees({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
assignees: toAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`);
|
||||||
@@ -40,6 +40,7 @@ export interface Message {
|
|||||||
messageKey: string
|
messageKey: string
|
||||||
localId: number
|
localId: number
|
||||||
serverId: number
|
serverId: number
|
||||||
|
serverIdRaw?: string
|
||||||
localType: number
|
localType: number
|
||||||
createTime: number
|
createTime: number
|
||||||
sortSeq: number
|
sortSeq: number
|
||||||
@@ -1807,6 +1808,69 @@ class ChatService {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeUnsignedIntegerToken(raw: any): string | undefined {
|
||||||
|
if (raw === undefined || raw === null || raw === '') return undefined
|
||||||
|
|
||||||
|
if (typeof raw === 'bigint') {
|
||||||
|
return raw >= 0n ? raw.toString() : '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw === 'number') {
|
||||||
|
if (!Number.isFinite(raw)) return undefined
|
||||||
|
return String(Math.max(0, Math.floor(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(raw)) {
|
||||||
|
return this.normalizeUnsignedIntegerToken(raw.toString('utf-8').trim())
|
||||||
|
}
|
||||||
|
if (raw instanceof Uint8Array) {
|
||||||
|
return this.normalizeUnsignedIntegerToken(Buffer.from(raw).toString('utf-8').trim())
|
||||||
|
}
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return this.normalizeUnsignedIntegerToken(Buffer.from(raw).toString('utf-8').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
if ('value' in raw) return this.normalizeUnsignedIntegerToken(raw.value)
|
||||||
|
if ('intValue' in raw) return this.normalizeUnsignedIntegerToken(raw.intValue)
|
||||||
|
if ('low' in raw && 'high' in raw) {
|
||||||
|
try {
|
||||||
|
const low = BigInt(raw.low >>> 0)
|
||||||
|
const high = BigInt(raw.high >>> 0)
|
||||||
|
const value = (high << 32n) + low
|
||||||
|
return value >= 0n ? value.toString() : '0'
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = raw.toString ? String(raw).trim() : ''
|
||||||
|
if (text && text !== '[object Object]') {
|
||||||
|
return this.normalizeUnsignedIntegerToken(text)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(raw).trim()
|
||||||
|
if (!text) return undefined
|
||||||
|
if (/^\d+$/.test(text)) {
|
||||||
|
return text.replace(/^0+(?=\d)/, '') || '0'
|
||||||
|
}
|
||||||
|
if (/^[+-]?\d+$/.test(text)) {
|
||||||
|
try {
|
||||||
|
const value = BigInt(text)
|
||||||
|
return value >= 0n ? value.toString() : '0'
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(text)
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return String(Math.max(0, Math.floor(parsed)))
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
private coerceRowNumber(raw: any): number {
|
private coerceRowNumber(raw: any): number {
|
||||||
if (raw === undefined || raw === null) return NaN
|
if (raw === undefined || raw === null) return NaN
|
||||||
if (typeof raw === 'number') return raw
|
if (typeof raw === 'number') return raw
|
||||||
@@ -3158,6 +3222,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
||||||
|
const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
|
||||||
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_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)
|
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
||||||
|
|
||||||
@@ -3173,6 +3238,7 @@ class ChatService {
|
|||||||
}),
|
}),
|
||||||
localId,
|
localId,
|
||||||
serverId,
|
serverId,
|
||||||
|
serverIdRaw,
|
||||||
localType,
|
localType,
|
||||||
createTime,
|
createTime,
|
||||||
sortSeq,
|
sortSeq,
|
||||||
@@ -5554,32 +5620,115 @@ class ChatService {
|
|||||||
*/
|
*/
|
||||||
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> {
|
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
const verboseVoiceTrace = process.env.WEFLOW_VOICE_TRACE === '1'
|
||||||
|
const msgCreateTimeLabel = (value?: number): string => {
|
||||||
|
return Number.isFinite(Number(value)) ? String(Math.floor(Number(value))) : '无'
|
||||||
|
}
|
||||||
|
const lookupPath: string[] = []
|
||||||
|
const logLookupPath = (status: 'success' | 'fail', error?: string): void => {
|
||||||
|
const timeline = lookupPath.map((step, idx) => `${idx + 1}.${step}`).join(' -> ')
|
||||||
|
if (status === 'success') {
|
||||||
|
if (verboseVoiceTrace) {
|
||||||
|
console.info(`[Voice] 定位流程成功: ${timeline}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[Voice] 定位流程失败${error ? `(${error})` : ''}: ${timeline}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
lookupPath.push(`会话=${sessionId}, 消息=${msgId}, 传入createTime=${msgCreateTimeLabel(createTime)}, serverId=${String(serverId || 0)}`)
|
||||||
|
lookupPath.push(`消息来源提示=${senderWxidOpt || '无'}`)
|
||||||
|
|
||||||
const localId = parseInt(msgId, 10)
|
const localId = parseInt(msgId, 10)
|
||||||
if (isNaN(localId)) {
|
if (isNaN(localId)) {
|
||||||
|
logLookupPath('fail', '无效的消息ID')
|
||||||
return { success: false, error: '无效的消息ID' }
|
return { success: false, error: '无效的消息ID' }
|
||||||
}
|
}
|
||||||
|
|
||||||
let msgCreateTime = createTime
|
let msgCreateTime = createTime
|
||||||
let senderWxid: string | null = senderWxidOpt || null
|
let senderWxid: string | null = senderWxidOpt || null
|
||||||
|
let resolvedServerId: string | number = this.normalizeUnsignedIntegerToken(serverId) || 0
|
||||||
|
let locatedMsg: Message | null = null
|
||||||
|
let rejectedNonVoiceLookup = false
|
||||||
|
|
||||||
// 如果前端没传 createTime,才需要查询消息(这个很慢)
|
lookupPath.push(`初始解析localId=${localId}成功`)
|
||||||
if (!msgCreateTime) {
|
|
||||||
|
// 已提供强键(createTime + serverId)时,直接走语音定位,避免 localId 反查噪音与误导
|
||||||
|
const hasStrongInput = Number.isFinite(Number(msgCreateTime)) && Number(msgCreateTime) > 0
|
||||||
|
&& Boolean(this.normalizeUnsignedIntegerToken(serverId))
|
||||||
|
|
||||||
|
if (hasStrongInput) {
|
||||||
|
lookupPath.push('调用入参已具备强键(createTime+serverId),跳过localId反查')
|
||||||
|
} else {
|
||||||
const t1 = Date.now()
|
const t1 = Date.now()
|
||||||
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
||||||
const t2 = Date.now()
|
const t2 = Date.now()
|
||||||
|
lookupPath.push(`消息反查耗时=${t2 - t1}ms`)
|
||||||
|
if (!msgResult.success || !msgResult.message) {
|
||||||
|
lookupPath.push('未命中: getMessageByLocalId')
|
||||||
|
} else {
|
||||||
|
const dbMsg = msgResult.message as Message
|
||||||
|
const locatedServerId = this.normalizeUnsignedIntegerToken(dbMsg.serverIdRaw ?? dbMsg.serverId)
|
||||||
|
const incomingServerId = this.normalizeUnsignedIntegerToken(serverId)
|
||||||
|
lookupPath.push(`命中消息定位: localId=${dbMsg.localId}, createTime=${dbMsg.createTime}, sender=${dbMsg.senderUsername || ''}, serverId=${locatedServerId || '0'}, localType=${dbMsg.localType}, voice时长=${dbMsg.voiceDurationSeconds ?? 0}`)
|
||||||
|
|
||||||
|
if (incomingServerId && locatedServerId && incomingServerId !== locatedServerId) {
|
||||||
|
lookupPath.push(`serverId纠正: input=${incomingServerId}, db=${locatedServerId}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (msgResult.success && msgResult.message) {
|
// localId 在不同表可能重复,反查命中非语音时不覆盖调用侧入参
|
||||||
const msg = msgResult.message as any
|
if (Number(dbMsg.localType) === 34) {
|
||||||
msgCreateTime = msg.createTime
|
locatedMsg = dbMsg
|
||||||
senderWxid = msg.senderUsername || null
|
msgCreateTime = dbMsg.createTime || msgCreateTime
|
||||||
|
senderWxid = dbMsg.senderUsername || senderWxid || null
|
||||||
|
if (locatedServerId) {
|
||||||
|
resolvedServerId = locatedServerId
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rejectedNonVoiceLookup = true
|
||||||
|
lookupPath.push('消息反查命中但localType!=34,忽略反查覆盖,继续使用调用入参定位')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!msgCreateTime) {
|
if (!msgCreateTime) {
|
||||||
|
lookupPath.push('定位失败: 未找到消息时间戳')
|
||||||
|
logLookupPath('fail', '未找到消息时间戳')
|
||||||
return { success: false, error: '未找到消息时间戳' }
|
return { success: false, error: '未找到消息时间戳' }
|
||||||
}
|
}
|
||||||
|
if (!locatedMsg) {
|
||||||
|
lookupPath.push(rejectedNonVoiceLookup
|
||||||
|
? `定位结果: 反查命中非语音并已忽略, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`
|
||||||
|
: `定位结果: 未走消息反查流程, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`)
|
||||||
|
} else {
|
||||||
|
lookupPath.push(`定位结果: 语音消息被确认 localId=${localId}, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`)
|
||||||
|
}
|
||||||
|
lookupPath.push(`最终serverId=${String(resolvedServerId || 0)}`)
|
||||||
|
|
||||||
|
if (verboseVoiceTrace) {
|
||||||
|
if (locatedMsg) {
|
||||||
|
console.log('[Voice] 定位到的具体语音消息:', {
|
||||||
|
sessionId,
|
||||||
|
msgId,
|
||||||
|
localId: locatedMsg.localId,
|
||||||
|
createTime: locatedMsg.createTime,
|
||||||
|
senderUsername: locatedMsg.senderUsername,
|
||||||
|
serverId: locatedMsg.serverIdRaw || locatedMsg.serverId,
|
||||||
|
localType: locatedMsg.localType,
|
||||||
|
voiceDurationSeconds: locatedMsg.voiceDurationSeconds
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('[Voice] 定位到的语音消息:', {
|
||||||
|
sessionId,
|
||||||
|
msgId,
|
||||||
|
localId,
|
||||||
|
createTime: msgCreateTime,
|
||||||
|
senderUsername: senderWxid,
|
||||||
|
serverId: resolvedServerId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 sessionId + createTime + msgId 作为缓存 key,避免同秒语音串音
|
// 使用 sessionId + createTime + msgId 作为缓存 key,避免同秒语音串音
|
||||||
const cacheKey = this.getVoiceCacheKey(sessionId, String(localId), msgCreateTime)
|
const cacheKey = this.getVoiceCacheKey(sessionId, String(localId), msgCreateTime)
|
||||||
@@ -5587,6 +5736,8 @@ class ChatService {
|
|||||||
// 检查 WAV 内存缓存
|
// 检查 WAV 内存缓存
|
||||||
const wavCache = this.voiceWavCache.get(cacheKey)
|
const wavCache = this.voiceWavCache.get(cacheKey)
|
||||||
if (wavCache) {
|
if (wavCache) {
|
||||||
|
lookupPath.push('命中内存WAV缓存')
|
||||||
|
logLookupPath('success', '内存缓存')
|
||||||
return { success: true, data: wavCache.toString('base64') }
|
return { success: true, data: wavCache.toString('base64') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5597,11 +5748,15 @@ class ChatService {
|
|||||||
try {
|
try {
|
||||||
const wavData = readFileSync(wavFilePath)
|
const wavData = readFileSync(wavFilePath)
|
||||||
this.cacheVoiceWav(cacheKey, wavData)
|
this.cacheVoiceWav(cacheKey, wavData)
|
||||||
|
lookupPath.push('命中磁盘WAV缓存')
|
||||||
|
logLookupPath('success', '磁盘缓存')
|
||||||
return { success: true, data: wavData.toString('base64') }
|
return { success: true, data: wavData.toString('base64') }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
lookupPath.push('命中磁盘WAV缓存但读取失败')
|
||||||
console.error('[Voice] 读取缓存文件失败:', e)
|
console.error('[Voice] 读取缓存文件失败:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lookupPath.push('缓存未命中,进入DB定位')
|
||||||
|
|
||||||
// 构建查找候选
|
// 构建查找候选
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
@@ -5621,31 +5776,39 @@ class ChatService {
|
|||||||
if (myWxid && !candidates.includes(myWxid)) {
|
if (myWxid && !candidates.includes(myWxid)) {
|
||||||
candidates.push(myWxid)
|
candidates.push(myWxid)
|
||||||
}
|
}
|
||||||
|
lookupPath.push(`定位候选链=${JSON.stringify(candidates)}`)
|
||||||
|
|
||||||
const t3 = Date.now()
|
const t3 = Date.now()
|
||||||
// 从数据库读取 silk 数据
|
// 从数据库读取 silk 数据
|
||||||
const silkData = await this.getVoiceDataFromMediaDb(sessionId, msgCreateTime, localId, serverId || 0, candidates)
|
const silkData = await this.getVoiceDataFromMediaDb(sessionId, msgCreateTime, localId, resolvedServerId || 0, candidates, lookupPath, myWxid)
|
||||||
const t4 = Date.now()
|
const t4 = Date.now()
|
||||||
|
lookupPath.push(`DB定位耗时=${t4 - t3}ms`)
|
||||||
|
|
||||||
|
|
||||||
if (!silkData) {
|
if (!silkData) {
|
||||||
|
logLookupPath('fail', '未找到语音数据')
|
||||||
return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' }
|
return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' }
|
||||||
}
|
}
|
||||||
|
lookupPath.push('语音二进制定位完成')
|
||||||
|
|
||||||
const t5 = Date.now()
|
const t5 = Date.now()
|
||||||
// 使用 silk-wasm 解码
|
// 使用 silk-wasm 解码
|
||||||
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||||
const t6 = Date.now()
|
const t6 = Date.now()
|
||||||
|
lookupPath.push(`silk解码耗时=${t6 - t5}ms`)
|
||||||
|
|
||||||
|
|
||||||
if (!pcmData) {
|
if (!pcmData) {
|
||||||
|
logLookupPath('fail', 'Silk解码失败')
|
||||||
return { success: false, error: 'Silk 解码失败' }
|
return { success: false, error: 'Silk 解码失败' }
|
||||||
}
|
}
|
||||||
|
lookupPath.push('silk解码成功')
|
||||||
|
|
||||||
const t7 = Date.now()
|
const t7 = Date.now()
|
||||||
// PCM -> WAV
|
// PCM -> WAV
|
||||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||||
const t8 = Date.now()
|
const t8 = Date.now()
|
||||||
|
lookupPath.push(`WAV转码耗时=${t8 - t7}ms`)
|
||||||
|
|
||||||
|
|
||||||
// 缓存 WAV 数据到内存
|
// 缓存 WAV 数据到内存
|
||||||
@@ -5654,9 +5817,13 @@ class ChatService {
|
|||||||
// 缓存 WAV 数据到文件(异步,不阻塞返回)
|
// 缓存 WAV 数据到文件(异步,不阻塞返回)
|
||||||
this.cacheVoiceWavToFile(cacheKey, wavData)
|
this.cacheVoiceWavToFile(cacheKey, wavData)
|
||||||
|
|
||||||
|
lookupPath.push(`总耗时=${t8 - startTime}ms`)
|
||||||
|
logLookupPath('success')
|
||||||
|
|
||||||
return { success: true, data: wavData.toString('base64') }
|
return { success: true, data: wavData.toString('base64') }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
lookupPath.push(`异常: ${String(e)}`)
|
||||||
|
logLookupPath('fail', String(e))
|
||||||
console.error('ChatService: getVoiceData 失败:', e)
|
console.error('ChatService: getVoiceData 失败:', e)
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
@@ -5685,38 +5852,89 @@ class ChatService {
|
|||||||
createTime: number,
|
createTime: number,
|
||||||
localId: number,
|
localId: number,
|
||||||
svrId: string | number,
|
svrId: string | number,
|
||||||
candidates: string[]
|
candidates: string[],
|
||||||
|
lookupPath?: string[],
|
||||||
|
myWxid?: string
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
|
const candidatesList = Array.isArray(candidates)
|
||||||
|
? candidates.filter((value, index, arr) => {
|
||||||
|
const key = String(value || '').trim()
|
||||||
|
return Boolean(key) && arr.findIndex(v => String(v || '').trim() === key) === index
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
const createTimeInt = Math.max(0, Math.floor(Number(createTime || 0)))
|
||||||
|
const localIdInt = Math.max(0, Math.floor(Number(localId || 0)))
|
||||||
|
const svrIdToken = svrId || 0
|
||||||
|
|
||||||
|
const plans: Array<{ label: string; list: string[] }> = []
|
||||||
|
if (candidatesList.length > 0) {
|
||||||
|
const strict = String(myWxid || '').trim()
|
||||||
|
? candidatesList.filter(item => item !== String(myWxid || '').trim())
|
||||||
|
: candidatesList.slice()
|
||||||
|
if (strict.length > 0 && strict.length !== candidatesList.length) {
|
||||||
|
plans.push({ label: 'strict(no-self)', list: strict })
|
||||||
|
}
|
||||||
|
plans.push({ label: 'full', list: candidatesList })
|
||||||
|
} else {
|
||||||
|
plans.push({ label: 'empty', list: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupPath?.push(`构建音频查询参数 createTime=${createTimeInt}, localId=${localIdInt}, svrId=${svrIdToken}, plans=${plans.map(p => `${p.label}:${p.list.length}`).join('|')}`)
|
||||||
|
|
||||||
|
for (const plan of plans) {
|
||||||
|
lookupPath?.push(`尝试候选集[${plan.label}]=${JSON.stringify(plan.list)}`)
|
||||||
|
// 先走单条 native:svr_id 通过 int64 直传,避免 batch JSON 的大整数精度/解析差异
|
||||||
|
lookupPath?.push(`先尝试单条查询(${plan.label})`)
|
||||||
|
const single = await wcdbService.getVoiceData(
|
||||||
|
sessionId,
|
||||||
|
createTimeInt,
|
||||||
|
plan.list,
|
||||||
|
localIdInt,
|
||||||
|
svrIdToken
|
||||||
|
)
|
||||||
|
lookupPath?.push(`单条查询(${plan.label})结果: success=${single.success}, hasHex=${Boolean(single.hex)}`)
|
||||||
|
if (single.success && single.hex) {
|
||||||
|
const decoded = this.decodeVoiceBlob(single.hex)
|
||||||
|
if (decoded && decoded.length > 0) {
|
||||||
|
lookupPath?.push(`单条查询(${plan.label})解码成功`)
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
lookupPath?.push(`单条查询(${plan.label})解码为空`)
|
||||||
|
}
|
||||||
|
|
||||||
const batchResult = await wcdbService.getVoiceDataBatch([{
|
const batchResult = await wcdbService.getVoiceDataBatch([{
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
create_time: Math.max(0, Math.floor(Number(createTime || 0))),
|
create_time: createTimeInt,
|
||||||
local_id: Math.max(0, Math.floor(Number(localId || 0))),
|
local_id: localIdInt,
|
||||||
svr_id: svrId || 0,
|
svr_id: svrIdToken,
|
||||||
candidates: Array.isArray(candidates) ? candidates : []
|
candidates: plan.list
|
||||||
}])
|
}])
|
||||||
|
lookupPath?.push(`批量查询(${plan.label})结果: success=${batchResult.success}, rows=${Array.isArray(batchResult.rows) ? batchResult.rows.length : 0}`)
|
||||||
|
if (!batchResult.success) {
|
||||||
|
lookupPath?.push(`批量查询(${plan.label})失败: ${batchResult.error || '无错误信息'}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (batchResult.success && Array.isArray(batchResult.rows) && batchResult.rows.length > 0) {
|
if (batchResult.success && Array.isArray(batchResult.rows) && batchResult.rows.length > 0) {
|
||||||
const hex = String(batchResult.rows[0]?.hex || '').trim()
|
const hex = String(batchResult.rows[0]?.hex || '').trim()
|
||||||
|
lookupPath?.push(`命中批量结果(${plan.label})[0], hexLen=${hex.length}`)
|
||||||
if (hex) {
|
if (hex) {
|
||||||
const decoded = this.decodeVoiceBlob(hex)
|
const decoded = this.decodeVoiceBlob(hex)
|
||||||
if (decoded && decoded.length > 0) return decoded
|
if (decoded && decoded.length > 0) {
|
||||||
|
lookupPath?.push(`批量结果(${plan.label})解码成功`)
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
lookupPath?.push(`批量结果(${plan.label})解码为空`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lookupPath?.push(`批量结果(${plan.label})未命中`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback-native: 受控回退到旧单条 native 查询
|
lookupPath?.push('音频定位失败:未命中任何结果')
|
||||||
const single = await wcdbService.getVoiceData(
|
|
||||||
sessionId,
|
|
||||||
Math.max(0, Math.floor(Number(createTime || 0))),
|
|
||||||
Array.isArray(candidates) ? candidates : [],
|
|
||||||
Math.max(0, Math.floor(Number(localId || 0))),
|
|
||||||
svrId || 0
|
|
||||||
)
|
|
||||||
if (single.success && single.hex) {
|
|
||||||
const decoded = this.decodeVoiceBlob(single.hex)
|
|
||||||
if (decoded && decoded.length > 0) return decoded
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
lookupPath?.push(`音频定位异常: ${String(e)}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5870,7 +6088,7 @@ class ChatService {
|
|||||||
if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' }
|
if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' }
|
||||||
const msg = msgResult.message
|
const msg = msgResult.message
|
||||||
const senderWxid = msg.senderUsername || undefined
|
const senderWxid = msg.senderUsername || undefined
|
||||||
return this.getVoiceData(sessionId, msgId, msg.createTime, msg.serverId, senderWxid)
|
return this.getVoiceData(sessionId, msgId, msg.createTime, msg.serverIdRaw || msg.serverId, senderWxid)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: getVoiceData 失败:', e)
|
console.error('ChatService: getVoiceData 失败:', e)
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
@@ -5960,7 +6178,7 @@ class ChatService {
|
|||||||
|
|
||||||
if (msgResult.success && msgResult.message) {
|
if (msgResult.success && msgResult.message) {
|
||||||
msgCreateTime = msgResult.message.createTime
|
msgCreateTime = msgResult.message.createTime
|
||||||
serverId = msgResult.message.serverId
|
serverId = msgResult.message.serverIdRaw || msgResult.message.serverId
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6326,6 +6544,11 @@ class ChatService {
|
|||||||
|
|
||||||
for (const row of result.messages) {
|
for (const row of result.messages) {
|
||||||
let message = await this.parseMessage(row, { source: 'search', sessionId })
|
let message = await this.parseMessage(row, { source: 'search', sessionId })
|
||||||
|
const resolvedSessionId = String(
|
||||||
|
sessionId ||
|
||||||
|
this.getRowField(row, ['_session_id', 'session_id', 'sessionId', 'talker', 'username'])
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
const needsDetailHydration = isGroupSearch &&
|
const needsDetailHydration = isGroupSearch &&
|
||||||
Boolean(sessionId) &&
|
Boolean(sessionId) &&
|
||||||
message.localId > 0 &&
|
message.localId > 0 &&
|
||||||
@@ -6344,19 +6567,9 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGroupSearch && (needsDetailHydration || message.isSend === 1)) {
|
if (resolvedSessionId) {
|
||||||
console.info('[ChatService][GroupSearchHydratedHit]', {
|
;(message as Message & { sessionId?: string }).sessionId = resolvedSessionId
|
||||||
sessionId,
|
|
||||||
localId: message.localId,
|
|
||||||
senderUsername: message.senderUsername,
|
|
||||||
isSend: message.isSend,
|
|
||||||
senderDisplayName: message.senderDisplayName,
|
|
||||||
senderAvatarUrl: message.senderAvatarUrl,
|
|
||||||
usedDetailHydration: needsDetailHydration,
|
|
||||||
parsedContent: message.parsedContent
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push(message)
|
messages.push(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6390,6 +6603,7 @@ 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 localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
||||||
|
const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
|
||||||
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_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 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 createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
||||||
@@ -6409,6 +6623,7 @@ class ChatService {
|
|||||||
}),
|
}),
|
||||||
localId,
|
localId,
|
||||||
serverId,
|
serverId,
|
||||||
|
serverIdRaw,
|
||||||
localType,
|
localType,
|
||||||
createTime,
|
createTime,
|
||||||
sortSeq,
|
sortSeq,
|
||||||
@@ -6433,19 +6648,6 @@ class ChatService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.source === 'search' && String(options.sessionId || '').endsWith('@chatroom') && sendState.selfMatched) {
|
|
||||||
console.info('[ChatService][GroupSearchSelfHit]', {
|
|
||||||
sessionId: options.sessionId,
|
|
||||||
localId,
|
|
||||||
createTime,
|
|
||||||
senderUsername,
|
|
||||||
rawIsSend,
|
|
||||||
resolvedIsSend: sendState.isSend,
|
|
||||||
correctedBySelfIdentity: sendState.correctedBySelfIdentity,
|
|
||||||
rowKeys: Object.keys(row)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法)
|
// 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法)
|
||||||
if (msg.localType === 3) { // Image
|
if (msg.localType === 3) { // Image
|
||||||
const imgInfo = this.parseImageInfo(rawContent)
|
const imgInfo = this.parseImageInfo(rawContent)
|
||||||
|
|||||||
@@ -1233,6 +1233,28 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private preserveInt64FieldsInJson(jsonStr: string, fieldNames: string[]): string {
|
||||||
|
let normalized = String(jsonStr || '')
|
||||||
|
for (const fieldName of fieldNames) {
|
||||||
|
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const pattern = new RegExp(`("${escaped}"\\s*:\\s*)(-?\\d{16,})`, 'g')
|
||||||
|
normalized = normalized.replace(pattern, '$1"$2"')
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMessageJson(jsonStr: string): any {
|
||||||
|
const normalized = this.preserveInt64FieldsInJson(jsonStr, [
|
||||||
|
'server_id',
|
||||||
|
'serverId',
|
||||||
|
'ServerId',
|
||||||
|
'msg_server_id',
|
||||||
|
'msgServerId',
|
||||||
|
'MsgServerId'
|
||||||
|
])
|
||||||
|
return JSON.parse(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
private ensureReady(): boolean {
|
private ensureReady(): boolean {
|
||||||
return this.initialized && this.handle !== null
|
return this.initialized && this.handle !== null
|
||||||
}
|
}
|
||||||
@@ -1426,7 +1448,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析消息失败' }
|
if (!jsonStr) return { success: false, error: '解析消息失败' }
|
||||||
const messages = JSON.parse(jsonStr)
|
const messages = this.parseMessageJson(jsonStr)
|
||||||
return { success: true, messages }
|
return { success: true, messages }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
@@ -2491,7 +2513,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析批次失败' }
|
if (!jsonStr) return { success: false, error: '解析批次失败' }
|
||||||
const rows = JSON.parse(jsonStr)
|
const rows = this.parseMessageJson(jsonStr)
|
||||||
return { success: true, rows, hasMore: outHasMore[0] === 1 }
|
return { success: true, rows, hasMore: outHasMore[0] === 1 }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
@@ -2644,7 +2666,7 @@ export class WcdbCore {
|
|||||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` }
|
if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` }
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析消息失败' }
|
if (!jsonStr) return { success: false, error: '解析消息失败' }
|
||||||
const message = JSON.parse(jsonStr)
|
const message = this.parseMessageJson(jsonStr)
|
||||||
// 处理 wcdb_get_message_by_id 返回空对象的情况
|
// 处理 wcdb_get_message_by_id 返回空对象的情况
|
||||||
if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' }
|
if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' }
|
||||||
return { success: true, message }
|
return { success: true, message }
|
||||||
@@ -2862,7 +2884,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析搜索结果失败' }
|
if (!jsonStr) return { success: false, error: '解析搜索结果失败' }
|
||||||
const messages = JSON.parse(jsonStr)
|
const messages = this.parseMessageJson(jsonStr)
|
||||||
return { success: true, messages }
|
return { success: true, messages }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
|
|||||||
Binary file not shown.
@@ -4800,6 +4800,18 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.search-phase-hint {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
&.done {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局消息搜索结果面板
|
// 全局消息搜索结果面板
|
||||||
|
|||||||
@@ -41,6 +41,122 @@ interface PendingInSessionSearchPayload {
|
|||||||
results: Message[]
|
results: Message[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done'
|
||||||
|
type GlobalMsgSearchResult = Message & { sessionId: string }
|
||||||
|
|
||||||
|
interface GlobalMsgPrefixCacheEntry {
|
||||||
|
keyword: string
|
||||||
|
matchedSessionIds: Set<string>
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL_MSG_PER_SESSION_LIMIT = 10
|
||||||
|
const GLOBAL_MSG_SEED_LIMIT = 120
|
||||||
|
const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3
|
||||||
|
const GLOBAL_MSG_LEGACY_CONCURRENCY = 6
|
||||||
|
const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__'
|
||||||
|
const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2
|
||||||
|
const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare'
|
||||||
|
|
||||||
|
function isGlobalMsgSearchCanceled(error: unknown): boolean {
|
||||||
|
return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalMsgSearchSessionId(value: unknown): string | null {
|
||||||
|
const sessionId = String(value || '').trim()
|
||||||
|
if (!sessionId) return null
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalMsgSearchMessages(
|
||||||
|
messages: Message[] | undefined,
|
||||||
|
fallbackSessionId?: string
|
||||||
|
): GlobalMsgSearchResult[] {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return []
|
||||||
|
const dedup = new Set<string>()
|
||||||
|
const normalized: GlobalMsgSearchResult[] = []
|
||||||
|
const normalizedFallback = normalizeGlobalMsgSearchSessionId(fallbackSessionId)
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const raw = message as Message & { sessionId?: string; _session_id?: string }
|
||||||
|
const sessionId = normalizeGlobalMsgSearchSessionId(raw.sessionId || raw._session_id || normalizedFallback)
|
||||||
|
if (!sessionId) continue
|
||||||
|
const uniqueKey = raw.localId > 0
|
||||||
|
? `${sessionId}::local:${raw.localId}`
|
||||||
|
: `${sessionId}::key:${raw.messageKey || ''}:${raw.createTime || 0}`
|
||||||
|
if (dedup.has(uniqueKey)) continue
|
||||||
|
dedup.add(uniqueKey)
|
||||||
|
normalized.push({ ...message, sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGlobalMsgSearchSessionMap(messages: GlobalMsgSearchResult[]): Map<string, GlobalMsgSearchResult[]> {
|
||||||
|
const map = new Map<string, GlobalMsgSearchResult[]>()
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!message.sessionId) continue
|
||||||
|
const list = map.get(message.sessionId) || []
|
||||||
|
if (list.length >= GLOBAL_MSG_PER_SESSION_LIMIT) continue
|
||||||
|
list.push(message)
|
||||||
|
map.set(message.sessionId, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenGlobalMsgSearchSessionMap(map: Map<string, GlobalMsgSearchResult[]>): GlobalMsgSearchResult[] {
|
||||||
|
const all: GlobalMsgSearchResult[] = []
|
||||||
|
for (const list of map.values()) {
|
||||||
|
if (list.length > 0) all.push(...list)
|
||||||
|
}
|
||||||
|
return sortMessagesByCreateTimeDesc(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeGlobalMsgSearchResults(
|
||||||
|
seedMap: Map<string, GlobalMsgSearchResult[]>,
|
||||||
|
authoritativeMap: Map<string, GlobalMsgSearchResult[]>
|
||||||
|
): GlobalMsgSearchResult[] {
|
||||||
|
const merged = new Map<string, GlobalMsgSearchResult[]>()
|
||||||
|
for (const [sessionId, seedRows] of seedMap.entries()) {
|
||||||
|
if (authoritativeMap.has(sessionId)) {
|
||||||
|
merged.set(sessionId, authoritativeMap.get(sessionId) || [])
|
||||||
|
} else {
|
||||||
|
merged.set(sessionId, seedRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [sessionId, rows] of authoritativeMap.entries()) {
|
||||||
|
if (!merged.has(sessionId)) merged.set(sessionId, rows)
|
||||||
|
}
|
||||||
|
return flattenGlobalMsgSearchSessionMap(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRunGlobalMsgShadowCompareSample(): boolean {
|
||||||
|
if (!import.meta.env.DEV) return false
|
||||||
|
try {
|
||||||
|
const forced = window.localStorage.getItem(GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY)
|
||||||
|
if (forced === '1') return true
|
||||||
|
if (forced === '0') return false
|
||||||
|
} catch {
|
||||||
|
// ignore storage read failures
|
||||||
|
}
|
||||||
|
return Math.random() < GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGlobalMsgSearchSessionLocalIds(results: GlobalMsgSearchResult[]): Record<string, number[]> {
|
||||||
|
const grouped = new Map<string, number[]>()
|
||||||
|
for (const row of results) {
|
||||||
|
if (!row.sessionId || row.localId <= 0) continue
|
||||||
|
const list = grouped.get(row.sessionId) || []
|
||||||
|
list.push(row.localId)
|
||||||
|
grouped.set(row.sessionId, list)
|
||||||
|
}
|
||||||
|
const output: Record<string, number[]> = {}
|
||||||
|
for (const [sessionId, localIds] of grouped.entries()) {
|
||||||
|
output[sessionId] = localIds
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'localId'>>(items: T[]): T[] {
|
function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'localId'>>(items: T[]): T[] {
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
|
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
|
||||||
@@ -594,9 +710,6 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
<span className="session-name">
|
<span className="session-name">
|
||||||
{(() => {
|
{(() => {
|
||||||
const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword
|
const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword
|
||||||
if (shouldHighlight) {
|
|
||||||
console.log('高亮名字:', session.displayName, 'keyword:', searchKeyword)
|
|
||||||
}
|
|
||||||
return shouldHighlight ? (
|
return shouldHighlight ? (
|
||||||
<HighlightText text={session.displayName || session.username} keyword={searchKeyword} />
|
<HighlightText text={session.displayName || session.username} keyword={searchKeyword} />
|
||||||
) : (
|
) : (
|
||||||
@@ -795,11 +908,15 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 全局消息搜索
|
// 全局消息搜索
|
||||||
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
|
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
|
||||||
const [globalMsgQuery, setGlobalMsgQuery] = useState('')
|
const [globalMsgQuery, setGlobalMsgQuery] = useState('')
|
||||||
const [globalMsgResults, setGlobalMsgResults] = useState<Array<Message & { sessionId: string }>>([])
|
const [globalMsgResults, setGlobalMsgResults] = useState<GlobalMsgSearchResult[]>([])
|
||||||
const [globalMsgSearching, setGlobalMsgSearching] = useState(false)
|
const [globalMsgSearching, setGlobalMsgSearching] = useState(false)
|
||||||
|
const [globalMsgSearchPhase, setGlobalMsgSearchPhase] = useState<GlobalMsgSearchPhase>('idle')
|
||||||
|
const [globalMsgIsBackfilling, setGlobalMsgIsBackfilling] = useState(false)
|
||||||
|
const [globalMsgAuthoritativeSessionCount, setGlobalMsgAuthoritativeSessionCount] = useState(0)
|
||||||
const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
|
const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
|
||||||
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
|
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
|
||||||
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
|
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
|
||||||
|
const globalMsgPrefixCacheRef = useRef<GlobalMsgPrefixCacheEntry | null>(null)
|
||||||
|
|
||||||
// 自定义删除确认对话框
|
// 自定义删除确认对话框
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
@@ -2887,22 +3004,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
? (senderAvatarUrl || myAvatarUrl)
|
? (senderAvatarUrl || myAvatarUrl)
|
||||||
: (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined))
|
: (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined))
|
||||||
|
|
||||||
if (inferredSelfFromSender) {
|
|
||||||
console.info('[InSessionSearch][GroupSelfHit][hydrate]', {
|
|
||||||
sessionId: normalizedSessionId,
|
|
||||||
localId: message.localId,
|
|
||||||
senderUsername,
|
|
||||||
rawIsSend: message.isSend,
|
|
||||||
nextIsSend,
|
|
||||||
rawSenderDisplayName: message.senderDisplayName,
|
|
||||||
nextSenderDisplayName,
|
|
||||||
rawSenderAvatarUrl: message.senderAvatarUrl,
|
|
||||||
nextSenderAvatarUrl,
|
|
||||||
myWxid,
|
|
||||||
hasMyAvatarUrl: Boolean(myAvatarUrl)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
senderUsername === message.senderUsername &&
|
senderUsername === message.senderUsername &&
|
||||||
nextIsSend === message.isSend &&
|
nextIsSend === message.isSend &&
|
||||||
@@ -3109,24 +3210,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
(isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)
|
(isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (inferredSelfFromSender) {
|
|
||||||
console.info('[InSessionSearch][GroupSelfHit][enrich]', {
|
|
||||||
sessionId: normalizedSessionId,
|
|
||||||
localId: message.localId,
|
|
||||||
senderUsername: sender,
|
|
||||||
rawIsSend: message.isSend,
|
|
||||||
nextIsSend,
|
|
||||||
profileDisplayName,
|
|
||||||
currentSenderDisplayName,
|
|
||||||
nextSenderDisplayName,
|
|
||||||
profileAvatarUrl: normalizeSearchAvatarUrl(profile?.avatarUrl),
|
|
||||||
currentSenderAvatarUrl,
|
|
||||||
nextSenderAvatarUrl,
|
|
||||||
myWxid,
|
|
||||||
hasMyAvatarUrl: Boolean(myAvatarUrl)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
sender === message.senderUsername &&
|
sender === message.senderUsername &&
|
||||||
nextIsSend === message.isSend &&
|
nextIsSend === message.isSend &&
|
||||||
@@ -3181,8 +3264,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
||||||
if (currentSessionRef.current !== normalizedSessionId) return
|
if (currentSessionRef.current !== normalizedSessionId) return
|
||||||
setInSessionResults(enrichedResults)
|
setInSessionResults(enrichedResults)
|
||||||
}).catch((error) => {
|
}).catch(() => {
|
||||||
console.warn('[InSessionSearch] 恢复全局搜索结果发送者信息失败:', error)
|
// ignore sender enrichment errors and keep current search results usable
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
||||||
if (currentSessionRef.current !== normalizedSessionId) return
|
if (currentSessionRef.current !== normalizedSessionId) return
|
||||||
@@ -3382,8 +3465,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => {
|
void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => {
|
||||||
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||||
setInSessionResults(enriched)
|
setInSessionResults(enriched)
|
||||||
}).catch((error) => {
|
}).catch(() => {
|
||||||
console.warn('[InSessionSearch] 补充发送者信息失败:', error)
|
// ignore sender enrichment errors and keep current search results usable
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||||
setInSessionEnriching(false)
|
setInSessionEnriching(false)
|
||||||
@@ -3417,55 +3500,31 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 全局消息搜索
|
// 全局消息搜索
|
||||||
const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const globalMsgSearchGenRef = useRef(0)
|
const globalMsgSearchGenRef = useRef(0)
|
||||||
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
|
const ensureGlobalMsgSearchNotStale = useCallback((gen: number) => {
|
||||||
const normalizedKeyword = keyword.trim()
|
if (gen !== globalMsgSearchGenRef.current) {
|
||||||
setGlobalMsgQuery(keyword)
|
throw new Error(GLOBAL_MSG_SEARCH_CANCELED_ERROR)
|
||||||
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
|
||||||
globalMsgSearchTimerRef.current = null
|
|
||||||
globalMsgSearchGenRef.current += 1
|
|
||||||
if (!normalizedKeyword) {
|
|
||||||
pendingGlobalMsgSearchReplayRef.current = null
|
|
||||||
setGlobalMsgResults([])
|
|
||||||
setGlobalMsgSearchError(null)
|
|
||||||
setShowGlobalMsgSearch(false)
|
|
||||||
setGlobalMsgSearching(false)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
setShowGlobalMsgSearch(true)
|
}, [])
|
||||||
setGlobalMsgSearchError(null)
|
|
||||||
|
|
||||||
const sessionList = Array.isArray(sessionsRef.current) ? sessionsRef.current.filter((session) => String(session.username || '').trim()) : []
|
const runLegacyGlobalMsgSearch = useCallback(async (
|
||||||
if (!isConnectedRef.current || sessionList.length === 0) {
|
keyword: string,
|
||||||
pendingGlobalMsgSearchReplayRef.current = normalizedKeyword
|
sessionList: ChatSession[],
|
||||||
setGlobalMsgResults([])
|
gen: number
|
||||||
setGlobalMsgSearchError(null)
|
): Promise<GlobalMsgSearchResult[]> => {
|
||||||
setGlobalMsgSearching(false)
|
const results: GlobalMsgSearchResult[] = []
|
||||||
return
|
for (let index = 0; index < sessionList.length; index += GLOBAL_MSG_LEGACY_CONCURRENCY) {
|
||||||
}
|
ensureGlobalMsgSearchNotStale(gen)
|
||||||
|
const chunk = sessionList.slice(index, index + GLOBAL_MSG_LEGACY_CONCURRENCY)
|
||||||
pendingGlobalMsgSearchReplayRef.current = null
|
|
||||||
const gen = globalMsgSearchGenRef.current
|
|
||||||
globalMsgSearchTimerRef.current = setTimeout(async () => {
|
|
||||||
if (gen !== globalMsgSearchGenRef.current) return
|
|
||||||
setGlobalMsgSearching(true)
|
|
||||||
try {
|
|
||||||
const results: Array<Message & { sessionId: string }> = []
|
|
||||||
const concurrency = 6
|
|
||||||
|
|
||||||
for (let index = 0; index < sessionList.length; index += concurrency) {
|
|
||||||
const chunk = sessionList.slice(index, index + concurrency)
|
|
||||||
const chunkResults = await Promise.allSettled(
|
const chunkResults = await Promise.allSettled(
|
||||||
chunk.map(async (session) => {
|
chunk.map(async (session) => {
|
||||||
const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, 10, 0)
|
const res = await window.electronAPI.chat.searchMessages(keyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0)
|
||||||
if (!res?.success) {
|
if (!res?.success) {
|
||||||
throw new Error(res?.error || `搜索失败: ${session.username}`)
|
throw new Error(res?.error || `搜索失败: ${session.username}`)
|
||||||
}
|
}
|
||||||
if (!res?.messages?.length) return []
|
return normalizeGlobalMsgSearchMessages(res?.messages || [], session.username)
|
||||||
return res.messages.map((msg) => ({ ...msg, sessionId: session.username }))
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
ensureGlobalMsgSearchNotStale(gen)
|
||||||
if (gen !== globalMsgSearchGenRef.current) return
|
|
||||||
|
|
||||||
for (const item of chunkResults) {
|
for (const item of chunkResults) {
|
||||||
if (item.status === 'rejected') {
|
if (item.status === 'rejected') {
|
||||||
@@ -3476,36 +3535,244 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return sortMessagesByCreateTimeDesc(results)
|
||||||
|
}, [ensureGlobalMsgSearchNotStale])
|
||||||
|
|
||||||
results.sort((a, b) => {
|
const compareGlobalMsgSearchShadow = useCallback((
|
||||||
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
|
keyword: string,
|
||||||
if (timeDiff !== 0) return timeDiff
|
stagedResults: GlobalMsgSearchResult[],
|
||||||
return (b.localId || 0) - (a.localId || 0)
|
legacyResults: GlobalMsgSearchResult[]
|
||||||
|
) => {
|
||||||
|
const stagedMap = buildGlobalMsgSearchSessionLocalIds(stagedResults)
|
||||||
|
const legacyMap = buildGlobalMsgSearchSessionLocalIds(legacyResults)
|
||||||
|
const stagedSessions = Object.keys(stagedMap).sort()
|
||||||
|
const legacySessions = Object.keys(legacyMap).sort()
|
||||||
|
|
||||||
|
let mismatch = stagedSessions.length !== legacySessions.length
|
||||||
|
if (!mismatch) {
|
||||||
|
for (let i = 0; i < stagedSessions.length; i += 1) {
|
||||||
|
if (stagedSessions[i] !== legacySessions[i]) {
|
||||||
|
mismatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mismatch) {
|
||||||
|
for (const sessionId of stagedSessions) {
|
||||||
|
const stagedIds = stagedMap[sessionId] || []
|
||||||
|
const legacyIds = legacyMap[sessionId] || []
|
||||||
|
if (stagedIds.length !== legacyIds.length) {
|
||||||
|
mismatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for (let i = 0; i < stagedIds.length; i += 1) {
|
||||||
|
if (stagedIds[i] !== legacyIds[i]) {
|
||||||
|
mismatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mismatch) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mismatch) {
|
||||||
|
const stagedOrder = stagedResults.map((row) => `${row.sessionId}:${row.localId || 0}:${row.messageKey || ''}`)
|
||||||
|
const legacyOrder = legacyResults.map((row) => `${row.sessionId}:${row.localId || 0}:${row.messageKey || ''}`)
|
||||||
|
if (stagedOrder.length !== legacyOrder.length) {
|
||||||
|
mismatch = true
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < stagedOrder.length; i += 1) {
|
||||||
|
if (stagedOrder[i] !== legacyOrder[i]) {
|
||||||
|
mismatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mismatch) return
|
||||||
|
console.warn('[GlobalMsgSearch] shadow compare mismatch', {
|
||||||
|
keyword,
|
||||||
|
stagedSessionCount: stagedSessions.length,
|
||||||
|
legacySessionCount: legacySessions.length,
|
||||||
|
stagedResultCount: stagedResults.length,
|
||||||
|
legacyResultCount: legacyResults.length,
|
||||||
|
stagedMap,
|
||||||
|
legacyMap
|
||||||
})
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (gen !== globalMsgSearchGenRef.current) return
|
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
|
||||||
setGlobalMsgResults(results)
|
const normalizedKeyword = keyword.trim()
|
||||||
|
setGlobalMsgQuery(keyword)
|
||||||
|
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
||||||
|
globalMsgSearchTimerRef.current = null
|
||||||
|
globalMsgSearchGenRef.current += 1
|
||||||
|
if (!normalizedKeyword) {
|
||||||
|
pendingGlobalMsgSearchReplayRef.current = null
|
||||||
|
globalMsgPrefixCacheRef.current = null
|
||||||
|
setGlobalMsgResults([])
|
||||||
setGlobalMsgSearchError(null)
|
setGlobalMsgSearchError(null)
|
||||||
|
setShowGlobalMsgSearch(false)
|
||||||
|
setGlobalMsgSearching(false)
|
||||||
|
setGlobalMsgSearchPhase('idle')
|
||||||
|
setGlobalMsgIsBackfilling(false)
|
||||||
|
setGlobalMsgAuthoritativeSessionCount(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowGlobalMsgSearch(true)
|
||||||
|
setGlobalMsgSearchError(null)
|
||||||
|
setGlobalMsgSearchPhase('seed')
|
||||||
|
setGlobalMsgIsBackfilling(false)
|
||||||
|
setGlobalMsgAuthoritativeSessionCount(0)
|
||||||
|
|
||||||
|
const sessionList = Array.isArray(sessionsRef.current) ? sessionsRef.current.filter((session) => String(session.username || '').trim()) : []
|
||||||
|
if (!isConnectedRef.current || sessionList.length === 0) {
|
||||||
|
pendingGlobalMsgSearchReplayRef.current = normalizedKeyword
|
||||||
|
setGlobalMsgResults([])
|
||||||
|
setGlobalMsgSearchError(null)
|
||||||
|
setGlobalMsgSearching(false)
|
||||||
|
setGlobalMsgSearchPhase('idle')
|
||||||
|
setGlobalMsgIsBackfilling(false)
|
||||||
|
setGlobalMsgAuthoritativeSessionCount(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingGlobalMsgSearchReplayRef.current = null
|
||||||
|
const gen = globalMsgSearchGenRef.current
|
||||||
|
globalMsgSearchTimerRef.current = setTimeout(async () => {
|
||||||
|
if (gen !== globalMsgSearchGenRef.current) return
|
||||||
|
setGlobalMsgSearching(true)
|
||||||
|
setGlobalMsgSearchPhase('seed')
|
||||||
|
setGlobalMsgIsBackfilling(false)
|
||||||
|
setGlobalMsgAuthoritativeSessionCount(0)
|
||||||
|
try {
|
||||||
|
ensureGlobalMsgSearchNotStale(gen)
|
||||||
|
|
||||||
|
const seedResponse = await window.electronAPI.chat.searchMessages(normalizedKeyword, undefined, GLOBAL_MSG_SEED_LIMIT, 0)
|
||||||
|
if (!seedResponse?.success) {
|
||||||
|
throw new Error(seedResponse?.error || '搜索失败')
|
||||||
|
}
|
||||||
|
ensureGlobalMsgSearchNotStale(gen)
|
||||||
|
|
||||||
|
const seedRows = normalizeGlobalMsgSearchMessages(seedResponse?.messages || [])
|
||||||
|
const seedMap = buildGlobalMsgSearchSessionMap(seedRows)
|
||||||
|
const authoritativeMap = new Map<string, GlobalMsgSearchResult[]>()
|
||||||
|
setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap))
|
||||||
|
setGlobalMsgSearchError(null)
|
||||||
|
setGlobalMsgSearchPhase('backfill')
|
||||||
|
setGlobalMsgIsBackfilling(true)
|
||||||
|
|
||||||
|
const previousPrefixCache = globalMsgPrefixCacheRef.current
|
||||||
|
const previousKeyword = String(previousPrefixCache?.keyword || '').trim()
|
||||||
|
const canUsePrefixCache = Boolean(
|
||||||
|
previousPrefixCache &&
|
||||||
|
previousPrefixCache.completed &&
|
||||||
|
previousKeyword &&
|
||||||
|
normalizedKeyword.startsWith(previousKeyword)
|
||||||
|
)
|
||||||
|
let targetSessionList = canUsePrefixCache
|
||||||
|
? sessionList.filter((session) => previousPrefixCache?.matchedSessionIds.has(session.username))
|
||||||
|
: sessionList
|
||||||
|
if (canUsePrefixCache && previousPrefixCache) {
|
||||||
|
let foundOutsidePrefix = false
|
||||||
|
for (const sessionId of seedMap.keys()) {
|
||||||
|
if (!previousPrefixCache.matchedSessionIds.has(sessionId)) {
|
||||||
|
foundOutsidePrefix = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundOutsidePrefix) {
|
||||||
|
targetSessionList = sessionList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < targetSessionList.length; index += GLOBAL_MSG_BACKFILL_CONCURRENCY) {
|
||||||
|
ensureGlobalMsgSearchNotStale(gen)
|
||||||
|
const chunk = targetSessionList.slice(index, index + GLOBAL_MSG_BACKFILL_CONCURRENCY)
|
||||||
|
const chunkResults = await Promise.allSettled(
|
||||||
|
chunk.map(async (session) => {
|
||||||
|
const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0)
|
||||||
|
if (!res?.success) {
|
||||||
|
throw new Error(res?.error || `搜索失败: ${session.username}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessionId: session.username,
|
||||||
|
messages: normalizeGlobalMsgSearchMessages(res?.messages || [], session.username)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ensureGlobalMsgSearchNotStale(gen)
|
||||||
|
|
||||||
|
for (const item of chunkResults) {
|
||||||
|
if (item.status === 'rejected') {
|
||||||
|
throw item.reason instanceof Error ? item.reason : new Error(String(item.reason))
|
||||||
|
}
|
||||||
|
authoritativeMap.set(item.value.sessionId, item.value.messages)
|
||||||
|
}
|
||||||
|
setGlobalMsgAuthoritativeSessionCount(authoritativeMap.size)
|
||||||
|
setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap))
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureGlobalMsgSearchNotStale(gen)
|
||||||
|
const finalResults = composeGlobalMsgSearchResults(seedMap, authoritativeMap)
|
||||||
|
setGlobalMsgResults(finalResults)
|
||||||
|
setGlobalMsgSearchError(null)
|
||||||
|
setGlobalMsgSearchPhase('done')
|
||||||
|
setGlobalMsgIsBackfilling(false)
|
||||||
|
|
||||||
|
const matchedSessionIds = new Set<string>()
|
||||||
|
for (const row of finalResults) {
|
||||||
|
matchedSessionIds.add(row.sessionId)
|
||||||
|
}
|
||||||
|
globalMsgPrefixCacheRef.current = {
|
||||||
|
keyword: normalizedKeyword,
|
||||||
|
matchedSessionIds,
|
||||||
|
completed: true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRunGlobalMsgShadowCompareSample()) {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const legacyResults = await runLegacyGlobalMsgSearch(normalizedKeyword, sessionList, gen)
|
||||||
|
if (gen !== globalMsgSearchGenRef.current) return
|
||||||
|
compareGlobalMsgSearchShadow(normalizedKeyword, finalResults, legacyResults)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isGlobalMsgSearchCanceled(error)) return
|
||||||
|
console.warn('[GlobalMsgSearch] shadow compare failed:', error)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isGlobalMsgSearchCanceled(error)) return
|
||||||
if (gen !== globalMsgSearchGenRef.current) return
|
if (gen !== globalMsgSearchGenRef.current) return
|
||||||
setGlobalMsgResults([])
|
setGlobalMsgResults([])
|
||||||
setGlobalMsgSearchError(error instanceof Error ? error.message : String(error))
|
setGlobalMsgSearchError(error instanceof Error ? error.message : String(error))
|
||||||
|
setGlobalMsgSearchPhase('done')
|
||||||
|
setGlobalMsgIsBackfilling(false)
|
||||||
|
setGlobalMsgAuthoritativeSessionCount(0)
|
||||||
|
globalMsgPrefixCacheRef.current = null
|
||||||
} finally {
|
} finally {
|
||||||
if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false)
|
if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false)
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
}, [])
|
}, [compareGlobalMsgSearchShadow, ensureGlobalMsgSearchNotStale, runLegacyGlobalMsgSearch])
|
||||||
|
|
||||||
const handleCloseGlobalMsgSearch = useCallback(() => {
|
const handleCloseGlobalMsgSearch = useCallback(() => {
|
||||||
globalMsgSearchGenRef.current += 1
|
globalMsgSearchGenRef.current += 1
|
||||||
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
||||||
globalMsgSearchTimerRef.current = null
|
globalMsgSearchTimerRef.current = null
|
||||||
pendingGlobalMsgSearchReplayRef.current = null
|
pendingGlobalMsgSearchReplayRef.current = null
|
||||||
|
globalMsgPrefixCacheRef.current = null
|
||||||
setShowGlobalMsgSearch(false)
|
setShowGlobalMsgSearch(false)
|
||||||
setGlobalMsgQuery('')
|
setGlobalMsgQuery('')
|
||||||
setGlobalMsgResults([])
|
setGlobalMsgResults([])
|
||||||
setGlobalMsgSearchError(null)
|
setGlobalMsgSearchError(null)
|
||||||
setGlobalMsgSearching(false)
|
setGlobalMsgSearching(false)
|
||||||
|
setGlobalMsgSearchPhase('idle')
|
||||||
|
setGlobalMsgIsBackfilling(false)
|
||||||
|
setGlobalMsgAuthoritativeSessionCount(0)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
||||||
@@ -3837,6 +4104,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
clearTimeout(globalMsgSearchTimerRef.current)
|
clearTimeout(globalMsgSearchTimerRef.current)
|
||||||
globalMsgSearchTimerRef.current = null
|
globalMsgSearchTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
globalMsgPrefixCacheRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -4943,19 +5211,26 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
{/* 全局消息搜索结果 */}
|
{/* 全局消息搜索结果 */}
|
||||||
{globalMsgQuery && (
|
{globalMsgQuery && (
|
||||||
<div className="global-msg-search-results">
|
<div className="global-msg-search-results">
|
||||||
{globalMsgSearching ? (
|
{globalMsgSearchError ? (
|
||||||
<div className="search-loading">
|
|
||||||
<Loader2 className="spin" size={20} />
|
|
||||||
<span>搜索中...</span>
|
|
||||||
</div>
|
|
||||||
) : globalMsgSearchError ? (
|
|
||||||
<div className="no-results">
|
<div className="no-results">
|
||||||
<AlertCircle size={32} />
|
<AlertCircle size={32} />
|
||||||
<p>{globalMsgSearchError}</p>
|
<p>{globalMsgSearchError}</p>
|
||||||
</div>
|
</div>
|
||||||
) : globalMsgResults.length > 0 ? (
|
) : globalMsgResults.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="search-section-header">聊天记录:</div>
|
<div className="search-section-header">
|
||||||
|
聊天记录:
|
||||||
|
{globalMsgSearching && (
|
||||||
|
<span className="search-phase-hint">
|
||||||
|
{globalMsgIsBackfilling
|
||||||
|
? `补全中 ${globalMsgAuthoritativeSessionCount > 0 ? `(${globalMsgAuthoritativeSessionCount})` : ''}...`
|
||||||
|
: '搜索中...'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!globalMsgSearching && globalMsgSearchPhase === 'done' && (
|
||||||
|
<span className="search-phase-hint done">已完成</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="search-results-list">
|
<div className="search-results-list">
|
||||||
{Object.entries(
|
{Object.entries(
|
||||||
globalMsgResults.reduce((acc, msg) => {
|
globalMsgResults.reduce((acc, msg) => {
|
||||||
@@ -5005,6 +5280,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : globalMsgSearching ? (
|
||||||
|
<div className="search-loading">
|
||||||
|
<Loader2 className="spin" size={20} />
|
||||||
|
<span>{globalMsgSearchPhase === 'seed' ? '搜索中...' : '补全中...'}</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="no-results">
|
<div className="no-results">
|
||||||
<MessageSquare size={32} />
|
<MessageSquare size={32} />
|
||||||
@@ -6340,12 +6620,12 @@ const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displa
|
|||||||
|
|
||||||
const buildVoiceCacheIdentity = (
|
const buildVoiceCacheIdentity = (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
message: Pick<Message, 'localId' | 'createTime' | 'serverId'>
|
message: Pick<Message, 'localId' | 'createTime' | 'serverId' | 'serverIdRaw'>
|
||||||
): string => {
|
): string => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
const localId = Math.max(0, Math.floor(Number(message?.localId || 0)))
|
const localId = Math.max(0, Math.floor(Number(message?.localId || 0)))
|
||||||
const createTime = Math.max(0, Math.floor(Number(message?.createTime || 0)))
|
const createTime = Math.max(0, Math.floor(Number(message?.createTime || 0)))
|
||||||
const serverIdRaw = String(message?.serverId ?? '').trim()
|
const serverIdRaw = String(message?.serverIdRaw ?? message?.serverId ?? '').trim()
|
||||||
const serverId = /^\d+$/.test(serverIdRaw)
|
const serverId = /^\d+$/.test(serverIdRaw)
|
||||||
? serverIdRaw.replace(/^0+(?=\d)/, '')
|
? serverIdRaw.replace(/^0+(?=\d)/, '')
|
||||||
: String(Math.max(0, Math.floor(Number(serverIdRaw || 0))))
|
: String(Math.max(0, Math.floor(Number(serverIdRaw || 0))))
|
||||||
@@ -7401,7 +7681,7 @@ function MessageBubble({
|
|||||||
session.username,
|
session.username,
|
||||||
String(message.localId),
|
String(message.localId),
|
||||||
message.createTime,
|
message.createTime,
|
||||||
message.serverId
|
message.serverIdRaw || message.serverId
|
||||||
)
|
)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const url = `data:audio/wav;base64,${result.data}`
|
const url = `data:audio/wav;base64,${result.data}`
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface Message {
|
|||||||
messageKey: string
|
messageKey: string
|
||||||
localId: number
|
localId: number
|
||||||
serverId: number
|
serverId: number
|
||||||
|
serverIdRaw?: string
|
||||||
localType: number
|
localType: number
|
||||||
createTime: number
|
createTime: number
|
||||||
sortSeq: number
|
sortSeq: number
|
||||||
|
|||||||
Reference in New Issue
Block a user