Merge branch 'develop' into feat/tracememo-v2.2.0

This commit is contained in:
Wxw-Gu
2026-08-11 14:44:58 +08:00
6 changed files with 358 additions and 148 deletions
+1
View File
@@ -1 +1,2 @@
shamefully-hoist=true
electron_mirror=https://npmmirror.com/mirrors/electron/
+1
View File
@@ -55,6 +55,7 @@ TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以
- [macOS 数据访问说明](./platform/macos.md)
- [开发、测试与构建](./development/overview.md)
- [本地启动排障](./development/local-startup-troubleshooting.md)
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
@@ -0,0 +1,62 @@
# 本地启动排障
本文面向运行源码开发环境的贡献者。常规启动顺序和测试入口请先阅读[开发、测试与构建](./overview.md)。
## 启动成功的判断标准
执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用:
- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`
- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`
- 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`
`6131` 是应用提供给本机集成使用的 API 端口,不是 Vite 的页面端口。
## Go 命令找不到
如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行:
```bash
go version
```
命令不可用表示当前终端的 `PATH` 没有找到 Go。Windows 默认安装位置是 `C:\Program Files\Go\bin`。确认 Go 已安装并把该目录加入系统 `PATH` 后,关闭并重新打开终端或 IDE,再重新执行 `go version``pnpm dev`
如果 Go 刚完成安装,已经打开的终端不会自动继承新的环境变量;重开终端是必要步骤。不要绕过连接器构建直接启动 `electron-vite dev`,否则 Agent Hub 的微信连接器不会生成。
## Electron 二进制缺失或下载失败
`electron-vite dev``Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
项目的 [`.npmrc`](../../.npmrc) 已设置:
```ini
electron_mirror=https://npmmirror.com/mirrors/electron/
```
pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。
依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装:
```bash
pnpm install --frozen-lockfile
```
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例:
```powershell
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
pnpm install --frozen-lockfile
```
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
## 页面地址无法通过 IPv4 访问
Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时直接访问 `http://127.0.0.1:5173/` 可能失败,但 `http://localhost:5173/` 仍然正常,Electron 也会使用后者加载页面。
排查时优先访问 `http://localhost:5173/`;需要显式验证 IPv6 时,使用 `http://[::1]:5173/`。不要因为 IPv4 回环地址不可用就判断 Electron 或 Vite 启动失败。
## 仍无法启动时
保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile``pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
+2
View File
@@ -18,6 +18,8 @@ pnpm install
pnpm dev
```
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
常用检查:
```bash
+179 -148
View File
@@ -58,6 +58,17 @@ export interface Wcdb4GroupMember {
m_nsHeadImgUrl: string
}
/**
* 联系人表中独立保存的成员名称字段。
*
* 群成员接口的 nickname 在部分微信数据版本中会被通讯录备注覆盖,因此不能用它
* 推断微信昵称或备注。
*/
type Wcdb4ContactMemberNames = {
wechatNickname: string
remark: string
}
export interface Wcdb4ImageHardlink {
file_name?: string
full_path?: string
@@ -1685,76 +1696,16 @@ export class Wcdb4Client {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
)
const members = (Array.isArray(rows) ? rows : []).map((row) => {
const username = this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
const wechatNickname = this.pickString(row, [
'nickname',
'nickName',
'wechatNickname',
'wechat_nickname',
'm_nsNickName'
])
const remark = this.pickString(row, [
'remark',
'remarkName',
'remark_name',
'contactRemark',
'contact_remark'
])
const rowGroupNickname = this.pickString(row, [
'groupNickname',
'group_nickname',
'displayName',
'display_name'
])
const memberNickname = this.pickString(row, ['name'])
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl'
])
if (username) {
if (avatar) this.avatarCache.set(username, avatar)
}
return {
m_nsUsrName: username,
nickname: wechatNickname || memberNickname || remark || rowGroupNickname,
groupNickname: groupNicknames.get(username) || rowGroupNickname,
wechatNickname: wechatNickname || memberNickname,
remark,
m_nsHeadImgUrl: avatar
}
})
const missingDisplayNames = members
.filter((member) => !member.nickname)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
this.hydrateDisplayNames(missingDisplayNames)
const memberRows = Array.isArray(rows) ? rows : []
const contactNames = this.readContactMemberNames(this.groupMemberUsernames(memberRows))
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
this.hydrateAvatarUrls(
members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
)
return members.map((member) => ({
...member,
nickname:
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
wechatNickname:
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
return this.withCachedGroupMemberAvatars(members)
} catch {
return []
}
@@ -1828,76 +1779,98 @@ export class Wcdb4Client {
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
chatroomId
)
const members = (Array.isArray(rows) ? rows : []).map((row) => {
const username = this.pickString(row, [
const memberRows = Array.isArray(rows) ? rows : []
const contactNames = await this.readContactMemberNamesAsync(
this.groupMemberUsernames(memberRows)
)
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
const missingAvatars = members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
await this.hydrateAvatarUrlsAsync(missingAvatars)
return this.withCachedGroupMemberAvatars(members)
} catch (error) {
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
return []
}
}
/**
* 将群成员接口与联系人表组装为语义明确的成员快照。
*
* 群昵称只接受群聊数据;微信昵称与通讯录备注只接受 contact 表。即使群成员
* 接口返回了 nickname,也不能作为名称字段的回退,以免把通讯录备注误展示为微信昵称。
*/
private normalizeGroupMembers(
rows: Record<string, unknown>[],
groupNicknames: Map<string, string>,
contactNames: Map<string, Wcdb4ContactMemberNames>
): Wcdb4GroupMember[] {
return rows.map((row) => {
const username = this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
const rowGroupNickname = this.pickString(row, [
'groupNickname',
'group_nickname',
'displayName',
'display_name'
])
const groupNickname = groupNicknames.get(username) || rowGroupNickname
const contact = contactNames.get(username)
const wechatNickname = contact?.wechatNickname || ''
const remark = contact?.remark || ''
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl'
])
if (username && avatar) this.avatarCache.set(username, avatar)
return {
m_nsUsrName: username,
// nickname 是旧调用方使用的通用显示字段,保持安全的名称优先级。
nickname: wechatNickname || groupNickname || username,
groupNickname,
wechatNickname,
remark,
m_nsHeadImgUrl: avatar
}
})
}
/**
* 从原始群成员行提取联系人查询键,避免把空值传进 SQL IN 条件。
*/
private groupMemberUsernames(rows: Record<string, unknown>[]): string[] {
return rows
.map((row) =>
this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
const wechatNickname = this.pickString(row, [
'nickname',
'nickName',
'wechatNickname',
'wechat_nickname',
'm_nsNickName'
])
const remark = this.pickString(row, [
'remark',
'remarkName',
'remark_name',
'contactRemark',
'contact_remark'
])
const rowGroupNickname = this.pickString(row, [
'groupNickname',
'group_nickname',
'displayName',
'display_name'
])
const memberNickname = this.pickString(row, ['name'])
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl'
])
if (username && avatar) this.avatarCache.set(username, avatar)
return {
m_nsUsrName: username,
nickname: wechatNickname || memberNickname || remark || rowGroupNickname,
groupNickname: groupNicknames.get(username) || rowGroupNickname,
wechatNickname: wechatNickname || memberNickname,
remark,
m_nsHeadImgUrl: avatar
}
})
)
.filter(Boolean)
}
const missingNames = members
.filter((member) => !member.nickname)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
const missingAvatars = members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
await Promise.all([
this.hydrateDisplayNamesAsync(missingNames),
this.hydrateAvatarUrlsAsync(missingAvatars)
])
return members.map((member) => ({
...member,
nickname:
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
wechatNickname:
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
} catch (error) {
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
return []
}
/**
* 为已标准化的成员补充头像缓存,不参与名称字段的回退。
*/
private withCachedGroupMemberAvatars(members: Wcdb4GroupMember[]): Wcdb4GroupMember[] {
return members.map((member) => ({
...member,
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
}
getGroupNicknames(chatroomId: string): Map<string, string> {
@@ -2668,27 +2641,6 @@ export class Wcdb4Client {
return false
}
private hydrateDisplayNames(usernames: string[]): void {
if (!this.wcdbGetDisplayNames) return
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
if (missing.length === 0) return
try {
const rows = this.callJson<Record<string, string> | Record<string, unknown>[]>(
(handle, outJson) => this.wcdbGetDisplayNames!(handle, JSON.stringify(missing), outJson)
)
this.readStringMap(rows, [
'nickname',
'displayName',
'display_name',
'remark',
'name'
]).forEach((name, username) => this.displayNameCache.set(username, name))
} catch {
// Names are optional; usernames are still enough to load chats.
}
}
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
if (!this.wcdbGetDisplayNames) return
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
@@ -2794,6 +2746,85 @@ export class Wcdb4Client {
}
}
/**
* 从 contact 表读取群成员的微信昵称与通讯录备注。
*
* 查询失败时返回空映射:群成员快照仍可用,并由调用方回退到群昵称或 wxid;
* 不得使用群成员接口的 nickname,因为该字段可能实际保存的是通讯录备注。
*/
private readContactMemberNames(usernames: string[]): Map<string, Wcdb4ContactMemberNames> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
const inList = this.uniq(usernames)
.map((username) => `'${username.replace(/'/g, "''")}'`)
.join(',')
if (!inList) return result
try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(
handle,
'contact',
'',
`SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`,
outJson
)
)
return this.contactMemberNamesFromRows(rows)
} catch {
return result
}
}
/**
* 异步读取 contact 表的成员名称字段,语义与同步路径保持一致。
*/
private async readContactMemberNamesAsync(
usernames: string[]
): Promise<Map<string, Wcdb4ContactMemberNames>> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
const inList = this.uniq(usernames)
.map((username) => `'${username.replace(/'/g, "''")}'`)
.join(',')
if (!inList) return result
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'contact',
'',
`SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`
)
return this.contactMemberNamesFromRows(rows)
} catch {
return result
}
}
/**
* 将 contact 查询行转换为按 wxid 索引的名称映射。
*/
private contactMemberNamesFromRows(
rows: Record<string, unknown>[]
): Map<string, Wcdb4ContactMemberNames> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!Array.isArray(rows)) return result
for (const row of rows) {
const username = this.pickString(row, ['username', 'user_name', 'userName', 'm_nsUsrName'])
if (!username) continue
result.set(username, {
wechatNickname: this.pickString(row, ['nick_name', 'nickName', 'm_nsNickName']),
remark: this.pickString(row, ['remark', 'remark_name', 'remarkName'])
})
}
return result
}
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
const result = new Map<string, string>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client } from '../../src/main/wcdb4-client'
const groupMemberRows = [
{
username: 'wxid-member',
// 某些微信数据版本会在这个字段返回通讯录备注,不能作为微信昵称使用。
nickname: '被污染的接口备注',
groupNickname: '行内群昵称',
avatarUrl: 'https://example.com/member.jpg'
}
]
const contactRows = [
{
username: 'wxid-member',
nick_name: '真实微信昵称',
remark: '真实通讯录备注'
}
]
function expectedMember(overrides: Partial<Record<string, string>> = {}) {
return {
m_nsUsrName: 'wxid-member',
nickname: '真实微信昵称',
groupNickname: '真实群昵称',
wechatNickname: '真实微信昵称',
remark: '真实通讯录备注',
m_nsHeadImgUrl: 'https://example.com/member.jpg',
...overrides
}
}
describe('WCDB group member names', () => {
it('uses contact nick_name and remark instead of the ambiguous synchronous member nickname', () => {
const getGroupMembers = vi.fn(() => 0)
const executeQuery = vi.fn(() => 0)
const callJson = vi.fn((call: (handle: number, output: [null]) => number) => {
const queryCount = executeQuery.mock.calls.length
call(1, [null])
return executeQuery.mock.calls.length > queryCount ? contactRows : groupMemberRows
})
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
wcdbGetGroupMembers: getGroupMembers,
wcdbExecQuery: executeQuery,
callJson,
getGroupNicknames: vi.fn(() => new Map([['wxid-member', '真实群昵称']])),
avatarCache: new Map<string, string>(),
wcdbGetAvatarUrls: null
}) as Wcdb4Client
expect(client.getGroupMembers('fixture@chatroom')).toEqual([expectedMember()])
expect(executeQuery).toHaveBeenCalledWith(
1,
'contact',
'',
expect.stringContaining('SELECT username, nick_name, remark FROM contact'),
expect.any(Array)
)
})
it('uses the same independent contact fields in the asynchronous snapshot path', async () => {
const getGroupMembers = vi.fn()
const executeQuery = vi.fn()
const callJsonAsync = vi.fn(async (fn: unknown) => {
if (fn === getGroupMembers) return groupMemberRows
if (fn === executeQuery) return contactRows
throw new Error('unexpected native query')
})
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
wcdbGetGroupMembers: getGroupMembers,
wcdbExecQuery: executeQuery,
callJsonAsync,
getGroupNicknamesAsync: vi.fn(async () => new Map([['wxid-member', '真实群昵称']])),
avatarCache: new Map<string, string>(),
wcdbGetAvatarUrls: null
}) as Wcdb4Client
await expect(client.getGroupMembersAsync('fixture@chatroom')).resolves.toEqual([
expectedMember()
])
expect(callJsonAsync).toHaveBeenLastCalledWith(
executeQuery,
'contact',
'',
expect.stringContaining('SELECT username, nick_name, remark FROM contact')
)
})
it('falls back to the group nickname without leaking an ambiguous member nickname', () => {
const getGroupMembers = vi.fn(() => 0)
const executeQuery = vi.fn(() => 0)
const callJson = vi.fn((call: (handle: number, output: [null]) => number) => {
const queryCount = executeQuery.mock.calls.length
call(1, [null])
if (executeQuery.mock.calls.length > queryCount)
throw new Error('contact database unavailable')
return groupMemberRows
})
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
wcdbGetGroupMembers: getGroupMembers,
wcdbExecQuery: executeQuery,
callJson,
getGroupNicknames: vi.fn(() => new Map([['wxid-member', '真实群昵称']])),
avatarCache: new Map<string, string>(),
wcdbGetAvatarUrls: null
}) as Wcdb4Client
expect(client.getGroupMembers('fixture@chatroom')).toEqual([
expectedMember({ nickname: '真实群昵称', wechatNickname: '', remark: '' })
])
})
})