计划优化 P1/5

This commit is contained in:
cc
2026-03-19 20:58:21 +08:00
parent 958677c5b1
commit 35e9ea13de
8 changed files with 751 additions and 139 deletions

View File

@@ -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
View 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(", ")}`);

View File

@@ -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 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 : []
}])
if (batchResult.success && Array.isArray(batchResult.rows) && batchResult.rows.length > 0) {
const hex = String(batchResult.rows[0]?.hex || '').trim()
if (hex) {
const decoded = this.decodeVoiceBlob(hex)
if (decoded && decoded.length > 0) return decoded
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)}`)
// 先走单条 nativesvr_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: 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) {
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)

View File

@@ -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.

View File

@@ -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);
}
}
}
// 全局消息搜索结果面板

View File

@@ -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,6 +3500,109 @@ function ChatPage(props: ChatPageProps) {
// 全局消息搜索
const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const globalMsgSearchGenRef = useRef(0)
const ensureGlobalMsgSearchNotStale = useCallback((gen: number) => {
if (gen !== globalMsgSearchGenRef.current) {
throw new Error(GLOBAL_MSG_SEARCH_CANCELED_ERROR)
}
}, [])
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(keyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0)
if (!res?.success) {
throw new Error(res?.error || `搜索失败: ${session.username}`)
}
return 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))
}
if (item.value.length > 0) {
results.push(...item.value)
}
}
}
return sortMessagesByCreateTimeDesc(results)
}, [ensureGlobalMsgSearchNotStale])
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
})
}, [])
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
const normalizedKeyword = keyword.trim()
setGlobalMsgQuery(keyword)
@@ -3425,14 +3611,21 @@ function ChatPage(props: ChatPageProps) {
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) {
@@ -3440,6 +3633,9 @@ function ChatPage(props: ChatPageProps) {
setGlobalMsgResults([])
setGlobalMsgSearchError(null)
setGlobalMsgSearching(false)
setGlobalMsgSearchPhase('idle')
setGlobalMsgIsBackfilling(false)
setGlobalMsgAuthoritativeSessionCount(0)
return
}
@@ -3448,64 +3644,135 @@ function ChatPage(props: ChatPageProps) {
globalMsgSearchTimerRef.current = setTimeout(async () => {
if (gen !== globalMsgSearchGenRef.current) return
setGlobalMsgSearching(true)
setGlobalMsgSearchPhase('seed')
setGlobalMsgIsBackfilling(false)
setGlobalMsgAuthoritativeSessionCount(0)
try {
const results: Array<Message & { sessionId: string }> = []
const concurrency = 6
ensureGlobalMsgSearchNotStale(gen)
for (let index = 0; index < sessionList.length; index += concurrency) {
const chunk = sessionList.slice(index, index + concurrency)
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, 10, 0)
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}`)
}
if (!res?.messages?.length) return []
return res.messages.map((msg) => ({ ...msg, sessionId: session.username }))
return {
sessionId: session.username,
messages: normalizeGlobalMsgSearchMessages(res?.messages || [], session.username)
}
})
)
if (gen !== globalMsgSearchGenRef.current) return
ensureGlobalMsgSearchNotStale(gen)
for (const item of chunkResults) {
if (item.status === 'rejected') {
throw item.reason instanceof Error ? item.reason : new Error(String(item.reason))
}
if (item.value.length > 0) {
results.push(...item.value)
}
authoritativeMap.set(item.value.sessionId, item.value.messages)
}
setGlobalMsgAuthoritativeSessionCount(authoritativeMap.size)
setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap))
}
results.sort((a, b) => {
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
if (timeDiff !== 0) return timeDiff
return (b.localId || 0) - (a.localId || 0)
})
if (gen !== globalMsgSearchGenRef.current) return
setGlobalMsgResults(results)
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}`

View File

@@ -46,6 +46,7 @@ export interface Message {
messageKey: string
localId: number
serverId: number
serverIdRaw?: string
localType: number
createTime: number
sortSeq: number