diff --git a/electron/main.ts b/electron/main.ts index fdc8fc8..a02dd35 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -32,6 +32,7 @@ import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' import { insightService } from './services/insightService' import { insightRecordService } from './services/insightRecordService' +import { groupSummaryService } from './services/groupSummaryService' import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { bizService } from './services/bizService' import { backupService } from './services/backupService' @@ -1775,6 +1776,7 @@ function registerIpcHandlers() { } void messagePushService.handleConfigChanged(key) void insightService.handleConfigChanged(key) + void groupSummaryService.handleConfigChanged(key) return result }) @@ -1858,6 +1860,30 @@ function registerIpcHandlers() { return insightService.generateMessageInsight(payload) }) + ipcMain.handle('groupSummary:listRecords', async (_, filters?: { + sessionId?: string + startTime?: number + endTime?: number + limit?: number + offset?: number + }) => { + return groupSummaryService.listRecords(filters || {}) + }) + + ipcMain.handle('groupSummary:getRecord', async (_, id: string) => { + return groupSummaryService.getRecord(id) + }) + + ipcMain.handle('groupSummary:triggerManual', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + startTime: number + endTime: number + }) => { + return groupSummaryService.triggerManual(payload) + }) + ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => { try { if (!configService) { @@ -1894,6 +1920,7 @@ function registerIpcHandlers() { configService?.clear() messagePushService.handleConfigCleared() insightService.handleConfigCleared() + groupSummaryService.handleConfigCleared() return true }) @@ -4226,6 +4253,7 @@ app.whenReady().then(async () => { }) messagePushService.start() insightService.start() + groupSummaryService.start() await delay(200) // 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等) @@ -4384,6 +4412,7 @@ const shutdownAppServices = async (): Promise => { destroyNotificationWindow() messagePushService.stop() insightService.stop() + groupSummaryService.stop() // 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留 const forceExitTimer = setTimeout(() => { console.warn('[App] Force exit after timeout') diff --git a/electron/preload.ts b/electron/preload.ts index 6d85b53..efc0560 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -615,6 +615,18 @@ contextBridge.exposeInMainWorld('electronAPI', { }) => ipcRenderer.invoke('insight:generateMessageInsight', payload) }, + groupSummary: { + listRecords: (filters?: any) => ipcRenderer.invoke('groupSummary:listRecords', filters), + getRecord: (id: string) => ipcRenderer.invoke('groupSummary:getRecord', id), + triggerManual: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + startTime: number + endTime: number + }) => ipcRenderer.invoke('groupSummary:triggerManual', payload) + }, + social: { saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput), validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid) diff --git a/electron/services/config.ts b/electron/services/config.ts index c2148bc..73974bc 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -129,6 +129,11 @@ interface ConfigSchema { // AI 足迹 aiFootprintEnabled: boolean aiFootprintSystemPrompt: string + aiGroupSummaryEnabled: boolean + aiGroupSummaryIntervalHours: number + aiGroupSummarySystemPrompt: string + aiGroupSummaryFilterMode: 'whitelist' | 'blacklist' + aiGroupSummaryFilterList: string[] aiMessageInsightEnabled: boolean aiMessageInsightContextCount: number aiMessageInsightSystemPrompt: string @@ -255,6 +260,11 @@ export class ConfigService { aiInsightWeiboBindings: {}, aiFootprintEnabled: false, aiFootprintSystemPrompt: '', + aiGroupSummaryEnabled: false, + aiGroupSummaryIntervalHours: 4, + aiGroupSummarySystemPrompt: '', + aiGroupSummaryFilterMode: 'whitelist', + aiGroupSummaryFilterList: [], aiMessageInsightEnabled: false, aiMessageInsightContextCount: 50, aiMessageInsightSystemPrompt: '', diff --git a/electron/services/groupSummaryRecordService.ts b/electron/services/groupSummaryRecordService.ts new file mode 100644 index 0000000..6e0bde8 --- /dev/null +++ b/electron/services/groupSummaryRecordService.ts @@ -0,0 +1,263 @@ +import { app } from 'electron' +import fs from 'fs' +import path from 'path' +import { createHash, randomUUID } from 'crypto' +import { ConfigService } from './config' + +export type GroupSummaryTriggerType = 'auto' | 'manual' + +export interface GroupSummaryTopic { + title: string + participants: string[] + keyPoints: string[] + conclusion: string +} + +export interface GroupSummaryLog { + endpoint: string + model: string + temperature: number + triggerType: GroupSummaryTriggerType + periodStart: number + periodEnd: number + messageCount: number + readableMessageCount: number + systemPrompt: string + userPrompt: string + rawOutput: string + finalSummary: string + durationMs: number + createdAt: number + responseFormatJson?: boolean + responseFormatFallback?: boolean + responseFormatFallbackReason?: string + parsedTopics?: GroupSummaryTopic[] +} + +export interface GroupSummaryRecord { + id: string + accountScope: string + createdAt: number + sessionId: string + displayName: string + avatarUrl?: string + triggerType: GroupSummaryTriggerType + periodStart: number + periodEnd: number + messageCount: number + readableMessageCount: number + topics: GroupSummaryTopic[] + summaryText: string + rawOutput: string + log: GroupSummaryLog +} + +export interface GroupSummaryRecordSummary { + id: string + createdAt: number + sessionId: string + displayName: string + avatarUrl?: string + triggerType: GroupSummaryTriggerType + periodStart: number + periodEnd: number + messageCount: number + readableMessageCount: number + topics: GroupSummaryTopic[] + summaryText: string +} + +export interface GroupSummaryRecordFilters { + sessionId?: string + startTime?: number + endTime?: number + limit?: number + offset?: number +} + +export interface GroupSummaryRecordListResult { + success: boolean + records: GroupSummaryRecordSummary[] + total: number + error?: string +} + +class GroupSummaryRecordService { + private readonly maxRecordsPerScope = 2000 + private filePath: string | null = null + private loaded = false + private records: GroupSummaryRecord[] = [] + + private resolveFilePath(): string { + if (this.filePath) return this.filePath + 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 this.filePath + } + + private ensureLoaded(): void { + if (this.loaded) return + this.loaded = true + const filePath = this.resolveFilePath() + try { + if (!fs.existsSync(filePath)) return + 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[] + } + } catch { + this.records = [] + } + } + + private persist(): void { + try { + const filePath = this.resolveFilePath() + fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8') + } catch { + // Summary generation should not fail because local record persistence failed. + } + } + + private getCurrentAccountScope(): string { + const config = ConfigService.getInstance() + const myWxid = String(config.getMyWxidCleaned() || '').trim() + if (myWxid) return `wxid:${myWxid}` + + const dbPath = String(config.get('dbPath') || '').trim() + if (dbPath) { + const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16) + return `db:${hash}` + } + return 'default' + } + + private toSummary(record: GroupSummaryRecord): GroupSummaryRecordSummary { + return { + id: record.id, + createdAt: record.createdAt, + sessionId: record.sessionId, + displayName: record.displayName, + avatarUrl: record.avatarUrl, + triggerType: record.triggerType, + periodStart: record.periodStart, + periodEnd: record.periodEnd, + messageCount: record.messageCount, + readableMessageCount: record.readableMessageCount, + topics: Array.isArray(record.topics) ? record.topics : [], + summaryText: record.summaryText || '' + } + } + + private getScopedRecords(): GroupSummaryRecord[] { + this.ensureLoaded() + const scope = this.getCurrentAccountScope() + return this.records.filter((record) => record.accountScope === scope) + } + + addRecord(input: { + sessionId: string + displayName: string + avatarUrl?: string + triggerType: GroupSummaryTriggerType + periodStart: number + periodEnd: number + messageCount: number + readableMessageCount: number + topics: GroupSummaryTopic[] + summaryText: string + rawOutput: string + log: GroupSummaryLog + }): GroupSummaryRecord { + this.ensureLoaded() + const scope = this.getCurrentAccountScope() + const record: GroupSummaryRecord = { + id: randomUUID(), + accountScope: scope, + createdAt: Date.now(), + sessionId: input.sessionId, + displayName: input.displayName, + avatarUrl: input.avatarUrl, + triggerType: input.triggerType, + periodStart: input.periodStart, + periodEnd: input.periodEnd, + messageCount: input.messageCount, + readableMessageCount: input.readableMessageCount, + topics: input.topics, + summaryText: input.summaryText, + rawOutput: input.rawOutput, + log: input.log + } + + this.records.push(record) + const scopedRecords = this.records + .filter((item) => item.accountScope === scope) + .sort((a, b) => b.createdAt - a.createdAt) + 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 + } + + hasAutoRecord(sessionId: string, periodStart: number, periodEnd: number): boolean { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return false + return this.getScopedRecords().some((record) => + record.triggerType === 'auto' && + record.sessionId === normalizedSessionId && + Number(record.periodStart || 0) === periodStart && + Number(record.periodEnd || 0) === periodEnd + ) + } + + listRecords(filters: GroupSummaryRecordFilters = {}): GroupSummaryRecordListResult { + try { + const sessionId = String(filters.sessionId || '').trim() + const startTime = Number(filters.startTime || 0) + const endTime = Number(filters.endTime || 0) + 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)))) + + const filtered = this.getScopedRecords() + .filter((record) => { + if (sessionId && record.sessionId !== sessionId) return false + const periodStart = Number(record.periodStart || 0) + const periodEnd = Number(record.periodEnd || 0) + if (startTime > 0 && periodEnd < startTime) return false + if (endTime > 0 && periodStart > endTime) return false + return true + }) + .sort((a, b) => Number(b.periodStart || b.createdAt) - Number(a.periodStart || a.createdAt)) + + return { + success: true, + records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)), + total: filtered.length + } + } catch (error) { + return { success: false, records: [], total: 0, error: (error as Error).message || String(error) } + } + } + + getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } { + this.ensureLoaded() + const normalizedId = String(id || '').trim() + if (!normalizedId) return { success: false, error: '记录 ID 为空' } + 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 } + } + + clearRuntimeCache(): void { + this.loaded = false + this.records = [] + this.filePath = null + } +} + +export const groupSummaryRecordService = new GroupSummaryRecordService() diff --git a/electron/services/groupSummaryService.ts b/electron/services/groupSummaryService.ts new file mode 100644 index 0000000..0972aa7 --- /dev/null +++ b/electron/services/groupSummaryService.ts @@ -0,0 +1,689 @@ +import https from 'https' +import http from 'http' +import { URL } from 'url' +import { ConfigService } from './config' +import { chatService, type ChatSession, type Message } from './chatService' +import { wcdbService } from './wcdbService' +import { + groupSummaryRecordService, + type GroupSummaryLog, + type GroupSummaryRecord, + type GroupSummaryRecordFilters, + type GroupSummaryRecordListResult, + type GroupSummaryTopic, + type GroupSummaryTriggerType +} from './groupSummaryRecordService' + +const API_TIMEOUT_MS = 90_000 +const API_TEMPERATURE = 0.4 +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 SUMMARY_CONFIG_KEYS = new Set([ + 'aiGroupSummaryEnabled', + 'aiGroupSummaryIntervalHours', + 'aiGroupSummarySystemPrompt', + 'aiGroupSummaryFilterMode', + 'aiGroupSummaryFilterList', + 'aiModelApiBaseUrl', + 'aiModelApiKey', + 'aiModelApiModel', + 'aiInsightApiBaseUrl', + 'aiInsightApiKey', + 'aiInsightApiModel', + 'dbPath', + 'decryptKey', + 'myWxid' +]) + +type GroupSummaryFilterMode = 'whitelist' | 'blacklist' + +interface SharedAiModelConfig { + apiBaseUrl: string + apiKey: string + model: string +} + +interface GroupSummaryTriggerResult { + success: boolean + message: string + recordId?: string + record?: GroupSummaryRecord + skipped?: boolean + skippedReason?: string +} + +class ApiRequestError extends Error { + statusCode?: number + responseBody?: string + + constructor(message: string, statusCode?: number, responseBody?: string) { + super(message) + this.name = 'ApiRequestError' + this.statusCode = statusCode + this.responseBody = responseBody + } +} + +function buildApiUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, '') + const suffix = path.startsWith('/') ? path : `/${path}` + return `${base}${suffix}` +} + +function normalizeSessionIdList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) +} + +function normalizeIntervalHours(value: unknown): number { + const allowed = new Set([1, 2, 4, 8, 12, 24]) + const numeric = Math.floor(Number(value) || 4) + return allowed.has(numeric) ? numeric : 4 +} + +function getStartOfDaySeconds(date: Date = new Date()): number { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return Math.floor(next.getTime() / 1000) +} + +function clampText(value: unknown, maxLength: number): string { + const text = String(value || '').replace(/\s+/g, ' ').trim() + if (text.length <= maxLength) return text + return `${text.slice(0, Math.max(0, maxLength - 1))}…` +} + +function stripJsonFence(value: string): string { + const text = String(value || '').trim() + const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) + if (fenced) return fenced[1].trim() + const firstBrace = text.indexOf('{') + const lastBrace = text.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace > firstBrace) { + return text.slice(firstBrace, lastBrace + 1).trim() + } + return text +} + +function shouldFallbackJsonMode(error: unknown): boolean { + const statusCode = (error as ApiRequestError)?.statusCode + if (statusCode === 400 || statusCode === 404 || statusCode === 422) return true + const text = `${(error as Error)?.message || ''}\n${(error as ApiRequestError)?.responseBody || ''}`.toLowerCase() + return text.includes('response_format') || text.includes('json_object') || text.includes('json mode') +} + +function formatTimestamp(createTime: number): string { + const ms = createTime > 1_000_000_000_000 ? createTime : createTime * 1000 + const date = new Date(ms) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +} + +function callChatCompletions( + apiBaseUrl: string, + apiKey: string, + model: string, + messages: Array<{ role: string; content: string }>, + options?: { responseFormatJson?: boolean } +): Promise { + return new Promise((resolve, reject) => { + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + let urlObj: URL + try { + urlObj = new URL(endpoint) + } catch { + reject(new Error(`无效的 API URL: ${endpoint}`)) + return + } + + const payload: Record = { + model, + messages, + temperature: API_TEMPERATURE, + stream: false + } + if (options?.responseFormatJson) { + payload.response_format = { type: 'json_object' } + } + + const body = JSON.stringify(payload) + const requestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'POST' as const, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body).toString(), + Authorization: `Bearer ${apiKey}` + } + } + + const requestFn = urlObj.protocol === 'https:' ? https.request : http.request + const req = requestFn(requestOptions, (res) => { + let data = '' + res.on('data', (chunk) => { data += chunk }) + res.on('end', () => { + try { + if (res.statusCode && res.statusCode >= 400) { + reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data)) + return + } + const parsed = JSON.parse(data) + const content = parsed?.choices?.[0]?.message?.content + if (typeof content === 'string' && content.trim()) { + resolve(content.trim()) + } else { + reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`)) + } + } catch { + reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)) + } + }) + }) + + req.setTimeout(API_TIMEOUT_MS, () => { + req.destroy() + reject(new Error('API 请求超时')) + }) + req.on('error', reject) + req.write(body) + req.end() + }) +} + +function parseTopics(rawOutput: string): GroupSummaryTopic[] { + const parsed = JSON.parse(stripJsonFence(rawOutput)) as unknown + if (!parsed || typeof parsed !== 'object') { + throw new Error('模型输出格式异常:JSON 根节点不是对象') + } + const source = parsed as Record + const rawTopics = Array.isArray(source.topics) ? source.topics : [] + const topics = rawTopics.map((item, index) => { + const topic = item && typeof item === 'object' ? item as Record : {} + const participantsRaw = Array.isArray(topic.participants) ? topic.participants : [] + const keyPointsRaw = Array.isArray(topic.key_points) + ? topic.key_points + : (Array.isArray(topic.keyPoints) ? topic.keyPoints : []) + return { + title: clampText(topic.title || `话题 ${index + 1}`, 48) || `话题 ${index + 1}`, + participants: participantsRaw.map((value) => clampText(value, 24)).filter(Boolean).slice(0, 12), + keyPoints: keyPointsRaw.map((value) => clampText(value, 120)).filter(Boolean).slice(0, 8), + conclusion: clampText(topic.conclusion, 180) || '无明确结论' + } + }).filter((topic) => topic.title || topic.keyPoints.length > 0 || topic.conclusion) + + if (topics.length === 0) { + throw new Error('模型输出格式异常:topics 为空') + } + return topics +} + +function buildSummaryText(topics: GroupSummaryTopic[]): string { + return topics.map((topic) => { + const participants = topic.participants.length > 0 ? topic.participants.join('、') : '未明确' + const keyPoints = topic.keyPoints.length > 0 ? topic.keyPoints.join(';') : '无' + return `【${topic.title}】参与者:${participants}。关键/矛盾点:${keyPoints}。结论:${topic.conclusion}` + }).join('\n') +} + +function fallbackTopicFromRaw(rawOutput: string): GroupSummaryTopic { + return { + title: '未归类总结', + participants: [], + keyPoints: [clampText(rawOutput, 500)], + conclusion: '模型未按固定 JSON 格式返回,请查看完整日志。' + } +} + +class GroupSummaryService { + private config: ConfigService + private started = false + private scanTimer: NodeJS.Timeout | null = null + private processing = false + private dbConnected = false + + constructor() { + this.config = ConfigService.getInstance() + } + + start(): void { + if (this.started) return + this.started = true + void this.refreshConfiguration('startup') + } + + stop(): void { + this.started = false + this.clearTimers() + this.processing = false + this.dbConnected = false + } + + async handleConfigChanged(key: string): Promise { + const normalizedKey = String(key || '').trim() + if (!SUMMARY_CONFIG_KEYS.has(normalizedKey)) return + if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { + this.dbConnected = false + groupSummaryRecordService.clearRuntimeCache() + } + await this.refreshConfiguration(`config:${normalizedKey}`) + } + + handleConfigCleared(): void { + this.clearTimers() + this.processing = false + this.dbConnected = false + groupSummaryRecordService.clearRuntimeCache() + } + + listRecords(filters?: GroupSummaryRecordFilters): GroupSummaryRecordListResult { + return groupSummaryRecordService.listRecords(filters || {}) + } + + getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } { + return groupSummaryRecordService.getRecord(id) + } + + async triggerManual(params: { + sessionId: string + displayName?: string + avatarUrl?: string + startTime: number + endTime: number + }): Promise { + if (!this.isEnabled()) { + return { success: false, message: '请先在设置中开启「AI 群聊总结」' } + } + const sessionId = String(params?.sessionId || '').trim() + if (!sessionId.endsWith('@chatroom')) { + return { success: false, message: 'AI 群聊总结仅支持群聊' } + } + const startTime = this.normalizeTimestampSeconds(params?.startTime) + const endTime = this.normalizeTimestampSeconds(params?.endTime) + if (startTime <= 0 || endTime <= startTime) { + return { success: false, message: '请选择有效的总结时段' } + } + if (endTime - startTime > MAX_MANUAL_RANGE_SECONDS) { + return { success: false, message: '手动总结时段不能超过 48 小时' } + } + + const displayName = String(params?.displayName || sessionId).trim() || sessionId + const avatarUrl = String(params?.avatarUrl || '').trim() || undefined + return this.generateSummaryForPeriod({ + sessionId, + displayName, + avatarUrl, + periodStart: startTime, + periodEnd: endTime, + triggerType: 'manual' + }) + } + + private async refreshConfiguration(_reason: string): Promise { + if (!this.started) return + this.clearTimers() + if (!this.isEnabled()) return + await this.runDueAutoSummaries() + this.scheduleNextAutoRun() + } + + private isEnabled(): boolean { + return this.config.get('aiGroupSummaryEnabled') === true + } + + private clearTimers(): void { + if (this.scanTimer !== null) { + clearTimeout(this.scanTimer) + this.scanTimer = null + } + } + + private scheduleNextAutoRun(): void { + if (!this.started || !this.isEnabled()) return + const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours')) + const now = Math.floor(Date.now() / 1000) + const dayStart = getStartOfDaySeconds(new Date()) + const intervalSeconds = intervalHours * 60 * 60 + const elapsed = Math.max(0, now - dayStart) + const nextBoundary = dayStart + (Math.floor(elapsed / intervalSeconds) + 1) * intervalSeconds + const delayMs = Math.max(1_000, (nextBoundary - now) * 1000 + 1_000) + + this.scanTimer = setTimeout(async () => { + this.scanTimer = null + await this.runDueAutoSummaries() + this.scheduleNextAutoRun() + }, delayMs) + } + + private async ensureConnected(): Promise { + if (this.dbConnected) return true + const result = await chatService.connect() + this.dbConnected = result.success === true + return this.dbConnected + } + + private getSharedAiModelConfig(): SharedAiModelConfig { + const apiBaseUrl = String( + this.config.get('aiModelApiBaseUrl') + || this.config.get('aiInsightApiBaseUrl') + || '' + ).trim() + const apiKey = String( + this.config.get('aiModelApiKey') + || this.config.get('aiInsightApiKey') + || '' + ).trim() + const model = String( + this.config.get('aiModelApiModel') + || this.config.get('aiInsightApiModel') + || 'gpt-4o-mini' + ).trim() || 'gpt-4o-mini' + 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')) + .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 { + 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 getCompletedPeriodsToday(): 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 }) + } + return periods + } + + private async runDueAutoSummaries(): Promise { + if (!this.started || !this.isEnabled() || this.processing) return + this.processing = true + try { + const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) return + const { mode, list } = this.getFilterConfig() + if (mode === 'whitelist' && list.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 periods = this.getCompletedPeriodsToday() + for (const period of periods) { + for (const session of groupSessions) { + 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, + periodStart: period.start, + periodEnd: period.end, + triggerType: 'auto' + }) + } + } + } catch (error) { + console.warn('[GroupSummaryService] 自动总结失败:', error) + } finally { + this.processing = false + } + } + + private async readMessagesInPeriod(sessionId: string, startTime: number, endTime: number): Promise { + if (!await this.ensureConnected()) { + throw new Error('数据库连接失败,请先在“数据库连接”页完成配置') + } + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + SUMMARY_CURSOR_BATCH_SIZE, + true, + startTime, + endTime + ) + if (!cursorResult.success || !cursorResult.cursor) { + throw new Error(cursorResult.error || '打开消息游标失败') + } + + const cursor = cursorResult.cursor + const messages: Message[] = [] + try { + let hasMore = true + while (hasMore && messages.length < MAX_MESSAGES_PER_SUMMARY) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + throw new Error(batch.error || '读取消息失败') + } + hasMore = batch.hasMore === true + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) { + if (!hasMore) break + continue + } + const mapped = chatService.mapRowsToMessagesForApi(rows, sessionId) + for (const message of mapped) { + const createTime = Number(message.createTime || 0) + if (createTime < startTime || createTime > endTime) continue + messages.push(message) + if (messages.length >= MAX_MESSAGES_PER_SUMMARY) break + } + } + } finally { + await wcdbService.closeMessageCursor(cursor).catch(() => {}) + } + + return messages.sort((a, b) => { + if (a.createTime !== b.createTime) return a.createTime - b.createTime + if (a.sortSeq !== b.sortSeq) return a.sortSeq - b.sortSeq + return a.localId - b.localId + }) + } + + private normalizeMessageText(message: Message): string { + const parsedContent = String(message.parsedContent || '').replace(/\s+/g, ' ').trim() + const quotedContent = String(message.quotedContent || '').replace(/\s+/g, ' ').trim() + const quotedSender = String(message.quotedSender || '').replace(/\s+/g, ' ').trim() + let text = parsedContent + if (quotedContent) { + const quote = quotedSender ? `${quotedSender}:${quotedContent}` : quotedContent + text = text && text !== '[引用消息]' ? `${text} [引用 ${quote}]` : `[引用 ${quote}]` + } + if (!text) { + text = String(message.linkTitle || message.fileName || message.appMsgDesc || '').replace(/\s+/g, ' ').trim() + } + if (!text) return '' + if (/^<\?xml|^ { + const readableMessages = messages.filter((message) => this.normalizeMessageText(message)) + const senderIds = Array.from(new Set( + readableMessages + .map((message) => String(message.senderUsername || '').trim()) + .filter(Boolean) + )) + const contacts = senderIds.length > 0 + ? (await chatService.enrichSessionsContactInfo(senderIds).catch(() => null))?.contacts || {} + : {} + const myWxid = String(this.config.getMyWxidCleaned() || '').trim() + + const lines = readableMessages.map((message) => { + const senderUsername = String(message.senderUsername || '').trim() + const senderName = message.isSend === 1 || (senderUsername && myWxid && senderUsername === myWxid) + ? '我' + : (contacts[senderUsername]?.displayName || senderUsername || '未知成员') + return `${formatTimestamp(message.createTime)} ${senderName}:${this.normalizeMessageText(message)}` + }) + + return { + transcript: lines.join('\n'), + readableMessages + } + } + + private async generateSummaryForPeriod(params: { + sessionId: string + displayName: string + avatarUrl?: string + periodStart: number + periodEnd: number + triggerType: GroupSummaryTriggerType + }): Promise { + const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + try { + const messages = await this.readMessagesInPeriod(params.sessionId, params.periodStart, params.periodEnd) + const { transcript, readableMessages } = await this.buildTranscript(params.sessionId, messages) + if (readableMessages.length < MIN_SUMMARY_MESSAGES) { + return { + success: true, + skipped: true, + skippedReason: 'message_count_too_low', + message: `该时段可总结消息少于 ${MIN_SUMMARY_MESSAGES} 条,已跳过` + } + } + + 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 userPrompt = `群聊:${params.displayName} +总结时段:${formatTimestamp(params.periodStart)} 至 ${formatTimestamp(params.periodEnd)} +消息数量:${readableMessages.length} + +群聊记录: +${transcript} + +请只输出指定 JSON。` + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + const requestMessages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ] + + let rawOutput = '' + let responseFormatJson = true + let responseFormatFallback = false + let responseFormatFallbackReason = '' + const startedAt = Date.now() + try { + rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages, { responseFormatJson: true }) + } catch (error) { + if (!shouldFallbackJsonMode(error)) throw error + responseFormatJson = false + responseFormatFallback = true + responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持' + rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages) + } + + let topics: GroupSummaryTopic[] + let finalSummary: string + try { + topics = parseTopics(rawOutput) + finalSummary = buildSummaryText(topics) + } catch { + topics = [fallbackTopicFromRaw(rawOutput)] + finalSummary = buildSummaryText(topics) + } + + const log: GroupSummaryLog = { + endpoint, + model, + temperature: API_TEMPERATURE, + triggerType: params.triggerType, + periodStart: params.periodStart, + periodEnd: params.periodEnd, + messageCount: messages.length, + readableMessageCount: readableMessages.length, + systemPrompt, + userPrompt, + rawOutput, + finalSummary, + durationMs: Date.now() - startedAt, + createdAt: Date.now(), + responseFormatJson, + responseFormatFallback, + responseFormatFallbackReason, + parsedTopics: topics + } + + const record = groupSummaryRecordService.addRecord({ + sessionId: params.sessionId, + displayName: params.displayName, + avatarUrl: params.avatarUrl, + triggerType: params.triggerType, + periodStart: params.periodStart, + periodEnd: params.periodEnd, + messageCount: messages.length, + readableMessageCount: readableMessages.length, + topics, + summaryText: finalSummary, + rawOutput, + log + }) + + return { success: true, message: '群聊总结已生成', recordId: record.id, record } + } catch (error) { + return { success: false, message: `生成失败:${(error as Error).message || String(error)}` } + } + } +} + +export const groupSummaryService = new GroupSummaryService() diff --git a/src/pages/Chat/ChatHeader.tsx b/src/pages/Chat/ChatHeader.tsx index 724f98e..bb6e1e3 100644 --- a/src/pages/Chat/ChatHeader.tsx +++ b/src/pages/Chat/ChatHeader.tsx @@ -8,6 +8,7 @@ import { Info, Loader2, Mic, + Newspaper, RefreshCw, Search, Sparkles, @@ -22,9 +23,11 @@ export interface ChatHeaderProps { isGroupChat: boolean standaloneSessionWindow: boolean showGroupMembersPanel: boolean + showGroupSummaryPanel: boolean showJumpPopover: boolean showInSessionSearch: boolean showDetailPanel: boolean + aiGroupSummaryEnabled: boolean shouldHideStandaloneDetailButton: boolean isPrivateSnsSupported: boolean isExportActionBusy: boolean @@ -39,6 +42,7 @@ export interface ChatHeaderProps { currentSessionId?: string | null jumpCalendarWrapRef: React.RefObject onTriggerSessionInsight: () => void + onToggleGroupSummaryPanel: () => void onGroupAnalytics: () => void onToggleGroupMembersPanel: () => void onExportCurrentSession: () => void @@ -56,9 +60,11 @@ function ChatHeader({ isGroupChat, standaloneSessionWindow, showGroupMembersPanel, + showGroupSummaryPanel, showJumpPopover, showInSessionSearch, showDetailPanel, + aiGroupSummaryEnabled, shouldHideStandaloneDetailButton, isPrivateSnsSupported, isExportActionBusy, @@ -73,6 +79,7 @@ function ChatHeader({ currentSessionId, jumpCalendarWrapRef, onTriggerSessionInsight, + onToggleGroupSummaryPanel, onGroupAnalytics, onToggleGroupMembersPanel, onExportCurrentSession, @@ -116,6 +123,17 @@ function ChatHeader({ > {isTriggeringSessionInsight ? : } + {isGroupChat && aiGroupSummaryEnabled && ( + + )} {!standaloneSessionWindow && isGroupChat && ( + + +
+
+ + setGroupSummaryDateFilter(event.target.value || formatDateInputLocal(new Date()))} + /> + +
+ +
+ {([1, 2, 4, 8, 12, 24] as const).map((hours) => ( + + ))} + +
+ + {groupSummaryRangeMode === 'custom' && ( +
+ setGroupSummaryCustomStart(event.target.value)} + /> + setGroupSummaryCustomEnd(event.target.value)} + /> +
+ )} + + +
少于 5 条可总结消息会自动跳过。
+
+ +
+ {groupSummaryLoading ? ( +
+ + 加载总结中... +
+ ) : groupSummaryError ? ( +
{groupSummaryError}
+ ) : groupSummaryRecords.length === 0 ? ( +
当前日期暂无群聊总结
+ ) : ( + <> +
共 {groupSummaryTotal} 条总结
+ {groupSummaryRecords.map((record) => ( +
+
+
+ {formatSummaryPeriod(record.periodStart, record.periodEnd)} + + {record.triggerType === 'manual' ? '手动' : '自动'} · {record.readableMessageCount} 条消息 + +
+ +
+
+ {record.topics.map((topic, topicIndex) => ( +
+
{topic.title}
+
+ 参与者 +

{topic.participants.length > 0 ? topic.participants.join('、') : '未明确'}

+
+
+ 关键/矛盾点 +

{topic.keyPoints.length > 0 ? topic.keyPoints.join(';') : '无'}

+
+
+ 结论 +

{topic.conclusion || '暂无明确结论'}

+
+
+ ))} +
+
+ ))} + + )} +
+ + )} + {/* 会话详情面板 */} {showDetailPanel && (
@@ -8339,6 +8693,79 @@ function ChatPage(props: ChatPageProps) { document.body )} + {groupSummaryLogRecord && createPortal( +
setGroupSummaryLogRecord(null)}> +
e.stopPropagation()}> +
+

群聊总结日志

+ +
+
+
+
+ 群聊 + {groupSummaryLogRecord.displayName} +
+
+ 时段 + {formatSummaryPeriod(groupSummaryLogRecord.periodStart, groupSummaryLogRecord.periodEnd)} +
+
+ 触发 + {groupSummaryLogRecord.triggerType === 'manual' ? '手动' : '自动'} +
+
+ 模型 + {groupSummaryLogRecord.log.model} +
+
+ 消息数 + {groupSummaryLogRecord.log.readableMessageCount} / {groupSummaryLogRecord.log.messageCount} +
+
+ JSON Mode + + {groupSummaryLogRecord.log.responseFormatJson ? '启用' : '未启用'} + {groupSummaryLogRecord.log.responseFormatFallback ? `,已降级:${groupSummaryLogRecord.log.responseFormatFallbackReason || '未知原因'}` : ''} + +
+
+
+
+ + 系统提示词 +
+
{groupSummaryLogRecord.log.systemPrompt}
+
+
+
+ + 用户提示词与完整记录 +
+
{groupSummaryLogRecord.log.userPrompt}
+
+
+
+ + 模型输出原文 +
+
{groupSummaryLogRecord.log.rawOutput}
+
+
+
+ + 最终总结 +
+
{groupSummaryLogRecord.log.finalSummary}
+
+
+
+
, + document.body + )} + {/* 修改消息弹窗 */} {editingMessage && createPortal(
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 0b51991..940cf60 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -32,6 +32,7 @@ type SettingsTab = | 'aiCommon' | 'insight' | 'aiFootprint' + | 'aiGroupSummary' | 'aiMessageInsight' | 'autoDownload' @@ -57,10 +58,11 @@ const filteredTabs = tabs.filter(tab => { return true }) -const aiTabs: Array<{ id: Extract; label: string }> = [ +const aiTabs: Array<{ id: Extract; label: string }> = [ { id: 'aiCommon', label: '基础配置' }, { id: 'insight', label: 'AI 见解' }, { id: 'aiFootprint', label: 'AI 足迹' }, + { id: 'aiGroupSummary', label: '群聊总结' }, { id: 'aiMessageInsight', label: '消息解析' } ] @@ -329,6 +331,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState(null) const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false) const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('') + 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) const [aiMessageInsightSystemPrompt, setAiMessageInsightSystemPrompt] = useState('') @@ -377,7 +386,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { }, [location.state]) useEffect(() => { - if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiMessageInsight') { + if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') { setAiGroupExpanded(true) } }, [activeTab]) @@ -595,6 +604,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings() const savedAiFootprintEnabled = await configService.getAiFootprintEnabled() const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt() + 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() const savedAiMessageInsightSystemPrompt = await configService.getAiMessageInsightSystemPrompt() @@ -624,6 +638,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiInsightWeiboBindings(savedAiInsightWeiboBindings) setAiFootprintEnabled(savedAiFootprintEnabled) setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt) + setAiGroupSummaryEnabled(savedAiGroupSummaryEnabled) + setAiGroupSummaryIntervalHours(savedAiGroupSummaryIntervalHours) + setAiGroupSummarySystemPrompt(savedAiGroupSummarySystemPrompt) + setAiGroupSummaryFilterMode(savedAiGroupSummaryFilterMode) + setAiGroupSummaryFilterList(savedAiGroupSummaryFilterList) setAiMessageInsightEnabled(savedAiMessageInsightEnabled) setAiMessageInsightContextCount(savedAiMessageInsightContextCount) setAiMessageInsightSystemPrompt(savedAiMessageInsightSystemPrompt) @@ -2914,6 +2933,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { messagePushFilterSearchKeyword ) + const groupSummaryFilterOptions = sessionFilterOptions.filter((session) => session.type === 'group') + const groupSummaryAvailableSessions = groupSummaryFilterOptions.filter((session) => { + const keyword = aiGroupSummaryFilterSearchKeyword.trim().toLowerCase() + if (aiGroupSummaryFilterList.includes(session.username)) return false + if (!keyword) return true + return String(session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + }) + const handleAddAllNotificationFilterSessions = async () => { const usernames = notificationAvailableSessions.map(session => session.username) if (usernames.length === 0) return @@ -4032,6 +4060,249 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) + const renderAiGroupSummaryTab = () => { + 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) + } + + const removeFromFilterList = async (username: string) => { + const next = aiGroupSummaryFilterList.filter((item) => item !== username) + setAiGroupSummaryFilterList(next) + await configService.setAiGroupSummaryFilterList(next) + showMessage('已从群聊总结名单移除', true) + } + + const addAllFiltered = async () => { + const usernames = groupSummaryAvailableSessions.map((session) => session.username) + if (usernames.length === 0) return + const next = Array.from(new Set([...aiGroupSummaryFilterList, ...usernames])) + setAiGroupSummaryFilterList(next) + await configService.setAiGroupSummaryFilterList(next) + showMessage(`已添加 ${usernames.length} 个群聊`, true) + } + + const clearFilterList = async () => { + if (aiGroupSummaryFilterList.length === 0) return + setAiGroupSummaryFilterList([]) + await configService.setAiGroupSummaryFilterList([]) + showMessage('已清空群聊总结名单', true) + } + + return ( +
+
+ + + 开启后,群聊页顶部会显示 AI 总结按钮;自动总结只对下面黑白名单命中的群聊生效。默认白名单且不选择任何群聊,不会自动消耗 token。 + +
+ {aiGroupSummaryEnabled ? '已开启' : '已关闭'} + +
+
+ +
+ +
+ + + 按本地系统时间从当天 00:00 开始切分完整时间段,到点总结上一段。时段内可总结消息少于 5 条时会跳过。 + +
+ {[1, 2, 4, 8, 12, 24].map((hours) => ( + + ))} +
+
+ +
+
+ + {aiGroupSummarySystemPrompt && ( + + )} +
+ + 固定输出格式不会被覆盖,这里只补充你的偏好,例如更关注决策、争议或待办。 + +