diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index bccc91c..582a9d2 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -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 diff --git a/.github/workflows/issue-auto-assign.yml b/.github/workflows/issue-auto-assign.yml new file mode 100644 index 0000000..cc76345 --- /dev/null +++ b/.github/workflows/issue-auto-assign.yml @@ -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(", ")}`); diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 4ba4ed6..d8f7c1d 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -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 { 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)}`) + // 先走单条 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: 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) diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 18bfe8c..eab7252 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -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) } diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 9ed2538..a7ae51e 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index be4d15f..f053a24 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -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); + } + } } // 全局消息搜索结果面板 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 1ebcf00..10349bf 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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 + 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() + 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 { + const map = new Map() + 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): GlobalMsgSearchResult[] { + const all: GlobalMsgSearchResult[] = [] + for (const list of map.values()) { + if (list.length > 0) all.push(...list) + } + return sortMessagesByCreateTimeDesc(all) +} + +function composeGlobalMsgSearchResults( + seedMap: Map, + authoritativeMap: Map +): GlobalMsgSearchResult[] { + const merged = new Map() + 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 { + const grouped = new Map() + 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 = {} + for (const [sessionId, localIds] of grouped.entries()) { + output[sessionId] = localIds + } + return output +} + function sortMessagesByCreateTimeDesc>(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({ {(() => { const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword - if (shouldHighlight) { - console.log('高亮名字:', session.displayName, 'keyword:', searchKeyword) - } return shouldHighlight ? ( ) : ( @@ -795,11 +908,15 @@ function ChatPage(props: ChatPageProps) { // 全局消息搜索 const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false) const [globalMsgQuery, setGlobalMsgQuery] = useState('') - const [globalMsgResults, setGlobalMsgResults] = useState>([]) + const [globalMsgResults, setGlobalMsgResults] = useState([]) const [globalMsgSearching, setGlobalMsgSearching] = useState(false) + const [globalMsgSearchPhase, setGlobalMsgSearchPhase] = useState('idle') + const [globalMsgIsBackfilling, setGlobalMsgIsBackfilling] = useState(false) + const [globalMsgAuthoritativeSessionCount, setGlobalMsgAuthoritativeSessionCount] = useState(0) const [globalMsgSearchError, setGlobalMsgSearchError] = useState(null) const pendingInSessionSearchRef = useRef(null) const pendingGlobalMsgSearchReplayRef = useRef(null) + const globalMsgPrefixCacheRef = useRef(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 | 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 => { + 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 = [] - 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() + 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() + 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 && (
- {globalMsgSearching ? ( -
- - 搜索中... -
- ) : globalMsgSearchError ? ( + {globalMsgSearchError ? (

{globalMsgSearchError}

) : globalMsgResults.length > 0 ? ( <> -
聊天记录:
+
+ 聊天记录: + {globalMsgSearching && ( + + {globalMsgIsBackfilling + ? `补全中 ${globalMsgAuthoritativeSessionCount > 0 ? `(${globalMsgAuthoritativeSessionCount})` : ''}...` + : '搜索中...'} + + )} + {!globalMsgSearching && globalMsgSearchPhase === 'done' && ( + 已完成 + )} +
{Object.entries( globalMsgResults.reduce((acc, msg) => { @@ -5005,6 +5280,11 @@ function ChatPage(props: ChatPageProps) { })}
+ ) : globalMsgSearching ? ( +
+ + {globalMsgSearchPhase === 'seed' ? '搜索中...' : '补全中...'} +
) : (
@@ -6340,12 +6620,12 @@ const senderAvatarLoading = new Map + message: Pick ): 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}` diff --git a/src/types/models.ts b/src/types/models.ts index 92d6506..de287c0 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -46,6 +46,7 @@ export interface Message { messageKey: string localId: number serverId: number + serverIdRaw?: string localType: number createTime: number sortSeq: number