feat: 新增群员统计与退群事件,并把本地索引集中到设置页

- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送
- 退群事件永久保存,并以本地推断条目并入档案消息流
- 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
This commit is contained in:
Wxw-Gu
2026-09-20 21:47:06 +08:00
parent b6d287a299
commit f5633a29cb
44 changed files with 3949 additions and 24 deletions
+46
View File
@@ -112,6 +112,7 @@ import { agentHubService } from './services/agent-hub-service'
import { WechatConnectorService } from './services/wechat-ilink'
import { wechatSendGateway } from './services/wechat-send-gateway'
import { groupExitMonitorService } from './services/group-exit-monitor-service'
import { GroupStatsService } from './services/group-stats-service'
import { wechatActionLogService } from './services/wechat-action-log-service'
import { wechatActionGateway } from './services/wechat-action-gateway'
import { personalWechatSendService } from './services/personal-wechat-send-service'
@@ -211,6 +212,7 @@ let voiceService: VoiceService | null = null
let voiceRecognition: VoiceRecognitionUseCase | null = null
let voiceBatchService: VoiceBatchService | null = null
let knowledgeSearchService: KnowledgeSearchService | null = null
let groupStatsService: GroupStatsService | null = null
let localQueryApiService: LocalQueryApiService | null = null
let aiSearchPipelineService: AiSearchPipelineService | null = null
let queryAgentService: QueryAgentService | null = null
@@ -754,6 +756,9 @@ app.whenReady().then(async () => {
})
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
// 群员统计复用同一个 Knowledge 实例:它只是「读派生库 + 读成员名单」的编排,
// 不持有自己的数据库,也不新建索引。
groupStatsService = new GroupStatsService(knowledgeSearchService)
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
localQueryApiService.setImageTextCoverageProvider(() =>
@@ -1469,6 +1474,28 @@ app.whenReady().then(async () => {
return snapshot
})
/**
* 群员统计(单群)。
*
* 时间单位刻意用 epoch **毫秒**:这一路完全走 Knowledge,而 Knowledge 内部口径就是毫秒。
* 沿用聊天消息的秒级口径会在 service 内部凭空多出一次换算 —— 而单位换错是**静默读 0 条**,
* 不会报错。
*/
ipcMain.handle(
'group-stats:getMemberStats',
async (_, request: { userMd5?: unknown; startTime?: unknown; endTime?: unknown }) => {
if (!groupStatsService) throw new Error('群员统计服务尚未就绪')
const userMd5 = String(request?.userMd5 || '').trim()
if (!userMd5) throw new Error('缺少会话标识')
const startTime = Number(request?.startTime)
const endTime = Number(request?.endTime)
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
throw new Error('统计时间范围无效')
}
return groupStatsService.getMemberStats({ userMd5, startTime, endTime })
}
)
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
groupExitMonitorService.setEnabled(enabled === true)
@@ -1485,6 +1512,25 @@ app.whenReady().then(async () => {
groupExitMonitorService.setNotificationTemplate(template)
)
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
/**
* 按群查退群事件(档案合并展示用)。
*
* 时间参数是 epoch **毫秒**,与事件的 `detectedAt` 同口径。
*/
ipcMain.handle('group-exit-monitor:listEvents', (_, query: unknown) => {
const input = (query || {}) as {
roomId?: unknown
sinceMs?: unknown
untilMs?: unknown
limit?: unknown
}
return groupExitMonitorService.listEvents({
roomId: typeof input.roomId === 'string' ? input.roomId : undefined,
sinceMs: Number(input.sinceMs),
untilMs: Number(input.untilMs),
limit: Number(input.limit)
})
})
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
ipcMain.handle('group-exit-monitor:resendEvent', (_, eventId: string) =>
groupExitMonitorService.resendEvent(eventId)
@@ -4,6 +4,7 @@ import type {
KnowledgeImageOcrState,
KnowledgeAttachmentMetadata,
KnowledgeEvidence,
KnowledgeMemberStatsResult,
KnowledgeMessageKind,
KnowledgePassProgress,
KnowledgeRuntimeState,
@@ -498,6 +499,37 @@ export class KnowledgeSearchService {
}
}
/**
* 单群发言聚合(群员统计的数据来源)。
*
* 这一层只**如实**返回引擎能给出的东西(含 `indexLatestAt`),不做追赶决策 ——
* 「要不要等索引、要不要把结果标成不完整」是产品判断,属于 GroupStatsService。
* 同时返回一次 `sourceLatestAt`(同步、零额外 WCDB 调用),让调用方一次拿到
* freshness 的两个口径,不必再发一次 status 请求。
*/
async memberStats(request: {
conversationId: string
startTime: number
endTime: number
}): Promise<{ result: KnowledgeMemberStatsResult | null; sourceLatestAt: number | null }> {
const sourceLatestAt = this.sourceLatestAt()
const accountId = this.currentAccountId()
if (!accountId) return { result: null, sourceLatestAt }
try {
const result = await this.service.memberStats({
accountId,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
conversationId: request.conversationId,
startTime: request.startTime,
endTime: request.endTime
})
return { result, sourceLatestAt }
} catch (error) {
console.warn('[Knowledge] member stats failed:', error)
return { result: null, sourceLatestAt }
}
}
/**
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
*/
+9
View File
@@ -5,6 +5,8 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchResult,
@@ -53,6 +55,13 @@ export class KnowledgeService {
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
}
/** 单个会话内「按发送者聚合」的发言统计(群员统计用)。 */
memberStats(
request: Omit<KnowledgeMemberStatsRequest, 'databaseRoot'>
): Promise<KnowledgeMemberStatsResult> {
return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot })
}
/** 只中止正在跑的索引任务;查询请求不受影响。 */
cancelIndex(): Promise<boolean> {
return this.worker.cancelActiveIndex()
+88
View File
@@ -13,6 +13,7 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeNormalizedMessage,
KnowledgeQuery,
@@ -317,6 +318,93 @@ export class KnowledgeStore {
}
}
/**
* 单个会话在时间窗内的「按发送者聚合」统计。
*
* 只回聚合结果、不回消息正文:群员统计只需要「谁说了几条、最后一条是什么时候」,
* 把消息逐条搬到主进程再统计会把几十万行推过 IPC 边界。
*
* 三条硬规则**全部由 SQL 保证**,不指望调用方记得:
* 1. `kind <> 'system'`:系统消息不是任何成员的发言(微信侧 10000/10002 在建库时
* 已归一为 `kind = 'system'`,见 `knowledge-search-service` 的 kind 映射);
* 2. `sender_id IS NULL` 的行不归给任何人,只计入 `unattributedMessages` ——
* 硬塞给某个成员会让「未发言」名单出现错误否定;
* 3. 时间窗口是**闭区间**,单位 **epoch 毫秒**,与 knowledge 内部口径一致,
* 不经过 WCDB 的秒级边界(跨错单位会静默读到 0 条)。
*/
memberStats(request: {
conversationId: string
startTime: number
endTime: number
}): KnowledgeMemberStatsResult {
const { conversationId, startTime, endTime } = request
const indexLatestAt = this.readIndexLatestAt()
const empty: KnowledgeMemberStatsResult = {
conversationId,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: null,
indexLatestAt
}
if (!conversationId) return empty
// 用 `(conversation_id, create_time)` 索引直接命中:这是本查询唯一的访问路径,
// 写成全表扫描等价于把单群统计的 26ms 变成 10s。
const scope = 'conversation_id = ? AND create_time >= ? AND create_time <= ?'
const args = [conversationId, startTime, endTime]
const count = (extra: string): number => {
const row = this.database
.prepare(`SELECT COUNT(*) AS n FROM knowledge_messages WHERE ${scope} AND ${extra}`)
.get(...args) as DbRow | undefined
return Number(row?.n) || 0
}
const rows = asRows(
this.database
.prepare(
`SELECT sender_id,
COUNT(*) AS message_count,
MAX(create_time) AS last_message_time
FROM knowledge_messages
WHERE ${scope} AND kind <> 'system' AND sender_id IS NOT NULL
GROUP BY sender_id
ORDER BY message_count DESC, last_message_time DESC`
)
.all(...args)
)
const senders: KnowledgeMemberStatsResult['senders'] = []
for (const row of rows) {
const senderId = String(row.sender_id ?? '').trim()
if (!senderId) continue
senders.push({
senderId,
messageCount: Number(row.message_count) || 0,
lastMessageTime: Number(row.last_message_time) || 0
})
}
// 窗口内最早一条消息:**不过滤 kind** —— 群的第一条常常是建群通知,
// 那才是用户认知里的「这个群第一条消息」。选「全部」时用它显示真实起点。
const earliestRow = this.database
.prepare(`SELECT MIN(create_time) AS m FROM knowledge_messages WHERE ${scope}`)
.get(...args) as DbRow | undefined
const earliestValue = Number(earliestRow?.m)
return {
conversationId,
totalMessages: count("kind <> 'system'"),
senders,
unattributedMessages: count("kind <> 'system' AND sender_id IS NULL"),
excludedSystemMessages: count("kind = 'system'"),
earliestMessageTime: Number.isFinite(earliestValue) && earliestValue > 0 ? earliestValue : null,
indexLatestAt
}
}
/**
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
*
@@ -6,6 +6,8 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchResult,
@@ -19,6 +21,7 @@ type WorkerResult =
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| KnowledgeMemberStatsResult
| { marks: Record<string, number> }
| { removed: true }
type PendingRequest = {
@@ -76,6 +79,11 @@ export class KnowledgeWorkerHost {
)
}
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
}
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
async cancelActiveIndex(): Promise<boolean> {
const target = this.activeIndexRequestId
+38
View File
@@ -1,6 +1,8 @@
import type {
KnowledgeCapacityPreflightRequest,
KnowledgeIndexRequest,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeStatusRequest,
@@ -187,6 +189,38 @@ async function handleHighWater(
})
}
/**
* 群员统计的按发送者聚合。
*
* 与 `handleSearch` 同样先判库是否存在:**「还没建索引」是正常状态,不是故障**,
* 返回空结果而不是抛错,让上层能稳定地区分「没人发言」与「索引不存在」。
*/
async function handleMemberStats(
request: KnowledgeWorkerRequest,
payload: KnowledgeMemberStatsRequest
): Promise<void> {
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
if (!existsSync(path)) {
const unavailable: KnowledgeMemberStatsResult = {
conversationId: payload.conversationId,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: null,
indexLatestAt: null
}
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return
}
send({
version: 1,
type: 'result',
requestId: request.requestId,
payload: getStore(payload).memberStats(payload)
})
}
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
try {
if (request.type === 'cancel') {
@@ -226,6 +260,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'memberStats') {
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
return
}
if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest)
return
+14
View File
@@ -1036,6 +1036,20 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
}
}
/**
* 把群会话 md5 解析成原生接口真正需要的 roomId(`xxx@chatroom`)。
*
* 存在的理由:群员统计从界面拿到的是会话 md5(与 `getGroupSnapshot(userMd5)` 同口径),
* 而成员相关的原生接口吃的是 username。没有这层导出,调用方就得自己去碰
* `dbRef.getWcdb4Client()`,等于让服务层绕开 chat-service 的封装。
* 非群会话或解析不到时返回 null。
*/
export function resolveGroupRoomId(userMd5: string): string | null {
if (!dbRef) return null
const roomId = dbRef.getWcdb4Client().getUsernameByMd5(userMd5)
return roomId && roomId.endsWith('@chatroom') ? roomId : null
}
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
export async function getGroupMemberIdsAsync(
roomId: string
+120 -7
View File
@@ -57,6 +57,14 @@ type StoredGroupSnapshot = Pick<
const DB_CHANGE_DEBOUNCE_MS = 350
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
/**
* **只约束「一次状态回传带多少条」**,不是存储上限。
*
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
*/
const MAX_EVENTS = 500
export interface GroupExitMonitorServiceDependencies {
@@ -104,7 +112,9 @@ class GroupExitMonitorService {
getState(): GroupExitMonitorState {
this.ensureLoaded()
return {
events: [...this.events],
events: this.events.slice(0, MAX_EVENTS),
/** 永久保留的事件总数(`events` 只是最近一批)。 */
totalEventCount: this.events.length,
enabled: this.enabled,
running: this.enabled && this.active && chat.isReady(),
nativeMonitorActive: this.nativeMonitorActive,
@@ -134,6 +144,7 @@ class GroupExitMonitorService {
) {
this.actionGateway.clearMemberEvents?.()
this.events = []
this.rewriteEventsToDisk([])
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
@@ -273,9 +284,41 @@ class GroupExitMonitorService {
return this.getState()
}
/**
* 按群 / 时间范围查退群事件(档案合并展示用)。
*
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
* 直接打在内存里的完整历史上(事件是永久保留的)。
*
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
*/
listEvents(
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
): GroupExitMonitorEvent[] {
this.ensureLoaded()
const roomId = String(query.roomId || '').trim()
const since = Number(query.sinceMs)
const until = Number(query.untilMs)
const limit = Number(query.limit)
// this.events 是倒序(新在前)。
let result = [...this.events].reverse()
if (roomId) result = result.filter((event) => event.roomId === roomId)
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
// 超量时保留**最近**的一批(尾部即最新)。
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
result = result.slice(-limit)
}
return result
}
clearEvents(): GroupExitMonitorState {
this.ensureLoaded()
this.events = []
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
this.rewriteEventsToDisk([])
this.actionGateway.clearMemberEvents?.()
this.lastReadAt = Date.now()
this.save()
@@ -593,7 +636,10 @@ class GroupExitMonitorService {
notificationStatus: 'not_requested'
}
this.actionGateway.registerMemberEvent?.(event)
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
this.events = [event, ...this.events]
this.appendEventsToDisk([event])
console.log(
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
)
@@ -681,13 +727,78 @@ class GroupExitMonitorService {
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
}
/**
* 退群事件的 append-only 存储(每行一条 JSON)。
*
* 与状态文件分开,因为两者的写入模式完全不同:
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
* - **事件**只增不改,且要求**永久保留**。
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
*/
private eventsPath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
}
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
private readEventsFromDisk(): GroupExitMonitorEvent[] {
let raw = ''
try {
raw = fs.readFileSync(this.eventsPath(), 'utf8')
} catch {
return []
}
const events: GroupExitMonitorEvent[] = []
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
try {
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
} catch {
// 跳过坏行
}
}
return events
}
/** 只追加新增的那几行,不重写历史。 */
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
if (!events.length) return
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 追加退群事件失败:', error)
}
}
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 重写退群事件失败:', error)
}
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const stored = fs.readJsonSync(this.filePath()) as StoredState
this.enabled = stored.enabled !== false
this.events = normalizeEvents(stored.events)
// 事件从 append-only 文件读;状态文件不再承载它们。
const fromDisk = this.readEventsFromDisk()
const legacy = normalizeEvents(stored.events)
if (legacy.length && !fromDisk.length) {
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
this.rewriteEventsToDisk(legacy)
this.events = legacy
} else {
this.events = normalizeEvents(fromDisk)
}
this.actionGateway.registerMemberEvents?.(this.events)
this.lastReadAt = Number(stored.lastReadAt) || 0
this.accountRoot = String(stored.accountRoot || '')
@@ -704,8 +815,9 @@ class GroupExitMonitorService {
)
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
} catch {
// 首次启动或文件损坏时从空记录开始。
this.events = []
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
// 不该被状态文件的问题连累,仍然读回来。
this.events = normalizeEvents(this.readEventsFromDisk())
this.enabled = true
this.lastReadAt = 0
this.monitorSelectionConfigured = true
@@ -725,7 +837,8 @@ class GroupExitMonitorService {
{
accountRoot: this.accountRoot,
enabled: this.enabled,
events: this.events,
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
@@ -935,7 +1048,7 @@ function normalizeEvents(
? { notification: normalizeNotification(value.notification) }
: {})
})
if (normalized.length >= MAX_EVENTS) break
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
}
return normalized
}
+208
View File
@@ -0,0 +1,208 @@
import { isKnowledgeFresh } from '../../shared/knowledge'
import {
GROUP_STATS_LIMITATION,
GROUP_STATS_STALE_LIMITATION,
type GroupMemberStatsActiveMember,
type GroupMemberStatsQuery,
type GroupMemberStatsResult,
type GroupMemberStatsSilentMember,
type GroupStatsFreshness
} from '../../shared/group-stats'
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import * as chat from './chat-service'
/**
* 触发一次追赶同步的最小间隔。
*
* 面板是可以反复开关的交互入口,而追赶同步跑的是真实增量 pass(会读 WCDB)。
* 没有这个节流,连续点击就等于连续触发索引。
*/
const CATCH_UP_MIN_INTERVAL_MS = 30_000
/**
* 追赶的有界等待预算。
*
* 与 Query 侧同一取舍:全量追赶可能以分钟计,交互查询绝不能无限等。
* 预算用完之后**如实**把结果标成不完整,而不是假装完整。
*/
const FRESHNESS_WAIT_BUDGET_MS = 2_000
/**
* 群员统计。
*
* 数据两路来源,职责严格分开:
* - **当前成员名单** 走 WCDB 的轻路径(只取 wxid + 显示名,**不** hydrate 头像);
* - **发言聚合** 走 Knowledge 派生库的 `GROUP BY sender_id`,不读 WCDB 原始消息。
*
* 「未发言」= 当前成员 ∖ 窗口内有发言的 sender。这个定义**不能**简化成
* 「整段窗口都没说话」—— 入群/退群时间在源头就不存在(整个 DB 没有该字段),
* 所以结论必须带上 limitation。
*/
export class GroupStatsService {
constructor(private readonly knowledge: KnowledgeSearchService) {}
async getMemberStats(query: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> {
const { userMd5, startTime, endTime } = query
const limitations = [GROUP_STATS_LIMITATION]
const roomId = chat.resolveGroupRoomId(userMd5)
if (!roomId) {
// 不是群 / md5 解析不到:如实返回空,不编造统计。
return this.build({
userMd5,
startTime,
endTime,
freshness: 'unknown',
limitations,
members: [],
activeMembers: [],
silentMembers: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: null
})
}
// 1) 当前群成员 —— 轻路径。只取 wxid,再按需解析显示名;绝不走
// `getGroupSnapshotAsync`(它会 materialize 整群并 hydrate 头像,实测 8 群 ≈ 42s)。
const membership = await chat.getGroupMemberIdsAsync(roomId)
const memberIds = membership?.memberIds ?? []
const members = await chat.getGroupMemberNamesAsync(userMd5, memberIds)
// 2) 发言聚合(只统计当前群;系统消息在 SQL 层就被排除)
let { result, sourceLatestAt } = await this.knowledge.memberStats({
conversationId: userMd5,
startTime,
endTime
})
let freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
// 3) 索引没追平 → 有界追赶一次再判。追不上就如实标 stale,
// **不允许**把落后索引算出来的「未发言」包装成完整结论。
if (freshness !== 'fresh') {
this.knowledge.requestCatchUp(CATCH_UP_MIN_INTERVAL_MS)
const settled = await this.knowledge.waitForIndexingComplete(FRESHNESS_WAIT_BUDGET_MS)
if (settled) {
const retried = await this.knowledge.memberStats({
conversationId: userMd5,
startTime,
endTime
})
result = retried.result
sourceLatestAt = retried.sourceLatestAt
freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
}
}
// 4) 差集:当前成员 × 窗口内发言
const statBySender = new Map<string, { messageCount: number; lastMessageTime: number }>()
for (const row of result?.senders ?? []) {
if (row.senderId) statBySender.set(row.senderId, row)
}
const knownSenderIds = new Set<string>()
const activeMembers: GroupMemberStatsActiveMember[] = []
const silentMembers: GroupMemberStatsSilentMember[] = []
for (const member of members) {
const wxid = String(member.wxid || '').trim()
if (!wxid) continue
knownSenderIds.add(wxid)
// 与 Knowledge 的 Join key 必须是 wxid,不是昵称 —— 昵称会变。
// 显示名沿用项目既有优先级(nickname 已内含 `wechatNickname || groupNickname || username`)。
const displayName = member.nickname || member.remark || wxid
const groupNickname = member.groupNickname || ''
const stat = statBySender.get(wxid)
if (stat && stat.messageCount > 0) {
activeMembers.push({
senderId: wxid,
displayName,
groupNickname,
messageCount: stat.messageCount,
lastMessageTime: stat.lastMessageTime
})
} else {
silentMembers.push({ senderId: wxid, displayName, groupNickname })
}
}
// 窗口内有发言、但已不在当前成员名单里的人(退群者)。
// 默认**不**进活跃榜:那份名单读作「当前群成员」,混入已退群的人会误导。
let formerSenderCount = 0
for (const senderId of statBySender.keys()) {
if (!knownSenderIds.has(senderId)) formerSenderCount += 1
}
activeMembers.sort(
(a, b) => b.messageCount - a.messageCount || b.lastMessageTime - a.lastMessageTime
)
silentMembers.sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'))
if (freshness !== 'fresh') limitations.push(GROUP_STATS_STALE_LIMITATION)
if (formerSenderCount > 0) {
limitations.push(
`另有 ${formerSenderCount} 位窗口内发言者已不在当前群成员名单中,未计入活跃榜。`
)
}
return this.build({
userMd5,
startTime,
endTime,
freshness,
limitations,
members,
activeMembers,
silentMembers,
unattributedMessages: result?.unattributedMessages ?? 0,
excludedSystemMessages: result?.excludedSystemMessages ?? 0,
firstMessageTime: result?.earliestMessageTime ?? null
})
}
private build(input: {
userMd5: string
startTime: number
endTime: number
freshness: GroupStatsFreshness
limitations: string[]
members: Array<{ wxid: string }>
activeMembers: GroupMemberStatsActiveMember[]
silentMembers: GroupMemberStatsSilentMember[]
unattributedMessages: number
excludedSystemMessages: number
firstMessageTime: number | null
}): GroupMemberStatsResult {
return {
conversationId: input.userMd5,
startTime: input.startTime,
endTime: input.endTime,
freshness: input.freshness,
// 只有索引确实追平,才允许调用方把「未发言」当作完整结论。
complete: input.freshness === 'fresh',
memberCount: input.members.length,
activeMemberCount: input.activeMembers.length,
silentMemberCount: input.silentMembers.length,
activeMembers: input.activeMembers,
silentMembers: input.silentMembers,
unattributedMessages: input.unattributedMessages,
excludedSystemMessages: input.excludedSystemMessages,
firstMessageTime: input.firstMessageTime,
limitations: input.limitations
}
}
}
/**
* `isKnowledgeFresh` 返回 `boolean | null`(任一侧口径缺失即 null)。
* 三态都要能表达:`null` 绝不能当成「新鲜」。
*/
function judgeFreshness(
indexLatestAt: number | null,
sourceLatestAt: number | null
): GroupStatsFreshness {
const fresh = isKnowledgeFresh({ indexLatestAt, sourceLatestAt })
if (fresh === true) return 'fresh'
if (fresh === false) return 'stale'
return 'unknown'
}
+9 -1
View File
@@ -82,6 +82,7 @@ import type {
PersonalWechatVoiceDiagnostic
} from '../shared/personal-wechat'
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
@@ -101,7 +102,7 @@ import type {
PersonalWechatVoiceEncodingEnvironmentResult
} from '../shared/personal-wechat-voice-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { ActionLogEntry } from '../shared/action-log'
import type {
AppUpdateCheckResult,
@@ -293,7 +294,14 @@ declare global {
avatar: string
}[]
} | null>
getGroupMemberStats: (request: GroupMemberStatsQuery) => Promise<GroupMemberStatsResult>
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
listGroupExitMonitorEvents: (query?: {
roomId?: string
sinceMs?: number
untilMs?: number
limit?: number
}) => Promise<GroupExitMonitorEvent[]>
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
setGroupExitMonitorGroups: (
roomIds: string[],
+11 -1
View File
@@ -71,7 +71,8 @@ import type {
} from '../shared/personal-wechat-voice-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
import type { ActionLogEntry } from '../shared/action-log'
import type { CacheClearScope, CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress } from '../shared/export'
@@ -167,8 +168,17 @@ const api = {
): Promise<MessagesAroundResult> =>
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
getGroupMemberStats: (request: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> =>
ipcRenderer.invoke('group-stats:getMemberStats', request),
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:getState'),
listGroupExitMonitorEvents: (query?: {
roomId?: string
sinceMs?: number
untilMs?: number
limit?: number
}): Promise<GroupExitMonitorEvent[]> =>
ipcRenderer.invoke('group-exit-monitor:listEvents', query),
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
setGroupExitMonitorGroups: (
+12
View File
@@ -1639,6 +1639,17 @@ function App(): React.ReactElement {
setActivePage('settings')
}
/**
* 跳到「设置 · 本地索引」。
*
* 用在群统计这类场景:结果不完整的原因是索引没追平,用户需要一个**可操作的去处**,
* 而不是只看到一句「结果可能不完整」却不知道去哪解决。
*/
const openLocalIndexSettings = (): void => {
setSettingsCategory('local-index')
setActivePage('settings')
}
const openAgentHub = (): void => {
setActivePage('agent-hub')
}
@@ -1907,6 +1918,7 @@ function App(): React.ReactElement {
onCreateGroupReport={handleOpenReportWorkspace}
onOpenTextToSpeechSettings={openTextToSpeechSettings}
onOpenPersonalWechatSettings={openWechatSendSettings}
onOpenLocalIndexSettings={openLocalIndexSettings}
isAiLoading={reportGeneration.isGenerating}
jumpToTime={archiveJumpTime}
jumpToMessageId={archiveJumpMessageId}
+20 -2
View File
@@ -7,6 +7,8 @@ import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState'
import { MessageList } from './chat/MessageList'
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
import { mergeGroupExitEvents } from '../../../shared/group-exit-event-message'
import { useGroupExitEvents } from '../hooks/useGroupExitEvents'
interface ChatWindowProps {
contact: Contact | null
@@ -22,6 +24,8 @@ interface ChatWindowProps {
onCreateGroupReport?: () => void
onOpenTextToSpeechSettings?: () => void
onOpenPersonalWechatSettings?: () => void
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
onOpenLocalIndexSettings?: () => void
isAiLoading?: boolean
jumpToTime?: number | null
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
@@ -42,6 +46,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onCreateGroupReport,
onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings,
onOpenLocalIndexSettings,
isAiLoading = false,
jumpToTime,
jumpToMessageId
@@ -122,8 +127,20 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}
}
/**
* 退群推断事件并入档案消息流。
*
* **先合并再过滤**:反过来的话搜索词就命中不了事件,而「谁退群了」
* 恰恰是最需要能被搜到的内容之一。
*/
const groupExitEvents = useGroupExitEvents(contact)
const allMessages = React.useMemo(
() => (groupExitEvents.length ? mergeGroupExitEvents(messages, groupExitEvents) : messages),
[messages, groupExitEvents]
)
const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => {
return allMessages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
.split(',')
.map((type) => type.trim())
@@ -132,7 +149,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
return typeMatch && contentMatch
})
}, [messages, contentFilter])
}, [allMessages, contentFilter])
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
try {
@@ -168,6 +185,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onRefreshData={onRefreshData}
onTestSend={() => void handleOpenPersonalWechatSend()}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
/>
<DataTrustBar messageCount={messages.length} />
<MessageList
@@ -14,6 +14,7 @@ import {
import { ConversationContentSearch } from './ConversationContentSearch'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
import { GroupMemberStatsDialog } from '../group-stats/GroupMemberStatsDialog'
interface ChatHeaderProps {
contact: Contact
@@ -27,6 +28,8 @@ interface ChatHeaderProps {
onRefreshData?: () => void
onTestSend: () => void
onOpenAiSettings: () => void
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
onOpenLocalIndexSettings?: () => void
}
export function ChatHeader({
@@ -40,9 +43,11 @@ export function ChatHeader({
onRefresh,
onRefreshData,
onTestSend,
onOpenAiSettings
onOpenAiSettings,
onOpenLocalIndexSettings
}: ChatHeaderProps): React.ReactElement {
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
const [statsOpen, setStatsOpen] = useState(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const typeLabel = isGroupChat ? '群聊' : '联系人'
const visibleCount = contentFilter ? filteredCount : loadedCount
@@ -93,12 +98,23 @@ export function ChatHeader({
</IconButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton label="更多" tooltip="" variant="ghost" className="h-8 w-8">
<Button
variant="outline"
size="sm"
className="chat-header-text-action"
aria-label="更多功能"
title="更多功能"
>
<MoreIcon />
</IconButton>
<span>更多功能</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onRefreshData?.()}>刷新数据</DropdownMenuItem>
{/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */}
{isGroupChat ? (
<DropdownMenuItem onSelect={() => setStatsOpen(true)}>群发言统计</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{supportsPersonalWechatSend ? (
@@ -148,6 +164,14 @@ export function ChatHeader({
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
</Button>
</div>
{isGroupChat ? (
<GroupMemberStatsDialog
open={statsOpen}
onOpenChange={setStatsOpen}
contact={contact}
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
/>
) : null}
</div>
)
}
@@ -0,0 +1,27 @@
import React from 'react'
import type { Message } from '../../../../shared/types'
interface GroupExitEventBubbleProps {
message: Message
}
/**
* 退群推断事件在档案里的展示。
*
* ⚠️ 与真实微信系统消息**必须视觉可区分**,原因不是审美:
* 这条内容**不是微信说的话**,而是 TraceMemo 通过「群成员快照前后对比」推断出来的。
* 两者的可信度完全不同 —— 微信系统消息是事实原文,这条是推断产物,
* 而且它记录的是**检测时刻**(可能比真实退群时间晚几分钟到几天)。
*
* 所以它带一个「本地推断」标记,且样式与 `wechat-system-message` 明确区分。
*/
export function GroupExitEventBubble({ message }: GroupExitEventBubbleProps): React.ReactElement {
return (
<div className="group-exit-event-row">
<div className="group-exit-event-bubble">
<span className="group-exit-event-tag">本地推断</span>
<span className="group-exit-event-text">{message.content}</span>
</div>
</div>
)
}
@@ -1,5 +1,7 @@
import React from 'react'
import { Contact } from '../../../../shared/types'
import { isGroupExitEventMessage } from '../../../../shared/group-exit-event-message'
import { GroupExitEventBubble } from './GroupExitEventBubble'
import { MessageBubble } from './MessageBubble'
import { MessageGroupModel } from './messageGrouping'
@@ -26,11 +28,17 @@ export function MessageGroup({
return (
<>
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
{group.messages.map((message) => (
{group.messages.map((message) =>
// 退群推断事件与真实微信系统消息都落在 isSystem 分支,
// 但必须分开渲染:前者是本地推断,后者是微信原话。
isGroupExitEventMessage(message) ? (
<GroupExitEventBubble key={message.id} message={message} />
) : (
<div key={message.id} className="wechat-system-message-row">
<div className="wechat-system-message">{message.content}</div>
</div>
))}
)
)}
</>
)
}
@@ -0,0 +1,365 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type { Contact } from '../../../../shared/types'
import {
GROUP_STATS_RANGE_OPTIONS,
formatActiveMemberStats,
formatMemberLineName,
formatSilentMemberStats,
formatStatsDateTime,
resolveGroupStatsRangeStart,
type GroupMemberStatsResult,
type GroupStatsRangeKey
} from '../../../../shared/group-stats'
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '../ui'
interface GroupMemberStatsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
contact: Contact
/** 跳到「设置 · 本地索引」,让用户能把没追平的索引处理掉。 */
onOpenLocalIndexSettings?: () => void
}
type CopyTarget = 'none' | 'active' | 'silent'
/**
* 群发言统计面板(单群)。
*
* - **结果**由 main 的 `GroupStatsService` 给出,组件只展示,不自己拼业务文本;
* - **可复制的文本**由 `shared/group-stats` 的 formatter 生成,保证「界面上看到的」
* 与「复制出去的」不会各说各话;
* - **名单不截断**:数字与列表必须能对上,否则用户会以为统计漏了人。
* 列表靠 CSS 限高滚动,不靠丢数据。
*/
export function GroupMemberStatsDialog({
open,
onOpenChange,
contact,
onOpenLocalIndexSettings
}: GroupMemberStatsDialogProps): React.ReactElement {
const [rangeKey, setRangeKey] = useState<GroupStatsRangeKey>('30d')
const [customStart, setCustomStart] = useState('')
const [customEnd, setCustomEnd] = useState('')
const [result, setResult] = useState<GroupMemberStatsResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState<CopyTarget>('none')
const [notice, setNotice] = useState<string | null>(null)
/** 发送能力必须由 main 判定:renderer 侧的 platform 常量会误判 macOS Intel。 */
const [canSendText, setCanSendText] = useState(false)
const [sending, setSending] = useState(false)
const timeWindow = useMemo(() => {
if (rangeKey === 'custom') {
const start = customStart ? new Date(`${customStart}T00:00:00`).getTime() : NaN
const end = customEnd ? new Date(`${customEnd}T23:59:59.999`).getTime() : NaN
return { startTime: start, endTime: end }
}
const endTime = Date.now()
return { startTime: resolveGroupStatsRangeStart(rangeKey, endTime), endTime }
}, [rangeKey, customStart, customEnd])
const rangeReady = Number.isFinite(timeWindow.startTime) && Number.isFinite(timeWindow.endTime)
useEffect(() => {
if (!open || !rangeReady || !contact.md5) return
let disposed = false
setLoading(true)
setError(null)
window.api
.getGroupMemberStats({
userMd5: contact.md5,
startTime: timeWindow.startTime,
endTime: timeWindow.endTime
})
.then((data) => {
if (!disposed) setResult(data)
})
.catch((loadError: unknown) => {
if (!disposed) setError(loadError instanceof Error ? loadError.message : String(loadError))
})
.finally(() => {
if (!disposed) setLoading(false)
})
return () => {
disposed = true
}
}, [open, rangeReady, contact.md5, timeWindow.startTime, timeWindow.endTime])
useEffect(() => {
if (!open) return
let disposed = false
window.api
.getPersonalWechatSenderStatus()
.then((status) => {
if (!disposed) setCanSendText(status?.canSendText === true)
})
.catch(() => {
if (!disposed) setCanSendText(false)
})
return () => {
disposed = true
}
}, [open])
const silentText = useMemo(() => (result ? formatSilentMemberStats(result) : ''), [result])
const activeText = useMemo(() => (result ? formatActiveMemberStats(result) : ''), [result])
const copy = useCallback(async (text: string, target: CopyTarget) => {
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(target)
setNotice(null)
setTimeout(() => setCopied('none'), 2000)
} catch {
setError('复制失败,请手动选择文本')
}
}, [])
const send = useCallback(
async (text: string) => {
if (!text || sending) return
setSending(true)
setNotice(null)
try {
await window.api.sendPersonalWechatMessage({
to: contact.m_nsUsrName,
isGroup: true,
type: 'text',
text
})
setNotice('已发送到当前群')
} catch (sendError: unknown) {
setError(sendError instanceof Error ? sendError.message : '发送失败')
} finally {
setSending(false)
}
},
[contact.m_nsUsrName, sending]
)
const stale = result !== null && result.freshness !== 'fresh'
const groupTitle = contact.m_nsNickName || contact.m_nsUsrName
/** 备注与群名相同时不重复展示,避免标题里出现两遍同一个人。 */
const groupRemark = contact.remark && contact.remark !== groupTitle ? contact.remark : ''
/** 选「全部」时 startTime 为 0,改用本机第一条消息的真实时间作为起点。 */
const rangeStart = result
? result.startTime > 0
? result.startTime
: result.firstMessageTime
: null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>群发言统计 · {groupTitle}</DialogTitle>
{groupRemark ? (
<p className="group-stats-subtitle">群备注:{groupRemark}</p>
) : null}
</DialogHeader>
<div className="group-stats-panel">
<div className="group-stats-ranges">
{GROUP_STATS_RANGE_OPTIONS.map((option) => (
<Button
key={option.key}
variant={rangeKey === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setRangeKey(option.key)}
>
{option.label}
</Button>
))}
{rangeKey === 'custom' ? (
<div className="group-stats-custom-range">
<input
type="date"
aria-label="开始日期"
value={customStart}
onChange={(event) => setCustomStart(event.target.value)}
/>
<span>至</span>
<input
type="date"
aria-label="结束日期"
value={customEnd}
onChange={(event) => setCustomEnd(event.target.value)}
/>
</div>
) : null}
</div>
{!rangeReady ? (
<p className="group-stats-hint">请选择完整的开始与结束日期。</p>
) : null}
{stale ? (
<div className="group-stats-stale">
<p role="alert" className="group-stats-warning">
本地索引尚未完全同步,以下结果可能不完整。
</p>
{onOpenLocalIndexSettings ? (
<Button
variant="outline"
size="sm"
onClick={() => {
// 先关面板再跳转:否则设置页会被这个 Dialog 挡在后面。
onOpenChange(false)
onOpenLocalIndexSettings()
}}
>
去建立索引
</Button>
) : null}
</div>
) : null}
{error ? (
<p role="alert" className="group-stats-error">
{error}
</p>
) : null}
{notice ? <p className="group-stats-notice">{notice}</p> : null}
{loading ? <p className="group-stats-hint">统计中…</p> : null}
{result && !loading ? (
<>
<div className="group-stats-overview">
<div className="group-stats-metric">
<div className="group-stats-metric-label">当前群成员</div>
<div className="group-stats-metric-value">{result.memberCount}</div>
</div>
<div className="group-stats-metric">
<div className="group-stats-metric-label">发过言</div>
<div className="group-stats-metric-value">{result.activeMemberCount}</div>
</div>
<div className="group-stats-metric">
<div className="group-stats-metric-label">没发言</div>
<div className="group-stats-metric-value">{result.silentMemberCount}</div>
</div>
</div>
<p className="group-stats-range-text">
统计区间:
{rangeStart
? `${formatStatsDateTime(rangeStart)} ~ ${formatStatsDateTime(result.endTime)}`
: `全部历史 ~ ${formatStatsDateTime(result.endTime)}`}
{result.startTime <= 0 && rangeStart ? '(本机该群第一条消息起)' : null}
</p>
{/* 两个名单用 tab 分开:一次只渲染一个列表,滚动压力减半,
而且「复制/发送」跟它作用的内容处在同一个 tab 里,不会看错对象。 */}
<Tabs defaultValue="active" className="group-stats-tabs">
<TabsList className="group-stats-tabs-list">
<TabsTrigger value="active">
发言排行({result.activeMemberCount})
</TabsTrigger>
<TabsTrigger value="silent">
未发言统计({result.silentMemberCount})
</TabsTrigger>
</TabsList>
<TabsContent value="active">
<div className="group-stats-section">
<div className="group-stats-section-head">
<h3>
发言成员排行
<span className="group-stats-section-count">
(共 {result.activeMemberCount} 人)
</span>
</h3>
<div className="group-stats-section-actions">
<Button
variant="outline"
size="sm"
onClick={() => void copy(activeText, 'active')}
>
{copied === 'active' ? '已复制' : '复制'}
</Button>
</div>
</div>
{result.activeMembers.length === 0 ? (
<p className="group-stats-empty">该区间内没有人发言。</p>
) : (
<ol className="group-stats-list">
{result.activeMembers.map((member, index) => (
<li key={member.senderId} className="group-stats-row">
<span className="group-stats-row-name">
<span className="group-stats-row-index">{index + 1}.</span>
{formatMemberLineName(member)}
</span>
<span className="group-stats-row-meta">
{member.messageCount} 条 ·{' '}
{formatStatsDateTime(member.lastMessageTime)}
</span>
</li>
))}
</ol>
)}
</div>
</TabsContent>
<TabsContent value="silent">
<div className="group-stats-section">
<div className="group-stats-section-head">
<h3>
未发言成员
<span className="group-stats-section-count">
(共 {result.silentMemberCount} 人)
</span>
</h3>
<div className="group-stats-section-actions">
<Button
variant="outline"
size="sm"
onClick={() => void copy(silentText, 'silent')}
>
{copied === 'silent' ? '已复制' : '复制'}
</Button>
{canSendText ? (
<Button
variant="default"
size="sm"
disabled={sending}
onClick={() => void send(silentText)}
>
{sending ? '发送中…' : '发送到当前群'}
</Button>
) : null}
</div>
</div>
{result.silentMembers.length === 0 ? (
<p className="group-stats-empty">全部成员在该区间内都发过言。</p>
) : (
<ol className="group-stats-list">
{result.silentMembers.map((member, index) => (
<li key={member.senderId} className="group-stats-row">
<span className="group-stats-row-name">
<span className="group-stats-row-index">{index + 1}.</span>
{formatMemberLineName(member)}
</span>
</li>
))}
</ol>
)}
</div>
</TabsContent>
</Tabs>
<p className="group-stats-notes">
{result.unattributedMessages > 0
? `另有 ${result.unattributedMessages} 条消息无法归属到具体成员(未计入任何人)。`
: null}
{result.excludedSystemMessages > 0
? `已排除 ${result.excludedSystemMessages} 条系统消息。`
: null}
{result.limitations.join(' ')}
</p>
</>
) : null}
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,342 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Progress,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../ui'
import {
describeImageTextCoverage,
imageTextCoverageState,
imageTextPhaseLabel,
imageTextProcessedPercent,
imageTextStateLabel,
imageTextStateTone
} from '../../../../shared/image-text-index'
import { useImageTextIndexStatus } from './hooks/useImageTextIndexStatus'
import {
IndexStatusBadge,
type IndexStatusTone
} from '../../features/settings/components/IndexStatusBadge'
interface ImageTextIndexSummaryProps {
dbReady: boolean
onNotice: (message: string) => void
}
/**
* 「图片文字索引」卡片(**设置页宽版**)。
*
* 为什么不是直接复用 `ImageTextIndexCard`:那个组件是给**问问微信侧栏**写的,
* 而侧栏最窄时正文只有约 145px(见 `search.scss` 的注释)。
* 四列 metric 布局塞进 145px 会直接崩掉 —— 反过来让侧栏继续用表格布局又浪费设置页的宽度。
* 所以两者**共享全部数据来源与文案函数**(`useImageTextIndexStatus` +
* `shared/image-text-index`),只有**版式**不同。
*
* 状态徽章、统计数字、进度条都走公共组件/函数,不存在第二套计算口径。
*/
export function ImageTextIndexSummary({
dbReady,
onNotice
}: ImageTextIndexSummaryProps): React.ReactElement {
const {
status,
count,
counting,
pending,
running,
paused,
established,
refreshCount,
start,
pause,
resume,
cancel,
resetFailures,
repair
} = useImageTextIndexStatus({ dbReady, onNotice })
const [confirming, setConfirming] = useState(false)
const [confirmCount, setConfirmCount] = useState<number | null>(null)
/**
* 处理范围(天)。`0` = 全部历史。
*
* 保留这个选择器是因为它来自真实业务能力,不是装饰:全量回填几万张图没法用来排查问题,
* 先跑「最近 1 天」才能证明链路真的通了。
*/
const [rangeDays, setRangeDays] = useState('0')
const sinceMs = useMemo(() => {
const days = Number(rangeDays)
return Number.isFinite(days) && days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : undefined
}, [rangeDays])
useEffect(() => {
if (!dbReady) return
void refreshCount(sinceMs)
}, [dbReady, sinceMs, refreshCount])
const coverage = status?.coverage ?? null
const progress = status?.progress ?? null
const coverageState = coverage ? imageTextCoverageState(coverage) : 'not_built'
const percent = coverage
? imageTextProcessedPercent(coverage.processed, coverage.totalImageMessages)
: 0
const interrupted = paused || progress?.state === 'cancelled'
const phaseLabel = progress?.currentPhase ? imageTextPhaseLabel(progress.currentPhase) : null
const labelInput = {
progressState: progress?.state,
running,
paused,
established,
coverageState,
percent
}
const stateLabel = imageTextStateLabel(labelInput)
const tone = imageTextStateTone(labelInput) as IndexStatusTone
const detectedImages = count?.totalImageMessages ?? coverage?.totalImageMessages ?? null
const nothingCounted =
count !== null && count.scannedConversations === 0 && count.failedConversations > 0
const failureCount = coverage?.failed ?? 0
const requestStart = async (): Promise<void> => {
const fresh = await refreshCount(sinceMs)
setConfirmCount(fresh?.totalImageMessages ?? detectedImages)
setConfirming(true)
}
return (
<section className="local-index-card" aria-label="图片文字索引状态">
<header className="local-index-card-head">
<div className="local-index-card-title">
<h3>图片文字索引</h3>
<p>本地 OCR 提取图片文字,让截图、报价图也能被搜到</p>
</div>
<IndexStatusBadge label={stateLabel} tone={tone} />
</header>
{/* 未建立:先给一个可核对的图片量,再让用户决定跑不跑 */}
{!established && !running ? (
<>
<div className="local-index-stats">
<div className="local-index-stat">
<strong className="local-index-stat-value">
{counting
? '统计中…'
: nothingCounted
? '无法统计'
: detectedImages === null
? '—'
: detectedImages.toLocaleString()}
</strong>
<span className="local-index-stat-label">检测到图片消息</span>
</div>
</div>
<div className="local-index-inline">
<span className="local-index-stat-label">处理范围</span>
<Select value={rangeDays} onValueChange={setRangeDays}>
<SelectTrigger className="h-7 w-[110px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">全部历史</SelectItem>
<SelectItem value="1">近 1 天</SelectItem>
<SelectItem value="7">近 7 天</SelectItem>
<SelectItem value="30">近 30 天</SelectItem>
</SelectContent>
</Select>
</div>
{nothingCounted ? (
<p className="local-index-note is-error">
{`无法统计本账号的图片消息(${count?.error || '读取消息表失败'})。这不代表没有图片,可以重新统计再试一次。`}
</p>
) : null}
</>
) : null}
{/* 运行中 / 暂停:原地切进度,按钮按真实状态显隐,不允许重复点 */}
{(running || paused) && progress ? (
<div className="local-index-progress">
<div className="local-index-progress-head">
<span>识别图片文字</span>
<span className="local-index-progress-count">
{progress.processed.toLocaleString()} / {progress.totalImageMessages.toLocaleString()}
</span>
</div>
{/* 复用项目 `Progress`(更新页 / 导出任务中心同一组件),不另起一套轨道样式。 */}
<Progress
value={Math.min(100, Math.max(0, progress.percent))}
aria-label="图片文字索引进度"
/>
<p className="local-index-note">
{phaseLabel ?? '正在识别…'}
{` · 识别出文字 ${progress.indexed.toLocaleString()} · 无文字 ${progress.empty.toLocaleString()}`}
</p>
</div>
) : null}
{/* 已建立:四个可核对的数字 */}
{established && !running && !paused && coverage ? (
<>
<div className="local-index-stats is-four">
<div className="local-index-stat">
<strong className="local-index-stat-value">
{coverage.indexed.toLocaleString()}
</strong>
<span className="local-index-stat-label">已识别文字</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">{coverage.empty.toLocaleString()}</strong>
<span className="local-index-stat-label">无文字图片</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">
{coverage.missing.toLocaleString()}
</strong>
<span className="local-index-stat-label">图片已清理</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">{coverage.failed.toLocaleString()}</strong>
<span className="local-index-stat-label">识别失败</span>
</div>
</div>
</>
) : null}
{progress?.state === 'error' ? (
<p className="local-index-note is-error">
{progress.lastError || '图片文字索引建立失败,可以稍后重试。'}
</p>
) : null}
{coverage?.systemicFailure === true ? (
<p className="local-index-note is-error">
{`${(coverage.failed ?? 0).toLocaleString()} 条处理失败,成功识别 0 条 —— 当前无法搜索图片中的文字。`}
</p>
) : null}
{paused ? (
<p className="local-index-note">已暂停。已识别的结果都保留了,点「继续」会从断点接着做。</p>
) : null}
{!dbReady ? (
<p className="local-index-note is-error">请先连接微信数据,然后再建立图片文字索引。</p>
) : null}
{/* footer:左侧覆盖量说明,右侧操作。按钮不再悬浮在 stats 右下角。 */}
<div className="local-index-footer">
{established && !running && !paused && coverage ? (
<p className="local-index-note">{describeImageTextCoverage(coverage)}</p>
) : null}
<div className="local-index-actions">
{running ? (
<>
<Button
size="sm"
variant="outline"
disabled={pending !== null}
onClick={() => void pause()}
>
{pending === 'pause' ? '暂停中…' : '暂停'}
</Button>
<Button
size="sm"
variant="outline"
disabled={pending !== null}
onClick={() => void cancel()}
>
{pending === 'cancel' ? '取消中…' : '取消'}
</Button>
</>
) : null}
{/* 中断过就一定要有「继续」:checkpoint 是保留的,续做不会从头开始。 */}
{interrupted ? (
<Button size="sm" disabled={pending !== null} onClick={() => void resume()}>
{pending === 'resume' ? '继续中…' : '继续'}
</Button>
) : null}
{!running && !interrupted ? (
<>
{/* 修复类操作各自只在很窄的场景有用,收进「更多」;
平铺出来会让用户面对四个都带「索引」字样的按钮。 */}
{established || failureCount > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
aria-label="更多操作"
disabled={pending !== null}
>
···
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{established ? (
<DropdownMenuItem disabled={pending !== null} onSelect={() => void repair()}>
图片内容搜不到?修复搜索索引
</DropdownMenuItem>
) : null}
{failureCount > 0 ? (
<DropdownMenuItem
disabled={pending !== null}
onSelect={() => void resetFailures()}
>
{`重试识别失败的图片(${failureCount.toLocaleString()} 张)`}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
size="sm"
disabled={!dbReady || pending !== null || counting}
onClick={() => void requestStart()}
>
{/* 统计图片数期间按钮是禁用的;这时如实说"统计中",
而不是灰着一个写着「更新索引」却点不动的按钮。 */}
{counting ? '统计中…' : established ? '更新索引' : '建立图片文字索引'}
</Button>
</>
) : null}
</div>
</div>
{/* 确认弹窗复用 `confirming` 状态;保持与侧栏卡片一致的二次确认语义。 */}
{confirming ? (
<div className="local-index-confirm" role="dialog" aria-label="建立图片文字索引">
<p>
当前账号检测到约{' '}
<strong>
{confirmCount === null ? '未知数量' : confirmCount.toLocaleString()} 条图片消息
</strong>
。
</p>
<p>
识别仅在本机进行,原始图片不会因为本地识别而自动上传;可能需要较长时间,可以暂停稍后继续。
</p>
<div className="local-index-actions">
<Button size="sm" variant="outline" onClick={() => setConfirming(false)}>
取消
</Button>
<Button
size="sm"
onClick={() => {
setConfirming(false)
void start({ ...(sinceMs ? { sinceMs } : {}) })
}}
>
开始索引
</Button>
</div>
</div>
) : null}
</section>
)
}
@@ -0,0 +1,142 @@
import React from 'react'
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
import {
formatBytes,
formatIndexDate,
formatKnowledgeProcessed,
knowledgeIsStale,
knowledgeStateLabel
} from './searchFormatters'
import {
IndexStatusBadge,
type IndexStatusTone
} from '../../features/settings/components/IndexStatusBadge'
import { Button } from '../ui'
interface KnowledgeIndexCardProps {
dbReady: boolean
onNotice: (message: string) => void
}
/**
* 「聊天记录索引」卡片 —— 问问微信背后那份索引。
*
* 布局与服务端语义分得很开:
* - 数据全部来自 `useKnowledgeStatus`(与问问微信页**同一个 hook**,不存在第二套状态源);
* - 文案全部来自 `searchFormatters`(那边的词汇表有约束:区分「正在追新」与「正在补齐历史」、
* 不可查询时吃掉「可用」前缀、禁止笼统的「已同步」);
* - 这里只负责**信息层级**:标题 → 状态徽章 → 三个核心数字 → 操作。
*
* 状态刻意从标题里拆出来单独做成徽章:混在标题行里时既没有视觉权重差、也扫不到。
*/
export function KnowledgeIndexCard({
dbReady,
onNotice
}: KnowledgeIndexCardProps): React.ReactElement {
const {
knowledgeStatus,
knowledgeSyncing,
syncStarting,
cancelRequested,
startKnowledgeSync,
cancelKnowledgeSync
} = useKnowledgeStatus({ dbReady, onNotice })
const indexed = knowledgeStatus?.indexedMessageCount ?? 0
const usable = indexed > 0 || (knowledgeStatus?.indexedChunkCount ?? 0) > 0
const stale = knowledgeIsStale(knowledgeStatus)
const failed = knowledgeStatus?.state === 'error'
/** 徽章色只用主题 token,不硬编码彩虹色。 */
const tone: IndexStatusTone = failed
? 'error'
: !usable
? 'idle'
: stale || knowledgeSyncing
? 'warn'
: 'ok'
const cancellable = knowledgeStatus?.pass?.cancellable === true
return (
<section className="local-index-card" aria-label="聊天记录索引状态">
<header className="local-index-card-head">
<div className="local-index-card-title">
<h3>聊天记录索引</h3>
<p>用于搜索、问问微信和群聊统计</p>
</div>
<IndexStatusBadge label={knowledgeStateLabel(knowledgeStatus)} tone={tone} />
</header>
{usable ? (
<div className="local-index-stats">
<div className="local-index-stat">
<strong className="local-index-stat-value">{indexed.toLocaleString()}</strong>
<span className="local-index-stat-label">已收录消息</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">
{formatBytes(knowledgeStatus?.databaseBytes ?? 0)}
</strong>
<span className="local-index-stat-label">占用空间</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">
{knowledgeStatus?.indexLatestAt
? formatIndexDate(knowledgeStatus.indexLatestAt)
: '—'}
</strong>
<span className="local-index-stat-label">最后更新</span>
</div>
</div>
) : (
<p className="local-index-note">
还没有建立索引。建立后「问问微信」才能跨会话检索历史消息。
</p>
)}
{failed && knowledgeStatus?.lastError ? (
<p className="local-index-note is-error">{knowledgeStatus.lastError}</p>
) : null}
{!dbReady ? (
<p className="local-index-note is-error">请先连接微信数据,然后再建立聊天记录索引。</p>
) : null}
{/* footer:左侧状态说明,右侧操作。按钮不再悬浮在 stats 右下角。 */}
<div className="local-index-footer">
<p className="local-index-note">
{knowledgeSyncing && knowledgeStatus?.pass
? formatKnowledgeProcessed(knowledgeStatus)
: failed
? '上次同步没有完成,可以重新同步。'
: stale
? '有较新的聊天记录尚未入索引,点「同步最新记录」追平。'
: '索引数据仅保存在本机,可随时重新建立。'}
</p>
<div className="local-index-actions">
{knowledgeSyncing && cancellable ? (
<Button
size="sm"
variant="outline"
disabled={cancelRequested}
onClick={() => void cancelKnowledgeSync()}
>
{cancelRequested ? '正在取消…' : '取消同步'}
</Button>
) : null}
{/* 用项目 `Button` 而不是自写 `<button>` + 自己的主色样式:
`hsl(var(--tm-primary))` 在这套主题变量下不生效(实测背景回退成了浏览器默认灰),
而复用组件同时还省掉了一套与全站不一致的按钮外观。 */}
<Button
size="sm"
disabled={!dbReady || syncStarting || (knowledgeSyncing && !cancellable)}
onClick={() => void startKnowledgeSync()}
>
{knowledgeSyncing ? '同步中…' : usable ? '同步最新记录' : '建立索引'}
</Button>
</div>
</div>
</section>
)
}
@@ -9,6 +9,7 @@ import { AIModelPage } from './pages/AIModelPage'
import { RecallProtectionPage } from './pages/RecallProtectionPage'
import { AdvancedPage } from './pages/AdvancedPage'
import { CacheCleanupPage } from './pages/CacheCleanupPage'
import { LocalIndexPage } from './pages/LocalIndexPage'
import { AppearancePage } from './pages/AppearancePage'
import { AboutPage } from './pages/AboutPage'
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
@@ -120,6 +121,8 @@ export function SettingsWorkspace({
return <AdvancedPage onNotice={onNotice} />
case 'cache-cleanup':
return <CacheCleanupPage onNotice={onNotice} />
case 'local-index':
return <LocalIndexPage onNotice={onNotice} dbReady={dbReady} />
case 'storage-export':
return (
<SettingsEmptyState
@@ -0,0 +1,26 @@
import React from 'react'
export type IndexStatusTone = 'ok' | 'warn' | 'error' | 'idle'
interface IndexStatusBadgeProps {
label: string
tone: IndexStatusTone
}
/**
* 索引状态徽章(`● 已完成` 这种)。
*
* 存在的意义是把**状态**从标题里拆出来:之前状态是和卡片标题混在同一行的文字,
* 既没有视觉权重差、也无法一眼扫到。
*
* 颜色只用项目既有的 theme token(`--wxex-success` / `--wxex-warning` / `--wxex-danger`),
* 不另起一套彩虹色 —— 否则主题切换时会和整个应用脱节。
*/
export function IndexStatusBadge({ label, tone }: IndexStatusBadgeProps): React.ReactElement {
return (
<span className={`local-index-badge is-${tone}`}>
<span className="local-index-badge-dot" aria-hidden />
{label}
</span>
)
}
@@ -29,6 +29,10 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
items: [
// “存储与导出”暂不开放;保留 category/render case 以兼容已有页面状态。
// { id: 'storage-export', label: '存储与导出' },
// 「本地索引」= 问问微信的聊天记录索引 + 图片文字索引。
// 放在设置里是因为它属于「本地数据管理」,而原来只藏在问问微信页面里,
// 用户根本不知道它是什么、更不知道还要手动同步。
{ id: 'local-index', label: '本地索引' },
{ id: 'cache-cleanup', label: '缓存与清理' }
]
},
@@ -12,6 +12,7 @@ export type SettingsCategoryId =
| 'local-api'
| 'storage-export'
| 'cache-cleanup'
| 'local-index'
| 'appearance'
| 'advanced'
| 'about'
@@ -0,0 +1,36 @@
import React from 'react'
import { ImageTextIndexSummary } from '../../../components/search/ImageTextIndexSummary'
import { KnowledgeIndexCard } from '../../../components/search/KnowledgeIndexCard'
interface LocalIndexPageProps {
onNotice: (message: string) => void
dbReady: boolean
}
/**
* 设置 · 本地索引。
*
* 把「问问微信」背后的两份索引放到设置里集中管理 —— 之前它们只藏在搜索页,
* 用户既不知道有这么个东西,也不知道它需要手动同步。
*
* 信息层级(从上到下):页面标题 → 一句话说明 → 两张索引卡片。
* 卡片本身由 `KnowledgeIndexCard` / `ImageTextIndexSummary` 各自负责,
* 本页只管**容器宽度与卡片间距**,不碰卡片内部布局。
*/
export function LocalIndexPage({ onNotice, dbReady }: LocalIndexPageProps): React.ReactElement {
return (
<div className="local-index-page">
<header className="local-index-head">
<h2>本地索引</h2>
<p>
TraceMemo 的搜索、问问微信和图片文字识别依赖本地索引。
所有索引数据仅保存在本机,可随时从微信原始数据重新建立。
</p>
</header>
<div className="local-index-cards">
<KnowledgeIndexCard dbReady={dbReady} onNotice={onNotice} />
<ImageTextIndexSummary dbReady={dbReady} onNotice={onNotice} />
</div>
</div>
)
}
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
import type { Contact } from '../../../shared/types'
import type { GroupExitMonitorEvent } from '../../../shared/group-exit-monitor'
/**
* 取某个群的退群推断事件(档案合并展示用)。
*
* 两条硬约束:
* 1. **只对群聊请求** —— 单聊没有「群成员快照」这个概念;
* 2. **任何失败都退化成「没有事件」** —— 退群事件是附加信息,
* 拿不到它绝不能让档案本身的消息展示受影响。
*/
export function useGroupExitEvents(contact: Contact | null): GroupExitMonitorEvent[] {
const [events, setEvents] = useState<GroupExitMonitorEvent[]>([])
const roomId = contact?.type === 'group' ? contact.m_nsUsrName || '' : ''
useEffect(() => {
if (!roomId) {
setEvents([])
return
}
let disposed = false
window.api
.listGroupExitMonitorEvents({ roomId })
.then((list) => {
if (!disposed) setEvents(Array.isArray(list) ? list : [])
})
.catch(() => {
if (!disposed) setEvents([])
})
return () => {
disposed = true
}
}, [roomId])
return events
}
@@ -0,0 +1,44 @@
/**
* 档案里的「退群」推断事件。
*
* ⚠️ 视觉上必须与 `.wechat-system-message`(微信真实系统消息)**明确区分**:
* 那是微信的原话,这条是 TraceMemo 通过「群成员快照前后对比」推断出来的 ——
* 可信度不同,时间含义也不同(记录的是**检测时刻**,不是真实退群时间)。
* 所以用虚线边框 + 「本地推断」标记,而不是套用系统消息的样式。
*/
.group-exit-event-row {
display: flex;
justify-content: center;
padding: 6px 16px;
}
.group-exit-event-bubble {
display: inline-flex;
align-items: baseline;
gap: 6px;
max-width: 82%;
box-sizing: border-box;
border: 1px dashed var(--wxex-warning);
border-radius: var(--tm-radius-md, 6px);
background: color-mix(in srgb, var(--wxex-warning) 8%, transparent);
padding: 4px 10px;
font-size: 12px;
line-height: 1.6;
color: var(--wxex-text-secondary);
}
.group-exit-event-tag {
flex: none;
border-radius: 3px;
background: var(--wxex-warning);
color: #fff;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
line-height: 16px;
}
.group-exit-event-text {
word-break: break-all;
}
@@ -0,0 +1,246 @@
/**
* 群发言统计面板。
*
* ⚠️ 这里的样式**故意不写裸 Tailwind 布局类**:`tailwind.config.cjs` 设了
* `preflight: false`(项目要保住既有 SCSS),于是 Tailwind 不提供任何基础重置 ——
* 一旦用裸 Tailwind 搭结构,`<ol>` 会自带 `list-style` 与 `padding-left`、
* `<h3>/<p>` 会自带 margin、`border` 只有 `border-width` 没有 `border-style`
* (边框根本不显示)。所以按项目既有惯例:语义化类 + `--wxex-*` 变量。
*/
/**
* 滚动策略:**整个面板只有一层滚动**。
*
* DialogContent 默认没有任何 max-height / overflow(见 `ui/dialog.tsx`),所以
* 滚动条必须由我们自己给且**只给一处**:这里给面板容器,两个名单列表**不再各自滚动**。
* 早先的写法是「面板 68vh 滚动 + 每个列表 320px 滚动」,三层滚动条叠在一起互相打架。
*/
.group-stats-panel {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 62vh;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
color: var(--wxex-text-primary);
}
/* ---- 时间范围 ---- */
.group-stats-ranges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.group-stats-custom-range {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--wxex-text-secondary);
}
.group-stats-custom-range input {
box-sizing: border-box;
border: 1px solid var(--wxex-border);
border-radius: var(--tm-radius-sm, 4px);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
padding: 4px 6px;
font: inherit;
font-size: 13px;
}
/* ---- 状态提示 ---- */
.group-stats-warning,
.group-stats-error,
.group-stats-notice,
.group-stats-hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
.group-stats-warning {
color: var(--wxex-warning);
}
/** stale 提示行:左侧说明 + 右侧「去建立索引」的去处。 */
.group-stats-stale {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.group-stats-stale .group-stats-warning {
flex: 1;
min-width: 0;
}
.group-stats-error {
color: var(--wxex-danger);
}
.group-stats-notice {
color: var(--wxex-success);
}
.group-stats-hint {
color: var(--wxex-text-secondary);
}
/* ---- 概览 ---- */
.group-stats-overview {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.group-stats-metric {
border: 1px solid var(--wxex-border);
border-radius: var(--tm-radius-md, 6px);
background: var(--wxex-bg-elevated);
padding: 10px 12px;
text-align: center;
}
.group-stats-metric-label {
color: var(--wxex-text-secondary);
font-size: 12px;
margin-bottom: 2px;
}
.group-stats-metric-value {
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
.group-stats-range-text {
margin: 0;
color: var(--wxex-text-muted);
font-size: 12px;
}
/** 群备注副标题:跟在标题下方,与标题同左对齐。 */
.group-stats-subtitle {
margin: 2px 0 0;
color: var(--wxex-text-secondary);
font-size: 13px;
text-align: left;
}
/* ---- 分节 ---- */
/**
* 两个名单用 tab 分开:一次只渲染一个列表,滚动压力减半。
* `ui/tabs.tsx` 的类不依赖 Tailwind preflight(全是属性类,不含基础重置),
* 所以这里直接复用,只补一个对齐。
*/
.group-stats-tabs {
display: flex;
flex-direction: column;
}
.group-stats-tabs-list {
align-self: flex-start;
}
.group-stats-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.group-stats-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.group-stats-section-head h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--wxex-text-primary);
}
.group-stats-section-head .group-stats-section-count {
color: var(--wxex-text-secondary);
font-size: 12px;
font-weight: 400;
}
.group-stats-section-actions {
display: flex;
gap: 8px;
}
.group-stats-empty {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 13px;
}
/* ---- 名单 ---- */
.group-stats-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
border: 1px solid var(--wxex-border);
border-radius: var(--tm-radius-md, 6px);
background: var(--wxex-bg-app);
/* 刻意**不**设 max-height / overflow:列表全量展开,
滚动交给唯一的 .group-stats-panel。多一层滚动就多一条打架的滚动条。 */
}
.group-stats-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 5px 10px;
font-size: 13px;
line-height: 1.6;
}
.group-stats-row + .group-stats-row {
border-top: 1px solid var(--wxex-border);
}
.group-stats-row-name {
color: var(--wxex-text-primary);
word-break: break-all;
}
.group-stats-row-index {
color: var(--wxex-text-muted);
margin-right: 4px;
font-variant-numeric: tabular-nums;
}
.group-stats-row-meta {
flex: none;
color: var(--wxex-text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.group-stats-notes {
margin: 0;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 1.6;
}
+3
View File
@@ -17,4 +17,7 @@
@use './settings-preferences';
@use './settings-text-to-speech';
@use './group-exit-monitor';
@use './group-exit-event';
@use './group-member-stats';
@use './local-index';
@use './theme';
+276
View File
@@ -0,0 +1,276 @@
/**
* 设置 · 本地索引页。
*
* 设计约束(来自页面目标):这是一个「本机索引管理面板」,不是两个独立开发工具卡片。
* 层级自上而下:页面标题 → 一句话说明 → 卡片;卡片内部:标题 → 状态徽章 → 核心数字 → 操作。
*
* ⚠️ 不用裸 Tailwind:`tailwind.config.cjs` 设了 `preflight: false`,
* 裸类拿不到浏览器默认样式重置(`<ol>` 的 list-style、`<h3>/<p>` 的 margin、
* `border-style: none`),会直接崩掉版式。
*/
.local-index-page {
display: flex;
flex-direction: column;
gap: 20px;
/* 页面级四周 padding:设置页 panel(.settings-page-panel)本身没有任何 padding,
width:100% 会一路顶到窗口右缘 —— 右侧 gutter 必须在这里建立,而不是给卡片打 margin 补丁。
上/左收紧、底部留足、右侧略宽(视觉重心),都在页面目标给的区间内。 */
padding: 20px 32px 32px 24px;
/* 内容宽度:太窄会挤,太宽会让数字和状态离得太远、读不到一行。
1040 是 border-box 总宽(含 padding),内容区约 984px。
用 width:100% 保证窄窗口下自然收缩,而不是硬撑着留白。 */
width: 100%;
max-width: 1040px;
/* panel 比 max-width 宽时水平居中,左右 gutter 对称;
窄窗口(panel < 1040)时它不生效,由 padding 负责两侧留白。 */
margin-inline: auto;
box-sizing: border-box;
color: var(--wxex-text-primary);
}
/* ---- 页面标题 ---- */
.local-index-head h2 {
margin: 0 0 6px;
font-size: 20px;
font-weight: 700;
line-height: 1.3;
color: var(--wxex-text-primary);
}
.local-index-head p {
margin: 0;
max-width: 66ch;
color: var(--wxex-text-secondary);
font-size: 13px;
line-height: 1.7;
}
/* ---- 卡片容器 ---- */
.local-index-cards {
display: flex;
flex-direction: column;
/* 标准垂直间距:两张卡片要看起来像一个连续模块,而不是两块被空白隔开的工具。 */
gap: 18px;
}
.local-index-card {
display: flex;
flex-direction: column;
gap: 14px;
box-sizing: border-box;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg, 8px);
background: var(--wxex-bg-elevated);
padding: 16px 18px;
/* 高度由内容自然撑开 —— 不设 min-height,不用空白凑版面。 */
}
/* ---- 卡片头部:标题 + 状态徽章 ---- */
.local-index-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.local-index-card-title {
min-width: 0;
}
.local-index-card-title h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--wxex-text-primary);
}
.local-index-card-title p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 1.6;
}
/* ---- 状态徽章(颜色全部走主题 token,不硬编码彩虹色) ---- */
.local-index-badge {
display: inline-flex;
align-items: center;
gap: 5px;
flex: none;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: var(--wxex-bg-sidebar);
padding: 2px 10px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.local-index-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentcolor;
}
.local-index-badge.is-ok {
border-color: color-mix(in srgb, var(--wxex-success) 45%, var(--wxex-border));
color: var(--wxex-success);
}
.local-index-badge.is-warn {
border-color: color-mix(in srgb, var(--wxex-warning) 55%, var(--wxex-border));
color: var(--wxex-warning);
}
.local-index-badge.is-error {
border-color: color-mix(in srgb, var(--wxex-danger) 55%, var(--wxex-border));
color: var(--wxex-danger);
}
.local-index-badge.is-idle {
color: var(--wxex-text-muted);
}
/* ---- 核心数字:数字是主体,label 是注解 ---- */
.local-index-stats {
display: grid;
/* 窄窗口自动换行成单列;宽窗口排成 N 列,不写死列数。 */
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 14px 20px;
}
.local-index-stat {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.local-index-stat-value {
color: var(--wxex-text-primary);
font-size: 19px;
font-weight: 700;
line-height: 1.25;
font-variant-numeric: tabular-nums;
/* 数字很长时允许收缩,不撑破卡片。 */
overflow-wrap: anywhere;
}
.local-index-stat-label {
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 1.5;
}
/* ---- 说明 / 状态行 ---- */
.local-index-note {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 1.65;
}
.local-index-note.is-error {
color: var(--wxex-danger);
}
.local-index-inline {
display: flex;
align-items: center;
gap: 8px;
}
/* ---- 进度 ---- */
.local-index-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.local-index-progress-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
color: var(--wxex-text-secondary);
font-size: 12px;
}
.local-index-progress-count {
color: var(--wxex-text-primary);
font-variant-numeric: tabular-nums;
}
/* 进度轨道复用 `ui/progress.tsx`(Radix),这里不写第二套。 */
/* ---- Card footer:明确的 [状态说明] ←→ [操作按钮] 两栏 ----
按钮不再悬浮在 stats 区域右下角,而是固定在卡片底栏右侧,
左侧放覆盖量 / 同步状态这类辅助说明。 */
.local-index-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
}
.local-index-footer > .local-index-note {
min-width: 0;
}
/* 窄窗口放不下时按钮整体换到第二行,仍靠右,不与说明文字挤在一行。 */
.local-index-footer > .local-index-actions {
margin-left: auto;
}
/* ---- 操作区:右对齐,按钮宽度按内容,不占满整行 ---- */
.local-index-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
/* 窗口很窄时才让主按钮撑满一行,避免按钮被压成碎块。 */
@media (max-width: 520px) {
.local-index-actions {
justify-content: stretch;
}
.local-index-actions > * {
flex: 1 1 auto;
}
}
/* ---- 确认区(就地展开,不用 alert) ---- */
.local-index-confirm {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md, 6px);
background: var(--wxex-bg-app);
padding: 12px 14px;
}
.local-index-confirm p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 1.65;
}
+11 -1
View File
@@ -206,7 +206,6 @@
.ai-search-knowledge-card {
display: grid;
gap: 10px;
margin-top: auto;
padding: 12px;
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
@@ -218,6 +217,17 @@
overflow-wrap: normal;
}
/**
* 「把知识库卡片推到底部」**只对问问微信侧栏成立**。
*
* 这条规则原先写在 `.ai-search-knowledge-card` 基类上,于是任何复用该卡片的页面
* (例如「设置 · 本地索引」)都会让每张卡片各自被推开一大段,卡片之间出现几百像素空白。
* 现在限定在 `.ai-search-workspace` 作用域内 —— 这里才是它语义成立的地方。
*/
.ai-search-workspace .ai-search-knowledge-card {
margin-top: auto;
}
.ai-search-knowledge-card.building,
.ai-search-knowledge-card.syncing {
border-color: color-mix(in srgb, var(--wxex-brand) 55%, var(--wxex-border));
+80
View File
@@ -0,0 +1,80 @@
import type { GroupExitMonitorEvent } from './group-exit-monitor'
import type { Message } from './types'
/**
* 退群事件在档案消息流里的标记类型。
*
* 复用它自己的 `type` 值而不是靠 `from === 'system'` 判断,是因为**真实微信也会发系统消息**
* (入群通知、撤回提示等)。两者都走系统消息的分支渲染,但必须能被区分出来 ——
* 退群事件是**本地推断的产物**,不是微信的原话,展示上不能让人误以为是。
*/
export const GROUP_EXIT_EVENT_MESSAGE_TYPE = 'group-exit-event'
/** 这条档案消息是不是退群推断事件。 */
export function isGroupExitEventMessage(message: Message): boolean {
return message.type === GROUP_EXIT_EVENT_MESSAGE_TYPE
}
const pad2 = (value: number): string => String(value).padStart(2, '0')
/** 本地时区的 `YYYY-MM-DD HH:mm`。 */
function formatLocalDateTime(epochMs: number): string {
const date = new Date(epochMs)
if (Number.isNaN(date.getTime())) return ''
return (
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
`${pad2(date.getHours())}:${pad2(date.getMinutes())}`
)
}
/**
* 把一个退群事件映射成档案里的消息条目。
*
* ⚠️ **单位陷阱**:`Message.createTime` 全项目都是 **epoch 秒**
* (见 `messageGrouping.getMessageTime`:`new Date(timestamp * 1000)`),
* 而事件的 `detectedAt` 是 **epoch 毫秒**。这里必须 `/1000`,
* 否则事件会被排到 55000 年之后,或者干脆从时间轴上消失。
*/
export function toGroupExitEventMessage(event: GroupExitMonitorEvent): Message {
const detectedAtMs = Number(event.detectedAt) || 0
return {
id: `group-exit:${event.id}`,
from: 'system',
type: GROUP_EXIT_EVENT_MESSAGE_TYPE,
datetime: formatLocalDateTime(detectedAtMs),
// 文本用事件自带的渲染结果(含"退群时间"等模板字段),不再二次拼接。
content: event.message,
isSender: false,
senderId: event.memberWxid,
name: event.memberName,
createTime: Math.floor(detectedAtMs / 1000),
sessionId: event.roomId
}
}
/**
* 把退群事件合并进档案消息流,按时间升序排列(与 `MessageList` 的预期一致)。
*
* 已存在的同 id 消息不重复插入,所以重复调用是幂等的 ——
* `getState` 会随着广播反复刷新,没有这层去重就会越插越多。
*/
export function mergeGroupExitEvents(
messages: Message[],
events: GroupExitMonitorEvent[]
): Message[] {
const existingIds = new Set(messages.map((message) => message.id))
const extra = events
.map(toGroupExitEventMessage)
.filter((message) => !existingIds.has(message.id))
if (!extra.length) return messages
const combined = [...messages, ...extra]
combined.sort((a, b) => {
const left = Number(a.createTime) || 0
const right = Number(b.createTime) || 0
if (left !== right) return left - right
// 同一秒内保持输入顺序稳定,避免每次渲染都抖动。
return 0
})
return combined
}
+3
View File
@@ -173,7 +173,10 @@ export function buildMemberLeftNotification(
}
export interface GroupExitMonitorState {
/** 最近一批事件(一次回报最多带回这么多条,避免把整部历史推过 IPC)。 */
events: GroupExitMonitorEvent[]
/** 永久保留的事件总数;`events` 只是其中最近的若干条。 */
totalEventCount?: number
enabled: boolean
running: boolean
nativeMonitorActive: boolean
+259
View File
@@ -0,0 +1,259 @@
/**
* 群员统计的跨层契约与文本渲染。
*
* 放在 `shared` 的理由:renderer 需要结果类型做展示、需要 formatter 生成「复制」内容,
* 而 formatter 是**纯函数**(不依赖 Electron / Node),正好符合 shared 的约束 ——
* 业务文本绝不能由 React 组件拼接,否则复制出来的内容和界面上看到的会各说各话。
*/
/** 窗口内有发言的成员。 */
export interface GroupMemberStatsActiveMember {
senderId: string
/** 主显示名(微信昵称优先,与项目既有优先级一致)。 */
displayName: string
/**
* 群昵称(这个人在本群里显示的名字)。
*
* 与 `displayName` 相同或为空时不展示 —— 同一个人在不同群里叫不同名字,
* 只给一个名字会让人对不上号,但两个名字一样时重复展示又是噪声。
*/
groupNickname: string
messageCount: number
/** 该成员在窗口内最后一条消息的时间,Unix epoch 毫秒。 */
lastMessageTime: number
}
/** 窗口内没有发言记录的**当前**群成员。 */
export interface GroupMemberStatsSilentMember {
senderId: string
displayName: string
groupNickname: string
}
/**
* 派生索引相对源数据的新鲜度。
*
* - `fresh` 索引已追到源数据最新,结果可以当作完整结论
* - `stale` 索引落后于源数据,结果**必须**标注为可能不完整
* - `unknown` 口径缺失(例如源侧无缓存),无法判定,同样不得当作完整结论
*/
export type GroupStatsFreshness = 'fresh' | 'stale' | 'unknown'
/**
* 群员统计查询请求(IPC 契约)。
*
* 时间用 epoch **毫秒**:这一路完全走 Knowledge,而 Knowledge 的内部口径就是毫秒。
* 不沿用聊天消息的秒级口径,是为了让 service 内部不存在秒/毫秒跨边界 ——
* 单位换错是**静默读到 0 条**,不会抛错。
*/
export interface GroupMemberStatsQuery {
/** 群会话 md5(= `md5(username)`),与 `getGroupSnapshot(userMd5)` 同口径。 */
userMd5: string
/** 统计窗口起(含),Unix epoch 毫秒。 */
startTime: number
/** 统计窗口止(含),Unix epoch 毫秒。 */
endTime: number
}
export interface GroupMemberStatsResult {
conversationId: string
/** 统计窗口起(含),Unix epoch 毫秒。 */
startTime: number
/** 统计窗口止(含),Unix epoch 毫秒。 */
endTime: number
freshness: GroupStatsFreshness
/**
* 是否可作为完整结论使用。
*
* **只有 `freshness === 'fresh'` 时才为 true。** 界面不得把 stale 的「未发言」
* 名单包装成完整结论 —— 那是会把实际发过言的人挂到群里的错误否定。
*/
complete: boolean
/** 当前群成员总数。 */
memberCount: number
activeMemberCount: number
silentMemberCount: number
/** 按 `messageCount` 降序;同数按最后发言时间降序。 */
activeMembers: GroupMemberStatsActiveMember[]
/** 按显示名排序,便于人眼核对。 */
silentMembers: GroupMemberStatsSilentMember[]
/**
* 窗口内 `sender_id` 缺失、无法归属任何成员的消息数。
*
* 只用于诊断,不计入任何成员的条数 —— 硬塞给某个人会制造假的「有发言」。
*/
unattributedMessages: number
/** 窗口内被排除的系统消息数(微信侧 10000 / 10002 等)。 */
excludedSystemMessages: number
/**
* 窗口内**最早一条消息**的时间(epoch ms);null 表示窗口内没有消息。
*
* 选「全部」时用它显示真实起点 ——「本机这个群第一条消息是 2020-02-02」,
* 比「全部历史」这种含糊说法有用得多。
*/
firstMessageTime: number | null
/** 必须展示给用户的边界说明;为空表示无需额外声明。 */
limitations: string[]
}
/** 未发言统计必须附带的边界说明(**不要删掉这个含义**)。 */
export const GROUP_STATS_LIMITATION =
'统计基于当前群成员名单与本地已归档消息。窗口期前入群、统计期间退群或加入的成员,以及未归档时段,可能影响结果,仅供参考。'
/** 索引未追平时必须展示的额外说明。 */
export const GROUP_STATS_STALE_LIMITATION =
'本地索引尚未完全同步,以下结果可能不完整。'
/** 统计时间范围的快捷选项。 */
export type GroupStatsRangeKey = '7d' | '30d' | '90d' | 'all' | 'custom'
export const GROUP_STATS_RANGE_OPTIONS: Array<{ key: GroupStatsRangeKey; label: string }> = [
{ key: '7d', label: '近 7 天' },
{ key: '30d', label: '近 30 天' },
{ key: '90d', label: '近 90 天' },
{ key: 'all', label: '全部' },
{ key: 'custom', label: '自定义' }
]
const RANGE_DAYS: Record<'7d' | '30d' | '90d', number> = {
'7d': 7,
'30d': 30,
'90d': 90
}
/**
* 由「结束时刻 + 快捷范围」推出起点(epoch ms)。
*
* 用固定 24h 天数而不是日历日:用户要的是「最近 30 天」这种滑动窗口,
* 日历日会随月份长度漂移,滑窗不会。
*
* `all` 返回 0(Unix epoch 起点)—— 语义是「不放下界」,覆盖全部已归档历史。
*/
export function resolveGroupStatsRangeStart(
key: Exclude<GroupStatsRangeKey, 'custom'>,
endTime: number
): number {
if (key === 'all') return 0
return endTime - RANGE_DAYS[key] * 24 * 60 * 60 * 1000
}
const DIVIDER = '--------------------'
/**
* 名单一行里的名字:`主名(群昵称)`。
*
* 同一个人在微信里和在这个群里可能叫两个名字,只给一个会让人对不上号;
* 但两者相同时重复展示又是纯噪声,所以只在**真的不同**时才加括号。
*/
export function formatMemberLineName(member: {
displayName: string
groupNickname: string
}): string {
if (member.groupNickname && member.groupNickname !== member.displayName) {
return `${member.displayName}(${member.groupNickname})`
}
return member.displayName
}
const pad2 = (value: number): string => String(value).padStart(2, '0')
/** 本地时区的 `YYYY-MM-DD`。 */
export function formatStatsDate(epochMs: number): string {
const date = new Date(epochMs)
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
}
/** 本地时区的 `YYYY-MM-DD HH:mm`。 */
export function formatStatsDateTime(epochMs: number): string {
const date = new Date(epochMs)
if (Number.isNaN(date.getTime())) return ''
return `${formatStatsDate(epochMs)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
}
/**
* 区间的人类可读文案。
*
* 选「全部」时 `startTime === 0`(不放下界),此时用 `firstMessageTime`
* 显示**本机这个群第一条消息**的真实日期 ——「2020-02-02 至 2026-09-20」比
* 「全部历史」有用得多。两者都拿不到时才退化成「全部历史」,
* 且绝不格式化成 1970-01-01 假装统计真的从那儿开始。
*/
const rangeLabel = (
result: Pick<GroupMemberStatsResult, 'startTime' | 'endTime' | 'firstMessageTime'>
): string => {
const start = result.startTime > 0 ? result.startTime : result.firstMessageTime
if (start && start > 0) {
return `${formatStatsDate(start)} 至 ${formatStatsDate(result.endTime)}`
}
return `全部历史(至 ${formatStatsDate(result.endTime)})`
}
/**
* 未发言名单的纯文本模板。
*
* 刻意使用纯文本而非 Markdown:这段文本的归宿是聊天窗口,任何富文本标记
* 在微信里都会变成裸露的符号。
*/
export function formatSilentMemberStats(
result: GroupMemberStatsResult,
options: { limit?: number } = {}
): string {
const limit = options.limit && options.limit > 0 ? options.limit : result.silentMembers.length
const listed = result.silentMembers.slice(0, limit)
const lines = [
'未发言统计',
`以下当前群成员在 ${rangeLabel(result)} 的本地已归档消息中无发言记录`,
DIVIDER
]
if (listed.length === 0) {
lines.push('(无)')
} else {
listed.forEach((member, index) => {
lines.push(`${index + 1}. ${formatMemberLineName(member)}`)
})
if (listed.length < result.silentMembers.length) {
lines.push(`… 其余 ${result.silentMembers.length - listed.length} 人未列出`)
}
}
const notes = [...result.limitations]
if (notes.length) {
lines.push('', `注:${notes.join(' ')}`)
}
return lines.join('\n')
}
/** 活跃榜的纯文本模板。 */
export function formatActiveMemberStats(
result: GroupMemberStatsResult,
options: { limit?: number } = {}
): string {
const limit = options.limit && options.limit > 0 ? options.limit : result.activeMembers.length
const listed = result.activeMembers.slice(0, limit)
const lines = [
'群聊活跃统计',
`统计时间:${rangeLabel(result)}`,
DIVIDER
]
if (listed.length === 0) {
lines.push('(无)')
} else {
listed.forEach((member, index) => {
lines.push(`${index + 1}. ${formatMemberLineName(member)} ${member.messageCount} 条`)
})
if (listed.length < result.activeMembers.length) {
lines.push(`… 其余 ${result.activeMembers.length - listed.length} 人未列出`)
}
}
const notes = [...result.limitations]
if (notes.length) {
lines.push('', `注:${notes.join(' ')}`)
}
return lines.join('\n')
}
+45
View File
@@ -878,3 +878,48 @@ export interface ImageTextIndexStatus {
/** 各阶段耗时画像(可选的附加诊断字段,UI 不渲染)。 */
stageTimings?: ImageTextIndexStageTimings
}
/**
* 图片索引的状态文案。
*
* **两处 UI 共用**(问问微信侧栏卡片 / 设置页宽版卡片)—— 各写一份必然会分叉,
* 而这段文案里有几条不能丢的语义:
* - 「已建立」不能等于「全失败」(`coverageState === 'failed'` 必须叫异常);
* - 「取消」不等于「部分完成」(进度保留,但被打断过这件事要说出来);
* - 未完成时进度封顶 99.9%(`imageTextProcessedPercent` 保证),不允许显示成 100%。
*
* 参数用宽松类型而不是具体 interface:调用方两边的局部状态形状不同(侧栏卡片多几个
* 派生布尔值),传具体类型会逼着两边先做一次无用适配。
*/
export function imageTextStateLabel(input: {
progressState?: string
running: boolean
paused: boolean
established: boolean
coverageState: string
percent: number
}): string {
if (input.progressState === 'error') return '建立失败'
if (input.running) return `建立中 · ${input.percent}%`
if (input.paused) return `已暂停 · ${input.percent}%`
if (input.progressState === 'cancelled') return `已取消 · ${input.percent}%`
if (!input.established) return '未建立'
if (input.coverageState === 'failed') return '图片文字索引异常'
if (input.coverageState === 'complete') return '已完成'
return `部分完成 · ${input.percent}%`
}
/** 状态徽章色调(两处 UI 共用同一套映射)。 */
export function imageTextStateTone(input: {
progressState?: string
running: boolean
paused: boolean
established: boolean
coverageState: string
}): 'ok' | 'warn' | 'error' | 'idle' {
if (input.progressState === 'error' || input.coverageState === 'failed') return 'error'
if (!input.established) return 'idle'
if (input.running || input.paused || input.progressState === 'cancelled') return 'warn'
if (input.coverageState === 'complete') return 'ok'
return 'warn'
}
+68 -1
View File
@@ -491,6 +491,62 @@ export interface KnowledgeStatusRequest {
fts: KnowledgeFtsConfig
}
/**
* 单个会话内的「按发送者聚合」统计请求。
*
* 存在的理由:群员统计(谁说话了、说了多少条、谁一个字没说)只需要
* `GROUP BY sender_id` 的聚合结果,**不需要**把消息逐条搬到主进程再算。
*
* 刻意不做成 `KnowledgeQuery` 的一个分支:检索是「找内容」,这里是「数人头」,
* 两者的谓词与返回形状没有交集,塞在一起会让两端都长出用不到的条件分支。
*/
export interface KnowledgeMemberStatsRequest {
accountId: string
databaseRoot: string
fts: KnowledgeFtsConfig
/** 会话 md5(= `md5(username)`),不是原始 `@chatroom`。 */
conversationId: string
/** Unix epoch **毫秒**,闭区间。与 knowledge 内部口径一致,不经过 WCDB 的秒级边界。 */
startTime: number
/** Unix epoch **毫秒**,闭区间。 */
endTime: number
}
/** 单个发送者在指定会话 / 时间窗内的发言聚合。 */
export interface KnowledgeMemberStatRow {
senderId: string
messageCount: number
/** 该发送者在窗口内最后一条消息的时间(epoch ms)。 */
lastMessageTime: number
}
export interface KnowledgeMemberStatsResult {
conversationId: string
/** 窗口内、排除系统消息之后的总消息数(**含**未归属消息)。 */
totalMessages: number
/** 按 `messageCount` 降序。 */
senders: KnowledgeMemberStatRow[]
/**
* 窗口内 `sender_id` 缺失的消息数。
*
* 这些消息**不得归属任何成员**:把它们硬塞给某个人,会让「未发言」名单出现
* 错误否定,而这份名单是会被发到群里的。
*/
unattributedMessages: number
/** 窗口内被 `kind = 'system'` 排除的消息数。 */
excludedSystemMessages: number
/**
* 窗口内**最早一条消息**的时间(epoch ms),**不过滤 kind**。
*
* 用途:选「全部」时向用户显示真实的数据起点(「本机这个群第一条消息是什么时候」),
* 而不是含糊的「全部历史」。刻意不过滤系统消息 —— 群的第一条往往就是建群通知,
* 那才是用户认知里的「第一条」。
*/
earliestMessageTime: number | null
/** 派生索引里最新一条消息的时间(epoch ms);null 表示无法判定。 */
indexLatestAt: number | null
}
/**
* 索引「已覆盖到的源数据时间」与源数据「当前最新活跃时间」之间允许的固定落差。
*
@@ -515,7 +571,16 @@ export function isKnowledgeFresh(
export interface KnowledgeWorkerRequest {
version: 1
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close' | 'highWater'
type:
| 'index'
| 'preflight'
| 'search'
| 'status'
| 'remove'
| 'cancel'
| 'close'
| 'highWater'
| 'memberStats'
requestId: string
/** Parent monotonic wall-clock used only for transport timing. */
sentAt?: number
@@ -524,6 +589,7 @@ export interface KnowledgeWorkerRequest {
| KnowledgeCapacityPreflightRequest
| KnowledgeSearchRequest
| KnowledgeStatusRequest
| KnowledgeMemberStatsRequest
| { accountId: string; databaseRoot: string }
| { targetRequestId: string }
| Record<string, never>
@@ -539,6 +605,7 @@ export interface KnowledgeWorkerResponse {
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| KnowledgeMemberStatsResult
| { removed: true }
error?: string
transport?: {
@@ -0,0 +1,65 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChatHeader } from '../../src/renderer/src/components/chat/ChatHeader'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
import type { Contact } from '../../src/shared/types'
const groupContact: Contact = {
m_nsUsrName: '研发群@chatroom',
m_nsNickName: '研发群',
md5: 'g'.repeat(32),
type: 'group'
}
const userContact: Contact = {
m_nsUsrName: 'wxid_friend',
m_nsNickName: '好友',
md5: 'u'.repeat(32),
type: 'user'
}
beforeEach(() => {
;(window as unknown as { api: Record<string, unknown> }).api = {
getGroupMemberStats: vi.fn(),
getPersonalWechatSenderStatus: vi.fn().mockResolvedValue({ canSendText: false })
}
})
function renderHeader(contact: Contact, isGroupChat: boolean): void {
render(
<TooltipProvider>
<ChatHeader
contact={contact}
isGroupChat={isGroupChat}
loadedCount={10}
filteredCount={10}
contentFilter=""
isAiLoading={false}
onContentFilterChange={vi.fn()}
onTestSend={vi.fn()}
onOpenAiSettings={vi.fn()}
/>
</TooltipProvider>
)
}
async function openMoreMenu(): Promise<void> {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: '更多功能' }))
await waitFor(() => expect(screen.getByText('刷新数据')).toBeTruthy())
}
describe('ChatHeader · 群发言统计入口', () => {
it('群聊显示「群发言统计」入口', async () => {
renderHeader(groupContact, true)
await openMoreMenu()
expect(screen.getByText('群发言统计')).toBeTruthy()
})
it('非群聊不显示「群发言统计」入口', async () => {
renderHeader(userContact, false)
await openMoreMenu()
expect(screen.queryByText('群发言统计')).toBeNull()
})
})
+1 -1
View File
@@ -53,7 +53,7 @@ describe('chat menus', () => {
/>
)
const trigger = screen.getByRole('button', { name: '更多' })
const trigger = screen.getByRole('button', { name: '更多功能' })
await user.click(trigger)
expect(screen.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
await user.keyboard('{Escape}')
@@ -0,0 +1,235 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GroupMemberStatsDialog } from '../../src/renderer/src/components/group-stats/GroupMemberStatsDialog'
import {
GROUP_STATS_LIMITATION,
GROUP_STATS_STALE_LIMITATION,
type GroupMemberStatsResult
} from '../../src/shared/group-stats'
import type { Contact } from '../../src/shared/types'
const contact: Contact = {
m_nsUsrName: '研发群@chatroom',
m_nsNickName: '研发群',
remark: '公司研发大群',
md5: 'g'.repeat(32),
type: 'group'
}
const at = (year: number, month: number, day: number): number =>
new Date(year, month - 1, day).getTime()
const baseResult = (
overrides: Partial<GroupMemberStatsResult> = {}
): GroupMemberStatsResult => ({
conversationId: contact.md5,
startTime: at(2026, 6, 15),
endTime: at(2026, 9, 4),
freshness: 'fresh',
complete: true,
memberCount: 5,
activeMemberCount: 3,
silentMemberCount: 2,
activeMembers: [
{
senderId: 'wxid_a',
displayName: '张三',
groupNickname: '老张',
messageCount: 328,
lastMessageTime: at(2026, 9, 1)
},
{
senderId: 'wxid_b',
displayName: '李四',
groupNickname: '',
messageCount: 217,
lastMessageTime: at(2026, 9, 2)
},
{
senderId: 'wxid_c',
displayName: '王五',
groupNickname: '',
messageCount: 196,
lastMessageTime: at(2026, 9, 3)
}
],
silentMembers: [
{ senderId: 'wxid_d', displayName: '轩轩', groupNickname: '轩轩子' },
{ senderId: 'wxid_e', displayName: '十二', groupNickname: '' }
],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: at(2026, 6, 15),
limitations: [GROUP_STATS_LIMITATION],
...overrides
})
const getGroupMemberStats = vi.fn()
const getPersonalWechatSenderStatus = vi.fn()
const sendPersonalWechatMessage = vi.fn()
beforeEach(() => {
getGroupMemberStats.mockReset().mockResolvedValue(baseResult())
getPersonalWechatSenderStatus.mockReset().mockResolvedValue({ canSendText: false })
sendPersonalWechatMessage.mockReset().mockResolvedValue({ ok: true })
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) }
})
;(window as unknown as { api: Record<string, unknown> }).api = {
getGroupMemberStats,
getPersonalWechatSenderStatus,
sendPersonalWechatMessage
}
})
afterEach(() => {
vi.restoreAllMocks()
})
const renderDialog = () =>
render(<GroupMemberStatsDialog open onOpenChange={vi.fn()} contact={contact} />)
/**
* Radix Tabs 只挂载 active 的内容,未发言名单要先切过去才存在。
*
* ⚠️ 必须先等 tab 出现:数据没加载完时整个结果区(含 Tabs)都还没渲染,
* 直接 `getByRole('tab')` 会因为「DOM 里还没有 tab」而失败。
*/
const switchToSilentTab = async (): Promise<void> => {
const user = userEvent.setup()
const tab = await screen.findByRole('tab', { name: /未发言统计/ })
await user.click(tab)
await waitFor(() => expect(screen.getByText(/轩轩/)).toBeTruthy())
}
describe('GroupMemberStatsDialog', () => {
it('展示群备注与三个概览数字', async () => {
renderDialog()
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
expect(screen.getByText('群备注:公司研发大群')).toBeTruthy()
expect(screen.getByText('当前群成员')).toBeTruthy()
expect(screen.getByText('发过言')).toBeTruthy()
expect(screen.getByText('没发言')).toBeTruthy()
expect(screen.getByText(/共 3 人/)).toBeTruthy()
})
it('默认展示「发言排行」tab,且不渲染未发言名单', async () => {
renderDialog()
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
expect(screen.getByText(/李四/)).toBeTruthy()
// 未发言的人此时不应出现在 DOM 里,避免两个名单互相干扰
expect(screen.queryByText(/轩轩/)).toBeNull()
})
it('切到「未发言统计」tab 后才展示未发言名单', async () => {
renderDialog()
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
await switchToSilentTab()
expect(screen.getByText(/十二/)).toBeTruthy()
expect(screen.getByText(/共 2 人/)).toBeTruthy()
// 切过去之后也不应再看到发言榜的人
expect(screen.queryByText(/张三/)).toBeNull()
})
it('canSendText = false 时只提供复制,不出现发送按钮', async () => {
renderDialog()
await switchToSilentTab()
expect(screen.queryByRole('button', { name: '发送到当前群' })).toBeNull()
expect(screen.getAllByRole('button', { name: '复制' }).length).toBeGreaterThan(0)
})
it('canSendText = true 时出现发送按钮', async () => {
getPersonalWechatSenderStatus.mockResolvedValue({ canSendText: true })
renderDialog()
await switchToSilentTab()
expect(screen.getByRole('button', { name: '发送到当前群' })).toBeTruthy()
})
it('复制走 formatter 生成的完整文本', async () => {
renderDialog()
await switchToSilentTab()
const user = userEvent.setup()
// userEvent.setup() 会安装它自己的 clipboard stub,覆盖 beforeEach 里的定义,
// 所以必须在 setup 之后再 spy,否则断言的是一个永远不会被调用的替身。
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText')
await user.click(screen.getByRole('button', { name: '复制' }))
await waitFor(() => expect(clipboardSpy).toHaveBeenCalled())
const text = clipboardSpy.mock.calls[0][0] as string
expect(text).toContain('未发言统计')
expect(text).toContain('1. 轩轩')
expect(text).toContain('2. 十二')
})
it('索引未追平时显示 warning 且不宣称完整', async () => {
getGroupMemberStats.mockResolvedValue(
baseResult({
freshness: 'stale',
complete: false,
limitations: [GROUP_STATS_LIMITATION, GROUP_STATS_STALE_LIMITATION]
})
)
renderDialog()
const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('本地索引尚未完全同步')
})
it('索引未追平时提供「去建立索引」的去处', async () => {
getGroupMemberStats.mockResolvedValue(
baseResult({
freshness: 'stale',
complete: false,
limitations: [GROUP_STATS_LIMITATION, GROUP_STATS_STALE_LIMITATION]
})
)
const onOpenLocalIndexSettings = vi.fn()
const onOpenChange = vi.fn()
render(
<GroupMemberStatsDialog
open
onOpenChange={onOpenChange}
contact={contact}
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
/>
)
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '去建立索引' }))
// 必须先关面板再跳,否则设置页会被这个 Dialog 挡在后面。
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onOpenLocalIndexSettings).toHaveBeenCalled()
})
it('没有提供跳转回调时不显示「去建立索引」', async () => {
getGroupMemberStats.mockResolvedValue(
baseResult({ freshness: 'stale', complete: false })
)
renderDialog()
await screen.findByRole('alert')
expect(screen.queryByRole('button', { name: '去建立索引' })).toBeNull()
})
it('发送失败时展示错误而不是静默失败', async () => {
getPersonalWechatSenderStatus.mockResolvedValue({ canSendText: true })
sendPersonalWechatMessage.mockRejectedValue(new Error('hook 未就绪'))
renderDialog()
await switchToSilentTab()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: '发送到当前群' }))
await waitFor(() => expect(screen.getByText('hook 未就绪')).toBeTruthy())
})
})
+195
View File
@@ -0,0 +1,195 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LocalIndexPage } from '../../src/renderer/src/features/settings/pages/LocalIndexPage'
const NOW = Date.now()
const DAY = 24 * 60 * 60 * 1000
const knowledgeStatus = (overrides: Record<string, unknown> = {}) => ({
accountId: 'acct',
state: 'ready',
indexedMessageCount: 2_517_181,
indexedChunkCount: 9000,
sourceMessageCount: 2_517_181,
processedMessages: 2_517_181,
totalMessages: 2_517_181,
estimatedRemainingMs: null,
databaseBytes: 6.4 * 1024 ** 3,
walBytes: 0,
shmBytes: 0,
indexLatestAt: NOW,
sourceLatestAt: NOW,
...overrides
})
const imageStatus = (overrides: Record<string, unknown> = {}) => ({
progress: {
state: 'idle',
processed: 349_263,
totalImageMessages: 349_263,
percent: 100,
indexed: 57_078,
empty: 74_816,
missing: 184_805,
failed: 8
},
coverage: {
// `established` / `complete` 是状态判定的两个开关
// (`imageTextCoverageState`:!established→not_built,complete 才→complete)。
// 漏任一个,卡片都会停在别的分支上。
established: true,
complete: true,
indexed: 57_078,
empty: 74_816,
missing: 184_805,
failed: 8,
processed: 349_263,
totalImageMessages: 349_263,
systemicFailure: false,
tiers: []
},
storage: {},
counting: false,
...overrides
})
let api: Record<string, ReturnType<typeof vi.fn>>
beforeEach(() => {
api = {
getKnowledgeStatus: vi.fn().mockResolvedValue(knowledgeStatus()),
onKnowledgeStatus: vi.fn(() => () => undefined),
startKnowledgeIndex: vi.fn().mockResolvedValue(knowledgeStatus({ state: 'syncing' })),
cancelKnowledgeIndex: vi.fn().mockResolvedValue({ cancellable: true, cancelled: true }),
getImageTextIndexStatus: vi.fn().mockResolvedValue(imageStatus()),
onImageTextIndexStatus: vi.fn(() => () => undefined),
countImageMessages: vi.fn().mockResolvedValue({
totalImageMessages: 349_263,
scannedConversations: 1375,
failedConversations: 0
}),
startImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
pauseImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
resumeImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
cancelImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
resetImageTextIndexFailures: vi.fn().mockResolvedValue(imageStatus()),
repairImageTextIndex: vi.fn().mockResolvedValue(imageStatus())
}
;(window as unknown as { api: Record<string, unknown> }).api = api
})
const renderPage = (dbReady = true) =>
render(<LocalIndexPage dbReady={dbReady} onNotice={vi.fn()} />)
describe('LocalIndexPage · 信息层级', () => {
it('页面标题与两处索引卡片都在', async () => {
renderPage()
expect(screen.getByRole('heading', { name: '本地索引' })).toBeTruthy()
await waitFor(() => expect(screen.getByRole('heading', { name: '聊天记录索引' })).toBeTruthy())
expect(screen.getByRole('heading', { name: '图片文字索引' })).toBeTruthy()
})
it('聊天记录索引显示三个核心数字', async () => {
renderPage()
await waitFor(() => expect(screen.getByText('2,517,181')).toBeTruthy())
expect(screen.getByText('已收录消息')).toBeTruthy()
expect(screen.getByText('6.4 GB')).toBeTruthy()
expect(screen.getByText('占用空间')).toBeTruthy()
expect(screen.getByText('最后更新')).toBeTruthy()
})
it('图片文字索引显示四个 metric', async () => {
renderPage()
await waitFor(() => expect(screen.getByText('57,078')).toBeTruthy())
expect(screen.getByText('已识别文字')).toBeTruthy()
expect(screen.getByText('74,816')).toBeTruthy()
expect(screen.getByText('无文字图片')).toBeTruthy()
expect(screen.getByText('184,805')).toBeTruthy()
expect(screen.getByText('图片已清理')).toBeTruthy()
expect(screen.getByText('8')).toBeTruthy()
expect(screen.getByText('识别失败')).toBeTruthy()
})
})
describe('LocalIndexPage · 状态徽章', () => {
it('索引追平时显示「可用 · 已追至最新」', async () => {
renderPage()
await waitFor(() => expect(screen.getByText('可用 · 已追至最新')).toBeTruthy())
})
it('索引落后时显示「可用 · 待追新」', async () => {
api.getKnowledgeStatus.mockResolvedValue(
knowledgeStatus({ indexLatestAt: NOW - 3 * DAY, sourceLatestAt: NOW })
)
renderPage()
await waitFor(() => expect(screen.getByText('可用 · 待追新')).toBeTruthy())
})
it('图片索引完成后显示「已完成」', async () => {
renderPage()
await waitFor(() => expect(screen.getByText('已完成')).toBeTruthy())
})
})
describe('LocalIndexPage · 运行态与进度', () => {
it('图片索引运行中:显示进度条与暂停/取消,不再显示主操作按钮', async () => {
api.getImageTextIndexStatus.mockResolvedValue(
imageStatus({
progress: {
state: 'running',
processed: 184_235,
totalImageMessages: 349_263,
percent: 52,
indexed: 1_000,
empty: 500,
missing: 0,
failed: 0
}
})
)
renderPage()
expect(await screen.findByRole('progressbar')).toBeTruthy()
expect(screen.getByText('184,235 / 349,263')).toBeTruthy()
expect(screen.getByRole('button', { name: '暂停' })).toBeTruthy()
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '更新索引' })).toBeNull()
})
})
describe('LocalIndexPage · 操作绑定与禁用', () => {
it('「同步最新记录」仍然走原来的 startKnowledgeIndex', async () => {
renderPage()
const button = await screen.findByRole('button', { name: '同步最新记录' })
const user = userEvent.setup()
await user.click(button)
await waitFor(() => expect(api.startKnowledgeIndex).toHaveBeenCalled())
})
it('数据库未连接时主按钮禁用并给出提示', async () => {
renderPage(false)
await waitFor(() => expect(screen.getByText('请先连接微信数据,然后再建立聊天记录索引。')).toBeTruthy())
expect((screen.getByRole('button', { name: '同步最新记录' }) as HTMLButtonElement).disabled).toBe(
true
)
})
it('「···」菜单里的修复动作仍绑定原逻辑', async () => {
renderPage()
const more = await screen.findByRole('button', { name: '更多操作' })
const user = userEvent.setup()
await user.click(more)
await user.click(await screen.findByText('图片内容搜不到?修复搜索索引'))
await waitFor(() => expect(api.repairImageTextIndex).toHaveBeenCalled())
})
})
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
GROUP_EXIT_EVENT_MESSAGE_TYPE,
isGroupExitEventMessage,
mergeGroupExitEvents,
toGroupExitEventMessage
} from '../../src/shared/group-exit-event-message'
import type { GroupExitMonitorEvent } from '../../src/shared/group-exit-monitor'
import type { Message } from '../../src/shared/types'
const at = (year: number, month: number, day: number, hour = 0, minute = 0): number =>
new Date(year, month - 1, day, hour, minute).getTime()
const makeEvent = (overrides: Partial<GroupExitMonitorEvent> = {}): GroupExitMonitorEvent => ({
id: 'evt-1',
contactId: 'g'.repeat(32),
roomId: 'room@chatroom',
groupName: '研发群',
memberWxid: 'wxid_a',
memberName: '老张',
previousCount: 10,
currentCount: 9,
delta: -1,
message: '老张退出了研发群',
detectedAt: at(2026, 9, 20, 14, 30),
...overrides
})
const makeMessage = (id: string, createTimeSec: number): Message => ({
id,
from: 'user',
type: '文本',
datetime: '2026-09-20 14:00',
content: 'hello',
isSender: false,
createTime: createTimeSec
})
describe('toGroupExitEventMessage', () => {
it('把事件映射成系统分支的消息,并带专用 type 标记', () => {
const message = toGroupExitEventMessage(makeEvent())
expect(message.id).toBe('group-exit:evt-1')
expect(message.from).toBe('system')
expect(message.type).toBe(GROUP_EXIT_EVENT_MESSAGE_TYPE)
expect(message.content).toBe('老张退出了研发群')
expect(message.isSender).toBe(false)
expect(message.senderId).toBe('wxid_a')
expect(message.sessionId).toBe('room@chatroom')
expect(isGroupExitEventMessage(message)).toBe(true)
})
it('⚠️ createTime 必须从毫秒换算成秒(全项目 Message.createTime 都是秒)', () => {
const detectedAt = at(2026, 9, 20, 14, 30)
const message = toGroupExitEventMessage(makeEvent({ detectedAt }))
// 直接塞毫秒会让事件被排到几万年之后,或者从时间轴上消失。
expect(message.createTime).toBe(Math.floor(detectedAt / 1000))
expect(message.createTime).toBeLessThan(1e11)
})
it('真实微信系统消息不会被误判为退群事件', () => {
const systemMessage: Message = {
id: 'm1',
from: 'system',
type: '系统消息',
datetime: '2026-09-20 14:00',
content: '某某加入了群聊',
isSender: false
}
expect(isGroupExitEventMessage(systemMessage)).toBe(false)
})
})
describe('mergeGroupExitEvents', () => {
it('按时间升序并入,事件落在正确位置', () => {
const messages = [
makeMessage('m1', Math.floor(at(2026, 9, 20, 10) / 1000)),
makeMessage('m2', Math.floor(at(2026, 9, 20, 16) / 1000))
]
const events = [makeEvent({ detectedAt: at(2026, 9, 20, 14, 30) })]
const merged = mergeGroupExitEvents(messages, events)
expect(merged.map((message) => message.id)).toEqual(['m1', 'group-exit:evt-1', 'm2'])
})
it('重复合并是幂等的(广播会反复刷新,不能越插越多)', () => {
const messages = [makeMessage('m1', Math.floor(at(2026, 9, 20, 10) / 1000))]
const events = [makeEvent()]
const once = mergeGroupExitEvents(messages, events)
const twice = mergeGroupExitEvents(once, events)
expect(twice).toHaveLength(2)
expect(twice.map((message) => message.id)).toEqual(once.map((message) => message.id))
})
it('没有事件时原样返回同一个引用(避免无谓的重渲染)', () => {
const messages = [makeMessage('m1', 1_700_000_000)]
expect(mergeGroupExitEvents(messages, [])).toBe(messages)
})
it('多个事件各自成组、保持时间顺序', () => {
const messages = [makeMessage('m1', Math.floor(at(2026, 9, 20, 8) / 1000))]
const events = [
makeEvent({ id: 'e1', detectedAt: at(2026, 9, 20, 12) }),
makeEvent({ id: 'e2', detectedAt: at(2026, 9, 20, 15) })
]
const merged = mergeGroupExitEvents(messages, events)
expect(merged.map((message) => message.id)).toEqual([
'm1',
'group-exit:e1',
'group-exit:e2'
])
})
})
@@ -1,4 +1,4 @@
import { mkdtempSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
import { existsSync, mkdtempSync, readFileSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -394,6 +394,7 @@ describe('GroupExitMonitorService', () => {
await service.start(true)
const statePath = join(mocks.userData, 'group-exit-monitor.json')
const eventsPath = join(mocks.userData, 'group-exit-monitor-events.jsonl')
await vi.waitFor(() => {
expect(readJsonSync(statePath).snapshots[0].members[1]).toMatchObject({
wxid: departed.wxid,
@@ -401,7 +402,10 @@ describe('GroupExitMonitorService', () => {
})
})
const baseline = readJsonSync(statePath)
expect(baseline.events).toEqual([])
// 事件**不再写在状态文件里**(它们走 append-only 的 JSONL),
// 所以这里断言的是「两条存储都没有事件」,而不是只看状态文件。
expect(baseline.events).toBeUndefined()
expect(existsSync(eventsPath) ? readFileSync(eventsPath, 'utf8').trim() : '').toBe('')
expect(baseline.snapshots).toEqual([
expect.objectContaining({
roomId: 'room@chatroom',
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import {
formatActiveMemberStats,
formatSilentMemberStats,
formatStatsDate,
formatStatsDateTime,
resolveGroupStatsRangeStart,
type GroupMemberStatsResult
} from '../../src/shared/group-stats'
/** 用本地时区构造时间,避免测试受运行机器时区影响。 */
const at = (year: number, month: number, day: number, hour = 0): number =>
new Date(year, month - 1, day, hour).getTime()
const base = (overrides: Partial<GroupMemberStatsResult> = {}): GroupMemberStatsResult => ({
conversationId: 'c'.repeat(32),
startTime: at(2026, 6, 15),
endTime: at(2026, 9, 4),
freshness: 'fresh',
complete: true,
memberCount: 48,
activeMemberCount: 45,
silentMemberCount: 3,
activeMembers: [],
silentMembers: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: at(2026, 6, 15),
limitations: [],
...overrides
})
describe('GroupStats formatter', () => {
it('未发言模板按编号列出,并带区间与人数前缀', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [
{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' },
{ senderId: 'wxid_2', displayName: '十二', groupNickname: '' },
{ senderId: 'wxid_3', displayName: '湘江中路', groupNickname: '' }
]
})
)
expect(text).toContain('未发言统计')
expect(text).toContain('2026-06-15 至 2026-09-04')
expect(text).toContain('1. 轩轩')
expect(text).toContain('2. 十二')
expect(text).toContain('3. 湘江中路')
// 纯文本:不能出现 Markdown 标记
expect(text).not.toContain('**')
expect(text).not.toContain('##')
})
it('未发言模板保留 limitation 文案', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }],
limitations: ['统计基于当前群成员名单与本地已归档消息。']
})
)
expect(text).toContain('注:统计基于当前群成员名单与本地已归档消息。')
})
it('未发言为空时给出明确文案而不是空白', () => {
const text = formatSilentMemberStats(base())
expect(text).toContain('(无)')
})
it('未发言模板支持 limit 并说明省略人数', () => {
const silentMembers = Array.from({ length: 5 }, (_, index) => ({
senderId: `wxid_${index}`,
displayName: `成员${index}`,
groupNickname: ''
}))
const text = formatSilentMemberStats(base({ silentMembers }), { limit: 2 })
expect(text).toContain('1. 成员0')
expect(text).toContain('2. 成员1')
expect(text).not.toContain('3. 成员2')
expect(text).toContain('其余 3 人未列出')
})
it('活跃模板带条数', () => {
const text = formatActiveMemberStats(
base({
activeMembers: [
{
senderId: 'wxid_1',
displayName: '张三',
groupNickname: '',
messageCount: 328,
lastMessageTime: at(2026, 9, 20, 12)
},
{
senderId: 'wxid_2',
displayName: '李四',
groupNickname: '',
messageCount: 217,
lastMessageTime: at(2026, 9, 19, 12)
}
]
})
)
expect(text).toContain('群聊活跃统计')
expect(text).toContain('统计时间:2026-06-15 至 2026-09-04')
expect(text).toContain('1. 张三 328 条')
expect(text).toContain('2. 李四 217 条')
})
it('群昵称与主名不同时显示成「微信名(群昵称)」', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [
{ senderId: 'wxid_1', displayName: '张三', groupNickname: '老张' },
// 两者相同时不重复展示,避免噪声
{ senderId: 'wxid_2', displayName: '李四', groupNickname: '李四' },
// 没有群昵称时只显示主名
{ senderId: 'wxid_3', displayName: '王五', groupNickname: '' }
]
})
)
expect(text).toContain('1. 张三(老张)')
expect(text).toContain('2. 李四')
expect(text).not.toContain('李四(李四)')
expect(text).toContain('3. 王五')
})
it('日期格式化使用本地时区且补零', () => {
expect(formatStatsDate(at(2026, 1, 5))).toBe('2026-01-05')
expect(formatStatsDateTime(at(2026, 1, 5, 9))).toBe('2026-01-05 09:00')
})
it('快捷范围按滑动窗口折算起点', () => {
const end = at(2026, 9, 20)
expect(resolveGroupStatsRangeStart('7d', end)).toBe(end - 7 * 24 * 60 * 60 * 1000)
expect(resolveGroupStatsRangeStart('30d', end)).toBe(end - 30 * 24 * 60 * 60 * 1000)
expect(resolveGroupStatsRangeStart('90d', end)).toBe(end - 90 * 24 * 60 * 60 * 1000)
})
it('「全部」不放下界(startTime = 0)', () => {
expect(resolveGroupStatsRangeStart('all', at(2026, 9, 20))).toBe(0)
})
it('选「全部」时区间文案用本机第一条消息的真实日期,而不是 1970-01-01', () => {
const text = formatSilentMemberStats(
base({
startTime: 0,
firstMessageTime: at(2020, 2, 2),
endTime: at(2026, 9, 20),
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }]
})
)
expect(text).toContain('2020-02-02 至 2026-09-20')
expect(text).not.toContain('1970')
expect(text).not.toContain('全部历史')
})
it('选「全部」但取不到第一条消息时,退化成「全部历史」且不假装从 1970 开始', () => {
const text = formatSilentMemberStats(
base({
startTime: 0,
firstMessageTime: null,
endTime: at(2026, 9, 20),
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }]
})
)
expect(text).toContain('全部历史')
expect(text).not.toContain('1970')
})
})
+297
View File
@@ -0,0 +1,297 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* 群员统计服务的差集 / freshness 行为。
*
* 依赖全部 mock:这一层要证明的是**编排规则**(谁进活跃榜、谁算未发言、
* 什么情况下结论不完整),不是 SQL 正确性 —— 后者由
* `knowledge-store-member-stats.test.ts` 用真实 SQLite 覆盖。
*/
const chatMock = vi.hoisted(() => ({
resolveGroupRoomId: vi.fn(),
getGroupMemberIdsAsync: vi.fn(),
getGroupMemberNamesAsync: vi.fn()
}))
vi.mock('../../src/main/services/chat-service', () => chatMock)
import { GroupStatsService } from '../../src/main/services/group-stats-service'
import {
GROUP_STATS_LIMITATION,
GROUP_STATS_STALE_LIMITATION
} from '../../src/shared/group-stats'
import type { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
import type { KnowledgeMemberStatsResult } from '../../src/shared/knowledge'
const USER_MD5 = 'a'.repeat(32)
const ROOM_ID = 'room@chatroom'
const NOW = 1_700_000_000_000
const DAY = 24 * 60 * 60 * 1000
type Member = {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
const member = (wxid: string, nickname: string): Member => ({
wxid,
nickname,
groupNickname: '',
wechatNickname: '',
remark: '',
avatar: ''
})
const statsResult = (
overrides: Partial<KnowledgeMemberStatsResult> = {}
): KnowledgeMemberStatsResult => ({
conversationId: USER_MD5,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: NOW - 30 * DAY,
indexLatestAt: NOW,
...overrides
})
function makeKnowledge(options: {
result?: KnowledgeMemberStatsResult | null
sourceLatestAt?: number | null
retryResult?: KnowledgeMemberStatsResult | null
} = {}): KnowledgeSearchService {
const first = options.result === undefined ? statsResult() : options.result
const retry = options.retryResult === undefined ? first : options.retryResult
let call = 0
return {
memberStats: vi.fn(async () => {
call += 1
return {
result: call === 1 ? first : retry,
sourceLatestAt: options.sourceLatestAt === undefined ? NOW : options.sourceLatestAt
}
}),
requestCatchUp: vi.fn(() => ({ triggered: true, inProgress: true })),
waitForIndexingComplete: vi.fn(async () => true)
} as unknown as KnowledgeSearchService
}
const run = (
knowledge: KnowledgeSearchService,
startTime = NOW - 30 * DAY,
endTime = NOW
) => new GroupStatsService(knowledge).getMemberStats({ userMd5: USER_MD5, startTime, endTime })
beforeEach(() => {
chatMock.resolveGroupRoomId.mockReset().mockReturnValue(ROOM_ID)
chatMock.getGroupMemberIdsAsync.mockReset().mockResolvedValue({
roomId: ROOM_ID,
memberIds: []
})
chatMock.getGroupMemberNamesAsync.mockReset().mockResolvedValue([])
})
describe('GroupStatsService · 差集', () => {
it('5 名成员里 3 人有发言、2 人未发言', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', '阿一'),
member('wxid_b', '阿二'),
member('wxid_c', '阿三'),
member('wxid_d', '阿四'),
member('wxid_e', '阿五')
])
const knowledge = makeKnowledge({
result: statsResult({
totalMessages: 16,
senders: [
{ senderId: 'wxid_a', messageCount: 10, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_b', messageCount: 5, lastMessageTime: NOW - 2000 },
{ senderId: 'wxid_c', messageCount: 1, lastMessageTime: NOW - 3000 }
]
})
})
const result = await run(knowledge)
expect(result.memberCount).toBe(5)
expect(result.activeMemberCount).toBe(3)
expect(result.silentMemberCount).toBe(2)
expect(result.activeMembers.map((item) => item.senderId)).toEqual([
'wxid_a',
'wxid_b',
'wxid_c'
])
expect(result.silentMembers.map((item) => item.senderId).sort()).toEqual([
'wxid_d',
'wxid_e'
])
})
it('历史消息的 sender 已退群时,不进当前成员活跃榜,并留下 limitation', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '阿一')])
const knowledge = makeKnowledge({
result: statsResult({
senders: [
{ senderId: 'wxid_a', messageCount: 5, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_left', messageCount: 3, lastMessageTime: NOW - 2000 }
]
})
})
const result = await run(knowledge)
expect(result.activeMembers.map((item) => item.senderId)).toEqual(['wxid_a'])
expect(result.activeMemberCount).toBe(1)
expect(result.limitations.some((text) => text.includes('已不在当前群成员名单中'))).toBe(true)
})
it('成员改昵称不影响统计:join 用 wxid,展示用当前昵称', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '改过的名字')])
const knowledge = makeKnowledge({
result: statsResult({
senders: [{ senderId: 'wxid_a', messageCount: 7, lastMessageTime: NOW - 500 }]
})
})
const result = await run(knowledge)
expect(result.activeMembers).toHaveLength(1)
expect(result.activeMembers[0].displayName).toBe('改过的名字')
expect(result.activeMembers[0].senderId).toBe('wxid_a')
expect(result.silentMemberCount).toBe(0)
})
it('sender 缺失的消息只计入诊断,不硬映射给任何成员', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', '阿一'),
member('wxid_b', '阿二')
])
const knowledge = makeKnowledge({
result: statsResult({
senders: [{ senderId: 'wxid_a', messageCount: 4, lastMessageTime: NOW - 1000 }],
unattributedMessages: 9
})
})
const result = await run(knowledge)
expect(result.unattributedMessages).toBe(9)
// 未归属消息既不能把人抬进活跃榜,也不能把人踢进未发言名单之外。
expect(result.activeMemberCount).toBe(1)
expect(result.silentMemberCount).toBe(1)
expect(result.activeMembers[0].senderId).toBe('wxid_a')
expect(result.silentMembers[0].senderId).toBe('wxid_b')
})
it('活跃榜按条数降序,同数按最后发言时间降序', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', 'A'),
member('wxid_b', 'B'),
member('wxid_c', 'C')
])
const knowledge = makeKnowledge({
result: statsResult({
senders: [
{ senderId: 'wxid_b', messageCount: 5, lastMessageTime: NOW - 9000 },
{ senderId: 'wxid_c', messageCount: 5, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_a', messageCount: 9, lastMessageTime: NOW - 5000 }
]
})
})
const result = await run(knowledge)
expect(result.activeMembers.map((item) => item.senderId)).toEqual([
'wxid_a',
'wxid_c',
'wxid_b'
])
})
})
describe('GroupStatsService · freshness', () => {
it('索引追平时 complete = true', async () => {
const result = await run(makeKnowledge({ sourceLatestAt: NOW }))
expect(result.freshness).toBe('fresh')
expect(result.complete).toBe(true)
expect(result.limitations).not.toContain(GROUP_STATS_STALE_LIMITATION)
})
it('索引落后时 complete = false,绝不输出完整结论', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: NOW - 3 * DAY }),
sourceLatestAt: NOW,
// 追赶后仍然落后 —— 必须保持不完整。
retryResult: statsResult({ indexLatestAt: NOW - 3 * DAY })
})
const result = await run(knowledge)
expect(result.freshness).toBe('stale')
expect(result.complete).toBe(false)
expect(result.limitations).toContain(GROUP_STATS_STALE_LIMITATION)
})
it('落后时先触发有界追赶,追平后回到完整结论', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: NOW - 3 * DAY }),
sourceLatestAt: NOW,
retryResult: statsResult({ indexLatestAt: NOW })
})
const result = await run(knowledge)
expect(knowledge.requestCatchUp).toHaveBeenCalled()
expect(knowledge.waitForIndexingComplete).toHaveBeenCalled()
expect(result.freshness).toBe('fresh')
expect(result.complete).toBe(true)
})
it('口径缺失(indexLatestAt 为 null)按 unknown 处理,不得当作完整', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: null }),
sourceLatestAt: null
})
const result = await run(knowledge)
expect(result.freshness).toBe('unknown')
expect(result.complete).toBe(false)
expect(result.limitations).toContain(GROUP_STATS_STALE_LIMITATION)
})
it('结果里始终带边界说明', async () => {
const result = await run(makeKnowledge())
expect(result.limitations).toContain(GROUP_STATS_LIMITATION)
})
})
describe('GroupStatsService · 异常输入', () => {
it('不是群会话时返回空结果,不编造统计', async () => {
chatMock.resolveGroupRoomId.mockReturnValue(null)
const result = await run(makeKnowledge())
expect(result.memberCount).toBe(0)
expect(result.activeMemberCount).toBe(0)
expect(result.silentMemberCount).toBe(0)
expect(result.complete).toBe(false)
expect(result.freshness).toBe('unknown')
expect(chatMock.getGroupMemberIdsAsync).not.toHaveBeenCalled()
})
it('Knowledge 返回 null(派生库不可用)时不崩溃,且不完整', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '阿一')])
const result = await run(makeKnowledge({ result: null, sourceLatestAt: null }))
expect(result.memberCount).toBe(1)
expect(result.silentMemberCount).toBe(1)
expect(result.complete).toBe(false)
})
})
@@ -0,0 +1,185 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
import {
DEFAULT_KNOWLEDGE_CHUNKER,
DEFAULT_KNOWLEDGE_FTS_CONFIG,
type KnowledgeIndexRequest,
type KnowledgeMessageKind,
type KnowledgeSourceMessage
} from '../../src/shared/knowledge'
/**
* 「按发送者聚合」SQL 的正确性。
*
* 这一层必须用**真实 SQLite**:系统消息排除、`sender_id IS NULL` 的归属、
* 时间窗闭区间,三条规则都写在 SQL 里 —— mock 掉数据库就等于什么都没验证。
*/
const ACCOUNT = 'acct-stats-test'
const CONV = 'c'.repeat(32)
const OTHER_CONV = 'd'.repeat(32)
const DAY = 24 * 60 * 60 * 1000
const BASE = new Date(2026, 5, 15, 12).getTime()
let root = ''
let store: KnowledgeStore | null = null
const message = (
id: string,
senderId: string | undefined,
kind: KnowledgeMessageKind,
createTime: number,
conversationId = CONV
): KnowledgeSourceMessage => ({
accountId: ACCOUNT,
conversationId,
messageId: id,
createTime,
senderId,
kind,
// 必须带可索引文本,否则会被 normalizer 的可索引性判定挡在索引之外,
// 测试就会因为「消息压根没入库」而假通过。
text: `payload-${id}`
})
async function seed(messages: KnowledgeSourceMessage[]): Promise<void> {
store = new KnowledgeStore(root, ACCOUNT, DEFAULT_KNOWLEDGE_FTS_CONFIG)
const conversations = Array.from(new Set(messages.map((item) => item.conversationId))).map(
(conversationId) => ({
conversationId,
completeSnapshot: true,
messages: messages.filter((item) => item.conversationId === conversationId),
sourceHighWaterTime: BASE + 10 * DAY
})
)
const request: KnowledgeIndexRequest = {
accountId: ACCOUNT,
databaseRoot: root,
conversations,
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
}
await store.index(request, undefined, () => undefined)
}
const query = (startTime: number, endTime: number, conversationId = CONV) =>
store!.memberStats({ conversationId, startTime, endTime })
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'tm-knowledge-stats-'))
})
afterEach(() => {
try {
store?.close()
} finally {
store = null
if (root) rmSync(root, { recursive: true, force: true })
}
})
describe('KnowledgeStore.memberStats', () => {
it('按 sender 聚合出条数与最后发言时间,并按条数降序', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', 'wxid_a', 'text', BASE + 5000),
message('m3', 'wxid_a', 'text', BASE + 3000),
message('m4', 'wxid_b', 'text', BASE + 2000)
])
const result = query(BASE, BASE + DAY)
expect(result.totalMessages).toBe(4)
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 3, lastMessageTime: BASE + 5000 },
{ senderId: 'wxid_b', messageCount: 1, lastMessageTime: BASE + 2000 }
])
})
it('系统消息不参与任何成员的发言统计', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', 'wxid_a', 'system', BASE + 2000),
message('m3', undefined, 'system', BASE + 3000)
])
const result = query(BASE, BASE + DAY)
// 只有那条 text 算发言;系统消息既不计入总数,也不进 senders。
expect(result.totalMessages).toBe(1)
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 1, lastMessageTime: BASE + 1000 }
])
expect(result.excludedSystemMessages).toBe(2)
expect(result.unattributedMessages).toBe(0)
})
it('sender_id 缺失的消息只计入诊断,不归属任何成员', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', undefined, 'text', BASE + 2000),
message('m3', undefined, 'other', BASE + 3000)
])
const result = query(BASE, BASE + DAY)
expect(result.unattributedMessages).toBe(2)
expect(result.totalMessages).toBe(3)
// 未归属消息**不得**变成一个空 sender 的分组。
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 1, lastMessageTime: BASE + 1000 }
])
})
it('时间窗口是闭区间,窗口外的消息不参与', async () => {
await seed([
message('before', 'wxid_a', 'text', BASE - 1),
message('start', 'wxid_a', 'text', BASE),
message('inside', 'wxid_a', 'text', BASE + 5000),
message('end', 'wxid_a', 'text', BASE + DAY),
message('after', 'wxid_a', 'text', BASE + DAY + 1)
])
const result = query(BASE, BASE + DAY)
// 闭区间:两头都算在内。
expect(result.totalMessages).toBe(3)
expect(result.senders[0].messageCount).toBe(3)
expect(result.senders[0].lastMessageTime).toBe(BASE + DAY)
})
it('只统计指定会话,不串到其它群', async () => {
await seed([
message('mine', 'wxid_a', 'text', BASE + 1000),
message('theirs', 'wxid_z', 'text', BASE + 1000, OTHER_CONV)
])
const result = query(BASE, BASE + DAY, CONV)
expect(result.totalMessages).toBe(1)
expect(result.senders.map((item) => item.senderId)).toEqual(['wxid_a'])
})
it('窗口内没有消息时返回空聚合而不是报错', async () => {
await seed([message('m1', 'wxid_a', 'text', BASE + 1000)])
const result = query(BASE + 100 * DAY, BASE + 101 * DAY)
expect(result.totalMessages).toBe(0)
expect(result.senders).toEqual([])
expect(result.unattributedMessages).toBe(0)
expect(result.indexLatestAt).not.toBeNull()
})
it('conversationId 为空时安全返回空结果', async () => {
await seed([message('m1', 'wxid_a', 'text', BASE + 1000)])
const result = store!.memberStats({ conversationId: '', startTime: BASE, endTime: BASE + DAY })
expect(result.totalMessages).toBe(0)
expect(result.senders).toEqual([])
})
})