From fbd3b78b87c3bbff923a570cfaaf6ce63ba6e549 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 23 May 2026 17:57:24 +0800 Subject: [PATCH] fix: Group Chat Summary --- electron/main.ts | 9 + electron/preload.ts | 8 +- electron/services/config.ts | 6 + .../services/groupSummaryRecordService.ts | 157 ++++++++++-- electron/services/groupSummaryService.ts | 228 +++++++++++++----- shared/groupSummaryPrompt.json | 3 + src/components/JumpToDatePopover.tsx | 18 +- src/pages/ChatPage.scss | 54 +++-- src/pages/ChatPage.tsx | 198 +++++++-------- src/pages/SettingsPage.tsx | 94 +++----- src/services/config.ts | 1 + src/types/electron.d.ts | 8 +- tsconfig.json | 2 +- tsconfig.node.json | 4 +- 14 files changed, 529 insertions(+), 261 deletions(-) create mode 100644 shared/groupSummaryPrompt.json diff --git a/electron/main.ts b/electron/main.ts index a02dd35..b85a9a0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1884,6 +1884,15 @@ function registerIpcHandlers() { return groupSummaryService.triggerManual(payload) }) + ipcMain.handle('groupSummary:triggerDay', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + date: string + }) => { + return groupSummaryService.triggerDay(payload) + }) + ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => { try { if (!configService) { diff --git a/electron/preload.ts b/electron/preload.ts index efc0560..47e9809 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -624,7 +624,13 @@ contextBridge.exposeInMainWorld('electronAPI', { avatarUrl?: string startTime: number endTime: number - }) => ipcRenderer.invoke('groupSummary:triggerManual', payload) + }) => ipcRenderer.invoke('groupSummary:triggerManual', payload), + triggerDay: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + date: string + }) => ipcRenderer.invoke('groupSummary:triggerDay', payload) }, social: { diff --git a/electron/services/config.ts b/electron/services/config.ts index 73974bc..64f16a4 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -833,6 +833,12 @@ export class ConfigService { if (!sharedModel && legacyModel) { this.set('aiModelApiModel', legacyModel) } + + const groupSummaryFilterMode = String(this.store.get('aiGroupSummaryFilterMode' as any) || '').trim() + if (groupSummaryFilterMode === 'blacklist') { + this.store.set('aiGroupSummaryFilterList' as any, [] as any) + this.store.set('aiGroupSummaryFilterMode' as any, 'whitelist' as any) + } } // === 验证 === diff --git a/electron/services/groupSummaryRecordService.ts b/electron/services/groupSummaryRecordService.ts index 6e0bde8..5cadced 100644 --- a/electron/services/groupSummaryRecordService.ts +++ b/electron/services/groupSummaryRecordService.ts @@ -82,21 +82,86 @@ export interface GroupSummaryRecordListResult { error?: string } +interface GroupSummaryIndexRecord extends GroupSummaryRecordSummary { + accountScope: string + logFile?: string +} + +interface LegacyGroupSummaryRecord extends GroupSummaryIndexRecord { + rawOutput?: string + log?: GroupSummaryLog +} + class GroupSummaryRecordService { private readonly maxRecordsPerScope = 2000 private filePath: string | null = null + private logDir: string | null = null private loaded = false - private records: GroupSummaryRecord[] = [] + private records: GroupSummaryIndexRecord[] = [] - private resolveFilePath(): string { - if (this.filePath) return this.filePath + private resolveUserDataPath(): string { const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd() fs.mkdirSync(userDataPath, { recursive: true }) - this.filePath = path.join(userDataPath, 'weflow-group-summary-records.json') + return userDataPath + } + + private resolveFilePath(): string { + if (this.filePath) return this.filePath + this.filePath = path.join(this.resolveUserDataPath(), 'weflow-group-summary-records.json') return this.filePath } + private resolveLogDir(): string { + if (this.logDir) return this.logDir + this.logDir = path.join(this.resolveUserDataPath(), 'weflow-group-summary-logs') + fs.mkdirSync(this.logDir, { recursive: true }) + return this.logDir + } + + private normalizeTimestampSeconds(value: unknown): number { + const numeric = Number(value || 0) + if (!Number.isFinite(numeric) || numeric <= 0) return 0 + let normalized = Math.floor(numeric) + while (normalized > 10000000000) { + normalized = Math.floor(normalized / 1000) + } + return normalized + } + + private safeLogFileName(id: string): string { + const normalized = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '') + return `${normalized || randomUUID()}.json` + } + + private writeLogFile(recordId: string, log: GroupSummaryLog, rawOutput: string): string | undefined { + try { + const fileName = this.safeLogFileName(recordId) + const logPath = path.join(this.resolveLogDir(), fileName) + fs.writeFileSync(logPath, JSON.stringify({ version: 1, rawOutput, log }, null, 2), 'utf-8') + return fileName + } catch { + return undefined + } + } + + private readLogFile(fileName?: string): { rawOutput: string; log: GroupSummaryLog } | null { + if (!fileName) return null + try { + const logPath = path.join(this.resolveLogDir(), this.safeLogFileName(fileName.replace(/\.json$/i, ''))) + if (!fs.existsSync(logPath)) return null + const parsed = JSON.parse(fs.readFileSync(logPath, 'utf-8')) + const log = parsed?.log + if (!log || typeof log !== 'object') return null + return { + rawOutput: typeof parsed?.rawOutput === 'string' ? parsed.rawOutput : String(log.rawOutput || ''), + log: log as GroupSummaryLog + } + } catch { + return null + } + } + private ensureLoaded(): void { if (this.loaded) return this.loaded = true @@ -106,18 +171,60 @@ class GroupSummaryRecordService { const raw = fs.readFileSync(filePath, 'utf-8') const parsed = JSON.parse(raw) const records = Array.isArray(parsed) ? parsed : parsed?.records - if (Array.isArray(records)) { - this.records = records.filter((item) => item && typeof item === 'object') as GroupSummaryRecord[] + if (!Array.isArray(records)) return + + const legacyRecords = records.filter((item) => item && typeof item === 'object') as LegacyGroupSummaryRecord[] + const needsMigration = legacyRecords.some((record) => Boolean(record.log || record.rawOutput)) + if (needsMigration) { + this.backupLegacyFile(filePath) + } + + this.records = legacyRecords.map((record) => { + const id = String(record.id || randomUUID()) + const logFile = record.log + ? this.writeLogFile(id, record.log, String(record.rawOutput || record.log.rawOutput || '')) + : record.logFile + return { + id, + accountScope: String(record.accountScope || 'default'), + createdAt: Number(record.createdAt || Date.now()), + sessionId: String(record.sessionId || ''), + displayName: String(record.displayName || record.sessionId || ''), + avatarUrl: record.avatarUrl, + triggerType: record.triggerType === 'auto' ? 'auto' : 'manual', + periodStart: this.normalizeTimestampSeconds(record.periodStart), + periodEnd: this.normalizeTimestampSeconds(record.periodEnd), + messageCount: Math.max(0, Math.floor(Number(record.messageCount || 0))), + readableMessageCount: Math.max(0, Math.floor(Number(record.readableMessageCount || 0))), + topics: Array.isArray(record.topics) ? record.topics : [], + summaryText: String(record.summaryText || ''), + logFile + } + }).filter((record) => record.sessionId && record.periodStart > 0 && record.periodEnd > record.periodStart) + + if (needsMigration) { + this.persist() } } catch { this.records = [] } } + private backupLegacyFile(filePath: string): void { + try { + const backupPath = `${filePath}.legacy-${Date.now()}.bak` + if (!fs.existsSync(backupPath)) { + fs.copyFileSync(filePath, backupPath) + } + } catch { + // Backup failure should not block reading existing records. + } + } + private persist(): void { try { const filePath = this.resolveFilePath() - fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8') + fs.writeFileSync(filePath, JSON.stringify({ version: 2, records: this.records }, null, 2), 'utf-8') } catch { // Summary generation should not fail because local record persistence failed. } @@ -136,7 +243,7 @@ class GroupSummaryRecordService { return 'default' } - private toSummary(record: GroupSummaryRecord): GroupSummaryRecordSummary { + private toSummary(record: GroupSummaryIndexRecord): GroupSummaryRecordSummary { return { id: record.id, createdAt: record.createdAt, @@ -153,7 +260,7 @@ class GroupSummaryRecordService { } } - private getScopedRecords(): GroupSummaryRecord[] { + private getScopedRecords(): GroupSummaryIndexRecord[] { this.ensureLoaded() const scope = this.getCurrentAccountScope() return this.records.filter((record) => record.accountScope === scope) @@ -172,11 +279,13 @@ class GroupSummaryRecordService { summaryText: string rawOutput: string log: GroupSummaryLog - }): GroupSummaryRecord { + }): GroupSummaryRecordSummary { this.ensureLoaded() const scope = this.getCurrentAccountScope() - const record: GroupSummaryRecord = { - id: randomUUID(), + const id = randomUUID() + const logFile = this.writeLogFile(id, input.log, input.rawOutput) + const record: GroupSummaryIndexRecord = { + id, accountScope: scope, createdAt: Date.now(), sessionId: input.sessionId, @@ -189,8 +298,7 @@ class GroupSummaryRecordService { readableMessageCount: input.readableMessageCount, topics: input.topics, summaryText: input.summaryText, - rawOutput: input.rawOutput, - log: input.log + logFile } this.records.push(record) @@ -200,7 +308,7 @@ class GroupSummaryRecordService { const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id)) this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id)) this.persist() - return record + return this.toSummary(record) } hasAutoRecord(sessionId: string, periodStart: number, periodEnd: number): boolean { @@ -217,8 +325,8 @@ class GroupSummaryRecordService { listRecords(filters: GroupSummaryRecordFilters = {}): GroupSummaryRecordListResult { try { const sessionId = String(filters.sessionId || '').trim() - const startTime = Number(filters.startTime || 0) - const endTime = Number(filters.endTime || 0) + const startTime = this.normalizeTimestampSeconds(filters.startTime) + const endTime = this.normalizeTimestampSeconds(filters.endTime) const offset = Math.max(0, Math.floor(Number(filters.offset || 0))) const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100)))) @@ -250,13 +358,26 @@ class GroupSummaryRecordService { const scope = this.getCurrentAccountScope() const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope) if (!record) return { success: false, error: '未找到该群聊总结记录' } - return { success: true, record } + + const logData = this.readLogFile(record.logFile) + if (!logData) return { success: false, error: '未找到该群聊总结日志' } + + return { + success: true, + record: { + ...this.toSummary(record), + accountScope: record.accountScope, + rawOutput: logData.rawOutput, + log: logData.log + } + } } clearRuntimeCache(): void { this.loaded = false this.records = [] this.filePath = null + this.logDir = null } } diff --git a/electron/services/groupSummaryService.ts b/electron/services/groupSummaryService.ts index 0972aa7..8eb4200 100644 --- a/electron/services/groupSummaryService.ts +++ b/electron/services/groupSummaryService.ts @@ -1,8 +1,9 @@ import https from 'https' import http from 'http' import { URL } from 'url' +import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json' import { ConfigService } from './config' -import { chatService, type ChatSession, type Message } from './chatService' +import { chatService, type Message } from './chatService' import { wcdbService } from './wcdbService' import { groupSummaryRecordService, @@ -10,6 +11,7 @@ import { type GroupSummaryRecord, type GroupSummaryRecordFilters, type GroupSummaryRecordListResult, + type GroupSummaryRecordSummary, type GroupSummaryTopic, type GroupSummaryTriggerType } from './groupSummaryRecordService' @@ -20,6 +22,7 @@ const MIN_SUMMARY_MESSAGES = 5 const MAX_MANUAL_RANGE_SECONDS = 48 * 60 * 60 const MAX_MESSAGES_PER_SUMMARY = 3000 const SUMMARY_CURSOR_BATCH_SIZE = 360 +const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim() const SUMMARY_CONFIG_KEYS = new Set([ 'aiGroupSummaryEnabled', 'aiGroupSummaryIntervalHours', @@ -37,8 +40,6 @@ const SUMMARY_CONFIG_KEYS = new Set([ 'myWxid' ]) -type GroupSummaryFilterMode = 'whitelist' | 'blacklist' - interface SharedAiModelConfig { apiBaseUrl: string apiKey: string @@ -49,11 +50,19 @@ interface GroupSummaryTriggerResult { success: boolean message: string recordId?: string - record?: GroupSummaryRecord + record?: GroupSummaryRecordSummary skipped?: boolean skippedReason?: string } +interface GroupSummaryDayTriggerResult { + success: boolean + message: string + generated: number + skipped: number + records: GroupSummaryRecordSummary[] +} + class ApiRequestError extends Error { statusCode?: number responseBody?: string @@ -248,6 +257,7 @@ class GroupSummaryService { private started = false private scanTimer: NodeJS.Timeout | null = null private processing = false + private pendingAutoRun = false private dbConnected = false constructor() { @@ -264,12 +274,14 @@ class GroupSummaryService { this.started = false this.clearTimers() this.processing = false + this.pendingAutoRun = false this.dbConnected = false } async handleConfigChanged(key: string): Promise { const normalizedKey = String(key || '').trim() if (!SUMMARY_CONFIG_KEYS.has(normalizedKey)) return + if (normalizedKey === 'aiGroupSummarySystemPrompt') return if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { this.dbConnected = false groupSummaryRecordService.clearRuntimeCache() @@ -280,6 +292,7 @@ class GroupSummaryService { handleConfigCleared(): void { this.clearTimers() this.processing = false + this.pendingAutoRun = false this.dbConnected = false groupSummaryRecordService.clearRuntimeCache() } @@ -327,11 +340,51 @@ class GroupSummaryService { }) } + async triggerDay(params: { + sessionId: string + displayName?: string + avatarUrl?: string + date: string + }): Promise { + if (!this.isEnabled()) { + return { success: false, message: '请先在设置中开启「AI 群聊总结」', generated: 0, skipped: 0, records: [] } + } + const sessionId = String(params?.sessionId || '').trim() + if (!sessionId.endsWith('@chatroom')) { + return { success: false, message: 'AI 群聊总结仅支持群聊', generated: 0, skipped: 0, records: [] } + } + const dayRange = this.parseLocalDateDayRange(params?.date) + if (!dayRange) { + return { success: false, message: '请选择有效日期', generated: 0, skipped: 0, records: [] } + } + const todayStart = getStartOfDaySeconds(new Date()) + if (dayRange.start > todayStart) { + return { success: false, message: '不能总结未来日期', generated: 0, skipped: 0, records: [] } + } + + const now = Math.floor(Date.now() / 1000) + const effectiveEnd = dayRange.start === todayStart ? Math.min(dayRange.end, now) : dayRange.end + const periods = this.getIntervalPeriods(dayRange.start, effectiveEnd, false) + if (periods.length === 0) { + return { success: true, message: '当前日期暂无已完成的总结时段', generated: 0, skipped: 0, records: [] } + } + + const displayName = String(params?.displayName || sessionId).trim() || sessionId + const avatarUrl = String(params?.avatarUrl || '').trim() || undefined + return this.generateSummariesForPeriods({ + sessionId, + displayName, + avatarUrl, + periods, + triggerType: 'manual' + }) + } + private async refreshConfiguration(_reason: string): Promise { if (!this.started) return this.clearTimers() if (!this.isEnabled()) return - await this.runDueAutoSummaries() + await this.queueDueAutoSummaries() this.scheduleNextAutoRun() } @@ -358,7 +411,7 @@ class GroupSummaryService { this.scanTimer = setTimeout(async () => { this.scanTimer = null - await this.runDueAutoSummaries() + await this.queueDueAutoSummaries() this.scheduleNextAutoRun() }, delayMs) } @@ -389,18 +442,9 @@ class GroupSummaryService { return { apiBaseUrl, apiKey, model } } - private getFilterConfig(): { mode: GroupSummaryFilterMode; list: string[] } { - const rawMode = String(this.config.get('aiGroupSummaryFilterMode') || '').trim() - const mode: GroupSummaryFilterMode = rawMode === 'blacklist' ? 'blacklist' : 'whitelist' - const list = normalizeSessionIdList(this.config.get('aiGroupSummaryFilterList')) + private getAutoScopeSessionIds(): string[] { + return normalizeSessionIdList(this.config.get('aiGroupSummaryFilterList')) .filter((sessionId) => sessionId.endsWith('@chatroom')) - return { mode, list } - } - - private isAutoSessionAllowed(sessionId: string): boolean { - const { mode, list } = this.getFilterConfig() - if (mode === 'whitelist') return list.includes(sessionId) - return !list.includes(sessionId) } private normalizeTimestampSeconds(value: unknown): number { @@ -413,45 +457,86 @@ class GroupSummaryService { return normalized } - private getCompletedPeriodsToday(): Array<{ start: number; end: number }> { + private parseLocalDateDayRange(value: unknown): { start: number; end: number } | null { + const text = String(value || '').trim() + const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!match) return null + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const start = new Date(year, month - 1, day, 0, 0, 0, 0) + if ( + !Number.isFinite(start.getTime()) || + start.getFullYear() !== year || + start.getMonth() !== month - 1 || + start.getDate() !== day + ) { + return null + } + const end = new Date(start) + end.setDate(end.getDate() + 1) + return { + start: Math.floor(start.getTime() / 1000), + end: Math.floor(end.getTime() / 1000) + } + } + + private getIntervalPeriods(startTime: number, endTime: number, includePartial: boolean): Array<{ start: number; end: number }> { const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours')) const intervalSeconds = intervalHours * 60 * 60 - const dayStart = getStartOfDaySeconds(new Date()) - const now = Math.floor(Date.now() / 1000) const periods: Array<{ start: number; end: number }> = [] - for (let start = dayStart; start + intervalSeconds <= now; start += intervalSeconds) { - periods.push({ start, end: start + intervalSeconds }) + for (let start = startTime; start < endTime; start += intervalSeconds) { + const end = Math.min(start + intervalSeconds, endTime) + if (!includePartial && end - start < intervalSeconds) continue + if (end > start) periods.push({ start, end }) } return periods } - private async runDueAutoSummaries(): Promise { - if (!this.started || !this.isEnabled() || this.processing) return + private getCompletedPeriodsToday(): Array<{ start: number; end: number }> { + const dayStart = getStartOfDaySeconds(new Date()) + const now = Math.floor(Date.now() / 1000) + return this.getIntervalPeriods(dayStart, now, false) + } + + private async queueDueAutoSummaries(): Promise { + if (!this.started || !this.isEnabled()) return + if (this.processing) { + this.pendingAutoRun = true + return + } this.processing = true + try { + do { + this.pendingAutoRun = false + await this.runDueAutoSummariesOnce() + } while (this.pendingAutoRun && this.started && this.isEnabled()) + } finally { + this.processing = false + } + } + + private async runDueAutoSummariesOnce(): Promise { + if (!this.started || !this.isEnabled()) return try { const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() if (!apiBaseUrl || !apiKey) return - const { mode, list } = this.getFilterConfig() - if (mode === 'whitelist' && list.length === 0) return + const scopeSessionIds = this.getAutoScopeSessionIds() + if (scopeSessionIds.length === 0) return if (!await this.ensureConnected()) return - const sessionsResult = await chatService.getSessions() - if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) return - const groupSessions = (sessionsResult.sessions as ChatSession[]) - .filter((session) => String(session.username || '').trim().endsWith('@chatroom')) - .filter((session) => this.isAutoSessionAllowed(String(session.username || '').trim())) + const contacts = (await chatService.enrichSessionsContactInfo(scopeSessionIds).catch(() => null))?.contacts || {} const periods = this.getCompletedPeriodsToday() for (const period of periods) { - for (const session of groupSessions) { + for (const sessionId of scopeSessionIds) { if (!this.started || !this.isEnabled()) return - const sessionId = String(session.username || '').trim() if (!sessionId) continue if (groupSummaryRecordService.hasAutoRecord(sessionId, period.start, period.end)) continue await this.generateSummaryForPeriod({ sessionId, - displayName: session.displayName || sessionId, - avatarUrl: session.avatarUrl, + displayName: contacts[sessionId]?.displayName || sessionId, + avatarUrl: contacts[sessionId]?.avatarUrl, periodStart: period.start, periodEnd: period.end, triggerType: 'auto' @@ -460,8 +545,6 @@ class GroupSummaryService { } } catch (error) { console.warn('[GroupSummaryService] 自动总结失败:', error) - } finally { - this.processing = false } } @@ -582,28 +665,8 @@ class GroupSummaryService { } } - const defaultSystemPrompt = `你是一个群聊会议纪要式总结助手。你只根据用户提供的群聊记录总结,不编造记录中没有的信息。 - -严格要求: -1. 必须且只能输出合法纯 JSON,禁止 Markdown 和解释说明。 -2. 按话题分类总结,每个话题包含参与者、关键/矛盾点、结论。 -3. 参与者写群成员显示名;不确定参与者时写已有发言人。 -4. 关键/矛盾点必须来自聊天记录,避免泛泛而谈。 -5. 结论要短、具体;没有结论时写“暂无明确结论”。 - -JSON 输出格式: -{ - "topics": [ - { - "title": "话题名称", - "participants": ["参与者A", "参与者B"], - "key_points": ["关键点或矛盾点"], - "conclusion": "结论" - } - ] -}` const customPrompt = String(this.config.get('aiGroupSummarySystemPrompt') || '').trim() - const systemPrompt = customPrompt ? `${defaultSystemPrompt}\n\n用户补充要求:\n${customPrompt}` : defaultSystemPrompt + const systemPrompt = customPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT const userPrompt = `群聊:${params.displayName} 总结时段:${formatTimestamp(params.periodStart)} 至 ${formatTimestamp(params.periodEnd)} 消息数量:${readableMessages.length} @@ -684,6 +747,55 @@ ${transcript} return { success: false, message: `生成失败:${(error as Error).message || String(error)}` } } } + + private async generateSummariesForPeriods(params: { + sessionId: string + displayName: string + avatarUrl?: string + periods: Array<{ start: number; end: number }> + triggerType: GroupSummaryTriggerType + }): Promise { + const records: GroupSummaryRecordSummary[] = [] + let skipped = 0 + let failed = 0 + let firstError = '' + + for (const period of params.periods) { + const result = await this.generateSummaryForPeriod({ + sessionId: params.sessionId, + displayName: params.displayName, + avatarUrl: params.avatarUrl, + periodStart: period.start, + periodEnd: period.end, + triggerType: params.triggerType + }) + if (result.success && result.record) { + records.push(result.record) + continue + } + if (result.success && result.skipped) { + skipped += 1 + continue + } + failed += 1 + if (!firstError) firstError = result.message + } + + const generated = records.length + const parts = [`生成 ${generated} 段`, `跳过 ${skipped} 段`] + if (failed > 0) parts.push(`失败 ${failed} 段`) + const message = failed > 0 && generated === 0 && skipped === 0 + ? (firstError || '群聊总结生成失败') + : `群聊总结完成:${parts.join(',')}` + + return { + success: generated > 0 || skipped > 0 || failed === 0, + message, + generated, + skipped, + records + } + } } export const groupSummaryService = new GroupSummaryService() diff --git a/shared/groupSummaryPrompt.json b/shared/groupSummaryPrompt.json new file mode 100644 index 0000000..9267994 --- /dev/null +++ b/shared/groupSummaryPrompt.json @@ -0,0 +1,3 @@ +{ + "defaultSystemPrompt": "你是一个群聊会议纪要式总结助手。你只根据用户提供的群聊记录总结,不编造记录中没有的信息。\n\n严格要求:\n1. 必须且只能输出合法纯 JSON,禁止 Markdown 和解释说明。\n2. 按话题分类总结,每个话题包含参与者、关键/矛盾点、结论。\n3. 参与者写群成员显示名;不确定参与者时写已有发言人。\n4. 关键/矛盾点必须来自聊天记录,避免泛泛而谈。\n5. 结论要短、具体;没有结论时写“暂无明确结论”。\n\nJSON 输出格式:\n{\n \"topics\": [\n {\n \"title\": \"话题名称\",\n \"participants\": [\"参与者A\", \"参与者B\"],\n \"key_points\": [\"关键点或矛盾点\"],\n \"conclusion\": \"结论\"\n }\n ]\n}" +} diff --git a/src/components/JumpToDatePopover.tsx b/src/components/JumpToDatePopover.tsx index e88b502..e377798 100644 --- a/src/components/JumpToDatePopover.tsx +++ b/src/components/JumpToDatePopover.tsx @@ -15,6 +15,7 @@ interface JumpToDatePopoverProps { messageDateCounts?: Record loadingDates?: boolean loadingDateCounts?: boolean + maxDate?: Date } const JumpToDatePopover: React.FC = ({ @@ -29,7 +30,8 @@ const JumpToDatePopover: React.FC = ({ hasLoadedMessageDates = false, messageDateCounts, loadingDates = false, - loadingDateCounts = false + loadingDateCounts = false, + maxDate }) => { type CalendarViewMode = 'day' | 'month' | 'year' const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12 @@ -73,6 +75,14 @@ const JumpToDatePopover: React.FC = ({ return messageDates.has(toDateKey(day)) } + const isAfterMaxDate = (day: number): boolean => { + if (!maxDate) return false + const max = new Date(maxDate) + max.setHours(23, 59, 59, 999) + const candidate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day, 0, 0, 0, 0) + return candidate.getTime() > max.getTime() + } + const isToday = (day: number): boolean => { const today = new Date() return day === today.getDate() @@ -102,6 +112,7 @@ const JumpToDatePopover: React.FC = ({ const handleDateClick = (day: number) => { if (hasLoadedMessageDates && !hasMessage(day)) return + if (isAfterMaxDate(day)) return const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) setSelectedDate(targetDate) onSelect(targetDate) @@ -113,7 +124,7 @@ const JumpToDatePopover: React.FC = ({ const classes = ['day-cell'] if (isToday(day)) classes.push('today') if (isSelected(day)) classes.push('selected') - if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message') + if ((hasLoadedMessageDates && !hasMessage(day)) || isAfterMaxDate(day)) classes.push('no-message') return classes.join(' ') } @@ -225,6 +236,7 @@ const JumpToDatePopover: React.FC = ({ if (day === null) return
const dateKey = toDateKey(day) const hasMessageOnDay = hasMessage(day) + const isDisabled = (hasLoadedMessageDates && !hasMessageOnDay) || isAfterMaxDate(day) const count = Number(messageDateCounts?.[dateKey] || 0) const showCount = count > 0 const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount @@ -233,7 +245,7 @@ const JumpToDatePopover: React.FC = ({ key={index} className={getDayClassName(day)} onClick={() => handleDateClick(day)} - disabled={hasLoadedMessageDates && !hasMessageOnDay} + disabled={isDisabled} type="button" > {day} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index befa5ff..a484486 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -3595,7 +3595,8 @@ font-weight: 600; } - input { + input, + .group-summary-date-trigger { height: 32px; min-width: 0; border: 1px solid var(--border-color); @@ -3607,6 +3608,40 @@ } } + .group-summary-date-picker { + position: relative; + min-width: 0; + } + + .group-summary-date-trigger { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.16s ease; + + svg { + color: var(--text-secondary); + flex-shrink: 0; + } + + &:hover, + &.open { + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + background: var(--bg-hover); + } + } + + .group-summary-calendar-popover { + right: auto; + left: 0; + top: calc(100% + 8px); + width: min(312px, calc(100vw - 32px)); + border-radius: 10px; + } + .group-summary-icon-btn, .group-summary-code-btn { width: 30px; @@ -3656,23 +3691,6 @@ } } - .group-summary-custom-range { - display: grid; - grid-template-columns: 1fr; - gap: 6px; - - input { - height: 32px; - min-width: 0; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--card-bg); - color: var(--text-primary); - padding: 0 9px; - font-size: 12px; - } - } - .group-summary-generate-btn { height: 34px; border: none; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index f34f499..e4514ab 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -69,7 +69,7 @@ interface QuotedMessageJumpTarget { type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done' type GlobalMsgSearchResult = Message & { sessionId: string } -type GroupSummaryRangeMode = 1 | 2 | 4 | 8 | 12 | 24 | 'custom' +type GroupSummaryRangeMode = 1 | 2 | 4 | 8 | 12 | 24 interface GlobalMsgPrefixCacheEntry { keyword: string @@ -80,7 +80,6 @@ interface GlobalMsgPrefixCacheEntry { const GLOBAL_MSG_PER_SESSION_LIMIT = 10 -const GROUP_SUMMARY_MAX_RANGE_HOURS = 48 const GLOBAL_MSG_SEED_LIMIT = 120 const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3 const GLOBAL_MSG_LEGACY_CONCURRENCY = 6 @@ -772,21 +771,6 @@ function formatDateInputLocal(date: Date): string { return `${y}-${m}-${day}` } -function formatDateTimeLocal(date: Date): string { - const y = date.getFullYear() - const m = `${date.getMonth() + 1}`.padStart(2, '0') - const day = `${date.getDate()}`.padStart(2, '0') - const h = `${date.getHours()}`.padStart(2, '0') - const min = `${date.getMinutes()}`.padStart(2, '0') - return `${y}-${m}-${day}T${h}:${min}` -} - -function parseDateTimeLocalSeconds(value: string): number { - const parsed = new Date(value) - const time = parsed.getTime() - return Number.isFinite(time) ? Math.floor(time / 1000) : 0 -} - function formatSummaryPeriod(start: number, end: number): string { return `${formatYmdHmDateTime(start * 1000)} - ${formatYmdHmDateTime(end * 1000)}` } @@ -1505,6 +1489,7 @@ function ChatPage(props: ChatPageProps) { const sessionListRef = useRef(null) const jumpCalendarWrapRef = useRef(null) const jumpPopoverPortalRef = useRef(null) + const groupSummaryDateWrapRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [jumpStartTime, setJumpStartTime] = useState(0) const [jumpEndTime, setJumpEndTime] = useState(0) @@ -1574,8 +1559,7 @@ function ChatPage(props: ChatPageProps) { const [groupSummaryError, setGroupSummaryError] = useState(null) const [groupSummaryDateFilter, setGroupSummaryDateFilter] = useState(() => formatDateInputLocal(new Date())) const [groupSummaryRangeMode, setGroupSummaryRangeMode] = useState(4) - const [groupSummaryCustomStart, setGroupSummaryCustomStart] = useState(() => formatDateTimeLocal(new Date(Date.now() - 4 * 60 * 60 * 1000))) - const [groupSummaryCustomEnd, setGroupSummaryCustomEnd] = useState(() => formatDateTimeLocal(new Date())) + const [showGroupSummaryDatePopover, setShowGroupSummaryDatePopover] = useState(false) const [isTriggeringGroupSummary, setIsTriggeringGroupSummary] = useState(false) const [groupSummaryHint, setGroupSummaryHint] = useState<{ success: boolean; message: string } | null>(null) const [groupSummaryLogRecord, setGroupSummaryLogRecord] = useState(null) @@ -1903,25 +1887,29 @@ function ChatPage(props: ChatPageProps) { }) }, []) - const getGroupSummaryDateRangeMs = useCallback(() => { - const date = groupSummaryDateFilter || formatDateInputLocal(new Date()) + const getGroupSummaryDateRangeSeconds = useCallback((dateValue = groupSummaryDateFilter) => { + const date = dateValue || formatDateInputLocal(new Date()) const start = new Date(`${date}T00:00:00`) if (!Number.isFinite(start.getTime())) { const fallback = new Date() fallback.setHours(0, 0, 0, 0) const fallbackEnd = new Date(fallback) fallbackEnd.setHours(23, 59, 59, 999) - return { startTime: fallback.getTime(), endTime: fallbackEnd.getTime() } + return { startTime: Math.floor(fallback.getTime() / 1000), endTime: Math.floor(fallbackEnd.getTime() / 1000) } } const end = new Date(start) end.setHours(23, 59, 59, 999) - return { startTime: start.getTime(), endTime: end.getTime() } + return { startTime: Math.floor(start.getTime() / 1000), endTime: Math.floor(end.getTime() / 1000) } + }, [groupSummaryDateFilter]) + + const isGroupSummaryToday = useMemo(() => { + return (groupSummaryDateFilter || formatDateInputLocal(new Date())) === formatDateInputLocal(new Date()) }, [groupSummaryDateFilter]) const loadGroupSummaryRecords = useCallback(async (sessionId?: string) => { const targetSessionId = String(sessionId || currentSessionRef.current || '').trim() if (!targetSessionId || !targetSessionId.endsWith('@chatroom')) return - const { startTime, endTime } = getGroupSummaryDateRangeMs() + const { startTime, endTime } = getGroupSummaryDateRangeSeconds() setGroupSummaryLoading(true) setGroupSummaryError(null) try { @@ -1950,60 +1938,65 @@ function ChatPage(props: ChatPageProps) { setGroupSummaryLoading(false) } } - }, [getGroupSummaryDateRangeMs]) + }, [getGroupSummaryDateRangeSeconds]) - const resolveGroupSummaryManualRange = useCallback(() => { + const resolveTodayGroupSummaryManualRange = useCallback(() => { const nowSeconds = Math.floor(Date.now() / 1000) - if (groupSummaryRangeMode !== 'custom') { - const hours = Number(groupSummaryRangeMode) - return { startTime: nowSeconds - hours * 60 * 60, endTime: nowSeconds } - } - return { - startTime: parseDateTimeLocalSeconds(groupSummaryCustomStart), - endTime: parseDateTimeLocalSeconds(groupSummaryCustomEnd) - } - }, [groupSummaryCustomEnd, groupSummaryCustomStart, groupSummaryRangeMode]) + const hours = Number(groupSummaryRangeMode) + return { startTime: nowSeconds - hours * 60 * 60, endTime: nowSeconds } + }, [groupSummaryRangeMode]) const triggerManualGroupSummary = useCallback(async () => { const sessionId = String(currentSessionId || '').trim() if (!sessionId || !sessionId.endsWith('@chatroom')) return const sessionInfo = sessionMapRef.current.get(sessionId) - const { startTime, endTime } = resolveGroupSummaryManualRange() - if (startTime <= 0 || endTime <= startTime) { - setGroupSummaryHint({ success: false, message: '请选择有效的总结时段' }) - return - } - if (endTime - startTime > GROUP_SUMMARY_MAX_RANGE_HOURS * 60 * 60) { - setGroupSummaryHint({ success: false, message: '手动总结时段不能超过 48 小时' }) - return - } + const selectedDate = groupSummaryDateFilter || formatDateInputLocal(new Date()) + const today = formatDateInputLocal(new Date()) setIsTriggeringGroupSummary(true) setGroupSummaryHint({ success: true, message: '正在生成群聊总结...' }) try { - const result = await window.electronAPI.groupSummary.triggerManual({ - sessionId, - displayName: sessionInfo?.displayName || sessionId, - avatarUrl: sessionInfo?.avatarUrl, - startTime, - endTime - }) - if (result.success) { - setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' }) - if (!result.skipped) { - const dateForFilter = formatDateInputLocal(new Date(startTime * 1000)) - setGroupSummaryDateFilter(dateForFilter) - await loadGroupSummaryRecords(sessionId) + if (selectedDate === today) { + const { startTime, endTime } = resolveTodayGroupSummaryManualRange() + if (startTime <= 0 || endTime <= startTime) { + setGroupSummaryHint({ success: false, message: '请选择有效的总结时段' }) + return + } + const result = await window.electronAPI.groupSummary.triggerManual({ + sessionId, + displayName: sessionInfo?.displayName || sessionId, + avatarUrl: sessionInfo?.avatarUrl, + startTime, + endTime + }) + if (result.success) { + setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' }) + if (!result.skipped) { + await loadGroupSummaryRecords(sessionId) + } + } else { + setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' }) } } else { - setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' }) + const result = await window.electronAPI.groupSummary.triggerDay({ + sessionId, + displayName: sessionInfo?.displayName || sessionId, + avatarUrl: sessionInfo?.avatarUrl, + date: selectedDate + }) + if (result.success) { + setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' }) + await loadGroupSummaryRecords(sessionId) + } else { + setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' }) + } } } catch (error) { setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) }) } finally { setIsTriggeringGroupSummary(false) } - }, [currentSessionId, loadGroupSummaryRecords, resolveGroupSummaryManualRange]) + }, [currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, resolveTodayGroupSummaryManualRange]) const openGroupSummaryLog = useCallback(async (recordId: string) => { try { @@ -5698,6 +5691,20 @@ function ChatPage(props: ChatPageProps) { } }, [showJumpPopover]) + useEffect(() => { + if (!showGroupSummaryDatePopover) return + const handleGlobalPointerDown = (event: MouseEvent) => { + const target = event.target as Node | null + if (!target) return + if (groupSummaryDateWrapRef.current?.contains(target)) return + setShowGroupSummaryDatePopover(false) + } + document.addEventListener('mousedown', handleGlobalPointerDown) + return () => { + document.removeEventListener('mousedown', handleGlobalPointerDown) + } + }, [showGroupSummaryDatePopover]) + useEffect(() => { if (!showJumpPopover) return const syncPosition = () => { @@ -7917,11 +7924,24 @@ function ChatPage(props: ChatPageProps) {
- setGroupSummaryDateFilter(event.target.value || formatDateInputLocal(new Date()))} - /> +
+ + setShowGroupSummaryDatePopover(false)} + currentDate={new Date(`${groupSummaryDateFilter || formatDateInputLocal(new Date())}T00:00:00`)} + onSelect={(date) => setGroupSummaryDateFilter(formatDateInputLocal(date))} + className="group-summary-calendar-popover" + maxDate={new Date()} + /> +
-
- {([1, 2, 4, 8, 12, 24] as const).map((hours) => ( - - ))} - -
- - {groupSummaryRangeMode === 'custom' && ( -
- setGroupSummaryCustomStart(event.target.value)} - /> - setGroupSummaryCustomEnd(event.target.value)} - /> + {isGroupSummaryToday ? ( +
+ {([1, 2, 4, 8, 12, 24] as const).map((hours) => ( + + ))}
+ ) : ( +
将按设置里的自动总结间隔切分选中日期的完整聊天记录。
)} -
少于 5 条可总结消息会自动跳过。
+
+ {isGroupSummaryToday ? '少于 5 条可总结消息会自动跳过。' : '每个切片少于 5 条可总结消息会自动跳过。'} +
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 940cf60..88cfb05 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -6,6 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' import { dialog } from '../services/ipc' import * as configService from '../services/config' +import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json' import type { ChatSession, ContactInfo } from '../types/models' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, @@ -70,6 +71,7 @@ const isMac = navigator.userAgent.toLowerCase().includes('mac') const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isWindows = !isMac && !isLinux const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' +const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim() const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' const dbPathPlaceholder = isMac @@ -334,9 +336,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false) const [aiGroupSummaryIntervalHours, setAiGroupSummaryIntervalHours] = useState(4) const [aiGroupSummarySystemPrompt, setAiGroupSummarySystemPrompt] = useState('') - const [aiGroupSummaryFilterMode, setAiGroupSummaryFilterMode] = useState('whitelist') const [aiGroupSummaryFilterList, setAiGroupSummaryFilterList] = useState([]) - const [aiGroupSummaryFilterDropdownOpen, setAiGroupSummaryFilterDropdownOpen] = useState(false) const [aiGroupSummaryFilterSearchKeyword, setAiGroupSummaryFilterSearchKeyword] = useState('') const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false) const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50) @@ -607,7 +607,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiGroupSummaryEnabled = await configService.getAiGroupSummaryEnabled() const savedAiGroupSummaryIntervalHours = await configService.getAiGroupSummaryIntervalHours() const savedAiGroupSummarySystemPrompt = await configService.getAiGroupSummarySystemPrompt() - const savedAiGroupSummaryFilterMode = await configService.getAiGroupSummaryFilterMode() const savedAiGroupSummaryFilterList = await configService.getAiGroupSummaryFilterList() const savedAiMessageInsightEnabled = await configService.getAiMessageInsightEnabled() const savedAiMessageInsightContextCount = await configService.getAiMessageInsightContextCount() @@ -641,7 +640,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiGroupSummaryEnabled(savedAiGroupSummaryEnabled) setAiGroupSummaryIntervalHours(savedAiGroupSummaryIntervalHours) setAiGroupSummarySystemPrompt(savedAiGroupSummarySystemPrompt) - setAiGroupSummaryFilterMode(savedAiGroupSummaryFilterMode) setAiGroupSummaryFilterList(savedAiGroupSummaryFilterList) setAiMessageInsightEnabled(savedAiMessageInsightEnabled) setAiMessageInsightContextCount(savedAiMessageInsightContextCount) @@ -4061,19 +4059,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) const renderAiGroupSummaryTab = () => { + const groupSummaryPromptDisplayValue = aiGroupSummarySystemPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT + const addToFilterList = async (username: string) => { if (!username.endsWith('@chatroom') || aiGroupSummaryFilterList.includes(username)) return const next = [...aiGroupSummaryFilterList, username] setAiGroupSummaryFilterList(next) await configService.setAiGroupSummaryFilterList(next) - showMessage('已添加到群聊总结名单', true) + showMessage('已添加到群聊总结作用域', true) } const removeFromFilterList = async (username: string) => { const next = aiGroupSummaryFilterList.filter((item) => item !== username) setAiGroupSummaryFilterList(next) await configService.setAiGroupSummaryFilterList(next) - showMessage('已从群聊总结名单移除', true) + showMessage('已从群聊总结作用域移除', true) } const addAllFiltered = async () => { @@ -4089,7 +4089,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { if (aiGroupSummaryFilterList.length === 0) return setAiGroupSummaryFilterList([]) await configService.setAiGroupSummaryFilterList([]) - showMessage('已清空群聊总结名单', true) + showMessage('已清空群聊总结作用域', true) } return ( @@ -4097,7 +4097,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- 开启后,群聊页顶部会显示 AI 总结按钮;自动总结只对下面黑白名单命中的群聊生效。默认白名单且不选择任何群聊,不会自动消耗 token。 + 开启后,群聊页顶部会显示 AI 总结按钮;自动总结只对下面作用域内的群聊生效。未选择任何群聊时不会自动消耗 token。
{aiGroupSummaryEnabled ? '已开启' : '已关闭'} @@ -4143,81 +4143,49 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- - {aiGroupSummarySystemPrompt && ( - - )} + +
- 固定输出格式不会被覆盖,这里只补充你的偏好,例如更关注决策、争议或待办。 + 群聊总结专用提示词。留空时使用内置默认提示词。