From f5633a29cb1d1a6323f67fc6d29276bd4db347b2 Mon Sep 17 00:00:00 2001 From: Wxw-Gu <84499436+Wxw-Gu@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:46:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=BE=A4=E5=91=98?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E4=B8=8E=E9=80=80=E7=BE=A4=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=8A=8A=E6=9C=AC=E5=9C=B0=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=E9=9B=86=E4=B8=AD=E5=88=B0=E8=AE=BE=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送 - 退群事件永久保存,并以本地推断条目并入档案消息流 - 设置新增本地索引页,集中管理聊天记录索引与图片文字索引 --- src/main/index.ts | 46 +++ .../knowledge/knowledge-search-service.ts | 32 ++ src/main/knowledge/knowledge-service.ts | 9 + src/main/knowledge/knowledge-store.ts | 88 +++++ src/main/knowledge/knowledge-worker-host.ts | 8 + src/main/knowledge/knowledge-worker.ts | 38 ++ src/main/services/chat-service.ts | 14 + .../services/group-exit-monitor-service.ts | 127 +++++- src/main/services/group-stats-service.ts | 208 ++++++++++ src/preload/index.d.ts | 10 +- src/preload/index.ts | 12 +- src/renderer/src/App.tsx | 12 + src/renderer/src/components/ChatWindow.tsx | 22 +- .../src/components/chat/ChatHeader.tsx | 30 +- .../components/chat/GroupExitEventBubble.tsx | 27 ++ .../src/components/chat/MessageGroup.tsx | 18 +- .../group-stats/GroupMemberStatsDialog.tsx | 365 ++++++++++++++++++ .../search/ImageTextIndexSummary.tsx | 342 ++++++++++++++++ .../components/search/KnowledgeIndexCard.tsx | 142 +++++++ .../features/settings/SettingsWorkspace.tsx | 3 + .../settings/components/IndexStatusBadge.tsx | 26 ++ .../settings/model/settingsNavigation.ts | 4 + .../src/features/settings/model/types.ts | 1 + .../settings/pages/LocalIndexPage.tsx | 36 ++ src/renderer/src/hooks/useGroupExitEvents.ts | 37 ++ src/renderer/src/styles/group-exit-event.scss | 44 +++ .../src/styles/group-member-stats.scss | 246 ++++++++++++ src/renderer/src/styles/index.scss | 3 + src/renderer/src/styles/local-index.scss | 276 +++++++++++++ src/renderer/src/styles/search.scss | 12 +- src/shared/group-exit-event-message.ts | 80 ++++ src/shared/group-exit-monitor.ts | 3 + src/shared/group-stats.ts | 259 +++++++++++++ src/shared/image-text-index.ts | 45 +++ src/shared/knowledge.ts | 69 +++- .../chat-header-group-stats.test.tsx | 65 ++++ tests/component/chat-menus.test.tsx | 2 +- .../group-member-stats-dialog.test.tsx | 235 +++++++++++ tests/component/local-index-page.test.tsx | 195 ++++++++++ tests/unit/group-exit-event-message.test.ts | 120 ++++++ tests/unit/group-exit-monitor-service.test.ts | 8 +- tests/unit/group-stats-formatter.test.ts | 172 +++++++++ tests/unit/group-stats-service.test.ts | 297 ++++++++++++++ .../unit/knowledge-store-member-stats.test.ts | 185 +++++++++ 44 files changed, 3949 insertions(+), 24 deletions(-) create mode 100644 src/main/services/group-stats-service.ts create mode 100644 src/renderer/src/components/chat/GroupExitEventBubble.tsx create mode 100644 src/renderer/src/components/group-stats/GroupMemberStatsDialog.tsx create mode 100644 src/renderer/src/components/search/ImageTextIndexSummary.tsx create mode 100644 src/renderer/src/components/search/KnowledgeIndexCard.tsx create mode 100644 src/renderer/src/features/settings/components/IndexStatusBadge.tsx create mode 100644 src/renderer/src/features/settings/pages/LocalIndexPage.tsx create mode 100644 src/renderer/src/hooks/useGroupExitEvents.ts create mode 100644 src/renderer/src/styles/group-exit-event.scss create mode 100644 src/renderer/src/styles/group-member-stats.scss create mode 100644 src/renderer/src/styles/local-index.scss create mode 100644 src/shared/group-exit-event-message.ts create mode 100644 src/shared/group-stats.ts create mode 100644 tests/component/chat-header-group-stats.test.tsx create mode 100644 tests/component/group-member-stats-dialog.test.tsx create mode 100644 tests/component/local-index-page.test.tsx create mode 100644 tests/unit/group-exit-event-message.test.ts create mode 100644 tests/unit/group-stats-formatter.test.ts create mode 100644 tests/unit/group-stats-service.test.ts create mode 100644 tests/unit/knowledge-store-member-stats.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index a493a53..394a601 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/main/knowledge/knowledge-search-service.ts b/src/main/knowledge/knowledge-search-service.ts index 623d0f6..91132dd 100644 --- a/src/main/knowledge/knowledge-search-service.ts +++ b/src/main/knowledge/knowledge-search-service.ts @@ -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` 组成。 */ diff --git a/src/main/knowledge/knowledge-service.ts b/src/main/knowledge/knowledge-service.ts index d4c059a..082dfee 100644 --- a/src/main/knowledge/knowledge-service.ts +++ b/src/main/knowledge/knowledge-service.ts @@ -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 + ): Promise { + return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot }) + } + /** 只中止正在跑的索引任务;查询请求不受影响。 */ cancelIndex(): Promise { return this.worker.cancelActiveIndex() diff --git a/src/main/knowledge/knowledge-store.ts b/src/main/knowledge/knowledge-store.ts index 8ff6c96..67972bb 100644 --- a/src/main/knowledge/knowledge-store.ts +++ b/src/main/knowledge/knowledge-store.ts @@ -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)——「索引更新到哪」的权威口径。 * diff --git a/src/main/knowledge/knowledge-worker-host.ts b/src/main/knowledge/knowledge-worker-host.ts index c84740a..8e319fd 100644 --- a/src/main/knowledge/knowledge-worker-host.ts +++ b/src/main/knowledge/knowledge-worker-host.ts @@ -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 } | { removed: true } type PendingRequest = { @@ -76,6 +79,11 @@ export class KnowledgeWorkerHost { ) } + /** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */ + memberStats(payload: KnowledgeMemberStatsRequest): Promise { + return this.request('memberStats', payload) as Promise + } + /** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */ async cancelActiveIndex(): Promise { const target = this.activeIndexRequestId diff --git a/src/main/knowledge/knowledge-worker.ts b/src/main/knowledge/knowledge-worker.ts index b5c6ce1..c42a765 100644 --- a/src/main/knowledge/knowledge-worker.ts +++ b/src/main/knowledge/knowledge-worker.ts @@ -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 { + 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 { 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 diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 95fb5dc..c15cc15 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -1036,6 +1036,20 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise 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 } diff --git a/src/main/services/group-stats-service.ts b/src/main/services/group-stats-service.ts new file mode 100644 index 0000000..6745ec9 --- /dev/null +++ b/src/main/services/group-stats-service.ts @@ -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 { + 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() + for (const row of result?.senders ?? []) { + if (row.senderId) statBySender.set(row.senderId, row) + } + + const knownSenderIds = new Set() + 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' +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 8d6c545..9a69925 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 getGroupExitMonitorState: () => Promise + listGroupExitMonitorEvents: (query?: { + roomId?: string + sinceMs?: number + untilMs?: number + limit?: number + }) => Promise setGroupExitMonitorEnabled: (enabled: boolean) => Promise setGroupExitMonitorGroups: ( roomIds: string[], diff --git a/src/preload/index.ts b/src/preload/index.ts index f4f3c92..cbf4627 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds), getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5), + getGroupMemberStats: (request: GroupMemberStatsQuery): Promise => + ipcRenderer.invoke('group-stats:getMemberStats', request), getGroupExitMonitorState: (): Promise => ipcRenderer.invoke('group-exit-monitor:getState'), + listGroupExitMonitorEvents: (query?: { + roomId?: string + sinceMs?: number + untilMs?: number + limit?: number + }): Promise => + ipcRenderer.invoke('group-exit-monitor:listEvents', query), setGroupExitMonitorEnabled: (enabled: boolean): Promise => ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled), setGroupExitMonitorGroups: ( diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 974a9c4..74f7d34 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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} diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index b3bd4c7..caa6898 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -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 = ({ onCreateGroupReport, onOpenTextToSpeechSettings, onOpenPersonalWechatSettings, + onOpenLocalIndexSettings, isAiLoading = false, jumpToTime, jumpToMessageId @@ -122,8 +127,20 @@ const ChatWindow: React.FC = ({ } } + /** + * 退群推断事件并入档案消息流。 + * + * **先合并再过滤**:反过来的话搜索词就命中不了事件,而「谁退群了」 + * 恰恰是最需要能被搜到的内容之一。 + */ + 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 = ({ const contentMatch = !contentFilter || msg.content.includes(contentFilter) return typeMatch && contentMatch }) - }, [messages, contentFilter]) + }, [allMessages, contentFilter]) const handleOpenPersonalWechatSend = useCallback(async (): Promise => { try { @@ -168,6 +185,7 @@ const ChatWindow: React.FC = ({ onRefreshData={onRefreshData} onTestSend={() => void handleOpenPersonalWechatSend()} onOpenAiSettings={onCreateGroupReport || (() => undefined)} + onOpenLocalIndexSettings={onOpenLocalIndexSettings} /> 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({ - + onRefreshData?.()}>刷新数据 + {/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */} + {isGroupChat ? ( + setStatsOpen(true)}>群发言统计 + ) : null} {supportsPersonalWechatSend ? ( @@ -148,6 +164,14 @@ export function ChatHeader({ {isAiLoading ? '生成中' : '生成 AI 日报'} + {isGroupChat ? ( + + ) : null} ) } diff --git a/src/renderer/src/components/chat/GroupExitEventBubble.tsx b/src/renderer/src/components/chat/GroupExitEventBubble.tsx new file mode 100644 index 0000000..fea0afc --- /dev/null +++ b/src/renderer/src/components/chat/GroupExitEventBubble.tsx @@ -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 ( +
+
+ 本地推断 + {message.content} +
+
+ ) +} diff --git a/src/renderer/src/components/chat/MessageGroup.tsx b/src/renderer/src/components/chat/MessageGroup.tsx index e3927db..5ae777f 100644 --- a/src/renderer/src/components/chat/MessageGroup.tsx +++ b/src/renderer/src/components/chat/MessageGroup.tsx @@ -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 &&
{group.timeLabel}
} - {group.messages.map((message) => ( -
-
{message.content}
-
- ))} + {group.messages.map((message) => + // 退群推断事件与真实微信系统消息都落在 isSystem 分支, + // 但必须分开渲染:前者是本地推断,后者是微信原话。 + isGroupExitEventMessage(message) ? ( + + ) : ( +
+
{message.content}
+
+ ) + )} ) } diff --git a/src/renderer/src/components/group-stats/GroupMemberStatsDialog.tsx b/src/renderer/src/components/group-stats/GroupMemberStatsDialog.tsx new file mode 100644 index 0000000..3c75a71 --- /dev/null +++ b/src/renderer/src/components/group-stats/GroupMemberStatsDialog.tsx @@ -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('30d') + const [customStart, setCustomStart] = useState('') + const [customEnd, setCustomEnd] = useState('') + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [copied, setCopied] = useState('none') + const [notice, setNotice] = useState(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 ( + + + + 群发言统计 · {groupTitle} + {groupRemark ? ( +

群备注:{groupRemark}

+ ) : null} +
+ +
+
+ {GROUP_STATS_RANGE_OPTIONS.map((option) => ( + + ))} + {rangeKey === 'custom' ? ( +
+ setCustomStart(event.target.value)} + /> + 至 + setCustomEnd(event.target.value)} + /> +
+ ) : null} +
+ + {!rangeReady ? ( +

请选择完整的开始与结束日期。

+ ) : null} + {stale ? ( +
+

+ 本地索引尚未完全同步,以下结果可能不完整。 +

+ {onOpenLocalIndexSettings ? ( + + ) : null} +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {notice ?

{notice}

: null} + + {loading ?

统计中…

: null} + + {result && !loading ? ( + <> +
+
+
当前群成员
+
{result.memberCount}
+
+
+
发过言
+
{result.activeMemberCount}
+
+
+
没发言
+
{result.silentMemberCount}
+
+
+ +

+ 统计区间: + {rangeStart + ? `${formatStatsDateTime(rangeStart)} ~ ${formatStatsDateTime(result.endTime)}` + : `全部历史 ~ ${formatStatsDateTime(result.endTime)}`} + {result.startTime <= 0 && rangeStart ? '(本机该群第一条消息起)' : null} +

+ + {/* 两个名单用 tab 分开:一次只渲染一个列表,滚动压力减半, + 而且「复制/发送」跟它作用的内容处在同一个 tab 里,不会看错对象。 */} + + + + 发言排行({result.activeMemberCount}) + + + 未发言统计({result.silentMemberCount}) + + + + +
+
+

+ 发言成员排行 + + (共 {result.activeMemberCount} 人) + +

+
+ +
+
+ {result.activeMembers.length === 0 ? ( +

该区间内没有人发言。

+ ) : ( +
    + {result.activeMembers.map((member, index) => ( +
  1. + + {index + 1}. + {formatMemberLineName(member)} + + + {member.messageCount} 条 ·{' '} + {formatStatsDateTime(member.lastMessageTime)} + +
  2. + ))} +
+ )} +
+
+ + +
+
+

+ 未发言成员 + + (共 {result.silentMemberCount} 人) + +

+
+ + {canSendText ? ( + + ) : null} +
+
+ {result.silentMembers.length === 0 ? ( +

全部成员在该区间内都发过言。

+ ) : ( +
    + {result.silentMembers.map((member, index) => ( +
  1. + + {index + 1}. + {formatMemberLineName(member)} + +
  2. + ))} +
+ )} +
+
+
+ +

+ {result.unattributedMessages > 0 + ? `另有 ${result.unattributedMessages} 条消息无法归属到具体成员(未计入任何人)。` + : null} + {result.excludedSystemMessages > 0 + ? `已排除 ${result.excludedSystemMessages} 条系统消息。` + : null} + {result.limitations.join(' ')} +

+ + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/search/ImageTextIndexSummary.tsx b/src/renderer/src/components/search/ImageTextIndexSummary.tsx new file mode 100644 index 0000000..6c096f5 --- /dev/null +++ b/src/renderer/src/components/search/ImageTextIndexSummary.tsx @@ -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(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 => { + const fresh = await refreshCount(sinceMs) + setConfirmCount(fresh?.totalImageMessages ?? detectedImages) + setConfirming(true) + } + + return ( +
+
+
+

图片文字索引

+

本地 OCR 提取图片文字,让截图、报价图也能被搜到

+
+ +
+ + {/* 未建立:先给一个可核对的图片量,再让用户决定跑不跑 */} + {!established && !running ? ( + <> +
+
+ + {counting + ? '统计中…' + : nothingCounted + ? '无法统计' + : detectedImages === null + ? '—' + : detectedImages.toLocaleString()} + + 检测到图片消息 +
+
+
+ 处理范围 + +
+ {nothingCounted ? ( +

+ {`无法统计本账号的图片消息(${count?.error || '读取消息表失败'})。这不代表没有图片,可以重新统计再试一次。`} +

+ ) : null} + + ) : null} + + {/* 运行中 / 暂停:原地切进度,按钮按真实状态显隐,不允许重复点 */} + {(running || paused) && progress ? ( +
+
+ 识别图片文字 + + {progress.processed.toLocaleString()} / {progress.totalImageMessages.toLocaleString()} + +
+ {/* 复用项目 `Progress`(更新页 / 导出任务中心同一组件),不另起一套轨道样式。 */} + +

+ {phaseLabel ?? '正在识别…'} + {` · 识别出文字 ${progress.indexed.toLocaleString()} · 无文字 ${progress.empty.toLocaleString()}`} +

+
+ ) : null} + + {/* 已建立:四个可核对的数字 */} + {established && !running && !paused && coverage ? ( + <> +
+
+ + {coverage.indexed.toLocaleString()} + + 已识别文字 +
+
+ {coverage.empty.toLocaleString()} + 无文字图片 +
+
+ + {coverage.missing.toLocaleString()} + + 图片已清理 +
+
+ {coverage.failed.toLocaleString()} + 识别失败 +
+
+ + ) : null} + + {progress?.state === 'error' ? ( +

+ {progress.lastError || '图片文字索引建立失败,可以稍后重试。'} +

+ ) : null} + {coverage?.systemicFailure === true ? ( +

+ {`${(coverage.failed ?? 0).toLocaleString()} 条处理失败,成功识别 0 条 —— 当前无法搜索图片中的文字。`} +

+ ) : null} + {paused ? ( +

已暂停。已识别的结果都保留了,点「继续」会从断点接着做。

+ ) : null} + {!dbReady ? ( +

请先连接微信数据,然后再建立图片文字索引。

+ ) : null} + + {/* footer:左侧覆盖量说明,右侧操作。按钮不再悬浮在 stats 右下角。 */} +
+ {established && !running && !paused && coverage ? ( +

{describeImageTextCoverage(coverage)}

+ ) : null} +
+ {running ? ( + <> + + + + ) : null} + + {/* 中断过就一定要有「继续」:checkpoint 是保留的,续做不会从头开始。 */} + {interrupted ? ( + + ) : null} + + {!running && !interrupted ? ( + <> + {/* 修复类操作各自只在很窄的场景有用,收进「更多」; + 平铺出来会让用户面对四个都带「索引」字样的按钮。 */} + {established || failureCount > 0 ? ( + + + + + + {established ? ( + void repair()}> + 图片内容搜不到?修复搜索索引 + + ) : null} + {failureCount > 0 ? ( + void resetFailures()} + > + {`重试识别失败的图片(${failureCount.toLocaleString()} 张)`} + + ) : null} + + + ) : null} + + + ) : null} +
+
+ + {/* 确认弹窗复用 `confirming` 状态;保持与侧栏卡片一致的二次确认语义。 */} + {confirming ? ( +
+

+ 当前账号检测到约{' '} + + {confirmCount === null ? '未知数量' : confirmCount.toLocaleString()} 条图片消息 + + 。 +

+

+ 识别仅在本机进行,原始图片不会因为本地识别而自动上传;可能需要较长时间,可以暂停稍后继续。 +

+
+ + +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/search/KnowledgeIndexCard.tsx b/src/renderer/src/components/search/KnowledgeIndexCard.tsx new file mode 100644 index 0000000..4d9a263 --- /dev/null +++ b/src/renderer/src/components/search/KnowledgeIndexCard.tsx @@ -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 ( +
+
+
+

聊天记录索引

+

用于搜索、问问微信和群聊统计

+
+ +
+ + {usable ? ( +
+
+ {indexed.toLocaleString()} + 已收录消息 +
+
+ + {formatBytes(knowledgeStatus?.databaseBytes ?? 0)} + + 占用空间 +
+
+ + {knowledgeStatus?.indexLatestAt + ? formatIndexDate(knowledgeStatus.indexLatestAt) + : '—'} + + 最后更新 +
+
+ ) : ( +

+ 还没有建立索引。建立后「问问微信」才能跨会话检索历史消息。 +

+ )} + + {failed && knowledgeStatus?.lastError ? ( +

{knowledgeStatus.lastError}

+ ) : null} + + {!dbReady ? ( +

请先连接微信数据,然后再建立聊天记录索引。

+ ) : null} + + {/* footer:左侧状态说明,右侧操作。按钮不再悬浮在 stats 右下角。 */} +
+

+ {knowledgeSyncing && knowledgeStatus?.pass + ? formatKnowledgeProcessed(knowledgeStatus) + : failed + ? '上次同步没有完成,可以重新同步。' + : stale + ? '有较新的聊天记录尚未入索引,点「同步最新记录」追平。' + : '索引数据仅保存在本机,可随时重新建立。'} +

+
+ {knowledgeSyncing && cancellable ? ( + + ) : null} + {/* 用项目 `Button` 而不是自写 ` +
+
+
+ ) +} diff --git a/src/renderer/src/features/settings/SettingsWorkspace.tsx b/src/renderer/src/features/settings/SettingsWorkspace.tsx index 9e84a96..f293ac3 100644 --- a/src/renderer/src/features/settings/SettingsWorkspace.tsx +++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx @@ -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 case 'cache-cleanup': return + case 'local-index': + return case 'storage-export': return ( + + {label} + + ) +} diff --git a/src/renderer/src/features/settings/model/settingsNavigation.ts b/src/renderer/src/features/settings/model/settingsNavigation.ts index 65e946a..491b25b 100644 --- a/src/renderer/src/features/settings/model/settingsNavigation.ts +++ b/src/renderer/src/features/settings/model/settingsNavigation.ts @@ -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: '缓存与清理' } ] }, diff --git a/src/renderer/src/features/settings/model/types.ts b/src/renderer/src/features/settings/model/types.ts index 1b4af93..43248ca 100644 --- a/src/renderer/src/features/settings/model/types.ts +++ b/src/renderer/src/features/settings/model/types.ts @@ -12,6 +12,7 @@ export type SettingsCategoryId = | 'local-api' | 'storage-export' | 'cache-cleanup' + | 'local-index' | 'appearance' | 'advanced' | 'about' diff --git a/src/renderer/src/features/settings/pages/LocalIndexPage.tsx b/src/renderer/src/features/settings/pages/LocalIndexPage.tsx new file mode 100644 index 0000000..2bb03d0 --- /dev/null +++ b/src/renderer/src/features/settings/pages/LocalIndexPage.tsx @@ -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 ( +
+
+

本地索引

+

+ TraceMemo 的搜索、问问微信和图片文字识别依赖本地索引。 + 所有索引数据仅保存在本机,可随时从微信原始数据重新建立。 +

+
+
+ + +
+
+ ) +} diff --git a/src/renderer/src/hooks/useGroupExitEvents.ts b/src/renderer/src/hooks/useGroupExitEvents.ts new file mode 100644 index 0000000..c6878c6 --- /dev/null +++ b/src/renderer/src/hooks/useGroupExitEvents.ts @@ -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([]) + 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 +} diff --git a/src/renderer/src/styles/group-exit-event.scss b/src/renderer/src/styles/group-exit-event.scss new file mode 100644 index 0000000..98ff5e2 --- /dev/null +++ b/src/renderer/src/styles/group-exit-event.scss @@ -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; +} diff --git a/src/renderer/src/styles/group-member-stats.scss b/src/renderer/src/styles/group-member-stats.scss new file mode 100644 index 0000000..9439e90 --- /dev/null +++ b/src/renderer/src/styles/group-member-stats.scss @@ -0,0 +1,246 @@ +/** + * 群发言统计面板。 + * + * ⚠️ 这里的样式**故意不写裸 Tailwind 布局类**:`tailwind.config.cjs` 设了 + * `preflight: false`(项目要保住既有 SCSS),于是 Tailwind 不提供任何基础重置 —— + * 一旦用裸 Tailwind 搭结构,`
    ` 会自带 `list-style` 与 `padding-left`、 + * `

    /

    ` 会自带 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; +} diff --git a/src/renderer/src/styles/index.scss b/src/renderer/src/styles/index.scss index 066f3a8..0927641 100644 --- a/src/renderer/src/styles/index.scss +++ b/src/renderer/src/styles/index.scss @@ -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'; diff --git a/src/renderer/src/styles/local-index.scss b/src/renderer/src/styles/local-index.scss new file mode 100644 index 0000000..ea194e9 --- /dev/null +++ b/src/renderer/src/styles/local-index.scss @@ -0,0 +1,276 @@ +/** + * 设置 · 本地索引页。 + * + * 设计约束(来自页面目标):这是一个「本机索引管理面板」,不是两个独立开发工具卡片。 + * 层级自上而下:页面标题 → 一句话说明 → 卡片;卡片内部:标题 → 状态徽章 → 核心数字 → 操作。 + * + * ⚠️ 不用裸 Tailwind:`tailwind.config.cjs` 设了 `preflight: false`, + * 裸类拿不到浏览器默认样式重置(`

      ` 的 list-style、`

      /

      ` 的 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; +} diff --git a/src/renderer/src/styles/search.scss b/src/renderer/src/styles/search.scss index ad27065..6b2173d 100644 --- a/src/renderer/src/styles/search.scss +++ b/src/renderer/src/styles/search.scss @@ -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)); diff --git a/src/shared/group-exit-event-message.ts b/src/shared/group-exit-event-message.ts new file mode 100644 index 0000000..ce02ac0 --- /dev/null +++ b/src/shared/group-exit-event-message.ts @@ -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 +} diff --git a/src/shared/group-exit-monitor.ts b/src/shared/group-exit-monitor.ts index 343eb68..4d75d70 100644 --- a/src/shared/group-exit-monitor.ts +++ b/src/shared/group-exit-monitor.ts @@ -173,7 +173,10 @@ export function buildMemberLeftNotification( } export interface GroupExitMonitorState { + /** 最近一批事件(一次回报最多带回这么多条,避免把整部历史推过 IPC)。 */ events: GroupExitMonitorEvent[] + /** 永久保留的事件总数;`events` 只是其中最近的若干条。 */ + totalEventCount?: number enabled: boolean running: boolean nativeMonitorActive: boolean diff --git a/src/shared/group-stats.ts b/src/shared/group-stats.ts new file mode 100644 index 0000000..d4cb0ad --- /dev/null +++ b/src/shared/group-stats.ts @@ -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, + 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 +): 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') +} diff --git a/src/shared/image-text-index.ts b/src/shared/image-text-index.ts index f1ab0c7..ed67810 100644 --- a/src/shared/image-text-index.ts +++ b/src/shared/image-text-index.ts @@ -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' +} diff --git a/src/shared/knowledge.ts b/src/shared/knowledge.ts index 0008d77..9fbb95c 100644 --- a/src/shared/knowledge.ts +++ b/src/shared/knowledge.ts @@ -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 @@ -539,6 +605,7 @@ export interface KnowledgeWorkerResponse { | KnowledgeCapacityPreflight | KnowledgeSearchResult | KnowledgeRuntimeStatus + | KnowledgeMemberStatsResult | { removed: true } error?: string transport?: { diff --git a/tests/component/chat-header-group-stats.test.tsx b/tests/component/chat-header-group-stats.test.tsx new file mode 100644 index 0000000..fb2ffc5 --- /dev/null +++ b/tests/component/chat-header-group-stats.test.tsx @@ -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 }).api = { + getGroupMemberStats: vi.fn(), + getPersonalWechatSenderStatus: vi.fn().mockResolvedValue({ canSendText: false }) + } +}) + +function renderHeader(contact: Contact, isGroupChat: boolean): void { + render( + + + + ) +} + +async function openMoreMenu(): Promise { + 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() + }) +}) diff --git a/tests/component/chat-menus.test.tsx b/tests/component/chat-menus.test.tsx index 4e7b546..49680f3 100644 --- a/tests/component/chat-menus.test.tsx +++ b/tests/component/chat-menus.test.tsx @@ -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}') diff --git a/tests/component/group-member-stats-dialog.test.tsx b/tests/component/group-member-stats-dialog.test.tsx new file mode 100644 index 0000000..8ca1bbc --- /dev/null +++ b/tests/component/group-member-stats-dialog.test.tsx @@ -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 => ({ + 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 }).api = { + getGroupMemberStats, + getPersonalWechatSenderStatus, + sendPersonalWechatMessage + } +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const renderDialog = () => + render() + +/** + * Radix Tabs 只挂载 active 的内容,未发言名单要先切过去才存在。 + * + * ⚠️ 必须先等 tab 出现:数据没加载完时整个结果区(含 Tabs)都还没渲染, + * 直接 `getByRole('tab')` 会因为「DOM 里还没有 tab」而失败。 + */ +const switchToSilentTab = async (): Promise => { + 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( + + ) + + 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()) + }) +}) diff --git a/tests/component/local-index-page.test.tsx b/tests/component/local-index-page.test.tsx new file mode 100644 index 0000000..459a7bb --- /dev/null +++ b/tests/component/local-index-page.test.tsx @@ -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 = {}) => ({ + 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 = {}) => ({ + 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> + +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 }).api = api +}) + +const renderPage = (dbReady = true) => + render() + +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()) + }) +}) diff --git a/tests/unit/group-exit-event-message.test.ts b/tests/unit/group-exit-event-message.test.ts new file mode 100644 index 0000000..5a8b7df --- /dev/null +++ b/tests/unit/group-exit-event-message.test.ts @@ -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 => ({ + 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' + ]) + }) +}) diff --git a/tests/unit/group-exit-monitor-service.test.ts b/tests/unit/group-exit-monitor-service.test.ts index 6de7761..20b2931 100644 --- a/tests/unit/group-exit-monitor-service.test.ts +++ b/tests/unit/group-exit-monitor-service.test.ts @@ -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', diff --git a/tests/unit/group-stats-formatter.test.ts b/tests/unit/group-stats-formatter.test.ts new file mode 100644 index 0000000..07c6901 --- /dev/null +++ b/tests/unit/group-stats-formatter.test.ts @@ -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 => ({ + 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') + }) +}) diff --git a/tests/unit/group-stats-service.test.ts b/tests/unit/group-stats-service.test.ts new file mode 100644 index 0000000..166ea3a --- /dev/null +++ b/tests/unit/group-stats-service.test.ts @@ -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 => ({ + 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) + }) +}) diff --git a/tests/unit/knowledge-store-member-stats.test.ts b/tests/unit/knowledge-store-member-stats.test.ts new file mode 100644 index 0000000..b39e1ed --- /dev/null +++ b/tests/unit/knowledge-store-member-stats.test.ts @@ -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 { + 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([]) + }) +})