优化表述与提示;导出文件命名格式优化;启用进程优化

This commit is contained in:
cc
2026-04-09 21:13:13 +08:00
parent fc3612abb2
commit d6c9a10766
9 changed files with 405 additions and 25 deletions

View File

@@ -323,6 +323,8 @@ class ChatService {
private contactLabelNameMapCacheAt = 0
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
private readonly slowGetContactsLogThresholdMs = 1200
@@ -513,6 +515,43 @@ class ChatService {
}
}
async warmupMessageDbSnapshot(): Promise<{ success: boolean; messageDbCount?: number; mediaDbCount?: number; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
const [messageSnapshot, mediaResult] = await Promise.all([
this.getMessageDbCountSnapshot(true),
wcdbService.listMediaDbs()
])
let messageDbCount = 0
if (messageSnapshot.success && Array.isArray(messageSnapshot.dbPaths)) {
messageDbCount = messageSnapshot.dbPaths.length
}
let mediaDbCount = 0
if (mediaResult.success && Array.isArray(mediaResult.data)) {
this.mediaDbsCache = [...mediaResult.data]
this.mediaDbsCacheTime = Date.now()
mediaDbCount = mediaResult.data.length
}
if (!messageSnapshot.success && !mediaResult.success) {
return {
success: false,
error: messageSnapshot.error || mediaResult.error || '初始化消息库索引失败'
}
}
return { success: true, messageDbCount, mediaDbCount }
} catch (e) {
return { success: false, error: String(e) }
}
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
if (this.connected && wcdbService.isReady()) {
return { success: true }
@@ -1362,8 +1401,50 @@ class ChatService {
}
}
private getContactsCacheScope(): string {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const myWxid = String(this.configService.get('myWxid') || '').trim()
return `${dbPath}::${myWxid}`
}
private cloneContacts(contacts: ContactInfo[]): ContactInfo[] {
return (contacts || []).map((contact) => ({
...contact,
labels: Array.isArray(contact.labels) ? [...contact.labels] : contact.labels
}))
}
private getContactsFromMemoryCache(mode: 'lite' | 'full', scope: string): ContactInfo[] | null {
const cached = this.contactsMemoryCache.get(mode)
if (!cached) return null
if (cached.scope !== scope) return null
if (Date.now() - cached.updatedAt > this.contactsMemoryCacheTtlMs) return null
return this.cloneContacts(cached.contacts)
}
private setContactsMemoryCache(mode: 'lite' | 'full', scope: string, contacts: ContactInfo[]): void {
this.contactsMemoryCache.set(mode, {
scope,
updatedAt: Date.now(),
contacts: this.cloneContacts(contacts)
})
}
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const isLiteMode = options?.lite === true
const mode: 'lite' | 'full' = isLiteMode ? 'lite' : 'full'
const cacheScope = this.getContactsCacheScope()
const cachedContacts = this.getContactsFromMemoryCache(mode, cacheScope)
if (cachedContacts) {
return { success: true, contacts: cachedContacts }
}
if (isLiteMode) {
const fullCachedContacts = this.getContactsFromMemoryCache('full', cacheScope)
if (fullCachedContacts) {
return { success: true, contacts: fullCachedContacts }
}
}
const startedAt = Date.now()
const stageDurations: Array<{ stage: string; ms: number }> = []
const captureStage = (stage: string, stageStartedAt: number) => {
@@ -1487,6 +1568,10 @@ class ChatService {
.join(', ')
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
}
this.setContactsMemoryCache(mode, cacheScope, result)
if (!isLiteMode) {
this.setContactsMemoryCache('lite', cacheScope, result)
}
return { success: true, contacts: result }
} catch (e) {
console.error('ChatService: 获取通讯录失败:', e)
@@ -2886,6 +2971,7 @@ class ChatService {
this.sessionTablesCache.clear()
this.messageTableColumnsCache.clear()
this.messageDbCountSnapshotCache = null
this.contactsMemoryCache.clear()
this.refreshSessionStatsCacheScope(scope)
this.refreshGroupMyMessageCountCacheScope(scope)
}
@@ -5983,6 +6069,7 @@ class ChatService {
if (includeContacts) {
this.avatarCache.clear()
this.contactCacheService.clear()
this.contactsMemoryCache.clear()
}
if (includeMessages) {

View File

@@ -92,6 +92,7 @@ export interface ExportOptions {
dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
fileNamingMode?: 'classic' | 'date-range'
exportMedia?: boolean
exportAvatars?: boolean
exportImages?: boolean
@@ -494,6 +495,80 @@ class ExportService {
}
}
private sanitizeExportFileNamePart(value: string): string {
return String(value || '')
.replace(/[<>:"\/\\|?*]/g, '_')
.replace(/\.+$/, '')
.trim()
}
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
}
private formatDateTokenBySeconds(seconds?: number): string | null {
if (!Number.isFinite(seconds) || (seconds || 0) <= 0) return null
const date = new Date(Math.floor(Number(seconds)) * 1000)
if (Number.isNaN(date.getTime())) return null
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}${m}${d}`
}
private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string {
const start = this.formatDateTokenBySeconds(dateRange?.start)
const end = this.formatDateTokenBySeconds(dateRange?.end)
if (start && end) {
if (start === end) return start
return start < end ? `${start}-${end}` : `${end}-${start}`
}
if (start) return `${start}-至今`
if (end) return `截至-${end}`
return '全部时间'
}
private buildSessionExportBaseName(
sessionId: string,
displayName: string,
options: ExportOptions
): string {
const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session'
const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '')
const namingMode = this.normalizeFileNamingMode(options.fileNamingMode)
const parts = [baseName]
if (suffix) parts.push(suffix)
if (namingMode === 'date-range') {
parts.push(this.buildDateRangeFileNamePart(options.dateRange))
}
return this.sanitizeExportFileNamePart(parts.join('_')) || 'session'
}
private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set<string>): Promise<string> {
const dir = path.dirname(preferredPath)
const ext = path.extname(preferredPath)
const base = path.basename(preferredPath, ext)
for (let attempt = 0; attempt < 10000; attempt += 1) {
const candidate = attempt === 0
? preferredPath
: path.join(dir, `${base}_${attempt + 1}${ext}`)
if (reservedPaths.has(candidate)) continue
const exists = await this.pathExists(candidate)
if (reservedPaths.has(candidate)) continue
if (exists) continue
reservedPaths.add(candidate)
return candidate
}
const fallback = path.join(dir, `${base}_${Date.now()}${ext}`)
reservedPaths.add(fallback)
return fallback
}
private isCloneUnsupportedError(code: string | undefined): boolean {
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
}
@@ -8911,6 +8986,7 @@ class ExportService {
? path.join(outputDir, 'texts')
: outputDir
const createdTaskDirs = new Set<string>()
const reservedOutputPaths = new Set<string>()
const ensureTaskDir = async (dirPath: string) => {
if (createdTaskDirs.has(dirPath)) return
await fs.promises.mkdir(dirPath, { recursive: true })
@@ -9159,10 +9235,8 @@ class ExportService {
phaseLabel: '准备导出'
})
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '')
const safeName = suffix ? `${baseName}_${suffix}` : baseName
const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode)
const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : ''
const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
@@ -9180,13 +9254,13 @@ class ExportService {
else if (effectiveOptions.format === 'txt') ext = '.txt'
else if (effectiveOptions.format === 'weclone') ext = '.csv'
else if (effectiveOptions.format === 'html') ext = '.html'
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
typeof messageCountHint === 'number' &&
messageCountHint >= 0 &&
typeof latestTimestampHint === 'number' &&
latestTimestampHint > 0 &&
await this.pathExists(outputPath)
await this.pathExists(preferredOutputPath)
if (canTrySkipUnchanged) {
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
const hasNoDataChange = Boolean(
@@ -9213,6 +9287,10 @@ class ExportService {
}
}
const outputPath = fileNamingMode === 'date-range'
? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
: preferredOutputPath
let result: { success: boolean; error?: string }
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)