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