fix: Group Chat Summary

This commit is contained in:
Jason
2026-05-23 17:57:24 +08:00
parent 87b39196c1
commit fbd3b78b87
14 changed files with 529 additions and 261 deletions

View File

@@ -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) {

View File

@@ -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: {

View File

@@ -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)
}
}
// === 验证 ===

View File

@@ -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
}
}

View File

@@ -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<void> {
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<GroupSummaryDayTriggerResult> {
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<void> {
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<void> {
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<void> {
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<void> {
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<GroupSummaryDayTriggerResult> {
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()