mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +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
|
||||
- label: 我已阅读过相关文档
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: 使用平台
|
||||
description: 选择出现问题的平台
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
@@ -76,9 +87,9 @@ body:
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: 操作系统
|
||||
description: 例如:Windows 11、macOS 14.2、Ubuntu 22.04
|
||||
placeholder: Windows 11
|
||||
label: 操作系统版本
|
||||
description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04
|
||||
placeholder: Windows 11 24H2
|
||||
validations:
|
||||
required: true
|
||||
- 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
|
||||
localId: number
|
||||
serverId: number
|
||||
serverIdRaw?: string
|
||||
localType: number
|
||||
createTime: number
|
||||
sortSeq: number
|
||||
@@ -1807,6 +1808,69 @@ class ChatService {
|
||||
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 {
|
||||
if (raw === undefined || raw === null) return NaN
|
||||
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 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 sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
||||
|
||||
@@ -3173,6 +3238,7 @@ class ChatService {
|
||||
}),
|
||||
localId,
|
||||
serverId,
|
||||
serverIdRaw,
|
||||
localType,
|
||||
createTime,
|
||||
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 }> {
|
||||
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 {
|
||||
lookupPath.push(`会话=${sessionId}, 消息=${msgId}, 传入createTime=${msgCreateTimeLabel(createTime)}, serverId=${String(serverId || 0)}`)
|
||||
lookupPath.push(`消息来源提示=${senderWxidOpt || '无'}`)
|
||||
|
||||
const localId = parseInt(msgId, 10)
|
||||
if (isNaN(localId)) {
|
||||
logLookupPath('fail', '无效的消息ID')
|
||||
return { success: false, error: '无效的消息ID' }
|
||||
}
|
||||
|
||||
let msgCreateTime = createTime
|
||||
let senderWxid: string | null = senderWxidOpt || null
|
||||
let resolvedServerId: string | number = this.normalizeUnsignedIntegerToken(serverId) || 0
|
||||
let locatedMsg: Message | null = null
|
||||
let rejectedNonVoiceLookup = false
|
||||
|
||||
// 如果前端没传 createTime,才需要查询消息(这个很慢)
|
||||
if (!msgCreateTime) {
|
||||
lookupPath.push(`初始解析localId=${localId}成功`)
|
||||
|
||||
// 已提供强键(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 msgResult = await this.getMessageByLocalId(sessionId, localId)
|
||||
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) {
|
||||
const msg = msgResult.message as any
|
||||
msgCreateTime = msg.createTime
|
||||
senderWxid = msg.senderUsername || null
|
||||
// localId 在不同表可能重复,反查命中非语音时不覆盖调用侧入参
|
||||
if (Number(dbMsg.localType) === 34) {
|
||||
locatedMsg = dbMsg
|
||||
msgCreateTime = dbMsg.createTime || msgCreateTime
|
||||
senderWxid = dbMsg.senderUsername || senderWxid || null
|
||||
if (locatedServerId) {
|
||||
resolvedServerId = locatedServerId
|
||||
}
|
||||
} else {
|
||||
rejectedNonVoiceLookup = true
|
||||
lookupPath.push('消息反查命中但localType!=34,忽略反查覆盖,继续使用调用入参定位')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!msgCreateTime) {
|
||||
lookupPath.push('定位失败: 未找到消息时间戳')
|
||||
logLookupPath('fail', '未找到消息时间戳')
|
||||
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,避免同秒语音串音
|
||||
const cacheKey = this.getVoiceCacheKey(sessionId, String(localId), msgCreateTime)
|
||||
@@ -5587,6 +5736,8 @@ class ChatService {
|
||||
// 检查 WAV 内存缓存
|
||||
const wavCache = this.voiceWavCache.get(cacheKey)
|
||||
if (wavCache) {
|
||||
lookupPath.push('命中内存WAV缓存')
|
||||
logLookupPath('success', '内存缓存')
|
||||
return { success: true, data: wavCache.toString('base64') }
|
||||
}
|
||||
|
||||
@@ -5597,11 +5748,15 @@ class ChatService {
|
||||
try {
|
||||
const wavData = readFileSync(wavFilePath)
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
lookupPath.push('命中磁盘WAV缓存')
|
||||
logLookupPath('success', '磁盘缓存')
|
||||
return { success: true, data: wavData.toString('base64') }
|
||||
} catch (e) {
|
||||
lookupPath.push('命中磁盘WAV缓存但读取失败')
|
||||
console.error('[Voice] 读取缓存文件失败:', e)
|
||||
}
|
||||
}
|
||||
lookupPath.push('缓存未命中,进入DB定位')
|
||||
|
||||
// 构建查找候选
|
||||
const candidates: string[] = []
|
||||
@@ -5621,31 +5776,39 @@ class ChatService {
|
||||
if (myWxid && !candidates.includes(myWxid)) {
|
||||
candidates.push(myWxid)
|
||||
}
|
||||
lookupPath.push(`定位候选链=${JSON.stringify(candidates)}`)
|
||||
|
||||
const t3 = Date.now()
|
||||
// 从数据库读取 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()
|
||||
lookupPath.push(`DB定位耗时=${t4 - t3}ms`)
|
||||
|
||||
|
||||
if (!silkData) {
|
||||
logLookupPath('fail', '未找到语音数据')
|
||||
return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' }
|
||||
}
|
||||
lookupPath.push('语音二进制定位完成')
|
||||
|
||||
const t5 = Date.now()
|
||||
// 使用 silk-wasm 解码
|
||||
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||
const t6 = Date.now()
|
||||
lookupPath.push(`silk解码耗时=${t6 - t5}ms`)
|
||||
|
||||
|
||||
if (!pcmData) {
|
||||
logLookupPath('fail', 'Silk解码失败')
|
||||
return { success: false, error: 'Silk 解码失败' }
|
||||
}
|
||||
lookupPath.push('silk解码成功')
|
||||
|
||||
const t7 = Date.now()
|
||||
// PCM -> WAV
|
||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||
const t8 = Date.now()
|
||||
lookupPath.push(`WAV转码耗时=${t8 - t7}ms`)
|
||||
|
||||
|
||||
// 缓存 WAV 数据到内存
|
||||
@@ -5654,9 +5817,13 @@ class ChatService {
|
||||
// 缓存 WAV 数据到文件(异步,不阻塞返回)
|
||||
this.cacheVoiceWavToFile(cacheKey, wavData)
|
||||
|
||||
lookupPath.push(`总耗时=${t8 - startTime}ms`)
|
||||
logLookupPath('success')
|
||||
|
||||
return { success: true, data: wavData.toString('base64') }
|
||||
} catch (e) {
|
||||
lookupPath.push(`异常: ${String(e)}`)
|
||||
logLookupPath('fail', String(e))
|
||||
console.error('ChatService: getVoiceData 失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
@@ -5685,38 +5852,89 @@ class ChatService {
|
||||
createTime: number,
|
||||
localId: number,
|
||||
svrId: string | number,
|
||||
candidates: string[]
|
||||
candidates: string[],
|
||||
lookupPath?: string[],
|
||||
myWxid?: string
|
||||
): Promise<Buffer | null> {
|
||||
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([{
|
||||
session_id: sessionId,
|
||||
create_time: Math.max(0, Math.floor(Number(createTime || 0))),
|
||||
local_id: Math.max(0, Math.floor(Number(localId || 0))),
|
||||
svr_id: svrId || 0,
|
||||
candidates: Array.isArray(candidates) ? candidates : []
|
||||
create_time: createTimeInt,
|
||||
local_id: localIdInt,
|
||||
svr_id: svrIdToken,
|
||||
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) {
|
||||
const hex = String(batchResult.rows[0]?.hex || '').trim()
|
||||
lookupPath?.push(`命中批量结果(${plan.label})[0], hexLen=${hex.length}`)
|
||||
if (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 查询
|
||||
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
|
||||
}
|
||||
lookupPath?.push('音频定位失败:未命中任何结果')
|
||||
return null
|
||||
} catch (e) {
|
||||
lookupPath?.push(`音频定位异常: ${String(e)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -5870,7 +6088,7 @@ class ChatService {
|
||||
if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' }
|
||||
const msg = msgResult.message
|
||||
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) {
|
||||
console.error('ChatService: getVoiceData 失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
@@ -5960,7 +6178,7 @@ class ChatService {
|
||||
|
||||
if (msgResult.success && msgResult.message) {
|
||||
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) {
|
||||
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 &&
|
||||
Boolean(sessionId) &&
|
||||
message.localId > 0 &&
|
||||
@@ -6344,19 +6567,9 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroupSearch && (needsDetailHydration || message.isSend === 1)) {
|
||||
console.info('[ChatService][GroupSearchHydratedHit]', {
|
||||
sessionId,
|
||||
localId: message.localId,
|
||||
senderUsername: message.senderUsername,
|
||||
isSend: message.isSend,
|
||||
senderDisplayName: message.senderDisplayName,
|
||||
senderAvatarUrl: message.senderAvatarUrl,
|
||||
usedDetailHydration: needsDetailHydration,
|
||||
parsedContent: message.parsedContent
|
||||
})
|
||||
if (resolvedSessionId) {
|
||||
;(message as Message & { sessionId?: string }).sessionId = resolvedSessionId
|
||||
}
|
||||
|
||||
messages.push(message)
|
||||
}
|
||||
|
||||
@@ -6390,6 +6603,7 @@ class ChatService {
|
||||
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
||||
// 实际项目中建议抽取 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 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 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)
|
||||
@@ -6409,6 +6623,7 @@ class ChatService {
|
||||
}),
|
||||
localId,
|
||||
serverId,
|
||||
serverIdRaw,
|
||||
localType,
|
||||
createTime,
|
||||
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
|
||||
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 {
|
||||
return this.initialized && this.handle !== null
|
||||
}
|
||||
@@ -1426,7 +1448,7 @@ export class WcdbCore {
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析消息失败' }
|
||||
const messages = JSON.parse(jsonStr)
|
||||
const messages = this.parseMessageJson(jsonStr)
|
||||
return { success: true, messages }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
@@ -2491,7 +2513,7 @@ export class WcdbCore {
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析批次失败' }
|
||||
const rows = JSON.parse(jsonStr)
|
||||
const rows = this.parseMessageJson(jsonStr)
|
||||
return { success: true, rows, hasMore: outHasMore[0] === 1 }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
@@ -2644,7 +2666,7 @@ export class WcdbCore {
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析消息失败' }
|
||||
const message = JSON.parse(jsonStr)
|
||||
const message = this.parseMessageJson(jsonStr)
|
||||
// 处理 wcdb_get_message_by_id 返回空对象的情况
|
||||
if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' }
|
||||
return { success: true, message }
|
||||
@@ -2862,7 +2884,7 @@ export class WcdbCore {
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析搜索结果失败' }
|
||||
const messages = JSON.parse(jsonStr)
|
||||
const messages = this.parseMessageJson(jsonStr)
|
||||
return { success: true, messages }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
|
||||
Binary file not shown.
@@ -4800,6 +4800,18 @@
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-secondary);
|
||||
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[]
|
||||
}
|
||||
|
||||
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[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
|
||||
@@ -594,9 +710,6 @@ const SessionItem = React.memo(function SessionItem({
|
||||
<span className="session-name">
|
||||
{(() => {
|
||||
const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword
|
||||
if (shouldHighlight) {
|
||||
console.log('高亮名字:', session.displayName, 'keyword:', searchKeyword)
|
||||
}
|
||||
return shouldHighlight ? (
|
||||
<HighlightText text={session.displayName || session.username} keyword={searchKeyword} />
|
||||
) : (
|
||||
@@ -795,11 +908,15 @@ function ChatPage(props: ChatPageProps) {
|
||||
// 全局消息搜索
|
||||
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
|
||||
const [globalMsgQuery, setGlobalMsgQuery] = useState('')
|
||||
const [globalMsgResults, setGlobalMsgResults] = useState<Array<Message & { sessionId: string }>>([])
|
||||
const [globalMsgResults, setGlobalMsgResults] = useState<GlobalMsgSearchResult[]>([])
|
||||
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 pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
|
||||
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
|
||||
const globalMsgPrefixCacheRef = useRef<GlobalMsgPrefixCacheEntry | null>(null)
|
||||
|
||||
// 自定义删除确认对话框
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
@@ -2887,22 +3004,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
? (senderAvatarUrl || myAvatarUrl)
|
||||
: (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 (
|
||||
senderUsername === message.senderUsername &&
|
||||
nextIsSend === message.isSend &&
|
||||
@@ -3109,24 +3210,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
(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 (
|
||||
sender === message.senderUsername &&
|
||||
nextIsSend === message.isSend &&
|
||||
@@ -3181,8 +3264,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
||||
if (currentSessionRef.current !== normalizedSessionId) return
|
||||
setInSessionResults(enrichedResults)
|
||||
}).catch((error) => {
|
||||
console.warn('[InSessionSearch] 恢复全局搜索结果发送者信息失败:', error)
|
||||
}).catch(() => {
|
||||
// ignore sender enrichment errors and keep current search results usable
|
||||
}).finally(() => {
|
||||
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
||||
if (currentSessionRef.current !== normalizedSessionId) return
|
||||
@@ -3382,8 +3465,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => {
|
||||
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||
setInSessionResults(enriched)
|
||||
}).catch((error) => {
|
||||
console.warn('[InSessionSearch] 补充发送者信息失败:', error)
|
||||
}).catch(() => {
|
||||
// ignore sender enrichment errors and keep current search results usable
|
||||
}).finally(() => {
|
||||
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||
setInSessionEnriching(false)
|
||||
@@ -3417,55 +3500,31 @@ function ChatPage(props: ChatPageProps) {
|
||||
// 全局消息搜索
|
||||
const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const globalMsgSearchGenRef = useRef(0)
|
||||
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim()
|
||||
setGlobalMsgQuery(keyword)
|
||||
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
|
||||
const ensureGlobalMsgSearchNotStale = useCallback((gen: number) => {
|
||||
if (gen !== globalMsgSearchGenRef.current) {
|
||||
throw new Error(GLOBAL_MSG_SEARCH_CANCELED_ERROR)
|
||||
}
|
||||
setShowGlobalMsgSearch(true)
|
||||
setGlobalMsgSearchError(null)
|
||||
}, [])
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
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 runLegacyGlobalMsgSearch = useCallback(async (
|
||||
keyword: string,
|
||||
sessionList: ChatSession[],
|
||||
gen: number
|
||||
): Promise<GlobalMsgSearchResult[]> => {
|
||||
const results: GlobalMsgSearchResult[] = []
|
||||
for (let index = 0; index < sessionList.length; index += GLOBAL_MSG_LEGACY_CONCURRENCY) {
|
||||
ensureGlobalMsgSearchNotStale(gen)
|
||||
const chunk = sessionList.slice(index, index + GLOBAL_MSG_LEGACY_CONCURRENCY)
|
||||
const chunkResults = await Promise.allSettled(
|
||||
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) {
|
||||
throw new Error(res?.error || `搜索失败: ${session.username}`)
|
||||
}
|
||||
if (!res?.messages?.length) return []
|
||||
return res.messages.map((msg) => ({ ...msg, sessionId: session.username }))
|
||||
return normalizeGlobalMsgSearchMessages(res?.messages || [], session.username)
|
||||
})
|
||||
)
|
||||
|
||||
if (gen !== globalMsgSearchGenRef.current) return
|
||||
ensureGlobalMsgSearchNotStale(gen)
|
||||
|
||||
for (const item of chunkResults) {
|
||||
if (item.status === 'rejected') {
|
||||
@@ -3476,36 +3535,244 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortMessagesByCreateTimeDesc(results)
|
||||
}, [ensureGlobalMsgSearchNotStale])
|
||||
|
||||
results.sort((a, b) => {
|
||||
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
|
||||
if (timeDiff !== 0) return timeDiff
|
||||
return (b.localId || 0) - (a.localId || 0)
|
||||
const compareGlobalMsgSearchShadow = useCallback((
|
||||
keyword: string,
|
||||
stagedResults: GlobalMsgSearchResult[],
|
||||
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
|
||||
setGlobalMsgResults(results)
|
||||
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
|
||||
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)
|
||||
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) {
|
||||
if (isGlobalMsgSearchCanceled(error)) return
|
||||
console.warn('[GlobalMsgSearch] shadow compare failed:', error)
|
||||
}
|
||||
})()
|
||||
}
|
||||
} catch (error) {
|
||||
if (isGlobalMsgSearchCanceled(error)) return
|
||||
if (gen !== globalMsgSearchGenRef.current) return
|
||||
setGlobalMsgResults([])
|
||||
setGlobalMsgSearchError(error instanceof Error ? error.message : String(error))
|
||||
setGlobalMsgSearchPhase('done')
|
||||
setGlobalMsgIsBackfilling(false)
|
||||
setGlobalMsgAuthoritativeSessionCount(0)
|
||||
globalMsgPrefixCacheRef.current = null
|
||||
} finally {
|
||||
if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false)
|
||||
}
|
||||
}, 500)
|
||||
}, [])
|
||||
}, [compareGlobalMsgSearchShadow, ensureGlobalMsgSearchNotStale, runLegacyGlobalMsgSearch])
|
||||
|
||||
const handleCloseGlobalMsgSearch = useCallback(() => {
|
||||
globalMsgSearchGenRef.current += 1
|
||||
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
||||
globalMsgSearchTimerRef.current = null
|
||||
pendingGlobalMsgSearchReplayRef.current = null
|
||||
globalMsgPrefixCacheRef.current = null
|
||||
setShowGlobalMsgSearch(false)
|
||||
setGlobalMsgQuery('')
|
||||
setGlobalMsgResults([])
|
||||
setGlobalMsgSearchError(null)
|
||||
setGlobalMsgSearching(false)
|
||||
setGlobalMsgSearchPhase('idle')
|
||||
setGlobalMsgIsBackfilling(false)
|
||||
setGlobalMsgAuthoritativeSessionCount(0)
|
||||
}, [])
|
||||
|
||||
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
||||
@@ -3837,6 +4104,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
clearTimeout(globalMsgSearchTimerRef.current)
|
||||
globalMsgSearchTimerRef.current = null
|
||||
}
|
||||
globalMsgPrefixCacheRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -4943,19 +5211,26 @@ function ChatPage(props: ChatPageProps) {
|
||||
{/* 全局消息搜索结果 */}
|
||||
{globalMsgQuery && (
|
||||
<div className="global-msg-search-results">
|
||||
{globalMsgSearching ? (
|
||||
<div className="search-loading">
|
||||
<Loader2 className="spin" size={20} />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
) : globalMsgSearchError ? (
|
||||
{globalMsgSearchError ? (
|
||||
<div className="no-results">
|
||||
<AlertCircle size={32} />
|
||||
<p>{globalMsgSearchError}</p>
|
||||
</div>
|
||||
) : 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">
|
||||
{Object.entries(
|
||||
globalMsgResults.reduce((acc, msg) => {
|
||||
@@ -5005,6 +5280,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : globalMsgSearching ? (
|
||||
<div className="search-loading">
|
||||
<Loader2 className="spin" size={20} />
|
||||
<span>{globalMsgSearchPhase === 'seed' ? '搜索中...' : '补全中...'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-results">
|
||||
<MessageSquare size={32} />
|
||||
@@ -6340,12 +6620,12 @@ const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displa
|
||||
|
||||
const buildVoiceCacheIdentity = (
|
||||
sessionId: string,
|
||||
message: Pick<Message, 'localId' | 'createTime' | 'serverId'>
|
||||
message: Pick<Message, 'localId' | 'createTime' | 'serverId' | 'serverIdRaw'>
|
||||
): string => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const localId = Math.max(0, Math.floor(Number(message?.localId || 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)
|
||||
? serverIdRaw.replace(/^0+(?=\d)/, '')
|
||||
: String(Math.max(0, Math.floor(Number(serverIdRaw || 0))))
|
||||
@@ -7401,7 +7681,7 @@ function MessageBubble({
|
||||
session.username,
|
||||
String(message.localId),
|
||||
message.createTime,
|
||||
message.serverId
|
||||
message.serverIdRaw || message.serverId
|
||||
)
|
||||
if (result.success && result.data) {
|
||||
const url = `data:audio/wav;base64,${result.data}`
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Message {
|
||||
messageKey: string
|
||||
localId: number
|
||||
serverId: number
|
||||
serverIdRaw?: string
|
||||
localType: number
|
||||
createTime: number
|
||||
sortSeq: number
|
||||
|
||||
Reference in New Issue
Block a user