mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-24 15:10:55 +00:00
feat: Add AI Summaries for Group Chats
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
263
electron/services/groupSummaryRecordService.ts
Normal file
263
electron/services/groupSummaryRecordService.ts
Normal 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()
|
||||
689
electron/services/groupSummaryService.ts
Normal file
689
electron/services/groupSummaryService.ts
Normal 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()
|
||||
Reference in New Issue
Block a user