diff --git a/README.md b/README.md
index d0cfc42..32cbb48 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出
- 查看聊天 · 找回信息 · AI 问答 · 群聊日报总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
+ 查看聊天 · 找回信息 · AI 问答 · 微信群聊总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
@@ -439,6 +439,15 @@ TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github
---
+## 最后说两句
+
+这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
+
+我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。
+
+也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
+
+
TraceMemo(迹忆)
diff --git a/docs/development/local-startup-troubleshooting.md b/docs/development/local-startup-troubleshooting.md
index db1cd70..8bc02b6 100644
--- a/docs/development/local-startup-troubleshooting.md
+++ b/docs/development/local-startup-troubleshooting.md
@@ -12,6 +12,16 @@
`6131` 是应用提供给本机集成使用的 API 端口,不是 Vite 的页面端口。
+## WCDB 消息读取诊断
+
+`WCDB_DEBUG_LOGS` 默认关闭。需要排查消息读取链路时,可以在启动命令前设置为 `1`:
+
+```bash
+WCDB_DEBUG_LOGS=1 pnpm dev
+```
+
+开启后会输出 `GETMSG-xxx` 请求耗时和 native `WCDB-EXPLAIN` 执行计划,不记录聊天正文。取消该环境变量或设为 `0` 即可关闭。
+
## Go 命令找不到
如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行:
diff --git a/electron-builder.win.yml b/electron-builder.win.yml
index ab80da6..d1d0156 100644
--- a/electron-builder.win.yml
+++ b/electron-builder.win.yml
@@ -39,8 +39,11 @@ extraResources:
- '**/*'
win:
icon: icon.ico
- # WCDB's Windows runtime checks the host executable name.
- executableName: electron
+ executableName: TraceMemo
+ target:
+ - target: nsis
+ arch:
+ - x64
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
diff --git a/electron-builder.yml b/electron-builder.yml
index ca68915..feb124e 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -30,12 +30,6 @@ extraResources:
to: skill/tracememo-reader
filter:
- '**/*'
-win:
- icon: icon.ico
- # WCDB's Windows runtime checks the host executable name. The dev runtime is
- # electron.exe, so keep the packaged executable compatible while using
- # TraceMemo as the product/shortcut name.
- executableName: electron
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
@@ -47,9 +41,6 @@ mac:
icon: icon.icns
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- # The bundled WCDB bridge still uses Electron as its internal executable
- # compatibility name; the public product and bundle identity are TraceMemo.
- CFBundleName: Electron
CFBundleDisplayName: TraceMemo
NSCameraUsageDescription: Application requests access to the device's camera.
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
diff --git a/package.json b/package.json
index b3ec68f..72dcf11 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "tracememo",
"version": "2.2.2",
"packageManager": "pnpm@7.33.7",
- "description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
+ "description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
"keywords": [
"wechat",
"wechat chat",
@@ -11,6 +11,7 @@
"windows微信",
"微信聊天记录",
"微信聊天记录搜索",
+ "微信群聊总结",
"微信AI",
"微信机器人",
"AI聊天搜索",
@@ -36,6 +37,7 @@
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
"start": "electron-vite preview",
+ "predev": "node -e \"require('electron')\"",
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
"test:unit": "vitest run --config vitest.unit.config.ts",
@@ -156,4 +158,4 @@
"ffmpeg-static"
]
}
-}
+}
\ No newline at end of file
diff --git a/resources/macos/libWCDB.dylib b/resources/macos/libWCDB.dylib
index 75eb279..71060b9 100755
Binary files a/resources/macos/libWCDB.dylib and b/resources/macos/libWCDB.dylib differ
diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib
index db376bb..abdd2a7 100755
Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ
diff --git a/resources/wcdb/win32/arm64/WCDB.dll b/resources/wcdb/win32/arm64/WCDB.dll
deleted file mode 100644
index 60c3508..0000000
Binary files a/resources/wcdb/win32/arm64/WCDB.dll and /dev/null differ
diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll
deleted file mode 100644
index 1a1139c..0000000
Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and /dev/null differ
diff --git a/resources/wcdb/win32/x64/WCDB.dll b/resources/wcdb/win32/x64/WCDB.dll
index 8127079..4aaf338 100644
Binary files a/resources/wcdb/win32/x64/WCDB.dll and b/resources/wcdb/win32/x64/WCDB.dll differ
diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll
index 8842616..88ddfad 100644
Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ
diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs
index 3b4dd14..be0dff6 100644
--- a/scripts/after-pack.cjs
+++ b/scripts/after-pack.cjs
@@ -1,11 +1,9 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
-const { chmodSync, existsSync, renameSync } = require('node:fs')
+const { chmodSync, existsSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const asar = require('@electron/asar')
-const COMPATIBILITY_NAME = 'Electron'
-const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
const REQUIRED_RUNTIME_PACKAGES = [
'@electron-toolkit/preload',
'@electron-toolkit/utils',
@@ -101,10 +99,6 @@ function validateAsarRuntimeDependencies(runtimeResources) {
)
}
}
-function setPlistValue(plistPath, key, value) {
- execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
-}
-
function validateReaderSkillRuntime(runtimeResources) {
const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md')
if (!existsSync(skillPath)) {
@@ -148,47 +142,6 @@ exports.default = async function afterPack(context) {
return
}
- if (context.electronPlatformName !== 'darwin') return
-
- const productName = context.packager.appInfo.productFilename
- const appPath = path.join(context.appOutDir, `${productName}.app`)
- const contentsPath = path.join(appPath, 'Contents')
- const sourceExecutable = path.join(contentsPath, 'MacOS', productName)
- const targetExecutable = path.join(contentsPath, 'MacOS', COMPATIBILITY_NAME)
-
- if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
- if (!existsSync(targetExecutable)) {
- throw new Error(`Missing Electron main executable: ${sourceExecutable}`)
- }
-
- const appPlistPath = path.join(contentsPath, 'Info.plist')
- setPlistValue(appPlistPath, 'CFBundleExecutable', COMPATIBILITY_NAME)
- setPlistValue(appPlistPath, 'CFBundleName', COMPATIBILITY_NAME)
-
- const frameworksPath = path.join(contentsPath, 'Frameworks')
-
- for (const suffix of HELPER_SUFFIXES) {
- const sourceName = `${productName} Helper${suffix}`
- const targetName = `${COMPATIBILITY_NAME} Helper${suffix}`
- const sourceBundle = path.join(frameworksPath, `${sourceName}.app`)
- const targetBundle = path.join(frameworksPath, `${targetName}.app`)
-
- if (existsSync(sourceBundle)) renameSync(sourceBundle, targetBundle)
- if (!existsSync(targetBundle)) {
- throw new Error(`Missing Electron helper bundle: ${sourceBundle}`)
- }
-
- const sourceExecutable = path.join(targetBundle, 'Contents', 'MacOS', sourceName)
- const targetExecutable = path.join(targetBundle, 'Contents', 'MacOS', targetName)
- if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
- if (!existsSync(targetExecutable)) {
- throw new Error(`Missing Electron helper executable: ${sourceExecutable}`)
- }
-
- const plistPath = path.join(targetBundle, 'Contents', 'Info.plist')
- setPlistValue(plistPath, 'CFBundleExecutable', targetName)
- setPlistValue(plistPath, 'CFBundleName', targetName)
- }
}
exports.getRuntimeResources = getRuntimeResources
diff --git a/src/main/app-data-bootstrap.ts b/src/main/app-data-bootstrap.ts
index 58ad39d..dcb19a5 100644
--- a/src/main/app-data-bootstrap.ts
+++ b/src/main/app-data-bootstrap.ts
@@ -9,17 +9,12 @@ export const LEGACY_MIGRATION_RESULT_FD_ENV = 'TRACEMEMO_LEGACY_MIGRATION_RESULT
// This module must remain the first main-process import. Static imports in
// settings/cache services can otherwise resolve Electron paths before the
-// TraceMemo userData is installed before any consumers. macOS uses the old
-// safeStorage identity only inside the helper; Windows keeps in both
-// processes because WCDB requires that technical runtime name.
+// TraceMemo userData is installed before any consumers. The migration helper
+// temporarily uses the legacy identity; normal Windows builds use TraceMemo.
const isLegacyMigrationHelper = process.env[LEGACY_MIGRATION_HELPER_ENV] === '1'
-const legacyRuntimeName = process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME
+const legacyRuntimeName = LEGACY_USER_DATA_NAME
const runtimeName =
- process.platform === 'win32'
- ? 'WeFlow'
- : isLegacyMigrationHelper
- ? legacyRuntimeName
- : TRACE_MEMO_RUNTIME_NAME
+ isLegacyMigrationHelper ? legacyRuntimeName : TRACE_MEMO_RUNTIME_NAME
app.setName(runtimeName)
const isolatedUserData = process.env['WXE_USER_DATA']
diff --git a/src/main/index.ts b/src/main/index.ts
index 2b22007..d1161a8 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -121,6 +121,14 @@ import type {
SynthesizeTextToSpeechRequest
} from '../shared/text-to-speech'
import { appLogger } from './app-logger'
+import { wcdbDebugLog } from './wcdb-debug'
+
+let getMessagesRequestSequence = 0
+
+function nextGetMessagesRequestId(): string {
+ getMessagesRequestSequence += 1
+ return `GETMSG-${String(getMessagesRequestSequence).padStart(3, '0')}`
+}
import type { AppLogEntry } from '../shared/app-log'
import { appUpdateService } from './services/app-update-service'
import { clearCache, getCacheSummary, openKnowledgeDirectory } from './services/cache-service'
@@ -1071,18 +1079,37 @@ app.whenReady().then(async () => {
endTime?: number,
options?: { limit?: number }
) => {
- const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
- if (chat.isReady()) {
- saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
+ const requestId = nextGetMessagesRequestId()
+ const startedAt = Date.now()
+ wcdbDebugLog(
+ `[${requestId}] IPC db:getMessages start userMd5=${userMd5} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
+ )
+ try {
+ const messages = await chat.listMessagesAsync(
+ userMd5,
+ startTime,
+ endTime,
+ options,
+ requestId
+ )
+ if (chat.isReady()) {
+ saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
+ }
+ wcdbDebugLog(
+ `[${requestId}] IPC db:getMessages end rows=${messages.length} cost=${Date.now() - startedAt}ms`
+ )
+ return messages
+ } catch (error) {
+ wcdbDebugLog(`[${requestId}] IPC db:getMessages error cost=${Date.now() - startedAt}ms`)
+ throw error
}
- return messages
}
)
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
if (snapshot && chat.isReady()) {
- saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
+ return saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
}
return snapshot
})
diff --git a/src/main/key-service-win.ts b/src/main/key-service-win.ts
index 19161c4..97a9cbe 100644
--- a/src/main/key-service-win.ts
+++ b/src/main/key-service-win.ts
@@ -105,7 +105,7 @@ export class KeyService {
private localizeNetworkDll(originalPath: string): string {
try {
- const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
+ const tempDir = join(os.tmpdir(), 'tracememo_dll_cache')
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true })
}
@@ -361,7 +361,7 @@ export class KeyService {
error.includes('ACCESS_DENIED') ||
error.includes('打开目标进程失败')
) {
- return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
+ return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 TraceMemo 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
}
return error
}
diff --git a/src/main/preload-env.ts b/src/main/preload-env.ts
index 346d0eb..6ec71e8 100644
--- a/src/main/preload-env.ts
+++ b/src/main/preload-env.ts
@@ -30,7 +30,7 @@ try {
])
process.env.WCDB_RESOURCES_PATH = process.env.WCDB_RESOURCES_PATH || resourcesRoot
- process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
+ process.env.TRACEMEMO_PROJECT_NAME = process.env.TRACEMEMO_PROJECT_NAME || 'TraceMemo'
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
} catch (error) {
console.error('[TraceMemo] failed to enforce local DLL priority:', error)
diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts
index 46ad6e6..6038242 100644
--- a/src/main/services/bootstrap-cache.ts
+++ b/src/main/services/bootstrap-cache.ts
@@ -603,23 +603,49 @@ export function saveCachedGroupSnapshot(
accountRoot: string,
userMd5: string,
snapshot: CachedGroupSnapshot
-): void {
+): CachedGroupSnapshot {
const normalizedRoot = normalizeRoot(accountRoot)
- if (!normalizedRoot || !userMd5) return
+ if (!normalizedRoot || !userMd5) return snapshot
const paths = getAccountCachePaths(normalizedRoot)
const file = getGroupCacheFile(normalizedRoot, userMd5)
+ const previous = readGroupSnapshotFile(normalizedRoot, userMd5)?.snapshot
+ const previousByWxid = new Map((previous?.members || []).map((member) => [member.wxid, member]))
+ const mergedSnapshot: CachedGroupSnapshot = previous && previous.roomId === snapshot.roomId
+ ? {
+ ...snapshot,
+ members: snapshot.members.map((member) => {
+ const oldMember = previousByWxid.get(member.wxid)
+ const freshGroupNickname = String(member.groupNickname || '').trim()
+ const freshWechatNickname = String(member.wechatNickname || '').trim()
+ const oldGroupNickname = String(oldMember?.groupNickname || '').trim()
+ const freshLooksLikeContactFallback =
+ !freshGroupNickname ||
+ freshGroupNickname === freshWechatNickname
+ const groupNickname =
+ freshLooksLikeContactFallback && oldGroupNickname
+ ? oldGroupNickname
+ : freshGroupNickname
+ return {
+ ...member,
+ groupNickname,
+ nickname: groupNickname || member.nickname
+ }
+ })
+ }
+ : snapshot
const value: CachedGroupSnapshotFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
userMd5,
updatedAt: Date.now(),
- snapshot
+ snapshot: mergedSnapshot
}
touchMemory(groupMemory, file, value, MAX_MEMORY_GROUP_SNAPSHOTS)
scheduleWrite(file, value, {
prune: { directory: paths.groups, maxFiles: MAX_GROUP_SNAPSHOTS }
})
+ return mergedSnapshot
}
export function saveCachedMessages(
diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts
index 5a94b19..114f654 100644
--- a/src/main/services/chat-service.ts
+++ b/src/main/services/chat-service.ts
@@ -15,6 +15,7 @@ import {
} from '../../shared/windows-runtime'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality'
+import { wcdbDebugLog } from '../wcdb-debug'
export function getCurrentKey(): string {
if (!dbRef) return ''
@@ -226,7 +227,8 @@ function listSourceMessages(
startTime?: number,
endTime?: number,
options?: { limit?: number },
- rawMessagesOverride?: WechatMessage[]
+ rawMessagesOverride?: WechatMessage[],
+ requestId = 'NO-REQUEST'
): FormattedMessage[] {
if (!dbRef) return []
@@ -234,13 +236,13 @@ function listSourceMessages(
const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const isGroupChat = Boolean(username?.endsWith('@chatroom'))
- console.log(
- `[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
+ wcdbDebugLog(
+ `[${requestId}] ChatService listSourceMessages start md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
)
const rawMessages =
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
- console.log(
- `[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
+ wcdbDebugLog(
+ `[${requestId}] ChatService raw snapshot ready raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
)
const formatted = rawMessages.map((msg: WechatMessage) => {
@@ -447,14 +449,43 @@ export async function listMessagesAsync(
userMd5: string,
startTime?: number,
endTime?: number,
- options?: { limit?: number }
+ options?: { limit?: number },
+ requestId = 'NO-REQUEST'
): Promise {
if (!dbRef) return []
- const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
- const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options, rawMessages)
+ const startedAt = Date.now()
+ wcdbDebugLog(`[${requestId}] ChatService listMessagesAsync start md5=${userMd5}`)
+ const rawMessages = await dbRef.getUserMessagesAsync(
+ userMd5,
+ startTime,
+ endTime,
+ options,
+ requestId
+ )
+ wcdbDebugLog(
+ `[${requestId}] ChatService getUserMessagesAsync end raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
+ )
+ const sourceMessages = listSourceMessages(
+ userMd5,
+ startTime,
+ endTime,
+ options,
+ rawMessages,
+ requestId
+ )
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
recordRecallArchiveMessages(userMd5, username, sourceMessages)
- return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
+ const result = mergeRecallArchiveMessages(
+ userMd5,
+ sourceMessages,
+ startTime,
+ endTime,
+ options?.limit
+ )
+ wcdbDebugLog(
+ `[${requestId}] ChatService listMessagesAsync end formatted=${result.length} cost=${Date.now() - startedAt}ms`
+ )
+ return result
}
export async function listMessagesForExport(
diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts
index b48b252..316e7e4 100644
--- a/src/main/services/settings-store.ts
+++ b/src/main/services/settings-store.ts
@@ -58,7 +58,6 @@ function getDefaultDbRootCandidates(home: string): string[] {
// 仅支持 WeChat 4.0:剔除 V3 时代的 "WeChat Files" 目录,
// 只认 xwechat_files(含 Documents\ 和 AppData\Roaming\Tencent\ 两种合法位置)。
const candidates = [
- ...getWeflowDbPathCandidates(home),
path.join(home, 'Documents', 'xwechat_files'),
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
]
@@ -66,25 +65,6 @@ function getDefaultDbRootCandidates(home: string): string[] {
return unique(candidates)
}
-function getWeflowDbPathCandidates(home: string): string[] {
- const configPaths = [
- path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
- path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json')
- ]
- const candidates: string[] = []
- for (const configPath of configPaths) {
- try {
- const config = fs.readJsonSync(configPath) as { dbPath?: unknown }
- if (typeof config.dbPath === 'string' && config.dbPath.trim()) {
- candidates.push(config.dbPath.trim())
- }
- } catch {
- // WeFlow is optional; ignore missing or unreadable config.
- }
- }
- return candidates
-}
-
function unique(values: string[]): string[] {
return Array.from(new Set(values))
}
diff --git a/src/main/wcdb-debug.ts b/src/main/wcdb-debug.ts
new file mode 100644
index 0000000..60e150a
--- /dev/null
+++ b/src/main/wcdb-debug.ts
@@ -0,0 +1,7 @@
+export function isWcdbDebugEnabled(): boolean {
+ return process.env.WCDB_DEBUG_LOGS === '1'
+}
+
+export function wcdbDebugLog(message: string): void {
+ if (isWcdbDebugEnabled()) console.log(message)
+}
diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts
index 371e47c..5a3c641 100644
--- a/src/main/wcdb4-client.ts
+++ b/src/main/wcdb4-client.ts
@@ -5,6 +5,7 @@ import crypto from 'crypto'
import { createRequire } from 'module'
import { createConnection, Socket } from 'net'
import { getResourceRoots } from './resource-paths'
+import { wcdbDebugLog } from './wcdb-debug'
export interface Wcdb4Session {
username: string
@@ -513,7 +514,6 @@ export class Wcdb4Client {
if (process.platform === 'win32') {
// 仅支持 WeChat 4.0:只认 xwechat_files,V3 时代的 "WeChat Files" 不再加入候选
const candidates = [
- ...Wcdb4Client.getWeflowDbPathCandidates(home),
path.join(home, 'Documents', 'xwechat_files'),
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
]
@@ -524,25 +524,6 @@ export class Wcdb4Client {
]
}
- private static getWeflowDbPathCandidates(home: string): string[] {
- const configPaths = [
- path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
- path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json')
- ]
- const candidates: string[] = []
- for (const configPath of configPaths) {
- try {
- const config = fs.readJsonSync(configPath) as { dbPath?: unknown }
- if (typeof config.dbPath === 'string' && config.dbPath.trim()) {
- candidates.push(config.dbPath.trim())
- }
- } catch {
- // WeFlow is optional; ignore missing or unreadable config.
- }
- }
- return candidates
- }
-
private static hasDbStorage(candidate: string): boolean {
try {
return (
@@ -1094,13 +1075,23 @@ export class Wcdb4Client {
username: string,
startTime?: number,
endTime?: number,
- options: Wcdb4MessageQueryOptions = {}
+ options: Wcdb4MessageQueryOptions = {},
+ requestId = 'NO-REQUEST'
): Promise {
const startedAt = Date.now()
const maxRows = this.normalizeMessageLimit(options.limit)
+ wcdbDebugLog(
+ `[${requestId}] WCDB getMessagesAsync start username=${username} maxRows=${maxRows || 0} start=${startTime || 0} end=${endTime || 0}`
+ )
let cursorMessages: Wcdb4Message[] = []
try {
- cursorMessages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
+ cursorMessages = await this.getMessagesByCursorAsync(
+ username,
+ startTime,
+ endTime,
+ maxRows,
+ requestId
+ )
} catch (error) {
console.warn(`[WCDB4] async cursor messages failed username=${username}:`, error)
}
@@ -1113,12 +1104,28 @@ export class Wcdb4Client {
// enumerate. A bounded query must inspect all matching stores so history
// cannot silently stop at a shard boundary.
let tableMessages: Wcdb4Message[] = []
- if (endTime || cursorMessages.length === 0) {
- tableMessages = await this.getMessagesByTableScanAsync(username, startTime, endTime, maxRows)
+ const cursorSatisfiesLimit = maxRows !== undefined && cursorMessages.length >= maxRows
+ let tableScanExecuted = false
+ if (!cursorSatisfiesLimit && (endTime || cursorMessages.length === 0)) {
+ tableScanExecuted = true
+ tableMessages = await this.getMessagesByTableScanAsync(
+ username,
+ startTime,
+ endTime,
+ maxRows,
+ requestId
+ )
}
+ wcdbDebugLog(
+ `[${requestId}] WCDB message source decision cursorSatisfiesLimit=${cursorSatisfiesLimit} tableScanExecuted=${tableScanExecuted} cursorRows=${cursorMessages.length} tableRows=${tableMessages.length}`
+ )
+ const mergeStartedAt = Date.now()
const messages = this.mergeMessageRows(cursorMessages, tableMessages, maxRows)
- console.log(
- `[WCDB4] getMessages async username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} cost=${Date.now() - startedAt}ms`
+ wcdbDebugLog(
+ `[${requestId}] WCDB mergeMessageRows cost=${Date.now() - mergeStartedAt}ms finalRows=${messages.length}`
+ )
+ wcdbDebugLog(
+ `[${requestId}] WCDB getMessagesAsync end username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} totalCost=${Date.now() - startedAt}ms`
)
return messages
}
@@ -1225,13 +1232,20 @@ export class Wcdb4Client {
username: string,
startTime?: number,
endTime?: number,
- limit?: number
+ limit?: number,
+ requestId = 'NO-REQUEST'
): Promise {
if (!this.wcdbExecQuery) return []
+ const startedAt = Date.now()
+ wcdbDebugLog(`[${requestId}] WCDB table scan start username=${username}`)
let tables: Wcdb4MessageStore[] = []
try {
+ const statsStartedAt = Date.now()
tables = await this.listMessageStoresAsync(username)
+ wcdbDebugLog(
+ `[${requestId}] WCDB get_message_table_stats end stores=${tables.length} cost=${Date.now() - statsStartedAt}ms`
+ )
} catch (error) {
console.warn(`[WCDB4] async message table stats failed username=${username}:`, error)
throw new Error(
@@ -1254,12 +1268,16 @@ export class Wcdb4Client {
for (const table of tables) {
try {
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}`
+ const queryStartedAt = Date.now()
const rows = await this.callJsonAsync[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
table.dbPath,
sql
)
+ wcdbDebugLog(
+ `[${requestId}] WCDB exec_query table=${table.tableName} db=${table.dbPath} rows=${Array.isArray(rows) ? rows.length : 0} cost=${Date.now() - queryStartedAt}ms`
+ )
successfulTables += 1
if (Array.isArray(rows)) allRows.push(...rows)
} catch (error) {
@@ -1274,7 +1292,11 @@ export class Wcdb4Client {
throw new Error('历史消息分片均读取失败,请检查数据目录或微信数据版本')
}
- return this.finalizeMessages(username, allRows, startTime, endTime, limit)
+ const result = this.finalizeMessages(username, allRows, startTime, endTime, limit)
+ wcdbDebugLog(
+ `[${requestId}] WCDB table scan end rows=${result.length} cost=${Date.now() - startedAt}ms`
+ )
+ return result
}
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
@@ -1682,6 +1704,13 @@ export class Wcdb4Client {
): Wcdb4Message[] {
const messages = rows.map((row) => this.normalizeMessage(row))
+ // Message rows carry a sender id, while the group-local display name lives
+ // in the separate group nickname map. Resolve that map once per group
+ // snapshot so chat bubbles do not fall back to the contact nickname.
+ const groupNicknames = username.endsWith('@chatroom')
+ ? this.getGroupNicknames(username)
+ : undefined
+
const sorted = messages
.filter((message) => {
const createTime = Number(message.msgCreateTime)
@@ -1695,7 +1724,10 @@ export class Wcdb4Client {
return visibleMessages.map((message) => {
if (!message.sender) return message
- const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
+ const senderNickname =
+ groupNicknames?.get(message.sender) ||
+ this.displayNameCache.get(message.sender) ||
+ message.senderNickname
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
const shouldPrefixSender =
username.endsWith('@chatroom') &&
@@ -1747,7 +1779,8 @@ export class Wcdb4Client {
username: string,
startTime?: number,
endTime?: number,
- limit?: number
+ limit?: number,
+ requestId = 'NO-REQUEST'
): Promise {
if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return []
@@ -1757,6 +1790,10 @@ export class Wcdb4Client {
const begin = this.normalizeTimestamp(startTime || 0)
const end = this.normalizeTimestamp(endTime || 0)
const ascending = limit ? 0 : 1
+ wcdbDebugLog(
+ `[${requestId}] WCDB open_message_cursor start username=${username} batchSize=${batchSize} ascending=${ascending} begin=${begin} end=${end}`
+ )
+ const openStartedAt = Date.now()
const openResult = await this.callAsyncCode(
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
handle,
@@ -1767,14 +1804,20 @@ export class Wcdb4Client {
end,
cursorOut
)
+ wcdbDebugLog(
+ `[${requestId}] WCDB open_message_cursor end code=${openResult} cursor=${cursorOut[0]} cost=${Date.now() - openStartedAt}ms`
+ )
if (openResult !== 0 || cursorOut[0] <= 0) return []
const cursor = cursorOut[0]
const allRows: Record[] = []
+ let fetchIndex = 0
try {
while (true) {
+ fetchIndex += 1
const outJson: WcdbVoidOut = [null]
const outHasMore: [number] = [0]
+ const fetchStartedAt = Date.now()
const fetchResult = await this.callAsyncCode(
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
handle,
@@ -1782,24 +1825,43 @@ export class Wcdb4Client {
outJson,
outHasMore
)
- if (fetchResult !== 0 || !outJson[0]) break
+ const fetchNativeCost = Date.now() - fetchStartedAt
+ if (fetchResult !== 0 || !outJson[0]) {
+ wcdbDebugLog(
+ `[${requestId}] WCDB fetch_message_batch #${fetchIndex} code=${fetchResult} nativeCost=${fetchNativeCost}ms output=${Boolean(outJson[0])}`
+ )
+ break
+ }
try {
+ const decodeStartedAt = Date.now()
const json = this.koffi!.decode(outJson[0], 'char', -1)
const batch = JSON.parse(json) as Record[]
if (Array.isArray(batch)) allRows.push(...batch)
+ wcdbDebugLog(
+ `[${requestId}] WCDB fetch_message_batch #${fetchIndex} code=${fetchResult} nativeCost=${fetchNativeCost}ms jsonCost=${Date.now() - decodeStartedAt}ms batchRows=${Array.isArray(batch) ? batch.length : 0} hasMore=${outHasMore[0]}`
+ )
} finally {
this.wcdbFreeString?.(outJson[0])
}
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
}
} finally {
+ const closeStartedAt = Date.now()
try {
this.wcdbCloseMessageCursor?.(handle, cursor)
} catch {
// Best-effort cursor cleanup.
}
+ wcdbDebugLog(
+ `[${requestId}] WCDB close_message_cursor cursor=${cursor} cost=${Date.now() - closeStartedAt}ms fetches=${fetchIndex}`
+ )
}
- return this.finalizeMessages(username, allRows, startTime, endTime, limit)
+ const finalizeStartedAt = Date.now()
+ const result = this.finalizeMessages(username, allRows, startTime, endTime, limit)
+ wcdbDebugLog(
+ `[${requestId}] WCDB finalizeMessages cost=${Date.now() - finalizeStartedAt}ms rows=${result.length}`
+ )
+ return result
}
async getGroupMembersAsync(chatroomId: string): Promise {
@@ -2672,6 +2734,7 @@ export class Wcdb4Client {
'WCDB_CT_compressContent'
])
const content = this.decodeMessageContent(contentRaw, compressRaw)
+ const contentSender = this.extractSenderFromMessageContent(content)
const sender = this.pickString(row, [
'sender_username',
'senderUsername',
@@ -2679,7 +2742,7 @@ export class Wcdb4Client {
'fromUsername',
'from_username',
'WCDB_CT_sender_username'
- ])
+ ]) || contentSender
const createTime = this.pickNumber(row, [
'create_time',
'createTime',
@@ -2719,7 +2782,7 @@ export class Wcdb4Client {
'type',
'WCDB_CT_local_type'
])
- const isSend = this.pickBoolean(row, [
+ const rawIsSend = this.pickValue(row, [
'computed_is_send',
'computedIsSend',
'is_send',
@@ -2727,6 +2790,22 @@ export class Wcdb4Client {
'mesDes',
'WCDB_CT_is_send'
])
+ const parsedIsSend = rawIsSend === undefined || rawIsSend === null
+ ? false
+ : this.pickBoolean(row, [
+ 'computed_is_send',
+ 'computedIsSend',
+ 'is_send',
+ 'isSend',
+ 'mesDes',
+ 'WCDB_CT_is_send'
+ ])
+ const isSelfSender = sender ? this.getMyUsernameCandidates().some((candidate) => {
+ const left = String(candidate || '').trim().toLowerCase()
+ const right = String(sender || '').trim().toLowerCase()
+ return Boolean(left && right && (left === right || left.startsWith(`${right}_`) || right.startsWith(`${left}_`)))
+ }) : false
+ const isSend = isSelfSender || parsedIsSend
return {
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
@@ -2742,6 +2821,16 @@ export class Wcdb4Client {
}
}
+ /** 解决群里自己的消息被识别成 user */
+ private extractSenderFromMessageContent(content: string): string {
+ const firstLine = String(content || '').split(/\r?\n/, 1)[0].trim()
+ if (!firstLine || firstLine.length > 128 || /[<>]/.test(firstLine)) return ''
+ const candidate = firstLine.replace(/:$/, '').trim()
+ if (!candidate || /\s/.test(candidate)) return ''
+ if (candidate.includes('@') || candidate.startsWith('wxid_') || candidate.startsWith('gh_')) return candidate
+ return this.getMyUsernameCandidates().includes(candidate) ? candidate : ''
+ }
+
private shouldHydrateSessionDisplayName(session: Wcdb4Session): boolean {
const username = String(session.username || '').trim()
const nickname = String(session.nickname || '').trim()
diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts
index 74e3b44..23b6d4a 100644
--- a/src/main/wechat-db.ts
+++ b/src/main/wechat-db.ts
@@ -1,4 +1,5 @@
import { Wcdb4Client, Wcdb4MessageQueryOptions } from './wcdb4-client'
+import { wcdbDebugLog } from './wcdb-debug'
export interface UserContact {
m_nsUsrName: string
@@ -197,13 +198,26 @@ export class WechatDb {
userMd5: string,
startTime?: number,
endTime?: number,
- options?: Wcdb4MessageQueryOptions
+ options?: Wcdb4MessageQueryOptions,
+ requestId = 'NO-REQUEST'
): Promise {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
- const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
- return messages.map((message) => ({ ...message.raw, ...message }))
+ const startedAt = Date.now()
+ wcdbDebugLog(`[${requestId}] WechatDb getUserMessagesAsync start username=${username}`)
+ const messages = await this.wcdb4Client.getMessagesAsync(
+ username,
+ startTime,
+ endTime,
+ options,
+ requestId
+ )
+ const mapped = messages.map((message) => ({ ...message.raw, ...message }))
+ wcdbDebugLog(
+ `[${requestId}] WechatDb getUserMessagesAsync end rows=${mapped.length} cost=${Date.now() - startedAt}ms`
+ )
+ return mapped
}
public async getUserMessagesForExport(
diff --git a/tests/unit/bootstrap-cache.test.ts b/tests/unit/bootstrap-cache.test.ts
index 65db6ab..4913a8e 100644
--- a/tests/unit/bootstrap-cache.test.ts
+++ b/tests/unit/bootstrap-cache.test.ts
@@ -19,6 +19,7 @@ import {
mergeCachedSelfInfo,
saveBootstrapContacts,
saveBootstrapSelf,
+ saveCachedGroupSnapshot,
saveCachedMessages
} from '../../src/main/services/bootstrap-cache'
@@ -141,4 +142,70 @@ describe('bootstrap cache', () => {
})
flushBootstrapCacheWritesSync()
})
+
+ it('preserves known group nicknames when a refresh only returns contact nicknames', () => {
+ const groupRoot = '/fixture/group-account'
+ const first = {
+ roomId: 'fixture@chatroom',
+ memberCount: 1,
+ members: [
+ {
+ wxid: 'wxid-member',
+ nickname: '群内昵称',
+ groupNickname: '群内昵称',
+ wechatNickname: '通讯录昵称',
+ remark: '',
+ avatar: ''
+ }
+ ]
+ }
+ const refreshed = {
+ ...first,
+ members: [{ ...first.members[0], nickname: '通讯录昵称', groupNickname: '通讯录昵称' }]
+ }
+
+ saveCachedGroupSnapshot(groupRoot, 'fixture-md5', first)
+ flushBootstrapCacheWritesSync()
+ clearBootstrapCache()
+ const merged = saveCachedGroupSnapshot(groupRoot, 'fixture-md5', refreshed)
+
+ expect(merged.members[0]).toMatchObject({
+ nickname: '群内昵称',
+ groupNickname: '群内昵称',
+ wechatNickname: '通讯录昵称'
+ })
+ })
+
+ it('accepts a refreshed group nickname when it is not a contact fallback', () => {
+ const groupRoot = '/fixture/group-nickname-update'
+ const first = {
+ roomId: 'fixture@chatroom',
+ memberCount: 1,
+ members: [
+ {
+ wxid: 'wxid-member',
+ nickname: '旧群昵称',
+ groupNickname: '旧群昵称',
+ wechatNickname: '',
+ remark: '',
+ avatar: ''
+ }
+ ]
+ }
+ const refreshed = {
+ ...first,
+ members: [{ ...first.members[0], nickname: '新群昵称', groupNickname: '新群昵称' }]
+ }
+
+ saveCachedGroupSnapshot(groupRoot, 'fixture-md5', first)
+ flushBootstrapCacheWritesSync()
+ clearBootstrapCache()
+ const merged = saveCachedGroupSnapshot(groupRoot, 'fixture-md5', refreshed)
+
+ expect(merged.members[0]).toMatchObject({
+ nickname: '新群昵称',
+ groupNickname: '新群昵称',
+ wechatNickname: ''
+ })
+ })
})
diff --git a/tests/unit/wcdb-debug.test.ts b/tests/unit/wcdb-debug.test.ts
new file mode 100644
index 0000000..3506b14
--- /dev/null
+++ b/tests/unit/wcdb-debug.test.ts
@@ -0,0 +1,32 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { isWcdbDebugEnabled, wcdbDebugLog } from '../../src/main/wcdb-debug'
+
+const originalDebugValue = process.env.WCDB_DEBUG_LOGS
+
+afterEach(() => {
+ if (originalDebugValue === undefined) delete process.env.WCDB_DEBUG_LOGS
+ else process.env.WCDB_DEBUG_LOGS = originalDebugValue
+ vi.restoreAllMocks()
+})
+
+describe('WCDB debug logging', () => {
+ it('is disabled by default', () => {
+ delete process.env.WCDB_DEBUG_LOGS
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ wcdbDebugLog('[GETMSG-001] hidden')
+
+ expect(isWcdbDebugEnabled()).toBe(false)
+ expect(log).not.toHaveBeenCalled()
+ })
+
+ it('logs when WCDB_DEBUG_LOGS is enabled', () => {
+ process.env.WCDB_DEBUG_LOGS = '1'
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ wcdbDebugLog('[GETMSG-001] visible')
+
+ expect(isWcdbDebugEnabled()).toBe(true)
+ expect(log).toHaveBeenCalledWith('[GETMSG-001] visible')
+ })
+})
diff --git a/tests/unit/wcdb-group-members.test.ts b/tests/unit/wcdb-group-members.test.ts
index 57f55b9..6f721c3 100644
--- a/tests/unit/wcdb-group-members.test.ts
+++ b/tests/unit/wcdb-group-members.test.ts
@@ -110,4 +110,35 @@ describe('WCDB group member names', () => {
expectedMember({ nickname: '真实群昵称', wechatNickname: '', remark: '' })
])
})
+
+ it('uses the group nickname for message sender display before contact display names', () => {
+ const client = Object.assign(Object.create(Wcdb4Client.prototype), {
+ accountRoot: '/fixture/account',
+ displayNameCache: new Map([['wxid-member', '通讯录昵称']]),
+ getGroupNicknames: vi.fn(() => new Map([['wxid-member', '真实群昵称']])),
+ avatarCache: new Map(),
+ myWxid: '',
+ wxid: ''
+ }) as Wcdb4Client
+
+ const finalizeMessages = (client as unknown as {
+ finalizeMessages: (
+ username: string,
+ rows: Record[]
+ ) => Array<{ senderNickname?: string }>
+ }).finalizeMessages
+
+ expect(
+ finalizeMessages.call(client, 'fixture@chatroom', [
+ {
+ sender_username: 'wxid-member',
+ create_time: 1731327263,
+ local_id: 1,
+ local_type: 1,
+ msg_content: 'fixture'
+ }
+ ])
+ ).toMatchObject([{ senderNickname: '真实群昵称' }])
+ expect(client.getGroupNicknames).toHaveBeenCalledWith('fixture@chatroom')
+ })
})