feat: Add AI Summaries for Group Chats

This commit is contained in:
Jason
2026-05-22 23:50:49 +08:00
parent 52ba55ee80
commit 87b39196c1
11 changed files with 2122 additions and 4 deletions

View File

@@ -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<void> => {
destroyNotificationWindow()
messagePushService.stop()
insightService.stop()
groupSummaryService.stop()
// 兜底5秒后强制退出防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout')

View File

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

View File

@@ -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: '',

View File

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

View File

@@ -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<string> {
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<string, unknown> = {
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<string, unknown>
const rawTopics = Array.isArray(source.topics) ? source.topics : []
const topics = rawTopics.map((item, index) => {
const topic = item && typeof item === 'object' ? item as Record<string, unknown> : {}
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<void> {
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<GroupSummaryTriggerResult> {
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<void> {
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<boolean> {
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<void> {
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<Message[]> {
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<string, any>[] : []
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|^<msg\b|^<appmsg\b|^<img\b|^<emoji\b/i.test(text)) return ''
return text
}
private async buildTranscript(sessionId: string, messages: Message[]): Promise<{ transcript: string; readableMessages: Message[] }> {
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<GroupSummaryTriggerResult> {
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()

View File

@@ -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<HTMLDivElement | null>
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 ? <Loader2 size={18} className="spin" /> : <Sparkles size={18} />}
</button>
{isGroupChat && aiGroupSummaryEnabled && (
<button
className={`icon-btn group-summary-btn ${showGroupSummaryPanel ? 'active' : ''}`}
onClick={onToggleGroupSummaryPanel}
disabled={!currentSessionId}
title="AI 群聊总结"
aria-label="AI 群聊总结"
>
<Newspaper size={18} />
</button>
)}
{!standaloneSessionWindow && isGroupChat && (
<button className="icon-btn group-analytics-btn" onClick={onGroupAnalytics} title="群聊分析">
<BarChart3 size={18} />
@@ -217,9 +235,11 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
prev.isGroupChat === next.isGroupChat &&
prev.standaloneSessionWindow === next.standaloneSessionWindow &&
prev.showGroupMembersPanel === next.showGroupMembersPanel &&
prev.showGroupSummaryPanel === next.showGroupSummaryPanel &&
prev.showJumpPopover === next.showJumpPopover &&
prev.showInSessionSearch === next.showInSessionSearch &&
prev.showDetailPanel === next.showDetailPanel &&
prev.aiGroupSummaryEnabled === next.aiGroupSummaryEnabled &&
prev.shouldHideStandaloneDetailButton === next.shouldHideStandaloneDetailButton &&
prev.isPrivateSnsSupported === next.isPrivateSnsSupported &&
prev.isExportActionBusy === next.isExportActionBusy &&
@@ -234,6 +254,7 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
prev.currentSessionId === next.currentSessionId &&
prev.jumpCalendarWrapRef === next.jumpCalendarWrapRef &&
prev.onTriggerSessionInsight === next.onTriggerSessionInsight &&
prev.onToggleGroupSummaryPanel === next.onToggleGroupSummaryPanel &&
prev.onGroupAnalytics === next.onGroupAnalytics &&
prev.onToggleGroupMembersPanel === next.onToggleGroupMembersPanel &&
prev.onExportCurrentSession === next.onExportCurrentSession &&

View File

@@ -3569,6 +3569,253 @@
}
}
.group-summary-panel {
width: clamp(320px, 30vw, 420px);
min-width: 320px;
max-width: 420px;
.group-summary-controls {
padding: 12px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-primary);
}
.group-summary-date-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
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-icon-btn,
.group-summary-code-btn {
width: 30px;
height: 30px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
border-radius: 8px;
background: var(--card-bg);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
color: var(--primary);
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
}
.spin {
animation: spin 1s linear infinite;
}
}
.group-summary-range-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
button {
height: 30px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
border-radius: 8px;
background: var(--card-bg);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.16s ease;
&.active {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--card-bg));
font-weight: 600;
}
}
}
.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;
border-radius: 8px;
background: var(--primary);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.16s ease, opacity 0.16s ease;
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
}
.spin {
animation: spin 1s linear infinite;
}
}
.group-summary-rule-hint,
.group-summary-count {
font-size: 12px;
color: var(--text-tertiary);
}
.group-summary-list {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.group-summary-record {
border: 1px solid color-mix(in srgb, var(--border-color) 74%, transparent);
background: var(--bg-secondary);
border-radius: 10px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.group-summary-record-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.group-summary-period {
display: block;
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.group-summary-meta {
display: block;
margin-top: 3px;
color: var(--text-tertiary);
font-size: 12px;
}
.group-summary-topic-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.group-summary-topic {
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
padding: 9px;
h5 {
margin: 0 0 7px;
font-size: 13px;
color: var(--text-primary);
line-height: 1.35;
}
}
.group-summary-topic-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px;
padding: 5px 0;
border-top: 1px dashed color-mix(in srgb, var(--border-color) 72%, transparent);
span {
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.45;
}
p {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
word-break: break-word;
}
}
}
.group-summary-log-modal {
width: min(860px, calc(100vw - 32px));
max-height: min(760px, calc(100vh - 32px));
.detail-content {
max-height: calc(100vh - 120px);
}
}
.group-summary-log-pre {
margin: 0;
max-height: 260px;
overflow: auto;
padding: 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 86%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
@keyframes detailCardEnter {
from {
opacity: 0;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper, Star, Sparkles } from 'lucide-react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper, Star, Sparkles, Code2 } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
@@ -8,6 +8,7 @@ import { useChatStore } from '../stores/chatStore'
import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import type { ChatRecordItem, ChatSession, Message } from '../types/models'
import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron'
import { getEmojiPath } from 'wechat-emojis'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
@@ -68,6 +69,7 @@ interface QuotedMessageJumpTarget {
type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done'
type GlobalMsgSearchResult = Message & { sessionId: string }
type GroupSummaryRangeMode = 1 | 2 | 4 | 8 | 12 | 24 | 'custom'
interface GlobalMsgPrefixCacheEntry {
keyword: string
@@ -78,6 +80,7 @@ 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
@@ -762,6 +765,32 @@ function formatYmdHmDateTime(timestamp?: number): string {
return `${y}-${m}-${day} ${h}:${min}`
}
function formatDateInputLocal(date: Date): string {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
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)}`
}
interface ChatPageProps {
standaloneSessionWindow?: boolean
initialSessionId?: string | null
@@ -1537,6 +1566,19 @@ function ChatPage(props: ChatPageProps) {
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null)
const [showMessageInfo, setShowMessageInfo] = useState<Message | null>(null)
const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null)
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
const [showGroupSummaryPanel, setShowGroupSummaryPanel] = useState(false)
const [groupSummaryRecords, setGroupSummaryRecords] = useState<GroupSummaryRecordSummary[]>([])
const [groupSummaryTotal, setGroupSummaryTotal] = useState(0)
const [groupSummaryLoading, setGroupSummaryLoading] = useState(false)
const [groupSummaryError, setGroupSummaryError] = useState<string | null>(null)
const [groupSummaryDateFilter, setGroupSummaryDateFilter] = useState(() => formatDateInputLocal(new Date()))
const [groupSummaryRangeMode, setGroupSummaryRangeMode] = useState<GroupSummaryRangeMode>(4)
const [groupSummaryCustomStart, setGroupSummaryCustomStart] = useState(() => formatDateTimeLocal(new Date(Date.now() - 4 * 60 * 60 * 1000)))
const [groupSummaryCustomEnd, setGroupSummaryCustomEnd] = useState(() => formatDateTimeLocal(new Date()))
const [isTriggeringGroupSummary, setIsTriggeringGroupSummary] = useState(false)
const [groupSummaryHint, setGroupSummaryHint] = useState<{ success: boolean; message: string } | null>(null)
const [groupSummaryLogRecord, setGroupSummaryLogRecord] = useState<GroupSummaryRecord | null>(null)
// 多选模式
const [isSelectionMode, setIsSelectionMode] = useState(false)
@@ -1861,6 +1903,121 @@ function ChatPage(props: ChatPageProps) {
})
}, [])
const getGroupSummaryDateRangeMs = useCallback(() => {
const date = groupSummaryDateFilter || 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() }
}
const end = new Date(start)
end.setHours(23, 59, 59, 999)
return { startTime: start.getTime(), endTime: end.getTime() }
}, [groupSummaryDateFilter])
const loadGroupSummaryRecords = useCallback(async (sessionId?: string) => {
const targetSessionId = String(sessionId || currentSessionRef.current || '').trim()
if (!targetSessionId || !targetSessionId.endsWith('@chatroom')) return
const { startTime, endTime } = getGroupSummaryDateRangeMs()
setGroupSummaryLoading(true)
setGroupSummaryError(null)
try {
const result = await window.electronAPI.groupSummary.listRecords({
sessionId: targetSessionId,
startTime,
endTime,
limit: 100
})
if (currentSessionRef.current !== targetSessionId) return
if (!result.success) {
setGroupSummaryRecords([])
setGroupSummaryTotal(0)
setGroupSummaryError(result.error || '读取群聊总结失败')
return
}
setGroupSummaryRecords(result.records || [])
setGroupSummaryTotal(result.total || 0)
} catch (error) {
if (currentSessionRef.current !== targetSessionId) return
setGroupSummaryRecords([])
setGroupSummaryTotal(0)
setGroupSummaryError((error as Error).message || String(error))
} finally {
if (currentSessionRef.current === targetSessionId) {
setGroupSummaryLoading(false)
}
}
}, [getGroupSummaryDateRangeMs])
const resolveGroupSummaryManualRange = 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 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
}
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)
}
} 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])
const openGroupSummaryLog = useCallback(async (recordId: string) => {
try {
const result = await window.electronAPI.groupSummary.getRecord(recordId)
if (!result.success || !result.record) {
setGroupSummaryHint({ success: false, message: result.error || '读取总结日志失败' })
return
}
setGroupSummaryLogRecord(result.record)
} catch (error) {
setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) })
}
}, [])
const handleToggleJumpPopover = useCallback(() => {
if (!currentSessionId) return
if (showJumpPopover) {
@@ -2903,9 +3060,21 @@ function ChatPage(props: ChatPageProps) {
return
}
setShowDetailPanel(false)
setShowGroupSummaryPanel(false)
setShowGroupMembersPanel(true)
}, [currentSessionId, showGroupMembersPanel, isGroupChatSession])
const toggleGroupSummaryPanel = useCallback(() => {
if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) return
if (showGroupSummaryPanel) {
setShowGroupSummaryPanel(false)
return
}
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(true)
}, [aiGroupSummaryEnabled, currentSessionId, showGroupSummaryPanel, isGroupChatSession])
// 切换详情面板
const toggleDetailPanel = useCallback(() => {
if (showDetailPanel) {
@@ -2913,6 +3082,7 @@ function ChatPage(props: ChatPageProps) {
return
}
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setShowDetailPanel(true)
if (currentSessionId) {
void loadSessionDetail(currentSessionId)
@@ -2929,6 +3099,15 @@ function ChatPage(props: ChatPageProps) {
void loadGroupMembersPanel(currentSessionId)
}, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession])
useEffect(() => {
if (!showGroupSummaryPanel) return
if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) {
setShowGroupSummaryPanel(false)
return
}
void loadGroupSummaryRecords(currentSessionId)
}, [aiGroupSummaryEnabled, currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, showGroupSummaryPanel, isGroupChatSession])
useEffect(() => {
const chatroomId = String(sessionDetail?.wxid || '').trim()
if (!chatroomId || !chatroomId.includes('@chatroom')) return
@@ -3010,6 +3189,10 @@ function ChatPage(props: ChatPageProps) {
setIsLoadingRelationStats(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setGroupSummaryRecords([])
setGroupSummaryError(null)
setGroupSummaryHint(null)
setGroupPanelMembers([])
setGroupMembersError(null)
setGroupMembersLoadingHint('')
@@ -4405,6 +4588,9 @@ function ChatPage(props: ChatPageProps) {
setShowJumpPopover(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setGroupSummaryError(null)
setGroupSummaryHint(null)
setGroupMemberSearchKeyword('')
setGroupMembersError(null)
setGroupMembersLoadingHint('')
@@ -5870,6 +6056,29 @@ function ChatPage(props: ChatPageProps) {
}
}, [])
useEffect(() => {
let canceled = false
const loadGroupSummaryConfig = () => {
void configService.getAiGroupSummaryEnabled()
.then((enabled) => {
if (!canceled) setAiGroupSummaryEnabled(enabled)
})
.catch((error) => {
console.warn('加载群聊总结配置失败:', error)
if (!canceled) setAiGroupSummaryEnabled(false)
})
}
loadGroupSummaryConfig()
const handleFocus = () => loadGroupSummaryConfig()
window.addEventListener('focus', handleFocus)
return () => {
canceled = true
window.removeEventListener('focus', handleFocus)
}
}, [])
useEffect(() => {
if (!standaloneSessionWindow) return
setStandaloneInitialLoadRequested(false)
@@ -7375,9 +7584,11 @@ function ChatPage(props: ChatPageProps) {
isGroupChat={isCurrentSessionGroup}
standaloneSessionWindow={standaloneSessionWindow}
showGroupMembersPanel={showGroupMembersPanel}
showGroupSummaryPanel={showGroupSummaryPanel}
showJumpPopover={showJumpPopover}
showInSessionSearch={showInSessionSearch}
showDetailPanel={showDetailPanel}
aiGroupSummaryEnabled={aiGroupSummaryEnabled}
shouldHideStandaloneDetailButton={shouldHideStandaloneDetailButton}
isPrivateSnsSupported={isCurrentSessionPrivateSnsSupported}
isExportActionBusy={isExportActionBusy}
@@ -7392,6 +7603,7 @@ function ChatPage(props: ChatPageProps) {
currentSessionId={currentSessionId}
jumpCalendarWrapRef={jumpCalendarWrapRef}
onTriggerSessionInsight={handleTriggerSessionInsight}
onToggleGroupSummaryPanel={toggleGroupSummaryPanel}
onGroupAnalytics={handleGroupAnalytics}
onToggleGroupMembersPanel={toggleGroupMembersPanel}
onExportCurrentSession={handleExportCurrentSession}
@@ -7443,6 +7655,13 @@ function ChatPage(props: ChatPageProps) {
</div>
)}
{groupSummaryHint && (
<div className={`session-insight-hint ${groupSummaryHint.success ? 'success' : 'error'}`} role="status" aria-live="polite">
{isTriggeringGroupSummary ? <Loader2 size={14} className="spin" /> : <Newspaper size={14} />}
<span>{groupSummaryHint.message}</span>
</div>
)}
<ContactSnsTimelineDialog
target={chatSnsTimelineTarget}
onClose={() => setChatSnsTimelineTarget(null)}
@@ -7683,6 +7902,141 @@ function ChatPage(props: ChatPageProps) {
</div>
)}
{showGroupSummaryPanel && isCurrentSessionGroup && (
<div className="detail-panel group-summary-panel">
<div className="detail-header">
<div className="detail-title-wrap">
<h4>AI </h4>
<span className="detail-title-sub">{currentSession?.displayName || currentSessionId}</span>
</div>
<button className="close-btn" onClick={() => setShowGroupSummaryPanel(false)}>
<X size={16} />
</button>
</div>
<div className="group-summary-controls">
<div className="group-summary-date-row">
<label></label>
<input
type="date"
value={groupSummaryDateFilter}
onChange={(event) => setGroupSummaryDateFilter(event.target.value || formatDateInputLocal(new Date()))}
/>
<button
type="button"
className="group-summary-icon-btn"
onClick={() => void loadGroupSummaryRecords(currentSessionId || undefined)}
title="刷新总结"
>
<RefreshCw size={14} className={groupSummaryLoading ? 'spin' : ''} />
</button>
</div>
<div className="group-summary-range-tabs">
{([1, 2, 4, 8, 12, 24] as const).map((hours) => (
<button
key={hours}
type="button"
className={groupSummaryRangeMode === hours ? 'active' : ''}
onClick={() => setGroupSummaryRangeMode(hours)}
>
{hours}h
</button>
))}
<button
type="button"
className={groupSummaryRangeMode === 'custom' ? 'active' : ''}
onClick={() => setGroupSummaryRangeMode('custom')}
>
</button>
</div>
{groupSummaryRangeMode === 'custom' && (
<div className="group-summary-custom-range">
<input
type="datetime-local"
value={groupSummaryCustomStart}
onChange={(event) => setGroupSummaryCustomStart(event.target.value)}
/>
<input
type="datetime-local"
value={groupSummaryCustomEnd}
onChange={(event) => setGroupSummaryCustomEnd(event.target.value)}
/>
</div>
)}
<button
type="button"
className="group-summary-generate-btn"
onClick={() => void triggerManualGroupSummary()}
disabled={isTriggeringGroupSummary}
>
{isTriggeringGroupSummary ? <Loader2 size={14} className="spin" /> : <Sparkles size={14} />}
<span></span>
</button>
<div className="group-summary-rule-hint"> 5 </div>
</div>
<div className="group-summary-list">
{groupSummaryLoading ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
<span>...</span>
</div>
) : groupSummaryError ? (
<div className="detail-empty">{groupSummaryError}</div>
) : groupSummaryRecords.length === 0 ? (
<div className="detail-empty"></div>
) : (
<>
<div className="group-summary-count"> {groupSummaryTotal} </div>
{groupSummaryRecords.map((record) => (
<div key={record.id} className="group-summary-record">
<div className="group-summary-record-head">
<div>
<span className="group-summary-period">{formatSummaryPeriod(record.periodStart, record.periodEnd)}</span>
<span className="group-summary-meta">
{record.triggerType === 'manual' ? '手动' : '自动'} · {record.readableMessageCount}
</span>
</div>
<button
type="button"
className="group-summary-code-btn"
onClick={() => void openGroupSummaryLog(record.id)}
title="查看完整日志"
>
<Code2 size={14} />
</button>
</div>
<div className="group-summary-topic-list">
{record.topics.map((topic, topicIndex) => (
<div key={`${record.id}-${topicIndex}`} className="group-summary-topic">
<h5>{topic.title}</h5>
<div className="group-summary-topic-row">
<span></span>
<p>{topic.participants.length > 0 ? topic.participants.join('、') : '未明确'}</p>
</div>
<div className="group-summary-topic-row">
<span>/</span>
<p>{topic.keyPoints.length > 0 ? topic.keyPoints.join('') : '无'}</p>
</div>
<div className="group-summary-topic-row">
<span></span>
<p>{topic.conclusion || '暂无明确结论'}</p>
</div>
</div>
))}
</div>
</div>
))}
</>
)}
</div>
</div>
)}
{/* 会话详情面板 */}
{showDetailPanel && (
<div className="detail-panel session-detail-panel">
@@ -8339,6 +8693,79 @@ function ChatPage(props: ChatPageProps) {
document.body
)}
{groupSummaryLogRecord && createPortal(
<div className="message-info-overlay" onClick={() => setGroupSummaryLogRecord(null)}>
<div className="message-info-modal group-summary-log-modal" onClick={(e) => e.stopPropagation()}>
<div className="detail-header">
<h4></h4>
<button className="close-btn" onClick={() => setGroupSummaryLogRecord(null)}>
<X size={16} />
</button>
</div>
<div className="detail-content">
<div className="detail-section">
<div className="detail-item">
<span className="label"></span>
<span className="value">{groupSummaryLogRecord.displayName}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{formatSummaryPeriod(groupSummaryLogRecord.periodStart, groupSummaryLogRecord.periodEnd)}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{groupSummaryLogRecord.triggerType === 'manual' ? '手动' : '自动'}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{groupSummaryLogRecord.log.model}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{groupSummaryLogRecord.log.readableMessageCount} / {groupSummaryLogRecord.log.messageCount}</span>
</div>
<div className="detail-item">
<span className="label">JSON Mode</span>
<span className="value">
{groupSummaryLogRecord.log.responseFormatJson ? '启用' : '未启用'}
{groupSummaryLogRecord.log.responseFormatFallback ? `,已降级:${groupSummaryLogRecord.log.responseFormatFallbackReason || '未知原因'}` : ''}
</span>
</div>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span></span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.systemPrompt}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span></span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.userPrompt}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span></span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.rawOutput}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Newspaper size={14} />
<span></span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.finalSummary}</pre>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 修改消息弹窗 */}
{editingMessage && createPortal(
<div className="modal-overlay">

View File

@@ -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<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint' | 'aiMessageInsight'>; label: string }> = [
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint' | 'aiGroupSummary' | 'aiMessageInsight'>; 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<string | null>(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<configService.AiGroupSummaryFilterMode>('whitelist')
const [aiGroupSummaryFilterList, setAiGroupSummaryFilterList] = useState<string[]>([])
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 = {}) {
</div>
)
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 (
<div className="tab-content">
<div className="form-group">
<label>AI </label>
<span className="form-hint">
AI token
</span>
<div className="log-toggle-line">
<span className="log-status">{aiGroupSummaryEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiGroupSummaryEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiGroupSummaryEnabled(val)
await configService.setAiGroupSummaryEnabled(val)
showMessage(val ? 'AI 群聊总结已开启' : 'AI 群聊总结已关闭', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
00:00 5
</span>
<div className="push-filter-type-tabs" style={{ marginTop: 10 }}>
{[1, 2, 4, 8, 12, 24].map((hours) => (
<button
key={hours}
type="button"
className={`push-filter-type-tab ${aiGroupSummaryIntervalHours === hours ? 'active' : ''}`}
onClick={() => {
setAiGroupSummaryIntervalHours(hours)
scheduleConfigSave('aiGroupSummaryIntervalHours', () => configService.setAiGroupSummaryIntervalHours(hours))
}}
>
{hours}
</button>
))}
</div>
</div>
<div className="form-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<label style={{ marginBottom: 0 }}></label>
{aiGroupSummarySystemPrompt && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={async () => {
setAiGroupSummarySystemPrompt('')
await configService.setAiGroupSummarySystemPrompt('')
}}
>
</button>
)}
</div>
<span className="form-hint">
</span>
<textarea
className="field-input"
rows={5}
style={{ width: '100%', resize: 'vertical', marginTop: 8 }}
value={aiGroupSummarySystemPrompt}
placeholder="例如:优先识别待办事项和负责人,忽略纯闲聊。"
onChange={(e) => {
const val = e.target.value
setAiGroupSummarySystemPrompt(val)
scheduleConfigSave('aiGroupSummarySystemPrompt', () => configService.setAiGroupSummarySystemPrompt(val))
}}
/>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
AI
</span>
<div className="custom-select" style={{ maxWidth: 240, marginTop: 8 }}>
<div
className={`custom-select-trigger ${aiGroupSummaryFilterDropdownOpen ? 'open' : ''}`}
onClick={() => setAiGroupSummaryFilterDropdownOpen(!aiGroupSummaryFilterDropdownOpen)}
>
<span className="custom-select-value">
{aiGroupSummaryFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${aiGroupSummaryFilterDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${aiGroupSummaryFilterDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'whitelist', label: '白名单模式' },
{ value: 'blacklist', label: '黑名单模式' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${aiGroupSummaryFilterMode === option.value ? 'selected' : ''}`}
onClick={async () => {
const mode = option.value as configService.AiGroupSummaryFilterMode
setAiGroupSummaryFilterMode(mode)
setAiGroupSummaryFilterDropdownOpen(false)
await configService.setAiGroupSummaryFilterMode(mode)
showMessage(mode === 'whitelist' ? '已切换为白名单模式' : '已切换为黑名单模式', true)
}}
>
{option.label}
{aiGroupSummaryFilterMode === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
{aiGroupSummaryFilterMode === 'whitelist' && aiGroupSummaryFilterList.length === 0 && (
<div className="api-docs" style={{ marginTop: 12 }}>
<div className="api-item">
<p className="api-desc"></p>
</div>
</div>
)}
<div className="notification-filter-container" style={{ marginTop: 12 }}>
<div className="filter-panel">
<div className="filter-panel-header">
<span></span>
{groupSummaryAvailableSessions.length > 0 && (
<button type="button" className="filter-panel-action" onClick={() => { void addAllFiltered() }}>
</button>
)}
<div className="filter-search-box">
<Search size={14} />
<input
type="text"
placeholder="搜索群聊..."
value={aiGroupSummaryFilterSearchKeyword}
onChange={(e) => setAiGroupSummaryFilterSearchKeyword(e.target.value)}
/>
</div>
</div>
<div className="filter-panel-list">
{groupSummaryAvailableSessions.length > 0 ? (
groupSummaryAvailableSessions.map(session => (
<div
key={session.username}
className="filter-panel-item"
onClick={() => { void addToFilterList(session.username) }}
>
<Avatar src={session.avatarUrl} name={session.displayName || session.username} size={28} />
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-type"></span>
<span className="filter-item-action">+</span>
</div>
))
) : (
<div className="filter-panel-empty">
{aiGroupSummaryFilterSearchKeyword ? '没有匹配的群聊' : '暂无可添加的群聊'}
</div>
)}
</div>
</div>
<div className="filter-panel">
<div className="filter-panel-header">
<span>{aiGroupSummaryFilterMode === 'whitelist' ? '白名单' : '黑名单'}</span>
{aiGroupSummaryFilterList.length > 0 && (
<span className="filter-panel-count">{aiGroupSummaryFilterList.length}</span>
)}
{aiGroupSummaryFilterList.length > 0 && (
<button type="button" className="filter-panel-action" onClick={() => { void clearFilterList() }}>
</button>
)}
</div>
<div className="filter-panel-list">
{aiGroupSummaryFilterList.length > 0 ? (
aiGroupSummaryFilterList.map(username => {
const info = getSessionFilterOptionInfo(username)
return (
<div
key={username}
className="filter-panel-item selected"
onClick={() => { void removeFromFilterList(username) }}
>
<Avatar src={info.avatarUrl} name={info.displayName} size={28} />
<span className="filter-item-name">{info.displayName}</span>
<span className="filter-item-type"></span>
<span className="filter-item-action">×</span>
</div>
)
})
) : (
<div className="filter-panel-empty"></div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
const renderAiMessageInsightTab = () => (
<div className="tab-content">
{(() => {
@@ -5161,7 +5432,7 @@ JSON 输出格式:
row.push(
<div key="ai-settings-group" className={`tab-group ${aiGroupExpanded ? 'expanded' : ''}`}>
<button
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiMessageInsight') ? 'active' : ''}`}
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') ? 'active' : ''}`}
onClick={() => setAiGroupExpanded((prev) => !prev)}
aria-expanded={aiGroupExpanded}
>
@@ -5203,6 +5474,7 @@ JSON 输出格式:
{activeTab === 'aiCommon' && renderAiCommonTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
{activeTab === 'aiGroupSummary' && renderAiGroupSummaryTab()}
{activeTab === 'aiMessageInsight' && renderAiMessageInsightTab()}
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
{activeTab === 'updates' && renderUpdatesTab()}

View File

@@ -120,6 +120,11 @@ export const CONFIG_KEYS = {
// AI 足迹
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
AI_GROUP_SUMMARY_ENABLED: 'aiGroupSummaryEnabled',
AI_GROUP_SUMMARY_INTERVAL_HOURS: 'aiGroupSummaryIntervalHours',
AI_GROUP_SUMMARY_SYSTEM_PROMPT: 'aiGroupSummarySystemPrompt',
AI_GROUP_SUMMARY_FILTER_MODE: 'aiGroupSummaryFilterMode',
AI_GROUP_SUMMARY_FILTER_LIST: 'aiGroupSummaryFilterList',
AI_MESSAGE_INSIGHT_ENABLED: 'aiMessageInsightEnabled',
AI_MESSAGE_INSIGHT_CONTEXT_COUNT: 'aiMessageInsightContextCount',
AI_MESSAGE_INSIGHT_SYSTEM_PROMPT: 'aiMessageInsightSystemPrompt',
@@ -2178,6 +2183,66 @@ export async function setAiFootprintSystemPrompt(prompt: string): Promise<void>
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
}
export type AiGroupSummaryFilterMode = 'whitelist' | 'blacklist'
const AI_GROUP_SUMMARY_INTERVALS = new Set([1, 2, 4, 8, 12, 24])
const normalizeAiGroupSummaryFilterList = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return Array.from(new Set(
value
.map((item) => String(item || '').trim())
.filter((item) => item.endsWith('@chatroom'))
))
}
export async function getAiGroupSummaryEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_ENABLED)
return value === true
}
export async function setAiGroupSummaryEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_ENABLED, enabled)
}
export async function getAiGroupSummaryIntervalHours(): Promise<number> {
const value = Number(await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_INTERVAL_HOURS))
const normalized = Number.isFinite(value) ? Math.floor(value) : 4
return AI_GROUP_SUMMARY_INTERVALS.has(normalized) ? normalized : 4
}
export async function setAiGroupSummaryIntervalHours(hours: number): Promise<void> {
const normalized = Math.floor(Number(hours) || 4)
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_INTERVAL_HOURS, AI_GROUP_SUMMARY_INTERVALS.has(normalized) ? normalized : 4)
}
export async function getAiGroupSummarySystemPrompt(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_SYSTEM_PROMPT)
return typeof value === 'string' ? value : ''
}
export async function setAiGroupSummarySystemPrompt(prompt: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_SYSTEM_PROMPT, prompt)
}
export async function getAiGroupSummaryFilterMode(): Promise<AiGroupSummaryFilterMode> {
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_MODE)
return value === 'blacklist' ? 'blacklist' : 'whitelist'
}
export async function setAiGroupSummaryFilterMode(mode: AiGroupSummaryFilterMode): Promise<void> {
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_MODE, mode === 'blacklist' ? 'blacklist' : 'whitelist')
}
export async function getAiGroupSummaryFilterList(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_LIST)
return normalizeAiGroupSummaryFilterList(value)
}
export async function setAiGroupSummaryFilterList(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_LIST, normalizeAiGroupSummaryFilterList(list))
}
export async function getAiMessageInsightEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_ENABLED)
return value === true

View File

@@ -124,6 +124,78 @@ export interface InsightRecordResult {
error?: string
}
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 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 GroupSummaryRecord extends GroupSummaryRecordSummary {
accountScope: string
rawOutput: string
log: GroupSummaryLog
}
export interface GroupSummaryRecordFilters {
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}
export interface GroupSummaryRecordListResult {
success: boolean
records: GroupSummaryRecordSummary[]
total: number
error?: string
}
export interface GroupSummaryRecordResult {
success: boolean
record?: GroupSummaryRecord
error?: string
}
export interface BackupProgress {
phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
message: string
@@ -1375,6 +1447,17 @@ export interface ElectronAPI {
forceRefresh?: boolean
}) => Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }>
}
groupSummary: {
listRecords: (filters?: GroupSummaryRecordFilters) => Promise<GroupSummaryRecordListResult>
getRecord: (id: string) => Promise<GroupSummaryRecordResult>
triggerManual: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
startTime: number
endTime: number
}) => Promise<{ success: boolean; message: string; recordId?: string; record?: GroupSummaryRecord; skipped?: boolean; skippedReason?: string }>
}
}
export interface ExportOptions {