51 files changed
+7517
-478
No files matched your search
@@ -7,14 +7,50 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
请提供尽可能详细的信息,帮助我们快速定位和修复问题。
|
||||
- type: dropdown
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: 是否已有相同问题?
|
||||
description: 请先搜索现有 Issues
|
||||
options:
|
||||
- 是,已有相同问题
|
||||
- 否,没有相同问题
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: confirm-unique
|
||||
attributes:
|
||||
label: 确认唯一性
|
||||
description: 如果选择了"否",请输入:我已确认没有相同问题出现
|
||||
placeholder: 我已确认没有相同问题出现
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: standard-operation
|
||||
attributes:
|
||||
label: 这是标准操作流程下出现的问题吗?
|
||||
description: 确认你是按照正常方式使用,而不是非常规操作
|
||||
options:
|
||||
- 是,标准操作
|
||||
- 否,非标准操作
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: code-issue-confirm
|
||||
attributes:
|
||||
label: 你确认这真的是我们的代码导致的吗?
|
||||
description: 请仔细思考,排除网络、系统、第三方服务等外部因素
|
||||
options:
|
||||
- 是,确认是代码问题
|
||||
- 不确定
|
||||
- 否,可能是其他原因
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
label: 提交前确认
|
||||
description: 请务必确认以下事项
|
||||
label: 基础确认
|
||||
options:
|
||||
- label: 我已搜索过现有的 Issues,确认这不是重复问题
|
||||
required: true
|
||||
- label: 我使用的是最新版本
|
||||
required: true
|
||||
- label: 我已阅读过相关文档
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name:🤔 找不到合适的模板?
|
||||
url: https://t.me/weflow_cc
|
||||
about: 如果你的问题不属于上述任何分类,请前往我们的 Telegram 频道与我们交流。
|
||||
@@ -7,6 +7,24 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
|
||||
- type: dropdown
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: 是否已有相同问题?
|
||||
description: 请先搜索现有 Issues
|
||||
options:
|
||||
- 是,已有相同问题
|
||||
- 否,没有相同问题
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: confirm-unique
|
||||
attributes:
|
||||
label: 确认唯一性
|
||||
description: 如果选择了"否",请输入:我已确认没有相同问题出现
|
||||
placeholder: 我已确认没有相同问题出现
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: doc-type
|
||||
attributes:
|
||||
|
||||
@@ -7,13 +7,37 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
|
||||
- type: dropdown
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: 是否已有相同问题?
|
||||
description: 请先搜索现有 Issues
|
||||
options:
|
||||
- 是,已有相同问题
|
||||
- 否,没有相同问题
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: confirm-unique
|
||||
attributes:
|
||||
label: 确认唯一性
|
||||
description: 如果选择了"否",请输入:我已确认没有相同问题出现
|
||||
placeholder: 我已确认没有相同问题出现
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: confirm-beneficial
|
||||
attributes:
|
||||
label: 你的建议真的有用吗?
|
||||
description: 请输入:我确认这真的有用
|
||||
placeholder: 我确认这真的有用
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
label: 提交前确认
|
||||
options:
|
||||
- label: 我已搜索过现有的 Issues,确认这个优化建议尚未被提出
|
||||
required: true
|
||||
- label: 这是对现有功能的改进,而不是全新功能
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -7,13 +7,37 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。
|
||||
- type: dropdown
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: 是否已有相同问题?
|
||||
description: 请先搜索现有 Issues
|
||||
options:
|
||||
- 是,已有相同问题
|
||||
- 否,没有相同问题
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: confirm-unique
|
||||
attributes:
|
||||
label: 确认唯一性
|
||||
description: 如果选择了"否",请输入:我已确认没有相同问题出现
|
||||
placeholder: 我已确认没有相同问题出现
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: confirm-useful
|
||||
attributes:
|
||||
label: 你的建议真的有用吗?
|
||||
description: 若你确认则输入:我认为这会对大部分人都有用
|
||||
placeholder: 我认为这会对大部分人都有用
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
label: 提交前确认
|
||||
options:
|
||||
- label: 我已搜索过现有的 Issues 和 Pull Requests,确认这个功能尚未被提出或实现
|
||||
required: true
|
||||
- label: 这是一个全新的功能,而不是对现有功能的改进
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -7,6 +7,24 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
在提问之前,请确保你已经仔细阅读过我们的官方文档。
|
||||
- type: dropdown
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: 是否已有相同问题?
|
||||
description: 请先搜索现有 Issues
|
||||
options:
|
||||
- 是,已有相同问题
|
||||
- 否,没有相同问题
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: confirm-unique
|
||||
attributes:
|
||||
label: 确认唯一性
|
||||
description: 如果选择了"否",请输入:我已确认没有相同问题出现
|
||||
placeholder: 我已确认没有相同问题出现
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
@@ -14,8 +32,6 @@ body:
|
||||
options:
|
||||
- label: 我已阅读过相关文档
|
||||
required: true
|
||||
- label: 我已搜索过现有的 Issues,没有找到类似问题
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: question-type
|
||||
attributes:
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ExportWorkerConfig {
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
isPackaged?: boolean
|
||||
}
|
||||
|
||||
const config = workerData as ExportWorkerConfig
|
||||
@@ -150,7 +151,10 @@ async function run() {
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid,
|
||||
imageXorKey: config.imageXorKey,
|
||||
imageAesKey: config.imageAesKey
|
||||
imageAesKey: config.imageAesKey,
|
||||
resourcesPath: config.resourcesPath,
|
||||
appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname,
|
||||
isPackaged: config.isPackaged
|
||||
})
|
||||
|
||||
const onProgress = (progress: any) => queueProgress(progress)
|
||||
@@ -173,7 +177,10 @@ async function run() {
|
||||
chatService.setRuntimeConfig({
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid
|
||||
myWxid: config.myWxid,
|
||||
resourcesPath: config.resourcesPath,
|
||||
appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname,
|
||||
isPackaged: config.isPackaged
|
||||
})
|
||||
result = await contactExportService.exportContacts(
|
||||
String(config.outputDir || ''),
|
||||
|
||||
+135
-40
@@ -32,6 +32,8 @@ import { httpService } from './services/httpService'
|
||||
import { messagePushService } from './services/messagePushService'
|
||||
import { insightService } from './services/insightService'
|
||||
import { insightRecordService } from './services/insightRecordService'
|
||||
import { insightProfileService } from './services/insightProfileService'
|
||||
import { groupSummaryService } from './services/groupSummaryService'
|
||||
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
|
||||
import { bizService } from './services/bizService'
|
||||
import { backupService } from './services/backupService'
|
||||
@@ -397,13 +399,7 @@ let keyService: any
|
||||
if (process.platform === 'darwin') {
|
||||
keyService = new KeyServiceMac()
|
||||
} else if (process.platform === 'linux') {
|
||||
// const { KeyServiceLinux } = require('./services/keyServiceLinux')
|
||||
// keyService = new KeyServiceLinux()
|
||||
|
||||
import('./services/keyServiceLinux').then(({ KeyServiceLinux }) => {
|
||||
keyService = new KeyServiceLinux();
|
||||
});
|
||||
|
||||
keyService = new KeyServiceLinux()
|
||||
} else {
|
||||
keyService = new KeyService()
|
||||
}
|
||||
@@ -444,6 +440,7 @@ const pruneChatHistoryPayloadStore = (): void => {
|
||||
}
|
||||
|
||||
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
type CloseRestoreMethod = 'tray' | 'dock'
|
||||
|
||||
// 更新下载状态管理(Issue #294 修复)
|
||||
let isDownloadInProgress = false
|
||||
@@ -817,29 +814,47 @@ const isSilentStartupEnabled = (): boolean => {
|
||||
return configService?.get('silentStartup') === true
|
||||
}
|
||||
|
||||
const getCloseRestoreMethod = (): CloseRestoreMethod | null => {
|
||||
if (tray) return 'tray'
|
||||
if (process.platform === 'darwin') return 'dock'
|
||||
return null
|
||||
}
|
||||
|
||||
const canKeepMainWindowInBackground = (): boolean => {
|
||||
return getCloseRestoreMethod() !== null
|
||||
}
|
||||
|
||||
const getPlatformIconName = (): string => {
|
||||
if (process.platform === 'linux') return 'icon.png'
|
||||
if (process.platform === 'darwin') return 'icon.icns'
|
||||
return 'icon.ico'
|
||||
}
|
||||
|
||||
const resolveAppIconPath = (): string => {
|
||||
const iconName = getPlatformIconName()
|
||||
if (!process.env.VITE_DEV_SERVER_URL) {
|
||||
return join(process.resourcesPath, iconName)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return join(__dirname, '../resources/icons/macos/icon.icns')
|
||||
}
|
||||
return join(__dirname, `../public/${iconName}`)
|
||||
}
|
||||
|
||||
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
|
||||
if (isClosePromptVisible) return
|
||||
isClosePromptVisible = true
|
||||
const restoreMethod = getCloseRestoreMethod()
|
||||
win.webContents.send('window:confirmCloseRequested', {
|
||||
canMinimizeToTray: Boolean(tray)
|
||||
canMinimizeToTray: restoreMethod !== null,
|
||||
restoreMethod: restoreMethod ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
// 获取图标路径 - 打包后在 resources 目录
|
||||
const { autoShow = true } = options
|
||||
let iconName = 'icon.ico';
|
||||
if (process.platform === 'linux') {
|
||||
iconName = 'icon.png';
|
||||
} else if (process.platform === 'darwin') {
|
||||
iconName = 'icon.icns';
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
|
||||
const iconPath = isDev
|
||||
? join(__dirname, `../public/${iconName}`)
|
||||
: join(process.resourcesPath, iconName);
|
||||
const iconPath = resolveAppIconPath()
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1400,
|
||||
@@ -912,7 +927,7 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
if (closeBehavior === 'tray' && tray) {
|
||||
if (closeBehavior === 'tray' && canKeepMainWindowInBackground()) {
|
||||
win.hide()
|
||||
return
|
||||
}
|
||||
@@ -1775,6 +1790,7 @@ function registerIpcHandlers() {
|
||||
}
|
||||
void messagePushService.handleConfigChanged(key)
|
||||
void insightService.handleConfigChanged(key)
|
||||
void groupSummaryService.handleConfigChanged(key)
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -1792,6 +1808,7 @@ function registerIpcHandlers() {
|
||||
sessionId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
sourceType?: 'insight' | 'message_analysis' | 'all'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => {
|
||||
@@ -1818,6 +1835,30 @@ function registerIpcHandlers() {
|
||||
return insightService.triggerTest()
|
||||
})
|
||||
|
||||
ipcMain.handle('insight:triggerSessionInsight', async (_, payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => {
|
||||
return insightService.triggerSessionInsight(payload)
|
||||
})
|
||||
|
||||
ipcMain.handle('insight:listProfileStatuses', async (_, sessionIds: string[]) => {
|
||||
return insightProfileService.listProfileStatuses(Array.isArray(sessionIds) ? sessionIds : [])
|
||||
})
|
||||
|
||||
ipcMain.handle('insight:generateProfile', async (_, payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => {
|
||||
return insightProfileService.generateProfile(payload)
|
||||
})
|
||||
|
||||
ipcMain.handle('insight:cancelProfile', async (_, sessionId?: string) => {
|
||||
return insightProfileService.cancelProfile(sessionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('insight:generateFootprintInsight', async (_, payload: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
@@ -1834,6 +1875,54 @@ function registerIpcHandlers() {
|
||||
return insightService.generateFootprintInsight(payload)
|
||||
})
|
||||
|
||||
ipcMain.handle('insight:generateMessageInsight', async (_, payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
targetLocalId?: number
|
||||
targetCreateTime?: number
|
||||
targetMessageKey?: string
|
||||
targetText: string
|
||||
targetSenderName?: string
|
||||
contextCount?: number
|
||||
forceRefresh?: boolean
|
||||
}) => {
|
||||
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('groupSummary:triggerDay', async (_, payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
date: string
|
||||
}) => {
|
||||
return groupSummaryService.triggerDay(payload)
|
||||
})
|
||||
|
||||
ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => {
|
||||
try {
|
||||
if (!configService) {
|
||||
@@ -1870,6 +1959,7 @@ function registerIpcHandlers() {
|
||||
configService?.clear()
|
||||
messagePushService.handleConfigCleared()
|
||||
insightService.handleConfigCleared()
|
||||
groupSummaryService.handleConfigCleared()
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -2113,7 +2203,7 @@ function registerIpcHandlers() {
|
||||
|
||||
try {
|
||||
if (action === 'tray') {
|
||||
if (tray) {
|
||||
if (canKeepMainWindowInBackground()) {
|
||||
mainWindow.hide()
|
||||
return true
|
||||
}
|
||||
@@ -2349,8 +2439,8 @@ function registerIpcHandlers() {
|
||||
return chatService.getContactTypeCounts()
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => {
|
||||
return chatService.getSessionMessageCounts(sessionIds)
|
||||
ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => {
|
||||
return chatService.getSessionMessageCounts(sessionIds, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: {
|
||||
@@ -3213,7 +3303,8 @@ function registerIpcHandlers() {
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath,
|
||||
userDataPath,
|
||||
logEnabled
|
||||
logEnabled,
|
||||
isPackaged: app.isPackaged
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3344,7 +3435,8 @@ function registerIpcHandlers() {
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
logEnabled: cfg.get('logEnabled')
|
||||
logEnabled: cfg.get('logEnabled'),
|
||||
isPackaged: app.isPackaged
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3411,7 +3503,8 @@ function registerIpcHandlers() {
|
||||
myWxid: String(cfg.getMyWxidCleaned() || '').trim(),
|
||||
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
logEnabled: cfg.get('logEnabled')
|
||||
logEnabled: cfg.get('logEnabled'),
|
||||
isPackaged: app.isPackaged
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3468,6 +3561,10 @@ function registerIpcHandlers() {
|
||||
return analyticsService.getTimeDistribution()
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getSelfSentDailyDistribution', async (_, beginTimestamp?: number, endTimestamp?: number, force?: boolean) => {
|
||||
return analyticsService.getSelfSentDailyDistribution(beginTimestamp, endTimestamp, force)
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getExcludedUsernames', async () => {
|
||||
return analyticsService.getExcludedUsernames()
|
||||
})
|
||||
@@ -4202,6 +4299,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
messagePushService.start()
|
||||
insightService.start()
|
||||
groupSummaryService.start()
|
||||
await delay(200)
|
||||
|
||||
// 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
|
||||
@@ -4255,18 +4353,7 @@ app.whenReady().then(async () => {
|
||||
ensureWeChatRequestHeaderInterceptor()
|
||||
mainWindow = createWindow({ autoShow: false })
|
||||
|
||||
let iconName = 'icon.ico';
|
||||
if (process.platform === 'linux') {
|
||||
iconName = 'icon.png';
|
||||
} else if (process.platform === 'darwin') {
|
||||
iconName = 'icon.icns';
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
|
||||
const resolvedTrayIcon = isDev
|
||||
? join(__dirname, `../public/${iconName}`)
|
||||
: join(process.resourcesPath, iconName);
|
||||
const resolvedTrayIcon = resolveAppIconPath()
|
||||
|
||||
|
||||
try {
|
||||
@@ -4344,6 +4431,14 @@ app.whenReady().then(async () => {
|
||||
await httpService.autoStart()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
mainWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow()
|
||||
}
|
||||
@@ -4360,6 +4455,7 @@ const shutdownAppServices = async (): Promise<void> => {
|
||||
destroyNotificationWindow()
|
||||
messagePushService.stop()
|
||||
insightService.stop()
|
||||
groupSummaryService.stop()
|
||||
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.warn('[App] Force exit after timeout')
|
||||
@@ -4388,4 +4484,3 @@ app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
+53
-4
@@ -1,5 +1,10 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
type CloseConfirmPayload = {
|
||||
canMinimizeToTray: boolean
|
||||
restoreMethod?: 'tray' | 'dock'
|
||||
}
|
||||
|
||||
// 暴露给渲染进程的 API
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 配置
|
||||
@@ -106,8 +111,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||
},
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => {
|
||||
const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload)
|
||||
onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => {
|
||||
const listener = (_: unknown, payload: CloseConfirmPayload) => callback(payload)
|
||||
ipcRenderer.on('window:confirmCloseRequested', listener)
|
||||
return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
|
||||
},
|
||||
@@ -195,7 +200,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
|
||||
getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds, options),
|
||||
enrichSessionsContactInfo: (
|
||||
usernames: string[],
|
||||
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||
@@ -394,6 +399,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
|
||||
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
||||
getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) =>
|
||||
ipcRenderer.invoke('analytics:getSelfSentDailyDistribution', beginTimestamp, endTimestamp, force),
|
||||
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
||||
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
||||
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
|
||||
@@ -583,6 +590,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id),
|
||||
clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters),
|
||||
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
|
||||
triggerSessionInsight: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => ipcRenderer.invoke('insight:triggerSessionInsight', payload),
|
||||
listProfileStatuses: (sessionIds: string[]) => ipcRenderer.invoke('insight:listProfileStatuses', sessionIds),
|
||||
generateProfile: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => ipcRenderer.invoke('insight:generateProfile', payload),
|
||||
cancelProfile: (sessionId?: string) => ipcRenderer.invoke('insight:cancelProfile', sessionId),
|
||||
generateFootprintInsight: (payload: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
@@ -595,7 +614,37 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
}
|
||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
|
||||
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload),
|
||||
generateMessageInsight: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
targetLocalId?: number
|
||||
targetCreateTime?: number
|
||||
targetMessageKey?: string
|
||||
targetText: string
|
||||
targetSenderName?: string
|
||||
contextCount?: number
|
||||
forceRefresh?: boolean
|
||||
}) => 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),
|
||||
triggerDay: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
date: string
|
||||
}) => ipcRenderer.invoke('groupSummary:triggerDay', payload)
|
||||
},
|
||||
|
||||
social: {
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
/**
|
||||
* 账号目录解析器(Worker 线程 / 主进程通用)
|
||||
*
|
||||
* 职责:在 dbPath 根目录下,根据传入的 wxid,找出微信"实际写入数据"
|
||||
* 的那个账号子目录,例如:
|
||||
* dbPath = <微信数据根目录>
|
||||
* wxid = customwxid_abcd 或 customwxid
|
||||
* 期望返回 <微信数据根目录>/customwxid_abcd(带后缀、有 session.db 的那个)
|
||||
*
|
||||
* 与 ConfigService.getAccountDir 行为保持一致;二者实现独立是因为本文件
|
||||
* 也会在 Worker 线程中被加载,无法依赖 electron-store。
|
||||
*/
|
||||
import { existsSync, readdirSync, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
// 解析结果缓存(进程内,避免重复 IO)。key = `${dbPath}|${cleanedWxid}`
|
||||
const accountDirCache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* 把 wxid 字符串"标准化"为目录前缀。
|
||||
* - wxid_xxx_yyyy → wxid_xxx (wxid_ 后只取第一段)
|
||||
* - 自定义微信号_后缀(4 位) → 自定义微信号 (例如 customwxid_abcd → customwxid)
|
||||
* - 其他形式 → 原样返回
|
||||
*
|
||||
* 注意:清洗只是为了得到"前缀"用于扫描匹配,并不代表清洗结果就是真实目录名。
|
||||
* 真实目录名仍需在 dbPath 下按"前缀 + 任意后缀"扫描得出。
|
||||
*/
|
||||
const cleanAccountDirName = (dirName: string): string => {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
@@ -27,6 +49,39 @@ const isDirectory = (path: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析账号目录的真实绝对路径。
|
||||
*
|
||||
* ## 修复 #996(错误码 -3001:未找到数据库目录)
|
||||
*
|
||||
* ### 旧实现存在的两处严重缺陷
|
||||
* 1. **对 wxid_ 开头的目录强制要求"带后缀"**:
|
||||
* 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀),
|
||||
* 旧逻辑会因为段数不足而把它过滤掉,导致这类用户根本匹配不到。
|
||||
*
|
||||
* 2. **对非 wxid_ 开头(自定义微信号)走短路返回,且不校验目录有效性**:
|
||||
* 旧实现写法是
|
||||
* ```
|
||||
* if (!lowerWxid.startsWith('wxid_')) {
|
||||
* const direct = join(root, cleanedWxid)
|
||||
* if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage
|
||||
* }
|
||||
* ```
|
||||
* 叠加 `cleanAccountDirName` 会把 `<自定义号>_<4位后缀>` 清洗成
|
||||
* `<自定义号>`,于是无论用户存的是哪个 wxid,都会命中旧的、无后缀的
|
||||
* 空目录(它真实存在但里面没有 db_storage),最终触发 -3001。
|
||||
*
|
||||
* ### 修复后的统一匹配流程
|
||||
* 1. 扫描 dbPath 下所有子目录;
|
||||
* 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与
|
||||
* **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式;
|
||||
* 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项
|
||||
* (没有 db_storage 也没有 FileStorage/Image[2]);
|
||||
* 4. 在剩余候选中按以下优先级排序,取最优:
|
||||
* - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录";
|
||||
* - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致;
|
||||
* - **修改时间更新** > 更旧:兜底。
|
||||
*/
|
||||
export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => {
|
||||
if (!dbPath || !wxid) return null
|
||||
|
||||
@@ -34,6 +89,7 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null
|
||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
|
||||
|
||||
// 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除
|
||||
const cached = accountDirCache.get(cacheKey)
|
||||
if (cached && existsSync(cached)) return cached
|
||||
if (cached && !existsSync(cached)) {
|
||||
@@ -41,16 +97,12 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null
|
||||
}
|
||||
|
||||
const lowerWxid = cleanedWxid.toLowerCase()
|
||||
if (!lowerWxid.startsWith('wxid_')) {
|
||||
const direct = join(normalized, cleanedWxid)
|
||||
if (existsSync(direct) && isDirectory(direct)) {
|
||||
accountDirCache.set(cacheKey, direct)
|
||||
return direct
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(normalized)
|
||||
type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number }
|
||||
const candidates: Candidate[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(normalized, entry)
|
||||
if (!isDirectory(entryPath)) continue
|
||||
@@ -58,16 +110,72 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
const isExactMatch = lowerEntry === lowerWxid
|
||||
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
|
||||
const shouldMatch = lowerWxid.startsWith('wxid_')
|
||||
? isSuffixMatch
|
||||
: (isExactMatch || isSuffixMatch)
|
||||
// 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过
|
||||
if (!isExactMatch && !isSuffixMatch) continue
|
||||
|
||||
if (shouldMatch) {
|
||||
accountDirCache.set(cacheKey, entryPath)
|
||||
return entryPath
|
||||
}
|
||||
// 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过
|
||||
// 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录
|
||||
// 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。
|
||||
if (!accountDirLooksValid(entryPath)) continue
|
||||
|
||||
let mtime = 0
|
||||
try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ }
|
||||
candidates.push({
|
||||
entryPath,
|
||||
isExact: isExactMatch,
|
||||
hasSession: accountDirHasSessionDb(entryPath),
|
||||
mtime,
|
||||
})
|
||||
}
|
||||
} catch { }
|
||||
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((a, b) => {
|
||||
// 1) 优先选有 session.db 的(真实写入数据的目录)
|
||||
if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1
|
||||
// 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录)
|
||||
if (a.isExact !== b.isExact) return a.isExact ? 1 : -1
|
||||
// 3) 最后按修改时间倒序(最新的优先)
|
||||
return b.mtime - a.mtime
|
||||
})
|
||||
const best = candidates[0].entryPath
|
||||
accountDirCache.set(cacheKey, best)
|
||||
return best
|
||||
}
|
||||
} catch { /* 扫描目录失败时直接 fallthrough 返回 null */ }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 浅层判定一个目录"看起来像不像账号目录":
|
||||
* 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。
|
||||
*
|
||||
* 用于在候选阶段剔除"同名但实际无数据"的残留空目录
|
||||
*(例如自定义微信号后遗留下来的旧 wxid 主目录)。
|
||||
*/
|
||||
const accountDirLooksValid = (entryPath: string): boolean => {
|
||||
return (
|
||||
existsSync(join(entryPath, 'db_storage')) ||
|
||||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
||||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测账号目录下是否存在 session.db。
|
||||
*
|
||||
* 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据,
|
||||
* 同时兼容微信 4.x 两种已知布局:
|
||||
* - db_storage/session/session.db (新版本嵌套布局)
|
||||
* - db_storage/session.db (部分版本扁平布局)
|
||||
*/
|
||||
const accountDirHasSessionDb = (entryPath: string): boolean => {
|
||||
const candidates = [
|
||||
join(entryPath, 'db_storage', 'session', 'session.db'),
|
||||
join(entryPath, 'db_storage', 'session.db'),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -27,6 +27,16 @@ export interface TimeDistribution {
|
||||
monthlyDistribution: Record<string, number>
|
||||
}
|
||||
|
||||
export interface SelfSentDailyDistribution {
|
||||
unit: 'day'
|
||||
dailyDistribution: Record<string, number>
|
||||
totalMessages: number
|
||||
firstMessageTime: number | null
|
||||
lastMessageTime: number | null
|
||||
beginTimestamp: number
|
||||
endTimestamp: number
|
||||
}
|
||||
|
||||
export interface ContactRanking {
|
||||
username: string
|
||||
displayName: string
|
||||
@@ -42,6 +52,7 @@ class AnalyticsService {
|
||||
private configService: ConfigService
|
||||
private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null
|
||||
private aggregateCache: { key: string; data: any; updatedAt: number } | null = null
|
||||
private selfSentDailyCache: { key: string; data: SelfSentDailyDistribution; updatedAt: number } | null = null
|
||||
private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null
|
||||
|
||||
constructor() {
|
||||
@@ -190,15 +201,12 @@ class AnalyticsService {
|
||||
sessionId: string,
|
||||
onRow: (row: Record<string, any>) => void,
|
||||
beginTimestamp = 0,
|
||||
endTimestamp = 0
|
||||
endTimestamp = 0,
|
||||
lite = false
|
||||
): Promise<void> {
|
||||
const cursorResult = await wcdbService.openMessageCursor(
|
||||
sessionId,
|
||||
500,
|
||||
true,
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
)
|
||||
const cursorResult = lite
|
||||
? await wcdbService.openMessageCursorLite(sessionId, 500, true, beginTimestamp, endTimestamp)
|
||||
: await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) return
|
||||
|
||||
try {
|
||||
@@ -223,6 +231,76 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
private getRowCreateTime(row: Record<string, any>): number {
|
||||
const raw = row.create_time ?? row.createTime ?? row.create_time_ms ?? '0'
|
||||
const parsed = parseInt(String(raw), 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 0
|
||||
return parsed > 1e12 ? Math.floor(parsed / 1000) : parsed
|
||||
}
|
||||
|
||||
private isRowSentByMe(row: Record<string, any>, cleanedWxid: string): boolean {
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
|
||||
const normalized = String(isSendRaw).trim().toLowerCase()
|
||||
let isSend = isSendRaw === 1 || isSendRaw === true || normalized === '1' || normalized === 'true'
|
||||
|
||||
if (isSendRaw === undefined || isSendRaw === null) {
|
||||
const senderUsername = row.sender_username || row.senderUsername || row.sender
|
||||
if (senderUsername && cleanedWxid) {
|
||||
const senderLower = String(senderUsername).toLowerCase()
|
||||
const myWxidLower = cleanedWxid.toLowerCase()
|
||||
isSend = senderLower === myWxidLower || senderLower.startsWith(`${myWxidLower}_`)
|
||||
}
|
||||
}
|
||||
|
||||
return isSend
|
||||
}
|
||||
|
||||
private formatDayKey(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
private sortDailyDistribution(daily: Record<string, number>): Record<string, number> {
|
||||
const sorted: Record<string, number> = {}
|
||||
for (const key of Object.keys(daily).sort()) {
|
||||
sorted[key] = daily[key]
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
private completeDailyDistribution(
|
||||
daily: Record<string, number>,
|
||||
firstTimestamp: number,
|
||||
lastTimestamp: number
|
||||
): Record<string, number> {
|
||||
if (!firstTimestamp || !lastTimestamp || lastTimestamp < firstTimestamp) {
|
||||
return this.sortDailyDistribution(daily)
|
||||
}
|
||||
|
||||
const start = new Date(firstTimestamp * 1000)
|
||||
const end = new Date(lastTimestamp * 1000)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
end.setHours(0, 0, 0, 0)
|
||||
|
||||
const roughDays = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
|
||||
if (roughDays <= 0 || roughDays > 5000) {
|
||||
return this.sortDailyDistribution(daily)
|
||||
}
|
||||
|
||||
const completed: Record<string, number> = {}
|
||||
const cursor = new Date(start)
|
||||
while (cursor.getTime() <= end.getTime()) {
|
||||
const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`
|
||||
completed[key] = daily[key] || 0
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
|
||||
return completed
|
||||
}
|
||||
|
||||
private setProgress(window: any, status: string, progress: number) {
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.webContents.send('analytics:progress', { status, progress })
|
||||
@@ -251,6 +329,7 @@ class AnalyticsService {
|
||||
hourly: {} as Record<number, number>,
|
||||
weekday: {} as Record<number, number>,
|
||||
daily: {} as Record<string, number>,
|
||||
sentDaily: {} as Record<string, number>,
|
||||
monthly: {} as Record<string, number>,
|
||||
sessions: {} as Record<string, { total: number; sent: number; received: number; lastTime: number }>,
|
||||
idMap: {}
|
||||
@@ -259,27 +338,13 @@ class AnalyticsService {
|
||||
for (const sessionId of sessionIds) {
|
||||
const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 }
|
||||
await this.iterateSessionMessages(sessionId, (row) => {
|
||||
const createTime = parseInt(row.create_time || row.createTime || row.create_time_ms || '0', 10)
|
||||
const createTime = this.getRowCreateTime(row)
|
||||
if (!createTime) return
|
||||
if (beginTimestamp > 0 && createTime < beginTimestamp) return
|
||||
if (endTimestamp > 0 && createTime > endTimestamp) return
|
||||
|
||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
|
||||
let isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
|
||||
|
||||
// 如果底层没有提供 is_send,则根据发送者用户名推断
|
||||
const senderUsername = row.sender_username || row.senderUsername || row.sender
|
||||
if (isSendRaw === undefined || isSendRaw === null) {
|
||||
if (senderUsername && (cleanedWxid)) {
|
||||
const senderLower = String(senderUsername).toLowerCase()
|
||||
const myWxidLower = cleanedWxid.toLowerCase()
|
||||
isSend = (
|
||||
senderLower === myWxidLower ||
|
||||
senderLower.startsWith(myWxidLower + '_')
|
||||
)
|
||||
}
|
||||
}
|
||||
const isSend = this.isRowSentByMe(row, cleanedWxid)
|
||||
|
||||
aggregate.total += 1
|
||||
sessionStat.total += 1
|
||||
@@ -314,6 +379,9 @@ class AnalyticsService {
|
||||
aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1
|
||||
aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1
|
||||
aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1
|
||||
if (isSend) {
|
||||
aggregate.sentDaily[dayKey] = (aggregate.sentDaily[dayKey] || 0) + 1
|
||||
}
|
||||
}, beginTimestamp, endTimestamp)
|
||||
|
||||
if (sessionStat.total > 0) {
|
||||
@@ -324,6 +392,49 @@ class AnalyticsService {
|
||||
return aggregate
|
||||
}
|
||||
|
||||
private async computeSelfSentDailyDistribution(
|
||||
sessionIds: string[],
|
||||
cleanedWxid: string,
|
||||
beginTimestamp = 0,
|
||||
endTimestamp = 0
|
||||
): Promise<SelfSentDailyDistribution> {
|
||||
const dailyDistribution: Record<string, number> = {}
|
||||
let totalMessages = 0
|
||||
let firstMessageTime = 0
|
||||
let lastMessageTime = 0
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
await this.iterateSessionMessages(sessionId, (row) => {
|
||||
const createTime = this.getRowCreateTime(row)
|
||||
if (!createTime) return
|
||||
if (beginTimestamp > 0 && createTime < beginTimestamp) return
|
||||
if (endTimestamp > 0 && createTime > endTimestamp) return
|
||||
if (!this.isRowSentByMe(row, cleanedWxid)) return
|
||||
|
||||
const dayKey = this.formatDayKey(createTime)
|
||||
dailyDistribution[dayKey] = (dailyDistribution[dayKey] || 0) + 1
|
||||
totalMessages += 1
|
||||
|
||||
if (firstMessageTime === 0 || createTime < firstMessageTime) {
|
||||
firstMessageTime = createTime
|
||||
}
|
||||
if (createTime > lastMessageTime) {
|
||||
lastMessageTime = createTime
|
||||
}
|
||||
}, beginTimestamp, endTimestamp, true)
|
||||
}
|
||||
|
||||
return {
|
||||
unit: 'day',
|
||||
dailyDistribution: this.completeDailyDistribution(dailyDistribution, firstMessageTime, lastMessageTime),
|
||||
totalMessages,
|
||||
firstMessageTime: firstMessageTime || null,
|
||||
lastMessageTime: lastMessageTime || null,
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
private async getAggregateWithFallback(
|
||||
sessionIds: string[],
|
||||
beginTimestamp = 0,
|
||||
@@ -668,9 +779,47 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
async getSelfSentDailyDistribution(
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0,
|
||||
force = false
|
||||
): Promise<{ success: boolean; data?: SelfSentDailyDistribution; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
|
||||
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
|
||||
if (sessionInfo.usernames.length === 0) {
|
||||
return { success: false, error: '未找到消息会话' }
|
||||
}
|
||||
|
||||
const cacheKey = `self-sent-daily-${this.buildAggregateCacheKey(sessionInfo.usernames, beginTimestamp, endTimestamp)}`
|
||||
if (force) this.selfSentDailyCache = null
|
||||
|
||||
if (!force && this.selfSentDailyCache && this.selfSentDailyCache.key === cacheKey) {
|
||||
if (Date.now() - this.selfSentDailyCache.updatedAt < 5 * 60 * 1000) {
|
||||
return { success: true, data: this.selfSentDailyCache.data }
|
||||
}
|
||||
}
|
||||
|
||||
const data = await this.computeSelfSentDailyDistribution(
|
||||
sessionInfo.usernames,
|
||||
conn.cleanedWxid,
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
)
|
||||
this.selfSentDailyCache = { key: cacheKey, data, updatedAt: Date.now() }
|
||||
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.aggregateCache = null
|
||||
this.fallbackAggregateCache = null
|
||||
this.selfSentDailyCache = null
|
||||
this.aggregatePromise = null
|
||||
try {
|
||||
await rm(this.getCacheFilePath(), { force: true })
|
||||
|
||||
@@ -460,6 +460,7 @@ export class BackupService {
|
||||
const dbStorage = join(accountDir, 'db_storage')
|
||||
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
|
||||
|
||||
const accountDirName = basename(accountDir)
|
||||
const opened = await withTimeout(
|
||||
wcdbService.open(accountDir, decryptKey),
|
||||
15000,
|
||||
@@ -467,10 +468,10 @@ export class BackupService {
|
||||
)
|
||||
if (!opened) {
|
||||
const detail = await wcdbService.getLastInitError().catch(() => null)
|
||||
return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` }
|
||||
return { success: false, error: detail || `目标账号 ${accountDir} 数据库连接失败` }
|
||||
}
|
||||
|
||||
return { success: true, wxid: accountDirName, dbPath, dbStorage }
|
||||
return { success: true, wxid: accountDir, dbPath, dbStorage }
|
||||
}
|
||||
|
||||
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
|
||||
@@ -857,10 +858,13 @@ export class BackupService {
|
||||
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
||||
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
||||
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
||||
emitBackupProgress({ phase: 'failed', message: '不支持的备份包格式' })
|
||||
return { success: false, error: '不支持的备份包格式' }
|
||||
}
|
||||
emitBackupProgress({ phase: 'done', message: '备份包已读取' })
|
||||
return { success: true, manifest }
|
||||
} catch (e) {
|
||||
emitBackupProgress({ phase: 'failed', message: e instanceof Error ? e.message : String(e) })
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) }
|
||||
} finally {
|
||||
if (extractDir) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join, dirname, basename, extname } from 'path'
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs'
|
||||
import { createRequire } from 'module'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import * as https from 'https'
|
||||
@@ -453,7 +454,7 @@ class ChatService {
|
||||
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
|
||||
}
|
||||
|
||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string }): void {
|
||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean }): void {
|
||||
this.runtimeConfig = config
|
||||
}
|
||||
|
||||
@@ -2585,6 +2586,93 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
async getMessagesAround(
|
||||
sessionId: string,
|
||||
target: { localId?: number; createTime: number; messageKey?: string },
|
||||
totalContextCount: number = 50
|
||||
): Promise<{
|
||||
success: boolean
|
||||
before: Message[]
|
||||
after: Message[]
|
||||
requested: number
|
||||
error?: string
|
||||
}> {
|
||||
const requested = Math.max(1, Math.min(200, Math.floor(Number(totalContextCount) || 50)))
|
||||
const targetCreateTime = Math.floor(Number(target?.createTime || 0))
|
||||
if (!sessionId || targetCreateTime <= 0) {
|
||||
return { success: false, before: [], after: [], requested, error: '无效的目标消息' }
|
||||
}
|
||||
|
||||
const collect = async (ascending: boolean): Promise<Message[]> => {
|
||||
let cursor: number | undefined
|
||||
try {
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(
|
||||
sessionId,
|
||||
Math.min(240, Math.max(60, requested + 20)),
|
||||
ascending,
|
||||
ascending ? targetCreateTime : 0,
|
||||
ascending ? 0 : targetCreateTime + 1
|
||||
)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
throw new Error(cursorResult.error || '打开消息游标失败')
|
||||
}
|
||||
cursor = cursorResult.cursor
|
||||
const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursor, requested + 1)
|
||||
if (!collected.success) {
|
||||
throw new Error(collected.error || '读取上下文消息失败')
|
||||
}
|
||||
const targetLocalId = Math.floor(Number(target?.localId || 0))
|
||||
const targetMessageKey = String(target?.messageKey || '').trim()
|
||||
return (collected.messages || []).filter((message) => {
|
||||
const sameLocalId = targetLocalId > 0 && Number(message.localId || 0) === targetLocalId
|
||||
const sameCreateTime = Number(message.createTime || 0) === targetCreateTime
|
||||
const sameKey = Boolean(targetMessageKey && message.messageKey === targetMessageKey)
|
||||
return !(sameKey || (sameLocalId && sameCreateTime))
|
||||
})
|
||||
} finally {
|
||||
if (cursor) {
|
||||
await wcdbService.closeMessageCursor(cursor).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [beforeCandidatesRaw, afterCandidatesRaw] = await Promise.all([
|
||||
collect(false),
|
||||
collect(true)
|
||||
])
|
||||
const beforeCandidates = beforeCandidatesRaw
|
||||
.filter((message) => Number(message.createTime || 0) <= targetCreateTime)
|
||||
.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq))
|
||||
const afterCandidates = afterCandidatesRaw
|
||||
.filter((message) => Number(message.createTime || 0) >= targetCreateTime)
|
||||
.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq))
|
||||
|
||||
const baseBefore = Math.floor(requested / 2)
|
||||
const baseAfter = requested - baseBefore
|
||||
const takeAfter = Math.min(baseAfter, afterCandidates.length)
|
||||
const takeBefore = Math.min(requested - takeAfter, beforeCandidates.length)
|
||||
const remainingAfter = Math.max(0, requested - takeBefore - takeAfter)
|
||||
const finalAfter = Math.min(afterCandidates.length, takeAfter + remainingAfter)
|
||||
const finalBefore = Math.min(beforeCandidates.length, requested - finalAfter)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
before: beforeCandidates.slice(Math.max(0, beforeCandidates.length - finalBefore)),
|
||||
after: afterCandidates.slice(0, finalAfter),
|
||||
requested
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
before: [],
|
||||
after: [],
|
||||
requested,
|
||||
error: (error as Error).message || String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getNewMessages(sessionId: string, minTime: number, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
@@ -8613,13 +8701,17 @@ class ChatService {
|
||||
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
||||
try {
|
||||
let wasmPath: string
|
||||
if (app.isPackaged) {
|
||||
wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
const isPackaged = this.runtimeConfig?.isPackaged ?? app.isPackaged
|
||||
const resourcesPath = this.runtimeConfig?.resourcesPath ?? process.resourcesPath
|
||||
const appPath = this.runtimeConfig?.appPath ?? app.getAppPath()
|
||||
|
||||
if (isPackaged) {
|
||||
wasmPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
if (!existsSync(wasmPath)) {
|
||||
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
wasmPath = join(resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
}
|
||||
} else {
|
||||
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
wasmPath = join(appPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
}
|
||||
|
||||
if (!existsSync(wasmPath)) {
|
||||
@@ -8627,7 +8719,9 @@ class ChatService {
|
||||
return null
|
||||
}
|
||||
|
||||
const silkWasm = require('silk-wasm')
|
||||
// 在 worker 环境中使用 createRequire 来正确加载模块
|
||||
const requireFromApp = createRequire(join(appPath, 'package.json'))
|
||||
const silkWasm = requireFromApp('silk-wasm')
|
||||
if (!silkWasm || !silkWasm.decode) {
|
||||
console.error('[ChatService][Voice] silk-wasm module invalid')
|
||||
return null
|
||||
@@ -9456,12 +9550,13 @@ class ChatService {
|
||||
|
||||
data = this.filterMyFootprintMentionsBySource(nativeRaw, myWxid, mentionLimit)
|
||||
|
||||
if (privateSessionIds.length > 0 && data.private_segments.length === 0) {
|
||||
if (data.private_sessions.length > 0) {
|
||||
const sessionsWithMessages = data.private_sessions.map(s => s.session_id)
|
||||
const privateSegments = await this.rebuildMyFootprintPrivateSegments({
|
||||
begin,
|
||||
end: normalizedEnd,
|
||||
myWxid,
|
||||
privateSessionIds
|
||||
privateSessionIds: sessionsWithMessages
|
||||
})
|
||||
if (privateSegments.length > 0) {
|
||||
data = {
|
||||
@@ -9561,7 +9656,7 @@ class ChatService {
|
||||
myWxid: string
|
||||
privateSessionIds: string[]
|
||||
}): Promise<MyFootprintPrivateSegment[]> {
|
||||
const sessionGapSeconds = 10 * 60
|
||||
const sessionGapSeconds = 5 * 60
|
||||
const segments: MyFootprintPrivateSegment[] = []
|
||||
|
||||
type WorkingSegment = {
|
||||
@@ -9579,14 +9674,17 @@ class ChatService {
|
||||
}
|
||||
|
||||
for (const sessionId of params.privateSessionIds) {
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(
|
||||
const cursorResult = await wcdbService.openMessageCursor(
|
||||
sessionId,
|
||||
360,
|
||||
true,
|
||||
params.begin,
|
||||
params.end
|
||||
0,
|
||||
0
|
||||
)
|
||||
if (!cursorResult.success || !cursorResult.cursor) continue
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
console.log(`[足迹分段] 打开游标失败: ${sessionId}, 原因: ${cursorResult.error || '未知'}`)
|
||||
continue
|
||||
}
|
||||
|
||||
let segmentCursor = 0
|
||||
let active: WorkingSegment | null = null
|
||||
@@ -9620,19 +9718,30 @@ class ChatService {
|
||||
}
|
||||
|
||||
let hasMore = true
|
||||
let batchCount = 0
|
||||
let totalMessages = 0
|
||||
try {
|
||||
while (hasMore) {
|
||||
const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||
batchCount++
|
||||
if (!batchResult.success || !Array.isArray(batchResult.rows)) break
|
||||
hasMore = Boolean(batchResult.hasMore)
|
||||
totalMessages += batchResult.rows.length
|
||||
|
||||
for (const row of batchResult.rows as Array<Record<string, any>>) {
|
||||
const createTime = this.toSafeInt(row.create_time, 0)
|
||||
const localId = this.toSafeInt(row.local_id, 0)
|
||||
const isSend = this.resolveFootprintRowIsSend(row, params.myWxid)
|
||||
|
||||
// 过滤时间范围外的消息
|
||||
if (createTime > 0 && (createTime < params.begin || createTime > params.end)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (createTime > 0) {
|
||||
const needNew = !active || (lastMessageTs > 0 && createTime - lastMessageTs > sessionGapSeconds)
|
||||
const referenceTs = lastMessageTs > 0 ? lastMessageTs : (active ? active.end_ts : 0)
|
||||
const timeDiff = referenceTs > 0 ? createTime - referenceTs : 0
|
||||
const needNew = !active || (referenceTs > 0 && timeDiff > sessionGapSeconds)
|
||||
if (needNew) {
|
||||
commit()
|
||||
segmentCursor += 1
|
||||
|
||||
+125
-23
@@ -129,6 +129,14 @@ interface ConfigSchema {
|
||||
// AI 足迹
|
||||
aiFootprintEnabled: boolean
|
||||
aiFootprintSystemPrompt: string
|
||||
aiGroupSummaryEnabled: boolean
|
||||
aiGroupSummaryIntervalHours: number
|
||||
aiGroupSummarySystemPrompt: string
|
||||
aiGroupSummaryFilterMode: 'whitelist' | 'blacklist'
|
||||
aiGroupSummaryFilterList: string[]
|
||||
aiMessageInsightEnabled: boolean
|
||||
aiMessageInsightContextCount: number
|
||||
aiMessageInsightSystemPrompt: string
|
||||
/** 是否将 AI 见解调试日志输出到桌面 */
|
||||
aiInsightDebugLogEnabled: boolean
|
||||
autoDownloadHighRes: boolean
|
||||
@@ -252,6 +260,14 @@ export class ConfigService {
|
||||
aiInsightWeiboBindings: {},
|
||||
aiFootprintEnabled: false,
|
||||
aiFootprintSystemPrompt: '',
|
||||
aiGroupSummaryEnabled: false,
|
||||
aiGroupSummaryIntervalHours: 4,
|
||||
aiGroupSummarySystemPrompt: '',
|
||||
aiGroupSummaryFilterMode: 'whitelist',
|
||||
aiGroupSummaryFilterList: [],
|
||||
aiMessageInsightEnabled: false,
|
||||
aiMessageInsightContextCount: 50,
|
||||
aiMessageInsightSystemPrompt: '',
|
||||
aiInsightDebugLogEnabled: false,
|
||||
autoDownloadHighRes: false,
|
||||
autoDownloadWhitelist: []
|
||||
@@ -817,6 +833,12 @@ export class ConfigService {
|
||||
if (!sharedModel && legacyModel) {
|
||||
this.set('aiModelApiModel', legacyModel)
|
||||
}
|
||||
|
||||
const groupSummaryFilterMode = String(this.store.get('aiGroupSummaryFilterMode' as any) || '').trim()
|
||||
if (groupSummaryFilterMode === 'blacklist') {
|
||||
this.store.set('aiGroupSummaryFilterList' as any, [] as any)
|
||||
this.store.set('aiGroupSummaryFilterMode' as any, 'whitelist' as any)
|
||||
}
|
||||
}
|
||||
|
||||
// === 验证 ===
|
||||
@@ -899,12 +921,78 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号目录路径
|
||||
* 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现
|
||||
* 浅层判定一个目录"看起来像不像账号目录":
|
||||
* 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。
|
||||
*
|
||||
* @param dbPath 数据库根目录(可选,默认从配置读取)
|
||||
* @param wxid 微信ID(可选,默认从配置读取)
|
||||
* @returns 账号目录的完整路径,如果找不到返回 null
|
||||
* 用于在 {@link getAccountDir} 候选阶段剔除"同名但实际无数据"的残留空目录
|
||||
* (例如自定义微信号后微信遗留下来的旧 wxid 主目录)。
|
||||
*/
|
||||
private accountDirLooksValid(entryPath: string): boolean {
|
||||
return (
|
||||
existsSync(join(entryPath, 'db_storage')) ||
|
||||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
||||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测账号目录下是否存在 session.db。
|
||||
*
|
||||
* 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据,
|
||||
* 同时兼容微信 4.x 两种已知布局:
|
||||
* - db_storage/session/session.db (新版本嵌套布局)
|
||||
* - db_storage/session.db (部分版本扁平布局)
|
||||
*/
|
||||
private accountDirHasSessionDb(entryPath: string): boolean {
|
||||
const candidates = [
|
||||
join(entryPath, 'db_storage', 'session', 'session.db'),
|
||||
join(entryPath, 'db_storage', 'session.db'),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号目录的真实绝对路径。
|
||||
*
|
||||
* 这是 WeFlow 统一的账号目录解析入口,所有服务都应通过本方法获取
|
||||
* 账号目录,而不要自行拼接 `join(dbPath, wxid)`。
|
||||
*
|
||||
* ## 修复 #996(错误码 -3001:未找到数据库目录)
|
||||
*
|
||||
* ### 旧实现存在的两处严重缺陷
|
||||
* 1. **对 wxid_ 开头强制要求"带后缀"**:
|
||||
* 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀),
|
||||
* 旧逻辑把它过滤掉,导致这类用户根本匹配不到自己的账号目录。
|
||||
*
|
||||
* 2. **对非 wxid_ 开头(自定义微信号)走短路返回,不校验目录有效性**:
|
||||
* 旧实现写法是
|
||||
* ```ts
|
||||
* if (!lowerWxid.startsWith('wxid_')) {
|
||||
* const direct = join(root, cleanedWxid)
|
||||
* if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage
|
||||
* }
|
||||
* ```
|
||||
* 叠加 {@link cleanAccountDirName} 会把 `<自定义号>_<4位后缀>` 清洗成
|
||||
* `<自定义号>`,于是无论用户保存的是哪个 wxid,都会命中旧的、
|
||||
* 无后缀的空目录(它真实存在但里面没有 db_storage),最终在
|
||||
* wcdbCore.open 阶段触发 -3001。
|
||||
*
|
||||
* ### 修复后的统一匹配流程
|
||||
* 1. 扫描 dbPath 下所有子目录;
|
||||
* 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与
|
||||
* **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式;
|
||||
* 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项;
|
||||
* 4. 在剩余候选中按以下优先级排序,取最优:
|
||||
* - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录";
|
||||
* - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致;
|
||||
* - **修改时间更新** > 更旧:兜底。
|
||||
*
|
||||
* @param dbPath 数据库根目录(可选,默认从配置读取 `dbPath`)
|
||||
* @param wxid 微信 ID(可选,默认从配置读取 `myWxid`)
|
||||
* @returns 账号目录的完整绝对路径;找不到返回 null
|
||||
*/
|
||||
getAccountDir(dbPath?: string, wxid?: string): string | null {
|
||||
const actualDbPath = dbPath || this.get('dbPath')
|
||||
@@ -916,26 +1004,20 @@ export class ConfigService {
|
||||
const normalized = actualDbPath.replace(/[\\/]+$/, '')
|
||||
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
|
||||
|
||||
// 检查缓存
|
||||
// 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除
|
||||
const cached = this.accountDirCache.get(cacheKey)
|
||||
if (cached && existsSync(cached)) return cached
|
||||
if (cached && !existsSync(cached)) {
|
||||
this.accountDirCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
// 尝试直接路径(非 wxid_ 开头的账号)
|
||||
const lowerWxid = cleanedWxid.toLowerCase()
|
||||
if (!lowerWxid.startsWith('wxid_')) {
|
||||
const direct = join(normalized, cleanedWxid)
|
||||
if (existsSync(direct) && this.isDirectory(direct)) {
|
||||
this.accountDirCache.set(cacheKey, direct)
|
||||
return direct
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描目录查找匹配的账号目录
|
||||
try {
|
||||
const entries = readdirSync(normalized)
|
||||
type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number }
|
||||
const candidates: Candidate[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(normalized, entry)
|
||||
if (!this.isDirectory(entryPath)) continue
|
||||
@@ -943,16 +1025,36 @@ export class ConfigService {
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
const isExactMatch = lowerEntry === lowerWxid
|
||||
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
|
||||
// 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过
|
||||
if (!isExactMatch && !isSuffixMatch) continue
|
||||
|
||||
// wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以
|
||||
const shouldMatch = lowerWxid.startsWith('wxid_')
|
||||
? isSuffixMatch
|
||||
: (isExactMatch || isSuffixMatch)
|
||||
// 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过
|
||||
// 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录
|
||||
// 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。
|
||||
if (!this.accountDirLooksValid(entryPath)) continue
|
||||
|
||||
if (shouldMatch) {
|
||||
this.accountDirCache.set(cacheKey, entryPath)
|
||||
return entryPath
|
||||
}
|
||||
let mtime = 0
|
||||
try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ }
|
||||
candidates.push({
|
||||
entryPath,
|
||||
isExact: isExactMatch,
|
||||
hasSession: this.accountDirHasSessionDb(entryPath),
|
||||
mtime,
|
||||
})
|
||||
}
|
||||
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((a, b) => {
|
||||
// 1) 优先选有 session.db 的(真实写入数据的目录)
|
||||
if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1
|
||||
// 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录)
|
||||
if (a.isExact !== b.isExact) return a.isExact ? 1 : -1
|
||||
// 3) 最后按修改时间倒序(最新的优先)
|
||||
return b.mtime - a.mtime
|
||||
})
|
||||
const best = candidates[0].entryPath
|
||||
this.accountDirCache.set(cacheKey, best)
|
||||
return best
|
||||
}
|
||||
} catch { }
|
||||
|
||||
|
||||
@@ -137,7 +137,25 @@ export class DbPathService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找账号目录(包含 db_storage 或图片目录)
|
||||
* 查找 dbPath 根目录下所有"看起来像账号目录"的子目录名。
|
||||
*
|
||||
* ## 修复 #996(错误码 -3001:未找到数据库目录)
|
||||
*
|
||||
* ### 旧实现的过滤逻辑及缺陷
|
||||
* 旧实现对名字以 `wxid_` 开头的目录额外加了一道判断:
|
||||
* "段数(按下划线切分)必须 ≥ 3,否则跳过"
|
||||
* 也就是 `wxid_X_<suffix>` 才算合法、`wxid_X` 一律忽略。
|
||||
*
|
||||
* 这种粗暴过滤会**误伤未自定义微信号的普通用户**——他们的真实账号目录
|
||||
* 就叫 `wxid_X`(没有任何数字后缀),结果在欢迎页扫描时压根看不到自己。
|
||||
*
|
||||
* ### 修复策略
|
||||
* 1. **不再依据"段数"过滤**:先按是否真的是账号目录(含 db_storage 或
|
||||
* FileStorage/Image[2])一视同仁地收集所有候选;
|
||||
* 2. **用 {@link dedupeAccountDirs} 做更精准的去重**:仅当 `wxid_X` 和
|
||||
* `wxid_X_<suffix>` 同时存在时(这是自定义微信号后微信遗留旧空目录
|
||||
* 的典型场景),才二选一保留"更像微信实际在用"的那个,避免下拉框里
|
||||
* 出现两个看起来一样但只有一个能用的混乱选项。
|
||||
*/
|
||||
findAccountDirs(rootPath: string): string[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
@@ -160,23 +178,93 @@ export class DbPathService {
|
||||
|
||||
// 检查是否有有效账号目录结构
|
||||
if (this.isAccountDir(entryPath)) {
|
||||
// 过滤掉不带后缀的 wxid_ 目录
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
if (lowerEntry.startsWith('wxid_')) {
|
||||
// wxid_ 开头的目录必须带后缀(wxid_xxx_yyyy 格式)
|
||||
const parts = entry.split('_')
|
||||
if (parts.length <= 2) {
|
||||
// wxid_xxx 格式,跳过
|
||||
continue
|
||||
}
|
||||
}
|
||||
accounts.push(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return accounts
|
||||
return this.dedupeAccountDirs(resolvedRootPath, accounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号目录去重:仅当存在"前缀-后缀变体对"时(即同时出现 `wxid_X` 与
|
||||
* `wxid_X_<suffix>`),才二选一保留"微信实际在用"的那个目录。
|
||||
*
|
||||
* - 仅有一个候选目录时,原样返回,不做任何处理;
|
||||
* - 没有匹配到变体对的目录也都保留(互不相关的多账号场景);
|
||||
* - 真正二选一时由 {@link shouldPreferSuffixedDir} 决定胜负。
|
||||
*/
|
||||
private dedupeAccountDirs(rootPath: string, names: string[]): string[] {
|
||||
if (names.length <= 1) return names.slice()
|
||||
|
||||
const lowered = names.map(n => n.toLowerCase())
|
||||
const toSkip = new Set<string>()
|
||||
|
||||
// O(n^2) 双层循环找出所有"前缀-后缀变体对"。账号数极少,性能可忽略。
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
for (let j = 0; j < names.length; j++) {
|
||||
if (i === j) continue
|
||||
// 判定 names[j] 是 names[i] 的"带后缀变体":以 `<i>_` 开头
|
||||
if (lowered[j].startsWith(lowered[i] + '_')) {
|
||||
const baseName = names[i]
|
||||
const suffixedName = names[j]
|
||||
if (this.shouldPreferSuffixedDir(rootPath, baseName, suffixedName)) {
|
||||
toSkip.add(baseName) // 留 suffixedName,去掉无后缀的旧目录
|
||||
} else {
|
||||
toSkip.add(suffixedName) // 反之亦然
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names.filter(n => !toSkip.has(n))
|
||||
}
|
||||
|
||||
/**
|
||||
* 在"无后缀目录"与"带后缀目录"之间二选一时,判定后者是否应该胜出。
|
||||
*
|
||||
* 优先级(从高到低):
|
||||
* 1) 谁含有 session.db 谁优先 —— 这是"数据真实写入"最强的信号;
|
||||
* 2) 都含或都不含 session.db 时,比较修改时间,更新的优先;
|
||||
* 3) 兜底返回 true,即默认保留带后缀的目录(与微信 4.x 自定义微信号
|
||||
* 后真实目录命名一致)。
|
||||
*/
|
||||
private shouldPreferSuffixedDir(rootPath: string, baseName: string, suffixedName: string): boolean {
|
||||
const basePath = join(rootPath, baseName)
|
||||
const suffixedPath = join(rootPath, suffixedName)
|
||||
|
||||
const baseHasSession = this.hasSessionDb(basePath)
|
||||
const suffixedHasSession = this.hasSessionDb(suffixedPath)
|
||||
if (baseHasSession !== suffixedHasSession) {
|
||||
return suffixedHasSession
|
||||
}
|
||||
|
||||
const baseTime = this.getAccountModifiedTime(basePath)
|
||||
const suffixedTime = this.getAccountModifiedTime(suffixedPath)
|
||||
if (baseTime !== suffixedTime) {
|
||||
return suffixedTime >= baseTime
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 浅层检测账号目录下是否存在 session.db("数据是否真实写入"的判据)。
|
||||
*
|
||||
* 仅检测两条已知路径,不做深度递归,避免在大目录上拖慢扫描:
|
||||
* - db_storage/session/session.db (新版本嵌套布局)
|
||||
* - db_storage/session.db (部分版本扁平布局)
|
||||
*/
|
||||
private hasSessionDb(accountDir: string): boolean {
|
||||
const candidates = [
|
||||
join(accountDir, 'db_storage', 'session', 'session.db'),
|
||||
join(accountDir, 'db_storage', 'session.db'),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private isAccountDir(entryPath: string): boolean {
|
||||
@@ -225,7 +313,20 @@ export class DbPathService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||
* 扫描 dbPath 下"目录名包含下划线"的文件夹作为 wxid 候选。
|
||||
* 与 {@link findAccountDirs} 的区别:本方法不要求目录里真的有 db_storage/
|
||||
* FileStorage,仅按命名特征判断,结果会暴露给"手动选择 wxid"的弹窗使用。
|
||||
*
|
||||
* ## 修复 #996(错误码 -3001:未找到数据库目录)
|
||||
*
|
||||
* 旧实现对 `wxid_` 开头的目录额外要求"段数 ≥ 3"才放行,会误伤未自定义
|
||||
* 微信号的普通用户(他们的真实目录就叫 `wxid_X`)。现在改为不再依据段数
|
||||
* 过滤,并在末尾通过 {@link dedupeAccountDirs} 处理 `wxid_X` 与
|
||||
* `wxid_X_<suffix>` 同时存在的去重场景。
|
||||
*
|
||||
* 排除规则保留:
|
||||
* - 微信本身的非账号目录(如 `all_users`);
|
||||
* - 不含下划线的文件夹(不可能是 wxid)。
|
||||
*/
|
||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
@@ -243,15 +344,6 @@ export class DbPathService {
|
||||
if (lower === 'all_users') continue
|
||||
if (!entry.includes('_')) continue
|
||||
|
||||
// 过滤掉不带后缀的 wxid_ 目录
|
||||
if (lower.startsWith('wxid_')) {
|
||||
const parts = entry.split('_')
|
||||
if (parts.length <= 2) {
|
||||
// wxid_xxx 格式,跳过
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||
}
|
||||
}
|
||||
@@ -266,7 +358,13 @@ export class DbPathService {
|
||||
}
|
||||
} catch { }
|
||||
|
||||
const sorted = wxids.sort((a, b) => {
|
||||
// 修复 #996:对扫描到的 wxid 候选做去重,避免同时显示 wxid_X 与 wxid_X_<suffix>。
|
||||
const dedupedNames = new Set(
|
||||
this.dedupeAccountDirs(resolvedRootPath, wxids.map(w => w.wxid))
|
||||
)
|
||||
const deduped = wxids.filter(w => dedupedNames.has(w.wxid))
|
||||
|
||||
const sorted = deduped.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
});
|
||||
|
||||
@@ -323,7 +323,7 @@ class ExportService {
|
||||
return error
|
||||
}
|
||||
|
||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
|
||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean } | null): void {
|
||||
this.runtimeConfig = config
|
||||
imageDecryptService.setRuntimeConfig({
|
||||
dbPath: config?.dbPath,
|
||||
@@ -331,6 +331,14 @@ class ExportService {
|
||||
imageXorKey: config?.imageXorKey,
|
||||
imageAesKey: config?.imageAesKey
|
||||
})
|
||||
chatService.setRuntimeConfig({
|
||||
dbPath: config?.dbPath,
|
||||
decryptKey: config?.decryptKey,
|
||||
myWxid: config?.myWxid,
|
||||
resourcesPath: config?.resourcesPath,
|
||||
appPath: config?.appPath,
|
||||
isPackaged: config?.isPackaged
|
||||
})
|
||||
}
|
||||
|
||||
private getConfiguredDbPath(): string {
|
||||
@@ -6651,7 +6659,7 @@ class ExportService {
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
// 使用预先转写的文字
|
||||
content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
|
||||
} else if (mediaItem && msg.localType === 3) {
|
||||
} else if (mediaItem && msg.localType !== 47) {
|
||||
content = mediaItem.relativePath
|
||||
} else {
|
||||
content = this.parseMessageContent(
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
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
|
||||
}
|
||||
|
||||
interface GroupSummaryIndexRecord extends GroupSummaryRecordSummary {
|
||||
accountScope: string
|
||||
logFile?: string
|
||||
}
|
||||
|
||||
interface LegacyGroupSummaryRecord extends GroupSummaryIndexRecord {
|
||||
rawOutput?: string
|
||||
log?: GroupSummaryLog
|
||||
}
|
||||
|
||||
class GroupSummaryRecordService {
|
||||
private readonly maxRecordsPerScope = 2000
|
||||
private filePath: string | null = null
|
||||
private logDir: string | null = null
|
||||
private loaded = false
|
||||
private records: GroupSummaryIndexRecord[] = []
|
||||
|
||||
private resolveUserDataPath(): string {
|
||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
|
||||
fs.mkdirSync(userDataPath, { recursive: true })
|
||||
return userDataPath
|
||||
}
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
this.filePath = path.join(this.resolveUserDataPath(), 'weflow-group-summary-records.json')
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
private resolveLogDir(): string {
|
||||
if (this.logDir) return this.logDir
|
||||
this.logDir = path.join(this.resolveUserDataPath(), 'weflow-group-summary-logs')
|
||||
fs.mkdirSync(this.logDir, { recursive: true })
|
||||
return this.logDir
|
||||
}
|
||||
|
||||
private normalizeTimestampSeconds(value: unknown): number {
|
||||
const numeric = Number(value || 0)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return 0
|
||||
let normalized = Math.floor(numeric)
|
||||
while (normalized > 10000000000) {
|
||||
normalized = Math.floor(normalized / 1000)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private safeLogFileName(id: string): string {
|
||||
const normalized = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
return `${normalized || randomUUID()}.json`
|
||||
}
|
||||
|
||||
private writeLogFile(recordId: string, log: GroupSummaryLog, rawOutput: string): string | undefined {
|
||||
try {
|
||||
const fileName = this.safeLogFileName(recordId)
|
||||
const logPath = path.join(this.resolveLogDir(), fileName)
|
||||
fs.writeFileSync(logPath, JSON.stringify({ version: 1, rawOutput, log }, null, 2), 'utf-8')
|
||||
return fileName
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private readLogFile(fileName?: string): { rawOutput: string; log: GroupSummaryLog } | null {
|
||||
if (!fileName) return null
|
||||
try {
|
||||
const logPath = path.join(this.resolveLogDir(), this.safeLogFileName(fileName.replace(/\.json$/i, '')))
|
||||
if (!fs.existsSync(logPath)) return null
|
||||
const parsed = JSON.parse(fs.readFileSync(logPath, 'utf-8'))
|
||||
const log = parsed?.log
|
||||
if (!log || typeof log !== 'object') return null
|
||||
return {
|
||||
rawOutput: typeof parsed?.rawOutput === 'string' ? parsed.rawOutput : String(log.rawOutput || ''),
|
||||
log: log as GroupSummaryLog
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
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)) return
|
||||
|
||||
const legacyRecords = records.filter((item) => item && typeof item === 'object') as LegacyGroupSummaryRecord[]
|
||||
const needsMigration = legacyRecords.some((record) => Boolean(record.log || record.rawOutput))
|
||||
if (needsMigration) {
|
||||
this.backupLegacyFile(filePath)
|
||||
}
|
||||
|
||||
this.records = legacyRecords.map((record) => {
|
||||
const id = String(record.id || randomUUID())
|
||||
const logFile = record.log
|
||||
? this.writeLogFile(id, record.log, String(record.rawOutput || record.log.rawOutput || ''))
|
||||
: record.logFile
|
||||
return {
|
||||
id,
|
||||
accountScope: String(record.accountScope || 'default'),
|
||||
createdAt: Number(record.createdAt || Date.now()),
|
||||
sessionId: String(record.sessionId || ''),
|
||||
displayName: String(record.displayName || record.sessionId || ''),
|
||||
avatarUrl: record.avatarUrl,
|
||||
triggerType: record.triggerType === 'auto' ? 'auto' : 'manual',
|
||||
periodStart: this.normalizeTimestampSeconds(record.periodStart),
|
||||
periodEnd: this.normalizeTimestampSeconds(record.periodEnd),
|
||||
messageCount: Math.max(0, Math.floor(Number(record.messageCount || 0))),
|
||||
readableMessageCount: Math.max(0, Math.floor(Number(record.readableMessageCount || 0))),
|
||||
topics: Array.isArray(record.topics) ? record.topics : [],
|
||||
summaryText: String(record.summaryText || ''),
|
||||
logFile
|
||||
}
|
||||
}).filter((record) => record.sessionId && record.periodStart > 0 && record.periodEnd > record.periodStart)
|
||||
|
||||
if (needsMigration) {
|
||||
this.persist()
|
||||
}
|
||||
} catch {
|
||||
this.records = []
|
||||
}
|
||||
}
|
||||
|
||||
private backupLegacyFile(filePath: string): void {
|
||||
try {
|
||||
const backupPath = `${filePath}.legacy-${Date.now()}.bak`
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.copyFileSync(filePath, backupPath)
|
||||
}
|
||||
} catch {
|
||||
// Backup failure should not block reading existing records.
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.resolveFilePath()
|
||||
fs.writeFileSync(filePath, JSON.stringify({ version: 2, 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: GroupSummaryIndexRecord): 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(): GroupSummaryIndexRecord[] {
|
||||
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
|
||||
}): GroupSummaryRecordSummary {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const id = randomUUID()
|
||||
const logFile = this.writeLogFile(id, input.log, input.rawOutput)
|
||||
const record: GroupSummaryIndexRecord = {
|
||||
id,
|
||||
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,
|
||||
logFile
|
||||
}
|
||||
|
||||
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 this.toSummary(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 = this.normalizeTimestampSeconds(filters.startTime)
|
||||
const endTime = this.normalizeTimestampSeconds(filters.endTime)
|
||||
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
|
||||
const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100))))
|
||||
|
||||
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: '未找到该群聊总结记录' }
|
||||
|
||||
const logData = this.readLogFile(record.logFile)
|
||||
if (!logData) return { success: false, error: '未找到该群聊总结日志' }
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: {
|
||||
...this.toSummary(record),
|
||||
accountScope: record.accountScope,
|
||||
rawOutput: logData.rawOutput,
|
||||
log: logData.log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearRuntimeCache(): void {
|
||||
this.loaded = false
|
||||
this.records = []
|
||||
this.filePath = null
|
||||
this.logDir = null
|
||||
}
|
||||
}
|
||||
|
||||
export const groupSummaryRecordService = new GroupSummaryRecordService()
|
||||
@@ -0,0 +1,801 @@
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import { URL } from 'url'
|
||||
import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, type Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import {
|
||||
groupSummaryRecordService,
|
||||
type GroupSummaryLog,
|
||||
type GroupSummaryRecord,
|
||||
type GroupSummaryRecordFilters,
|
||||
type GroupSummaryRecordListResult,
|
||||
type GroupSummaryRecordSummary,
|
||||
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 DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim()
|
||||
const SUMMARY_CONFIG_KEYS = new Set([
|
||||
'aiGroupSummaryEnabled',
|
||||
'aiGroupSummaryIntervalHours',
|
||||
'aiGroupSummarySystemPrompt',
|
||||
'aiGroupSummaryFilterMode',
|
||||
'aiGroupSummaryFilterList',
|
||||
'aiModelApiBaseUrl',
|
||||
'aiModelApiKey',
|
||||
'aiModelApiModel',
|
||||
'aiInsightApiBaseUrl',
|
||||
'aiInsightApiKey',
|
||||
'aiInsightApiModel',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
])
|
||||
|
||||
interface SharedAiModelConfig {
|
||||
apiBaseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
}
|
||||
|
||||
interface GroupSummaryTriggerResult {
|
||||
success: boolean
|
||||
message: string
|
||||
recordId?: string
|
||||
record?: GroupSummaryRecordSummary
|
||||
skipped?: boolean
|
||||
skippedReason?: string
|
||||
}
|
||||
|
||||
interface GroupSummaryDayTriggerResult {
|
||||
success: boolean
|
||||
message: string
|
||||
generated: number
|
||||
skipped: number
|
||||
records: GroupSummaryRecordSummary[]
|
||||
}
|
||||
|
||||
class ApiRequestError extends Error {
|
||||
statusCode?: number
|
||||
responseBody?: string
|
||||
|
||||
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 pendingAutoRun = 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.pendingAutoRun = false
|
||||
this.dbConnected = false
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
if (!SUMMARY_CONFIG_KEYS.has(normalizedKey)) return
|
||||
if (normalizedKey === 'aiGroupSummarySystemPrompt') return
|
||||
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||
this.dbConnected = false
|
||||
groupSummaryRecordService.clearRuntimeCache()
|
||||
}
|
||||
await this.refreshConfiguration(`config:${normalizedKey}`)
|
||||
}
|
||||
|
||||
handleConfigCleared(): void {
|
||||
this.clearTimers()
|
||||
this.processing = false
|
||||
this.pendingAutoRun = 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'
|
||||
})
|
||||
}
|
||||
|
||||
async triggerDay(params: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
date: string
|
||||
}): Promise<GroupSummaryDayTriggerResult> {
|
||||
if (!this.isEnabled()) {
|
||||
return { success: false, message: '请先在设置中开启「AI 群聊总结」', generated: 0, skipped: 0, records: [] }
|
||||
}
|
||||
const sessionId = String(params?.sessionId || '').trim()
|
||||
if (!sessionId.endsWith('@chatroom')) {
|
||||
return { success: false, message: 'AI 群聊总结仅支持群聊', generated: 0, skipped: 0, records: [] }
|
||||
}
|
||||
const dayRange = this.parseLocalDateDayRange(params?.date)
|
||||
if (!dayRange) {
|
||||
return { success: false, message: '请选择有效日期', generated: 0, skipped: 0, records: [] }
|
||||
}
|
||||
const todayStart = getStartOfDaySeconds(new Date())
|
||||
if (dayRange.start > todayStart) {
|
||||
return { success: false, message: '不能总结未来日期', generated: 0, skipped: 0, records: [] }
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const effectiveEnd = dayRange.start === todayStart ? Math.min(dayRange.end, now) : dayRange.end
|
||||
const periods = this.getIntervalPeriods(dayRange.start, effectiveEnd, false)
|
||||
if (periods.length === 0) {
|
||||
return { success: true, message: '当前日期暂无已完成的总结时段', generated: 0, skipped: 0, records: [] }
|
||||
}
|
||||
|
||||
const displayName = String(params?.displayName || sessionId).trim() || sessionId
|
||||
const avatarUrl = String(params?.avatarUrl || '').trim() || undefined
|
||||
return this.generateSummariesForPeriods({
|
||||
sessionId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
periods,
|
||||
triggerType: 'manual'
|
||||
})
|
||||
}
|
||||
|
||||
private async refreshConfiguration(_reason: string): Promise<void> {
|
||||
if (!this.started) return
|
||||
this.clearTimers()
|
||||
if (!this.isEnabled()) return
|
||||
await this.queueDueAutoSummaries()
|
||||
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.queueDueAutoSummaries()
|
||||
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 getAutoScopeSessionIds(): string[] {
|
||||
return normalizeSessionIdList(this.config.get('aiGroupSummaryFilterList'))
|
||||
.filter((sessionId) => sessionId.endsWith('@chatroom'))
|
||||
}
|
||||
|
||||
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 parseLocalDateDayRange(value: unknown): { start: number; end: number } | null {
|
||||
const text = String(value || '').trim()
|
||||
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (!match) return null
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
const start = new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||
if (
|
||||
!Number.isFinite(start.getTime()) ||
|
||||
start.getFullYear() !== year ||
|
||||
start.getMonth() !== month - 1 ||
|
||||
start.getDate() !== day
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 1)
|
||||
return {
|
||||
start: Math.floor(start.getTime() / 1000),
|
||||
end: Math.floor(end.getTime() / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private getIntervalPeriods(startTime: number, endTime: number, includePartial: boolean): Array<{ start: number; end: number }> {
|
||||
const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours'))
|
||||
const intervalSeconds = intervalHours * 60 * 60
|
||||
const periods: Array<{ start: number; end: number }> = []
|
||||
for (let start = startTime; start < endTime; start += intervalSeconds) {
|
||||
const end = Math.min(start + intervalSeconds, endTime)
|
||||
if (!includePartial && end - start < intervalSeconds) continue
|
||||
if (end > start) periods.push({ start, end })
|
||||
}
|
||||
return periods
|
||||
}
|
||||
|
||||
private getCompletedPeriodsToday(): Array<{ start: number; end: number }> {
|
||||
const dayStart = getStartOfDaySeconds(new Date())
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return this.getIntervalPeriods(dayStart, now, false)
|
||||
}
|
||||
|
||||
private async queueDueAutoSummaries(): Promise<void> {
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
if (this.processing) {
|
||||
this.pendingAutoRun = true
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
try {
|
||||
do {
|
||||
this.pendingAutoRun = false
|
||||
await this.runDueAutoSummariesOnce()
|
||||
} while (this.pendingAutoRun && this.started && this.isEnabled())
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async runDueAutoSummariesOnce(): Promise<void> {
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
try {
|
||||
const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig()
|
||||
if (!apiBaseUrl || !apiKey) return
|
||||
const scopeSessionIds = this.getAutoScopeSessionIds()
|
||||
if (scopeSessionIds.length === 0) return
|
||||
if (!await this.ensureConnected()) return
|
||||
|
||||
const contacts = (await chatService.enrichSessionsContactInfo(scopeSessionIds).catch(() => null))?.contacts || {}
|
||||
|
||||
const periods = this.getCompletedPeriodsToday()
|
||||
for (const period of periods) {
|
||||
for (const sessionId of scopeSessionIds) {
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
if (!sessionId) continue
|
||||
if (groupSummaryRecordService.hasAutoRecord(sessionId, period.start, period.end)) continue
|
||||
await this.generateSummaryForPeriod({
|
||||
sessionId,
|
||||
displayName: contacts[sessionId]?.displayName || sessionId,
|
||||
avatarUrl: contacts[sessionId]?.avatarUrl,
|
||||
periodStart: period.start,
|
||||
periodEnd: period.end,
|
||||
triggerType: 'auto'
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[GroupSummaryService] 自动总结失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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 customPrompt = String(this.config.get('aiGroupSummarySystemPrompt') || '').trim()
|
||||
const systemPrompt = customPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT
|
||||
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)}` }
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -1431,6 +1431,20 @@ class HttpService {
|
||||
return '.jpg'
|
||||
}
|
||||
|
||||
private writeFileIfLarger(fullPath: string, data: Buffer): void {
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath)
|
||||
if (!stat.isFile()) return
|
||||
if (data.length <= stat.size) return
|
||||
} catch {
|
||||
// If the existing export cannot be inspected, overwrite it below.
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(fullPath, data)
|
||||
}
|
||||
|
||||
private async exportMediaForMessages(
|
||||
messages: Message[],
|
||||
talker: string,
|
||||
@@ -1530,9 +1544,7 @@ class HttpService {
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
this.writeFileIfLarger(fullPath, imageBuffer)
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
}
|
||||
@@ -1545,9 +1557,7 @@ class HttpService {
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.copyFileSync(imagePath, fullPath)
|
||||
}
|
||||
this.writeFileIfLarger(fullPath, imageBuffer)
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,1001 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import { URL } from 'url'
|
||||
import { app } from 'electron'
|
||||
import { randomUUID, createHash } from 'crypto'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, type Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
const API_TIMEOUT_MS = 45_000
|
||||
const API_TEMPERATURE = 0.7
|
||||
const MONTH_MATERIAL_CHAR_LIMIT = 45_000
|
||||
const DIRECT_MONTH_MESSAGE_LIMIT = 1000
|
||||
const MONTH_CURSOR_BATCH_SIZE = 800
|
||||
const MAX_RETRY_ATTEMPTS = 5
|
||||
const MONTHLY_OUTPUT_MIN_TOKENS = 1600
|
||||
const FINAL_OUTPUT_MIN_TOKENS = 2400
|
||||
|
||||
type ProfileStatusValue = 'none' | 'ready' | 'running' | 'failed'
|
||||
|
||||
interface SharedAiModelConfig {
|
||||
apiBaseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
}
|
||||
|
||||
interface ActiveProfileTask {
|
||||
taskId: string
|
||||
sessionId: string
|
||||
displayName: string
|
||||
controller: AbortController
|
||||
phase: string
|
||||
startedAt: number
|
||||
cursor?: number
|
||||
}
|
||||
|
||||
interface MonthWindow {
|
||||
key: string
|
||||
label: string
|
||||
startSec: number
|
||||
endSec: number
|
||||
}
|
||||
|
||||
interface MonthStats {
|
||||
total: number
|
||||
mine: number
|
||||
peer: number
|
||||
activeDays: number
|
||||
longestActiveDayStreak: number
|
||||
longestSilenceDays: number
|
||||
topHours: string[]
|
||||
firstTime?: number
|
||||
lastTime?: number
|
||||
}
|
||||
|
||||
interface PreparedMonthMaterial {
|
||||
text: string
|
||||
compressed: boolean
|
||||
stats: MonthStats
|
||||
scannedMessages: number
|
||||
sampledMessages: number
|
||||
}
|
||||
|
||||
interface MonthSummary {
|
||||
month: string
|
||||
messageCount: number
|
||||
compressed: boolean
|
||||
sampledMessages: number
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface InsightProfileRecord {
|
||||
id: string
|
||||
accountScope: string
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
rangeStart: number
|
||||
rangeEnd: number
|
||||
months: string[]
|
||||
emptyMonths: string[]
|
||||
monthlySummaries: MonthSummary[]
|
||||
finalProfile: string
|
||||
stats: {
|
||||
scannedMessages: number
|
||||
summarizedMonths: number
|
||||
emptyMonths: number
|
||||
compressedMonths: number
|
||||
}
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface InsightProfileStatus {
|
||||
sessionId: string
|
||||
status: ProfileStatusValue
|
||||
updatedAt?: number
|
||||
error?: string
|
||||
phase?: string
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
export interface InsightProfileStatusListResult {
|
||||
success: boolean
|
||||
statuses: Record<string, InsightProfileStatus>
|
||||
activeTask?: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
phase: string
|
||||
startedAt: number
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface InsightProfileGenerateResult {
|
||||
success: boolean
|
||||
message: string
|
||||
cancelled?: boolean
|
||||
profile?: InsightProfileRecord
|
||||
error?: string
|
||||
}
|
||||
|
||||
class AbortRequestError extends Error {
|
||||
constructor(message = '画像任务已取消') {
|
||||
super(message)
|
||||
this.name = 'AbortError'
|
||||
}
|
||||
}
|
||||
|
||||
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 isAbortError(error: unknown): boolean {
|
||||
return (error as Error)?.name === 'AbortError' || String((error as Error)?.message || '').includes('取消')
|
||||
}
|
||||
|
||||
function abortIfNeeded(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new AbortRequestError()
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
abortIfNeeded(signal)
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
reject(new AbortRequestError())
|
||||
}
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeApiMaxTokens(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return 1024
|
||||
return Math.min(2_000_000, Math.max(1, Math.floor(numeric)))
|
||||
}
|
||||
|
||||
function buildApiUrl(baseUrl: string, apiPath: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const suffix = apiPath.startsWith('/') ? apiPath : `/${apiPath}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function formatPromptCurrentTime(date: Date = new Date()): string {
|
||||
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')
|
||||
return `当前系统时间:${year}年${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function appendPromptCurrentTime(prompt: string): string {
|
||||
const base = String(prompt || '').trimEnd()
|
||||
return base ? `${base}\n\n${formatPromptCurrentTime()}` : formatPromptCurrentTime()
|
||||
}
|
||||
|
||||
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 truncateStructuredText(value: unknown, maxLength: number): string {
|
||||
const text = String(value || '').replace(/\u0000/g, '').trim()
|
||||
if (text.length <= maxLength) return text
|
||||
return `${text.slice(0, Math.max(0, maxLength - 3))}...`
|
||||
}
|
||||
|
||||
function formatDateTime(timestampSeconds: number): string {
|
||||
if (!Number.isFinite(timestampSeconds) || timestampSeconds <= 0) return ''
|
||||
const date = new Date(timestampSeconds * 1000)
|
||||
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')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function formatMonthKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getMonthStart(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0)
|
||||
}
|
||||
|
||||
function toSeconds(date: Date): number {
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
function buildRecentTwelveMonthWindows(now: Date = new Date()): MonthWindow[] {
|
||||
const currentMonthStart = getMonthStart(now)
|
||||
const windows: MonthWindow[] = []
|
||||
for (let index = 11; index >= 0; index -= 1) {
|
||||
const start = new Date(currentMonthStart)
|
||||
start.setMonth(currentMonthStart.getMonth() - index)
|
||||
const next = new Date(start)
|
||||
next.setMonth(start.getMonth() + 1)
|
||||
const isCurrentMonth = index === 0
|
||||
const end = isCurrentMonth ? now : new Date(next.getTime() - 1000)
|
||||
const key = formatMonthKey(start)
|
||||
windows.push({
|
||||
key,
|
||||
label: key,
|
||||
startSec: toSeconds(start),
|
||||
endSec: Math.max(toSeconds(start), toSeconds(end))
|
||||
})
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
function callProfileApi(
|
||||
config: SharedAiModelConfig,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
maxTokens: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
abortIfNeeded(signal)
|
||||
const endpoint = buildApiUrl(config.apiBaseUrl, '/chat/completions')
|
||||
const urlObj = new URL(endpoint)
|
||||
const payload = JSON.stringify({
|
||||
model: config.model,
|
||||
messages,
|
||||
max_tokens: normalizeApiMaxTokens(maxTokens),
|
||||
temperature: API_TEMPERATURE,
|
||||
stream: false
|
||||
})
|
||||
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(payload).toString(),
|
||||
Authorization: `Bearer ${config.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)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const onAbort = () => {
|
||||
req.destroy(new AbortRequestError())
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
req.setTimeout(API_TIMEOUT_MS, () => {
|
||||
req.destroy()
|
||||
reject(new Error('API 请求超时'))
|
||||
})
|
||||
req.on('error', (error) => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reject(isAbortError(error) || signal?.aborted ? new AbortRequestError() : error)
|
||||
})
|
||||
req.on('close', () => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
})
|
||||
req.write(payload)
|
||||
req.end()
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function callProfileApiWithRetry(
|
||||
config: SharedAiModelConfig,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
maxTokens: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt += 1) {
|
||||
abortIfNeeded(signal)
|
||||
try {
|
||||
return await callProfileApi(config, messages, maxTokens, signal)
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || signal?.aborted) throw new AbortRequestError()
|
||||
lastError = error
|
||||
if (attempt >= MAX_RETRY_ATTEMPTS) break
|
||||
await sleep(Math.min(10_000, 800 * Math.pow(2, attempt - 1)), signal)
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError || 'API 请求失败'))
|
||||
}
|
||||
|
||||
class InsightProfileService {
|
||||
private readonly config = ConfigService.getInstance()
|
||||
private filePath: string | null = null
|
||||
private loaded = false
|
||||
private records: InsightProfileRecord[] = []
|
||||
private activeTask: ActiveProfileTask | null = null
|
||||
private failedStatus = new Map<string, { error: string; updatedAt: number }>()
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
const userDataPath = app?.getPath?.('userData') || process.cwd()
|
||||
fs.mkdirSync(userDataPath, { recursive: true })
|
||||
this.filePath = path.join(userDataPath, 'weflow-insight-profiles.json')
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
try {
|
||||
const filePath = this.resolveFilePath()
|
||||
if (!fs.existsSync(filePath)) return
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
const records = Array.isArray(parsed) ? parsed : parsed?.records
|
||||
if (Array.isArray(records)) {
|
||||
this.records = records.filter((item) => item && typeof item === 'object') as InsightProfileRecord[]
|
||||
}
|
||||
} catch {
|
||||
this.records = []
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
fs.writeFileSync(this.resolveFilePath(), JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
|
||||
} catch {
|
||||
// Profile generation should not crash when local persistence fails.
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentAccountScope(): string {
|
||||
const myWxid = String(this.config.getMyWxidCleaned() || '').trim()
|
||||
if (myWxid) return `wxid:${myWxid}`
|
||||
const dbPath = String(this.config.get('dbPath') || '').trim()
|
||||
if (dbPath) {
|
||||
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
|
||||
return `db:${hash}`
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
private getSharedAiModelConfig(): SharedAiModelConfig {
|
||||
const apiBaseUrl = String(
|
||||
this.config.get('aiModelApiBaseUrl')
|
||||
|| this.config.get('aiInsightApiBaseUrl')
|
||||
|| ''
|
||||
).trim().replace(/\/+$/, '')
|
||||
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'
|
||||
const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens'))
|
||||
return { apiBaseUrl, apiKey, model, maxTokens }
|
||||
}
|
||||
|
||||
private findLatestRecord(sessionId: string): InsightProfileRecord | null {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return null
|
||||
const matches = this.records
|
||||
.filter((record) => record.accountScope === scope && record.sessionId === normalizedSessionId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return matches[0] || null
|
||||
}
|
||||
|
||||
listProfileStatuses(sessionIds: string[]): InsightProfileStatusListResult {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
const latestBySession = new Map<string, InsightProfileRecord>()
|
||||
for (const record of this.records.filter((item) => item.accountScope === scope)) {
|
||||
const existing = latestBySession.get(record.sessionId)
|
||||
if (!existing || record.updatedAt > existing.updatedAt) {
|
||||
latestBySession.set(record.sessionId, record)
|
||||
}
|
||||
}
|
||||
|
||||
const statuses: Record<string, InsightProfileStatus> = {}
|
||||
for (const sessionId of normalizedIds) {
|
||||
const activeForSession = this.activeTask?.sessionId === sessionId
|
||||
if (activeForSession && this.activeTask) {
|
||||
statuses[sessionId] = {
|
||||
sessionId,
|
||||
status: 'running',
|
||||
phase: this.activeTask.phase,
|
||||
updatedAt: this.activeTask.startedAt,
|
||||
busy: false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const record = latestBySession.get(sessionId)
|
||||
if (record) {
|
||||
statuses[sessionId] = {
|
||||
sessionId,
|
||||
status: 'ready',
|
||||
updatedAt: record.updatedAt,
|
||||
busy: Boolean(this.activeTask)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const failed = this.failedStatus.get(sessionId)
|
||||
if (failed) {
|
||||
statuses[sessionId] = {
|
||||
sessionId,
|
||||
status: 'failed',
|
||||
updatedAt: failed.updatedAt,
|
||||
error: failed.error,
|
||||
busy: Boolean(this.activeTask)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
statuses[sessionId] = {
|
||||
sessionId,
|
||||
status: 'none',
|
||||
busy: Boolean(this.activeTask)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
statuses,
|
||||
activeTask: this.activeTask
|
||||
? {
|
||||
sessionId: this.activeTask.sessionId,
|
||||
displayName: this.activeTask.displayName,
|
||||
phase: this.activeTask.phase,
|
||||
startedAt: this.activeTask.startedAt
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
getProfileContextSection(sessionId: string): string {
|
||||
const record = this.findLatestRecord(sessionId)
|
||||
if (!record?.finalProfile) return ''
|
||||
const rangeStart = formatDateTime(record.rangeStart)
|
||||
const rangeEnd = formatDateTime(record.rangeEnd)
|
||||
return [
|
||||
`联系人长期 AI 画像(覆盖 ${rangeStart} 至 ${rangeEnd},生成于 ${new Date(record.updatedAt).toLocaleString('zh-CN')}):`,
|
||||
clampText(record.finalProfile, 3000)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
cancelProfile(sessionId?: string): { success: boolean; message: string } {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!this.activeTask) return { success: true, message: '当前没有画像任务' }
|
||||
if (normalizedSessionId && normalizedSessionId !== this.activeTask.sessionId) {
|
||||
return { success: false, message: '当前运行中的画像任务不属于该联系人' }
|
||||
}
|
||||
this.activeTask.phase = '正在取消画像...'
|
||||
this.activeTask.controller.abort()
|
||||
return { success: true, message: '已请求取消画像任务' }
|
||||
}
|
||||
|
||||
cancelActiveTask(reason = '画像任务已取消'): void {
|
||||
if (!this.activeTask) return
|
||||
this.activeTask.phase = reason
|
||||
this.activeTask.controller.abort()
|
||||
}
|
||||
|
||||
async generateProfile(params: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}): Promise<InsightProfileGenerateResult> {
|
||||
const sessionId = String(params?.sessionId || '').trim()
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) {
|
||||
return { success: false, message: 'AI 画像仅支持私聊联系人' }
|
||||
}
|
||||
if (this.activeTask) {
|
||||
return {
|
||||
success: false,
|
||||
message: `「${this.activeTask.displayName}」的画像正在生成,请等待完成或取消后再试`
|
||||
}
|
||||
}
|
||||
|
||||
const aiConfig = this.getSharedAiModelConfig()
|
||||
if (!aiConfig.apiBaseUrl || !aiConfig.apiKey) {
|
||||
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||
}
|
||||
|
||||
const existing = this.findLatestRecord(sessionId)
|
||||
const displayName = clampText(params?.displayName || existing?.displayName || sessionId, 80) || sessionId
|
||||
const controller = new AbortController()
|
||||
const task: ActiveProfileTask = {
|
||||
taskId: randomUUID(),
|
||||
sessionId,
|
||||
displayName,
|
||||
controller,
|
||||
phase: '正在初始化画像...',
|
||||
startedAt: Date.now()
|
||||
}
|
||||
this.activeTask = task
|
||||
|
||||
try {
|
||||
const connectResult = await chatService.connect()
|
||||
abortIfNeeded(controller.signal)
|
||||
if (!connectResult.success) {
|
||||
throw new Error('数据库连接失败,请先在“数据库连接”页完成配置')
|
||||
}
|
||||
|
||||
const windows = buildRecentTwelveMonthWindows()
|
||||
const monthlySummaries: MonthSummary[] = []
|
||||
const emptyMonths: string[] = []
|
||||
let scannedMessages = 0
|
||||
let compressedMonths = 0
|
||||
|
||||
for (let index = 0; index < windows.length; index += 1) {
|
||||
abortIfNeeded(controller.signal)
|
||||
const month = windows[index]
|
||||
task.phase = `正在读取 ${month.label} 聊天记录 (${index + 1}/12)...`
|
||||
const messages = await this.readMonthMessages(sessionId, month, task)
|
||||
scannedMessages += messages.length
|
||||
|
||||
if (messages.length === 0) {
|
||||
emptyMonths.push(month.label)
|
||||
continue
|
||||
}
|
||||
|
||||
const material = this.prepareMonthMaterial(messages, displayName)
|
||||
if (material.compressed) compressedMonths += 1
|
||||
|
||||
task.phase = `正在生成 ${month.label} 月度画像 (${monthlySummaries.length + 1})...`
|
||||
const summary = await this.generateMonthlySummary(aiConfig, displayName, month.label, material, controller.signal)
|
||||
monthlySummaries.push({
|
||||
month: month.label,
|
||||
messageCount: material.scannedMessages,
|
||||
compressed: material.compressed,
|
||||
sampledMessages: material.sampledMessages,
|
||||
summary
|
||||
})
|
||||
}
|
||||
|
||||
if (monthlySummaries.length === 0) {
|
||||
throw new Error('最近 12 个自然月没有可用于画像的聊天记录')
|
||||
}
|
||||
|
||||
task.phase = '正在汇总完整 AI 画像...'
|
||||
const finalProfile = await this.generateFinalProfile(aiConfig, displayName, windows, emptyMonths, monthlySummaries, controller.signal)
|
||||
abortIfNeeded(controller.signal)
|
||||
|
||||
const now = Date.now()
|
||||
const record: InsightProfileRecord = {
|
||||
id: randomUUID(),
|
||||
accountScope: this.getCurrentAccountScope(),
|
||||
sessionId,
|
||||
displayName,
|
||||
avatarUrl: String(params?.avatarUrl || existing?.avatarUrl || '').trim() || undefined,
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now,
|
||||
rangeStart: windows[0].startSec,
|
||||
rangeEnd: windows[windows.length - 1].endSec,
|
||||
months: windows.map((month) => month.label),
|
||||
emptyMonths,
|
||||
monthlySummaries,
|
||||
finalProfile,
|
||||
stats: {
|
||||
scannedMessages,
|
||||
summarizedMonths: monthlySummaries.length,
|
||||
emptyMonths: emptyMonths.length,
|
||||
compressedMonths
|
||||
},
|
||||
model: aiConfig.model
|
||||
}
|
||||
|
||||
this.upsertRecord(record)
|
||||
this.failedStatus.delete(sessionId)
|
||||
return {
|
||||
success: true,
|
||||
message: `已完成「${displayName}」的 AI 画像`,
|
||||
profile: record
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || controller.signal.aborted) {
|
||||
return { success: false, cancelled: true, message: '画像已取消' }
|
||||
}
|
||||
const message = (error as Error).message || String(error)
|
||||
if (!existing) {
|
||||
this.failedStatus.set(sessionId, { error: message, updatedAt: Date.now() })
|
||||
}
|
||||
return { success: false, message: `画像失败:${message}`, error: message }
|
||||
} finally {
|
||||
if (task.cursor) {
|
||||
await wcdbService.closeMessageCursor(task.cursor).catch(() => {})
|
||||
}
|
||||
if (this.activeTask?.taskId === task.taskId) {
|
||||
this.activeTask = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private upsertRecord(record: InsightProfileRecord): void {
|
||||
this.ensureLoaded()
|
||||
this.records = this.records.filter((item) => !(item.accountScope === record.accountScope && item.sessionId === record.sessionId))
|
||||
this.records.push(record)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
private async readMonthMessages(sessionId: string, month: MonthWindow, task: ActiveProfileTask): Promise<Message[]> {
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(
|
||||
sessionId,
|
||||
MONTH_CURSOR_BATCH_SIZE,
|
||||
true,
|
||||
month.startSec,
|
||||
month.endSec
|
||||
)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
throw new Error(cursorResult.error || `读取 ${month.label} 聊天记录失败`)
|
||||
}
|
||||
|
||||
task.cursor = cursorResult.cursor
|
||||
const messages: Message[] = []
|
||||
try {
|
||||
while (true) {
|
||||
abortIfNeeded(task.controller.signal)
|
||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||
if (!batch.success) {
|
||||
throw new Error(batch.error || `读取 ${month.label} 聊天记录失败`)
|
||||
}
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length > 0) {
|
||||
const mapped = chatService.mapRowsToMessagesLiteForApi(rows)
|
||||
for (const message of mapped) {
|
||||
const createTime = Number(message.createTime || 0)
|
||||
if (createTime < month.startSec || createTime > month.endSec) continue
|
||||
messages.push({
|
||||
...message,
|
||||
rawContent: clampText(message.rawContent || message.content || '', 1200),
|
||||
content: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!batch.hasMore) break
|
||||
}
|
||||
messages.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq) || (a.localId - b.localId))
|
||||
return messages
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {})
|
||||
if (task.cursor === cursorResult.cursor) task.cursor = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private prepareMonthMaterial(messages: Message[], peerDisplayName: string): PreparedMonthMaterial {
|
||||
const stats = this.computeMonthStats(messages)
|
||||
const lines = messages.map((message) => this.formatMessageLine(message, peerDisplayName))
|
||||
const fullText = lines.join('\n')
|
||||
if (messages.length <= DIRECT_MONTH_MESSAGE_LIMIT && fullText.length <= MONTH_MATERIAL_CHAR_LIMIT) {
|
||||
return {
|
||||
text: fullText,
|
||||
compressed: false,
|
||||
stats,
|
||||
scannedMessages: messages.length,
|
||||
sampledMessages: messages.length
|
||||
}
|
||||
}
|
||||
|
||||
const statsText = this.formatMonthStats(stats)
|
||||
const selectedIndices = this.selectRepresentativeIndices(messages)
|
||||
const sampledLines = Array.from(selectedIndices)
|
||||
.sort((a, b) => a - b)
|
||||
.map((index) => lines[index])
|
||||
|
||||
const sampledText = this.fitLinesToBudget(sampledLines, Math.max(10_000, MONTH_MATERIAL_CHAR_LIMIT - statsText.length - 800))
|
||||
const text = [
|
||||
'本月聊天记录已完整扫描。由于原文过长,以下为本地统计摘要、时间均匀抽样与高信息密度片段;请基于这些证据谨慎概括,不要把抽样片段视为全部事实。',
|
||||
'',
|
||||
statsText,
|
||||
'',
|
||||
'代表性聊天片段(按时间顺序):',
|
||||
sampledText || '无可读文本片段'
|
||||
].join('\n')
|
||||
|
||||
return {
|
||||
text: truncateStructuredText(text, MONTH_MATERIAL_CHAR_LIMIT),
|
||||
compressed: true,
|
||||
stats,
|
||||
scannedMessages: messages.length,
|
||||
sampledMessages: sampledLines.length
|
||||
}
|
||||
}
|
||||
|
||||
private computeMonthStats(messages: Message[]): MonthStats {
|
||||
const daySet = new Set<string>()
|
||||
const hourCounts = new Map<number, number>()
|
||||
let mine = 0
|
||||
let peer = 0
|
||||
let firstTime = 0
|
||||
let lastTime = 0
|
||||
|
||||
for (const message of messages) {
|
||||
const ts = Math.max(0, Math.floor(Number(message.createTime || 0)))
|
||||
if (ts > 0) {
|
||||
if (!firstTime || ts < firstTime) firstTime = ts
|
||||
if (!lastTime || ts > lastTime) lastTime = ts
|
||||
const date = new Date(ts * 1000)
|
||||
const day = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
daySet.add(day)
|
||||
const hour = date.getHours()
|
||||
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
|
||||
}
|
||||
if (message.isSend === 1) mine += 1
|
||||
else peer += 1
|
||||
}
|
||||
|
||||
const sortedDays = Array.from(daySet).sort()
|
||||
let longestActiveDayStreak = 0
|
||||
let currentStreak = 0
|
||||
let longestSilenceDays = 0
|
||||
let prevDayTime = 0
|
||||
for (const day of sortedDays) {
|
||||
const dayTime = new Date(`${day}T00:00:00`).getTime()
|
||||
if (!prevDayTime || dayTime - prevDayTime === 86_400_000) {
|
||||
currentStreak += 1
|
||||
} else {
|
||||
currentStreak = 1
|
||||
longestSilenceDays = Math.max(longestSilenceDays, Math.floor((dayTime - prevDayTime) / 86_400_000) - 1)
|
||||
}
|
||||
prevDayTime = dayTime
|
||||
longestActiveDayStreak = Math.max(longestActiveDayStreak, currentStreak)
|
||||
}
|
||||
|
||||
const topHours = Array.from(hourCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([hour, count]) => `${String(hour).padStart(2, '0')}:00(${count}条)`)
|
||||
|
||||
return {
|
||||
total: messages.length,
|
||||
mine,
|
||||
peer,
|
||||
activeDays: daySet.size,
|
||||
longestActiveDayStreak,
|
||||
longestSilenceDays,
|
||||
topHours,
|
||||
firstTime: firstTime || undefined,
|
||||
lastTime: lastTime || undefined
|
||||
}
|
||||
}
|
||||
|
||||
private formatMonthStats(stats: MonthStats): string {
|
||||
return [
|
||||
'本月统计摘要:',
|
||||
`消息总数:${stats.total}`,
|
||||
`我发送:${stats.mine};对方发送:${stats.peer}`,
|
||||
`活跃天数:${stats.activeDays}`,
|
||||
`最长连续活跃:${stats.longestActiveDayStreak} 天`,
|
||||
`最长无互动间隔:${stats.longestSilenceDays} 天`,
|
||||
`主要互动时段:${stats.topHours.length > 0 ? stats.topHours.join('、') : '无'}`,
|
||||
`首条消息时间:${stats.firstTime ? formatDateTime(stats.firstTime) : '无'}`,
|
||||
`末条消息时间:${stats.lastTime ? formatDateTime(stats.lastTime) : '无'}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
private selectRepresentativeIndices(messages: Message[]): Set<number> {
|
||||
const selected = new Set<number>()
|
||||
const addWindow = (center: number, radius = 2) => {
|
||||
for (let index = Math.max(0, center - radius); index <= Math.min(messages.length - 1, center + radius); index += 1) {
|
||||
selected.add(index)
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) return selected
|
||||
|
||||
const bucketCount = Math.min(24, Math.max(6, Math.ceil(messages.length / 250)))
|
||||
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
|
||||
const start = Math.floor((messages.length * bucket) / bucketCount)
|
||||
const end = Math.max(start, Math.floor((messages.length * (bucket + 1)) / bucketCount) - 1)
|
||||
addWindow(start, 1)
|
||||
addWindow(Math.floor((start + end) / 2), 1)
|
||||
addWindow(end, 1)
|
||||
}
|
||||
|
||||
const scored = messages.map((message, index) => ({
|
||||
index,
|
||||
score: this.scoreMessageForProfile(message)
|
||||
}))
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 120)
|
||||
|
||||
for (const item of scored) {
|
||||
addWindow(item.index, 2)
|
||||
}
|
||||
|
||||
addWindow(0, 3)
|
||||
addWindow(Math.floor(messages.length / 2), 3)
|
||||
addWindow(messages.length - 1, 3)
|
||||
return selected
|
||||
}
|
||||
|
||||
private scoreMessageForProfile(message: Message): number {
|
||||
const content = this.extractReadableContent(message)
|
||||
if (!content || content.startsWith('[')) return 0
|
||||
const emotionWords = [
|
||||
'谢谢', '感谢', '抱歉', '对不起', '开心', '高兴', '难过', '委屈', '生气', '焦虑', '压力', '累',
|
||||
'想你', '喜欢', '爱', '在乎', '担心', '害怕', '烦', '崩溃', '见面', '一起', '约', '陪', '帮'
|
||||
]
|
||||
let score = Math.min(80, content.length)
|
||||
if (/[??]/.test(content)) score += 18
|
||||
if (/[!!]{1,}/.test(content)) score += 8
|
||||
for (const word of emotionWords) {
|
||||
if (content.includes(word)) score += 24
|
||||
}
|
||||
if (message.quotedContent) score += 12
|
||||
if (content.length >= 80) score += 16
|
||||
return score
|
||||
}
|
||||
|
||||
private fitLinesToBudget(lines: string[], budget: number): string {
|
||||
const output: string[] = []
|
||||
let used = 0
|
||||
for (const line of lines) {
|
||||
const normalized = clampText(line, 700)
|
||||
const nextUsed = used + normalized.length + 1
|
||||
if (nextUsed > budget) break
|
||||
output.push(normalized)
|
||||
used = nextUsed
|
||||
}
|
||||
return output.join('\n')
|
||||
}
|
||||
|
||||
private extractReadableContent(message: Message): string {
|
||||
const parsed = String(message.parsedContent || '').trim()
|
||||
if (parsed) return clampText(parsed, 600)
|
||||
const raw = String(message.rawContent || message.content || '').trim()
|
||||
if (!raw) return '[其他消息]'
|
||||
if (/^(<\?xml|<msg\b|<appmsg\b|<img\b|<emoji\b|<voip\b|<sysmsg\b|<)/i.test(raw)) {
|
||||
return '[其他消息]'
|
||||
}
|
||||
return clampText(raw, 600)
|
||||
}
|
||||
|
||||
private formatMessageLine(message: Message, peerDisplayName: string): string {
|
||||
const sender = message.isSend === 1 ? '我' : peerDisplayName
|
||||
const content = this.extractReadableContent(message)
|
||||
const quoted = message.quotedContent ? ` [引用:${clampText(message.quotedContent, 120)}]` : ''
|
||||
return `${formatDateTime(message.createTime)} ${sender}:${content}${quoted}`
|
||||
}
|
||||
|
||||
private async generateMonthlySummary(
|
||||
config: SharedAiModelConfig,
|
||||
displayName: string,
|
||||
monthLabel: string,
|
||||
material: PreparedMonthMaterial,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
const systemPrompt = `你是一个克制、细致的长期关系画像分析助手。你只根据给定聊天材料分析,不做诊断,不给道德评判,不编造事实。你的目标是从一个自然月的聊天中提炼这个人的沟通风格、情绪模式、关系需求、关注主题、互动节奏,以及与“我”的关系变化线索。
|
||||
|
||||
要求:
|
||||
1. 输出中文纯文本,不使用 Markdown。
|
||||
2. 控制在 400-600 字。
|
||||
3. 必须区分“有证据支持的观察”和“不确定但可留意的倾向”。
|
||||
4. 不要逐条复述聊天记录,要提炼稳定模式和本月变化。
|
||||
5. 对敏感内容使用概括,不输出隐私细节。
|
||||
6. 如果材料经过压缩或抽样,明确保持谨慎,不把局部片段当成全部事实。`
|
||||
|
||||
const userPrompt = appendPromptCurrentTime(`对象:${displayName}
|
||||
月份:${monthLabel}
|
||||
材料状态:${material.compressed ? '本月原始记录过长,已完整扫描后进行本地结构化压缩与代表性抽样' : '本月记录未超过预算,按时间顺序提供'}
|
||||
扫描消息数:${material.scannedMessages}
|
||||
用于输入的代表消息数:${material.sampledMessages}
|
||||
|
||||
本月聊天材料:
|
||||
${material.text}
|
||||
|
||||
请输出本月画像总结,覆盖:沟通风格、情绪与压力线索、关注主题、与我的互动模式、本月关系变化、后续相处建议。`)
|
||||
|
||||
return callProfileApiWithRetry(
|
||||
config,
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
Math.max(config.maxTokens, MONTHLY_OUTPUT_MIN_TOKENS),
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
private async generateFinalProfile(
|
||||
config: SharedAiModelConfig,
|
||||
displayName: string,
|
||||
months: MonthWindow[],
|
||||
emptyMonths: string[],
|
||||
monthlySummaries: MonthSummary[],
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
const systemPrompt = `你是用户的私人关系画像整理助手。你需要把最近 12 个自然月的月度画像总结合成为一份长期 AI 画像。你只能基于月度总结和空月信息判断,不编造缺失月份内容。
|
||||
|
||||
要求:
|
||||
1. 输出中文纯文本,不使用 Markdown。
|
||||
2. 控制在 900-1400 字。
|
||||
3. 画像要稳定、克制、可用于后续 AI 见解上下文。
|
||||
4. 优先总结长期模式,其次指出近三个月变化。
|
||||
5. 给出与这个人互动时最值得注意的 3-5 条原则。
|
||||
6. 不做医学、法律、心理诊断;避免贴标签式结论。`
|
||||
|
||||
const summaryText = monthlySummaries
|
||||
.map((item) => `【${item.month}】消息数:${item.messageCount};材料${item.compressed ? '已压缩抽样' : '未压缩'}\n${item.summary}`)
|
||||
.join('\n\n')
|
||||
|
||||
const userPrompt = appendPromptCurrentTime(`对象:${displayName}
|
||||
时间范围:${months[0].label} 至 ${months[months.length - 1].label}
|
||||
空月:${emptyMonths.length > 0 ? emptyMonths.join('、') : '无'}
|
||||
|
||||
月度总结:
|
||||
${summaryText}
|
||||
|
||||
请生成完整 AI 画像,结构包含:整体印象、沟通风格、情绪/压力模式、核心关注、关系互动模式、最近变化、相处建议、后续 AI 见解使用注意事项。`)
|
||||
|
||||
return callProfileApiWithRetry(
|
||||
config,
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
Math.max(config.maxTokens, FINAL_OUTPUT_MIN_TOKENS),
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const insightProfileService = new InsightProfileService()
|
||||
@@ -4,7 +4,24 @@ import path from 'path'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
|
||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis'
|
||||
export type InsightRecordSourceType = 'insight' | 'message_analysis'
|
||||
|
||||
export interface MessageInsightAnalysis {
|
||||
explicitText: string
|
||||
emotion: string
|
||||
intent: string
|
||||
topic: string
|
||||
}
|
||||
|
||||
export interface MessageInsightTarget {
|
||||
targetLocalId: number
|
||||
targetCreateTime: number
|
||||
targetMessageKey: string
|
||||
targetSenderName: string
|
||||
targetTextPreview: string
|
||||
analysis: MessageInsightAnalysis
|
||||
}
|
||||
|
||||
export interface InsightRecordLog {
|
||||
endpoint: string
|
||||
@@ -20,11 +37,29 @@ export interface InsightRecordLog {
|
||||
finalInsight: string
|
||||
durationMs: number
|
||||
createdAt: number
|
||||
responseFormatJson?: boolean
|
||||
responseFormatFallback?: boolean
|
||||
responseFormatFallbackReason?: string
|
||||
targetMessage?: {
|
||||
localId: number
|
||||
createTime: number
|
||||
messageKey: string
|
||||
senderName: string
|
||||
textPreview: string
|
||||
}
|
||||
contextStats?: {
|
||||
requested: number
|
||||
beforeTarget: number
|
||||
afterTarget: number
|
||||
readError?: string
|
||||
}
|
||||
parsedAnalysis?: MessageInsightAnalysis
|
||||
}
|
||||
|
||||
export interface InsightRecord {
|
||||
id: string
|
||||
accountScope: string
|
||||
sourceType: InsightRecordSourceType
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
@@ -32,11 +67,13 @@ export interface InsightRecord {
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
read: boolean
|
||||
messageInsight?: MessageInsightTarget
|
||||
log: InsightRecordLog
|
||||
}
|
||||
|
||||
export interface InsightRecordSummary {
|
||||
id: string
|
||||
sourceType: InsightRecordSourceType
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
@@ -44,6 +81,7 @@ export interface InsightRecordSummary {
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
read: boolean
|
||||
messageInsight?: MessageInsightTarget
|
||||
}
|
||||
|
||||
export interface InsightRecordContactFacet {
|
||||
@@ -58,6 +96,7 @@ export interface InsightRecordFilters {
|
||||
sessionId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
sourceType?: InsightRecordSourceType | 'all'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
@@ -136,13 +175,15 @@ class InsightRecordService {
|
||||
private toSummary(record: InsightRecord): InsightRecordSummary {
|
||||
return {
|
||||
id: record.id,
|
||||
sourceType: record.sourceType || 'insight',
|
||||
createdAt: record.createdAt,
|
||||
sessionId: record.sessionId,
|
||||
displayName: record.displayName,
|
||||
avatarUrl: record.avatarUrl,
|
||||
triggerReason: record.triggerReason,
|
||||
insight: record.insight,
|
||||
read: record.read
|
||||
read: record.read,
|
||||
messageInsight: record.messageInsight
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,8 +197,10 @@ class InsightRecordService {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
sourceType?: InsightRecordSourceType
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
messageInsight?: MessageInsightTarget
|
||||
log: InsightRecordLog
|
||||
}): InsightRecord {
|
||||
this.ensureLoaded()
|
||||
@@ -166,6 +209,7 @@ class InsightRecordService {
|
||||
const record: InsightRecord = {
|
||||
id: randomUUID(),
|
||||
accountScope: scope,
|
||||
sourceType: input.sourceType || 'insight',
|
||||
createdAt: now,
|
||||
sessionId: input.sessionId,
|
||||
displayName: input.displayName,
|
||||
@@ -173,6 +217,7 @@ class InsightRecordService {
|
||||
triggerReason: input.triggerReason,
|
||||
insight: input.insight,
|
||||
read: false,
|
||||
messageInsight: input.messageInsight,
|
||||
log: input.log
|
||||
}
|
||||
|
||||
@@ -207,6 +252,7 @@ class InsightRecordService {
|
||||
|
||||
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||
const sessionId = String(filters.sessionId || '').trim()
|
||||
const sourceType = String(filters.sourceType || 'all').trim()
|
||||
const startTime = Number(filters.startTime || 0)
|
||||
const endTime = Number(filters.endTime || 0)
|
||||
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
|
||||
@@ -215,10 +261,22 @@ class InsightRecordService {
|
||||
const filtered = allScoped
|
||||
.filter((record) => {
|
||||
if (sessionId && record.sessionId !== sessionId) return false
|
||||
const recordSourceType = record.sourceType || 'insight'
|
||||
if (sourceType !== 'all' && sourceType && recordSourceType !== sourceType) return false
|
||||
if (startTime > 0 && record.createdAt < startTime) return false
|
||||
if (endTime > 0 && record.createdAt > endTime) return false
|
||||
if (keyword) {
|
||||
const haystack = `${record.displayName}\n${record.sessionId}\n${record.insight}`.toLowerCase()
|
||||
const haystack = [
|
||||
record.displayName,
|
||||
record.sessionId,
|
||||
record.insight,
|
||||
record.messageInsight?.targetSenderName,
|
||||
record.messageInsight?.targetTextPreview,
|
||||
record.messageInsight?.analysis?.explicitText,
|
||||
record.messageInsight?.analysis?.emotion,
|
||||
record.messageInsight?.analysis?.intent,
|
||||
record.messageInsight?.analysis?.topic
|
||||
].join('\n').toLowerCase()
|
||||
if (!haystack.includes(keyword)) return false
|
||||
}
|
||||
return true
|
||||
@@ -256,6 +314,36 @@ class InsightRecordService {
|
||||
return { success: true, record }
|
||||
}
|
||||
|
||||
findLatestMessageAnalysis(input: {
|
||||
sessionId: string
|
||||
targetLocalId?: number
|
||||
targetCreateTime?: number
|
||||
targetMessageKey?: string
|
||||
}): InsightRecord | null {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const sessionId = String(input.sessionId || '').trim()
|
||||
if (!sessionId) return null
|
||||
const targetLocalId = Math.floor(Number(input.targetLocalId || 0))
|
||||
const targetCreateTime = Math.floor(Number(input.targetCreateTime || 0))
|
||||
const targetMessageKey = String(input.targetMessageKey || '').trim()
|
||||
const matches = this.records
|
||||
.filter((record) => {
|
||||
if (record.accountScope !== scope) return false
|
||||
if ((record.sourceType || 'insight') !== 'message_analysis') return false
|
||||
if (record.sessionId !== sessionId) return false
|
||||
const target = record.messageInsight
|
||||
if (!target) return false
|
||||
if (targetLocalId > 0 && Number(target.targetLocalId || 0) === targetLocalId) {
|
||||
if (targetCreateTime <= 0 || Number(target.targetCreateTime || 0) === targetCreateTime) return true
|
||||
}
|
||||
if (targetMessageKey && target.targetMessageKey === targetMessageKey) return true
|
||||
return false
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
return matches[0] || null
|
||||
}
|
||||
|
||||
markRecordRead(id: string): { success: boolean; error?: string } {
|
||||
this.ensureLoaded()
|
||||
const normalizedId = String(id || '').trim()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* insightService.ts
|
||||
*
|
||||
* AI 见解后台服务:
|
||||
@@ -21,7 +21,13 @@ import { chatService, ChatSession, Message } from './chatService'
|
||||
import { snsService } from './snsService'
|
||||
import { weiboService } from './social/weiboService'
|
||||
import { showNotification } from '../windows/notificationWindow'
|
||||
import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService'
|
||||
import { insightProfileService } from './insightProfileService'
|
||||
import {
|
||||
insightRecordService,
|
||||
type InsightRecordLog,
|
||||
type InsightRecordTriggerReason,
|
||||
type MessageInsightAnalysis
|
||||
} from './insightRecordService'
|
||||
|
||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +47,18 @@ const API_MAX_TOKENS_MIN = 1
|
||||
const API_MAX_TOKENS_MAX = 2_000_000
|
||||
const API_TEMPERATURE = 0.7
|
||||
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
|
||||
const MIMO_FOOTPRINT_MIN_TOKENS = 4096
|
||||
const FOOTPRINT_API_TEMPERATURE = 0.2
|
||||
|
||||
const DEFAULT_FOOTPRINT_SYSTEM_PROMPT = `你是“我的微信足迹”模块的总结器,只能根据用户提供的统计数据生成最终复盘文案。
|
||||
硬性输出规则:
|
||||
1. 只输出最终总结正文,不输出思考过程、步骤、标题、列表、JSON、Markdown、代码块、引号或字段名。
|
||||
2. 输出 2 句中文,总长度 60-160 字,最多 180 字。
|
||||
3. 第 1 句概括联络活跃度、回复情况或 @我情况;第 2 句给出一个当天/当前范围内可执行的沟通建议。
|
||||
4. 必须引用至少 2 个输入数字,例如人数、回复率、@我次数或群聊数。
|
||||
5. 数据为 0 时如实说明,不臆测具体聊天内容、关系、情绪、诊断或原因。
|
||||
6. 禁止出现“首先”“其次”“根据”“综上”“作为AI”“我认为”“以下是”等过程性表达。
|
||||
输出格式:直接输出两句自然中文。`
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
@@ -79,8 +97,36 @@ interface SharedAiModelConfig {
|
||||
maxTokens: number
|
||||
}
|
||||
|
||||
interface SessionInsightTriggerResult {
|
||||
success: boolean
|
||||
message: string
|
||||
recordId?: string
|
||||
insight?: string
|
||||
skipped?: boolean
|
||||
notificationEnabled?: boolean
|
||||
}
|
||||
|
||||
type InsightFilterMode = 'whitelist' | 'blacklist'
|
||||
|
||||
interface CallApiOptions {
|
||||
temperature?: number
|
||||
disableThinking?: boolean
|
||||
useMaxCompletionTokens?: boolean
|
||||
responseFormatJson?: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||
@@ -161,6 +207,100 @@ function normalizeSessionIdList(value: unknown): string[] {
|
||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function isMimoModel(apiBaseUrl: string, model: string): boolean {
|
||||
const target = `${apiBaseUrl} ${model}`.toLowerCase()
|
||||
return target.includes('mimo') || target.includes('xiaomi')
|
||||
}
|
||||
|
||||
function buildFootprintSystemPrompt(customPrompt: string): string {
|
||||
const custom = String(customPrompt || '').trim()
|
||||
if (!custom || custom === DEFAULT_FOOTPRINT_SYSTEM_PROMPT) {
|
||||
return DEFAULT_FOOTPRINT_SYSTEM_PROMPT
|
||||
}
|
||||
return `${DEFAULT_FOOTPRINT_SYSTEM_PROMPT}
|
||||
|
||||
用户自定义补充要求如下,只能在不违反上述硬性输出规则时执行:
|
||||
${custom}`
|
||||
}
|
||||
|
||||
function normalizeFootprintInsight(text: string): string {
|
||||
let normalized = String(text || '').trim()
|
||||
if (!normalized) return ''
|
||||
|
||||
if (normalized.startsWith('{') && normalized.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
const value = parsed?.summary || parsed?.insight || parsed?.content || parsed?.text
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
normalized = value.trim()
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
normalized = normalized
|
||||
.replace(/^```(?:text|markdown|md|json)?/i, '')
|
||||
.replace(/```$/i, '')
|
||||
.replace(/^(足迹复盘|AI足迹总结|AI 足迹总结|总结|建议)[::]\s*/i, '')
|
||||
.replace(/^\s*[-*•]\s*/gm, '')
|
||||
.replace(/\s*\n+\s*/g, ' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (normalized.length > 180) {
|
||||
const sliced = normalized.slice(0, 180)
|
||||
const lastStop = Math.max(sliced.lastIndexOf('。'), sliced.lastIndexOf('!'), sliced.lastIndexOf('?'))
|
||||
normalized = lastStop >= 60 ? sliced.slice(0, lastStop + 1) : `${sliced.replace(/[,,;;、\s]+$/g, '')}。`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
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 parseMessageInsightAnalysis(rawOutput: string): MessageInsightAnalysis {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(stripJsonFence(rawOutput))
|
||||
} catch {
|
||||
throw new Error('模型输出格式异常:不是合法 JSON')
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('模型输出格式异常:JSON 根节点不是对象')
|
||||
}
|
||||
const source = parsed as Record<string, unknown>
|
||||
const explicitText = clampText(source.explicit_text ?? source.explicitText, 120)
|
||||
const emotion = clampText(source.emotion, 16)
|
||||
const intent = clampText(source.intent, 20)
|
||||
const topic = clampText(source.topic, 20)
|
||||
if (!explicitText || !emotion || !intent || !topic) {
|
||||
throw new Error('模型输出格式异常:缺少必要字段')
|
||||
}
|
||||
return { explicitText, emotion, intent, topic }
|
||||
}
|
||||
|
||||
function shouldFallbackJsonMode(error: unknown): boolean {
|
||||
const statusCode = Number((error as ApiRequestError)?.statusCode || 0)
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||
@@ -171,7 +311,8 @@ function callApi(
|
||||
model: string,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
timeoutMs: number = API_TIMEOUT_MS,
|
||||
maxTokens: number = API_MAX_TOKENS_DEFAULT
|
||||
maxTokens: number = API_MAX_TOKENS_DEFAULT,
|
||||
options: CallApiOptions = {}
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
@@ -183,15 +324,28 @@ function callApi(
|
||||
return
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
const normalizedMaxTokens = normalizeApiMaxTokens(maxTokens)
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
max_tokens: normalizeApiMaxTokens(maxTokens),
|
||||
temperature: API_TEMPERATURE,
|
||||
temperature: options.temperature ?? API_TEMPERATURE,
|
||||
stream: false
|
||||
})
|
||||
}
|
||||
if (options.useMaxCompletionTokens) {
|
||||
payload.max_completion_tokens = normalizedMaxTokens
|
||||
} else {
|
||||
payload.max_tokens = normalizedMaxTokens
|
||||
}
|
||||
if (options.disableThinking) {
|
||||
payload.thinking = { type: 'disabled' }
|
||||
payload.enable_thinking = false
|
||||
}
|
||||
if (options?.responseFormatJson) {
|
||||
payload.response_format = { type: 'json_object' }
|
||||
}
|
||||
const body = JSON.stringify(payload)
|
||||
|
||||
const options = {
|
||||
const requestOptions = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
@@ -205,17 +359,27 @@ function callApi(
|
||||
|
||||
const isHttps = urlObj.protocol === 'https:'
|
||||
const requestFn = isHttps ? https.request : http.request
|
||||
const req = requestFn(options, (res) => {
|
||||
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)}`))
|
||||
const finishReason = parsed?.choices?.[0]?.finish_reason
|
||||
const reasoningContent = parsed?.choices?.[0]?.message?.reasoning_content
|
||||
if (typeof reasoningContent === 'string' && reasoningContent.trim()) {
|
||||
reject(new Error(`API 仅返回推理内容未返回正文${finishReason ? `(finish_reason=${finishReason})` : ''},请增大最大输出 Token 或关闭思考模式`))
|
||||
return
|
||||
}
|
||||
reject(new Error(`API 返回格式异常${finishReason ? `(finish_reason=${finishReason})` : ''}: ${data.slice(0, 200)}`))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
|
||||
@@ -304,6 +468,7 @@ class InsightService {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
insightProfileService.cancelActiveTask('AI 见解服务已停止,画像任务已取消')
|
||||
if (hadActiveFlow) {
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
@@ -319,6 +484,7 @@ class InsightService {
|
||||
}
|
||||
|
||||
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||
insightProfileService.cancelActiveTask('数据库或账号配置已变化,画像任务已取消')
|
||||
this.clearRuntimeCache()
|
||||
}
|
||||
|
||||
@@ -328,6 +494,7 @@ class InsightService {
|
||||
handleConfigCleared(): void {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
insightProfileService.cancelActiveTask('配置已清除,画像任务已取消')
|
||||
this.processing = false
|
||||
}
|
||||
|
||||
@@ -465,11 +632,14 @@ class InsightService {
|
||||
const sessionId = session.username?.trim() || ''
|
||||
const displayName = session.displayName || sessionId
|
||||
insightLog('INFO', `测试目标会话:${displayName} (${sessionId})`)
|
||||
await this.generateInsightForSession({
|
||||
const result = await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName,
|
||||
triggerReason: 'test'
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
||||
return {
|
||||
success: true,
|
||||
@@ -482,6 +652,47 @@ class InsightService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动对指定会话立即触发一次 AI 见解。
|
||||
* 只新增触发入口;实际上下文、朋友圈/微博拼接、prompt 和入库仍走 generateInsightForSession。
|
||||
*/
|
||||
async triggerSessionInsight(params: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}): Promise<SessionInsightTriggerResult> {
|
||||
const sessionId = String(params?.sessionId || '').trim()
|
||||
if (!sessionId) {
|
||||
return { success: false, message: '当前会话无效,无法触发 AI 见解' }
|
||||
}
|
||||
if (!this.isEnabled()) {
|
||||
return { success: false, message: '请先在设置中开启「AI 见解」' }
|
||||
}
|
||||
|
||||
const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig()
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||
}
|
||||
|
||||
try {
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, message: '数据库连接失败,请先在"数据库连接"页完成配置' }
|
||||
}
|
||||
this.dbConnected = true
|
||||
|
||||
const displayName = String(params?.displayName || sessionId).trim() || sessionId
|
||||
insightLog('INFO', `手动触发当前会话见解:${displayName} (${sessionId})`)
|
||||
return await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName,
|
||||
triggerReason: 'manual'
|
||||
})
|
||||
} catch (error) {
|
||||
return { success: false, message: `触发失败:${(error as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取今日触发统计(供设置页展示) */
|
||||
getTodayStats(): { sessionId: string; count: number; times: string[] }[] {
|
||||
this.resetIfNewDay()
|
||||
@@ -523,6 +734,7 @@ class InsightService {
|
||||
const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围'
|
||||
const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : []
|
||||
const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : []
|
||||
const mimoMode = isMimoModel(apiBaseUrl, model)
|
||||
|
||||
const topPrivateText = privateSegments.length > 0
|
||||
? privateSegments
|
||||
@@ -546,20 +758,31 @@ class InsightService {
|
||||
.join('\n')
|
||||
: '无'
|
||||
|
||||
const defaultSystemPrompt = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。
|
||||
要求:
|
||||
1. 输出 2-3 句,总长度不超过 180 字。
|
||||
2. 必须包含:总体观察 + 一个可执行建议。
|
||||
3. 语气务实,不夸张,不使用 Markdown。`
|
||||
const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim()
|
||||
const systemPrompt = customPrompt || defaultSystemPrompt
|
||||
const systemPrompt = buildFootprintSystemPrompt(customPrompt)
|
||||
|
||||
const userPromptBase = `统计范围:${rangeLabel}
|
||||
有聊天的人数:${Number(summary.private_inbound_people) || 0}
|
||||
我有回复的人数:${Number(summary.private_outbound_people) || 0}
|
||||
回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}%
|
||||
@我次数:${Number(summary.mention_count) || 0}
|
||||
涉及群聊:${Number(summary.mention_group_count) || 0}
|
||||
const inboundPeople = Number(summary.private_inbound_people) || 0
|
||||
const repliedPeople = Number(summary.private_replied_people) || 0
|
||||
const outboundPeople = Number(summary.private_outbound_people) || 0
|
||||
const replyRate = (((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)
|
||||
const mentionCount = Number(summary.mention_count) || 0
|
||||
const mentionGroupCount = Number(summary.mention_group_count) || 0
|
||||
|
||||
const userPromptBase = `任务:基于下面的“我的微信足迹”统计生成最终总结正文。
|
||||
|
||||
输出要求再强调一次:
|
||||
- 只输出 2 句中文自然语言,不要输出分析过程。
|
||||
- 不要输出 JSON / Markdown / 列表 / 标题 / 代码块。
|
||||
- 第 1 句做总体观察,第 2 句给一个可执行建议。
|
||||
- 必须引用至少 2 个统计数字。
|
||||
|
||||
统计范围:${rangeLabel}
|
||||
有聊天的人数:${inboundPeople}
|
||||
我有回复的人数:${outboundPeople}
|
||||
实际回复了其中:${repliedPeople}
|
||||
回复率:${replyRate}%
|
||||
@我次数:${mentionCount}
|
||||
涉及群聊:${mentionGroupCount}
|
||||
|
||||
私聊重点:
|
||||
${topPrivateText}
|
||||
@@ -567,7 +790,7 @@ ${topPrivateText}
|
||||
群聊@我重点:
|
||||
${topMentionText}
|
||||
|
||||
请给出足迹复盘(2-3句,含建议):`
|
||||
现在直接输出最终总结正文:`
|
||||
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
||||
|
||||
try {
|
||||
@@ -580,9 +803,14 @@ ${topMentionText}
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
25_000,
|
||||
maxTokens
|
||||
mimoMode ? Math.max(maxTokens, MIMO_FOOTPRINT_MIN_TOKENS) : maxTokens,
|
||||
{
|
||||
temperature: FOOTPRINT_API_TEMPERATURE,
|
||||
disableThinking: mimoMode,
|
||||
useMaxCompletionTokens: mimoMode
|
||||
}
|
||||
)
|
||||
const insight = result.trim()
|
||||
const insight = normalizeFootprintInsight(result)
|
||||
if (!insight) return { success: false, message: '模型返回为空' }
|
||||
return { success: true, message: '生成成功', insight }
|
||||
} catch (error) {
|
||||
@@ -590,6 +818,207 @@ ${topMentionText}
|
||||
}
|
||||
}
|
||||
|
||||
async generateMessageInsight(params: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
targetLocalId?: number
|
||||
targetCreateTime?: number
|
||||
targetMessageKey?: string
|
||||
targetText: string
|
||||
targetSenderName?: string
|
||||
contextCount?: number
|
||||
forceRefresh?: boolean
|
||||
}): Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }> {
|
||||
const enabled = this.config.get('aiMessageInsightEnabled') === true
|
||||
if (!enabled) {
|
||||
return { success: false, message: '请先在设置中开启「消息解析」' }
|
||||
}
|
||||
|
||||
const sessionId = String(params?.sessionId || '').trim()
|
||||
const targetText = clampText(params?.targetText || '', 500)
|
||||
const targetCreateTime = Math.floor(Number(params?.targetCreateTime || 0))
|
||||
const targetLocalId = Math.floor(Number(params?.targetLocalId || 0))
|
||||
const targetMessageKey = String(params?.targetMessageKey || '').trim()
|
||||
if (!sessionId || !targetText || targetCreateTime <= 0) {
|
||||
return { success: false, message: '目标消息无效,无法解析' }
|
||||
}
|
||||
|
||||
if (params?.forceRefresh !== true) {
|
||||
const cached = insightRecordService.findLatestMessageAnalysis({
|
||||
sessionId,
|
||||
targetLocalId,
|
||||
targetCreateTime,
|
||||
targetMessageKey
|
||||
})
|
||||
if (cached?.messageInsight?.analysis) {
|
||||
return {
|
||||
success: true,
|
||||
message: '已读取缓存解析',
|
||||
cached: true,
|
||||
recordId: cached.id,
|
||||
data: cached.messageInsight.analysis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||
}
|
||||
|
||||
const configuredContextCount = Number(this.config.get('aiMessageInsightContextCount') || 50)
|
||||
const contextCount = Math.max(1, Math.min(200, Math.floor(Number(params?.contextCount || configuredContextCount) || 50)))
|
||||
const displayName = await this.resolveInsightSessionDisplayName(sessionId, String(params?.displayName || sessionId))
|
||||
const targetSenderName = clampText(params?.targetSenderName || displayName, 40) || displayName
|
||||
const targetTextPreview = clampText(targetText, 120)
|
||||
let avatarUrl = String(params?.avatarUrl || '').trim() || undefined
|
||||
if (!avatarUrl) {
|
||||
try {
|
||||
const contact = await chatService.getContactAvatar(sessionId)
|
||||
avatarUrl = String(contact?.avatarUrl || '').trim() || undefined
|
||||
} catch {
|
||||
avatarUrl = undefined
|
||||
}
|
||||
}
|
||||
|
||||
let beforeMessages: Message[] = []
|
||||
let afterMessages: Message[] = []
|
||||
let contextReadError = ''
|
||||
try {
|
||||
const aroundResult = await chatService.getMessagesAround(
|
||||
sessionId,
|
||||
{ localId: targetLocalId, createTime: targetCreateTime, messageKey: targetMessageKey },
|
||||
contextCount
|
||||
)
|
||||
if (aroundResult.success) {
|
||||
beforeMessages = aroundResult.before || []
|
||||
afterMessages = aroundResult.after || []
|
||||
} else {
|
||||
contextReadError = aroundResult.error || '读取上下文失败'
|
||||
}
|
||||
} catch (error) {
|
||||
contextReadError = (error as Error).message || String(error)
|
||||
}
|
||||
|
||||
const formatLine = (message: Message) => {
|
||||
const senderName = message.isSend === 1 ? '我' : (message.senderDisplayName || targetSenderName || displayName)
|
||||
return `${this.formatInsightMessageTimestamp(message.createTime)} ${senderName}:${this.formatInsightMessageContent(message)}`
|
||||
}
|
||||
const beforeText = beforeMessages.length > 0 ? beforeMessages.map(formatLine).join('\n') : '无'
|
||||
const afterText = afterMessages.length > 0 ? afterMessages.map(formatLine).join('\n') : '无'
|
||||
|
||||
const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。
|
||||
|
||||
严格要求:
|
||||
1. 必须且只能输出合法的纯 JSON。
|
||||
2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。
|
||||
3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。
|
||||
4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。
|
||||
5. emotion、intent、topic 必须是短标签。
|
||||
|
||||
JSON 输出格式:
|
||||
{
|
||||
"explicit_text": "暗示转明示,80字以内",
|
||||
"emotion": "2-6字情绪标签",
|
||||
"intent": "2-8字意图标签",
|
||||
"topic": "2-8字话题标签"
|
||||
}`
|
||||
const customPrompt = String(this.config.get('aiMessageInsightSystemPrompt') || '').trim()
|
||||
const systemPrompt = customPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT
|
||||
const userPromptBase = `会话:${displayName}
|
||||
目标发送者:${targetSenderName}
|
||||
目标消息时间:${this.formatInsightMessageTimestamp(targetCreateTime)}
|
||||
|
||||
目标消息:
|
||||
${targetText}
|
||||
|
||||
目标消息之前的上下文(${beforeMessages.length} 条):
|
||||
${beforeText}
|
||||
|
||||
目标消息之后的上下文(${afterMessages.length} 条):
|
||||
${afterText}
|
||||
|
||||
请分析目标消息,只输出指定 JSON。`
|
||||
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
||||
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 {
|
||||
try {
|
||||
rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens, { responseFormatJson: true })
|
||||
} catch (error) {
|
||||
if (!shouldFallbackJsonMode(error)) throw error
|
||||
responseFormatJson = false
|
||||
responseFormatFallback = true
|
||||
responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持'
|
||||
rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens)
|
||||
}
|
||||
const analysis = parseMessageInsightAnalysis(rawOutput)
|
||||
const finalInsight = analysis.explicitText
|
||||
const log: InsightRecordLog = {
|
||||
endpoint,
|
||||
model,
|
||||
maxTokens,
|
||||
temperature: API_TEMPERATURE,
|
||||
triggerReason: 'message_analysis',
|
||||
allowContext: true,
|
||||
contextCount,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
rawOutput,
|
||||
finalInsight,
|
||||
durationMs: Date.now() - startedAt,
|
||||
createdAt: Date.now(),
|
||||
responseFormatJson,
|
||||
responseFormatFallback,
|
||||
responseFormatFallbackReason,
|
||||
targetMessage: {
|
||||
localId: targetLocalId,
|
||||
createTime: targetCreateTime,
|
||||
messageKey: targetMessageKey,
|
||||
senderName: targetSenderName,
|
||||
textPreview: targetTextPreview
|
||||
},
|
||||
contextStats: {
|
||||
requested: contextCount,
|
||||
beforeTarget: beforeMessages.length,
|
||||
afterTarget: afterMessages.length,
|
||||
readError: contextReadError || undefined
|
||||
},
|
||||
parsedAnalysis: analysis
|
||||
}
|
||||
const record = insightRecordService.addRecord({
|
||||
sessionId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
sourceType: 'message_analysis',
|
||||
triggerReason: 'message_analysis',
|
||||
insight: finalInsight,
|
||||
messageInsight: {
|
||||
targetLocalId,
|
||||
targetCreateTime,
|
||||
targetMessageKey,
|
||||
targetSenderName,
|
||||
targetTextPreview,
|
||||
analysis
|
||||
},
|
||||
log
|
||||
})
|
||||
return { success: true, message: '解析完成', cached: false, recordId: record.id, data: analysis }
|
||||
} catch (error) {
|
||||
return { success: false, message: `解析失败:${(error as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 私有方法 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private isEnabled(): boolean {
|
||||
@@ -1099,10 +1528,10 @@ ${topMentionText}
|
||||
displayName: string
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
silentDays?: number
|
||||
}): Promise<void> {
|
||||
}): Promise<SessionInsightTriggerResult> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
if (!sessionId) return
|
||||
if (!this.isEnabled()) return
|
||||
if (!sessionId) return { success: false, message: '会话无效,无法生成见解' }
|
||||
if (!this.isEnabled()) return { success: false, message: '请先在设置中开启「AI 见解」' }
|
||||
|
||||
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||
const allowContext = this.config.get('aiInsightAllowContext') as boolean
|
||||
@@ -1120,7 +1549,7 @@ ${topMentionText}
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成')
|
||||
return
|
||||
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||
}
|
||||
|
||||
// ── 构建 prompt ────────────────────────────────────────────────────────────
|
||||
@@ -1141,6 +1570,7 @@ ${topMentionText}
|
||||
|
||||
const momentsContextSection = await this.getMomentsContextSection(sessionId)
|
||||
const socialContextSection = await this.getSocialContextSection(sessionId)
|
||||
const profileContextSection = insightProfileService.getProfileContextSection(sessionId)
|
||||
|
||||
// ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)────
|
||||
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
|
||||
@@ -1160,6 +1590,7 @@ ${topMentionText}
|
||||
? `已 ${silentDays} 天未联系「${resolvedDisplayName}」。`
|
||||
: '',
|
||||
contextSection,
|
||||
profileContextSection,
|
||||
momentsContextSection,
|
||||
socialContextSection,
|
||||
'请给出你的见解(≤80字):'
|
||||
@@ -1210,9 +1641,9 @@ ${topMentionText}
|
||||
// 模型主动选择跳过
|
||||
if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) {
|
||||
insightLog('INFO', `模型选择跳过 ${resolvedDisplayName}`)
|
||||
return
|
||||
return { success: true, message: `模型判断「${resolvedDisplayName}」暂无可生成的见解`, skipped: true }
|
||||
}
|
||||
if (!this.isEnabled()) return
|
||||
if (!this.isEnabled()) return { success: false, message: 'AI 见解已关闭,生成结果未保存' }
|
||||
|
||||
const insight = result.trim()
|
||||
const notifTitle = `见解 · ${resolvedDisplayName}`
|
||||
@@ -1277,6 +1708,15 @@ ${topMentionText}
|
||||
|
||||
insightLog('INFO', `已完成 ${resolvedDisplayName} 的见解处理`)
|
||||
this.recordTrigger(sessionId)
|
||||
return {
|
||||
success: true,
|
||||
message: insightNotificationEnabled
|
||||
? `已生成「${resolvedDisplayName}」的 AI 见解,请查看通知弹窗`
|
||||
: `已生成「${resolvedDisplayName}」的 AI 见解,AI 见解消息通知当前已关闭`,
|
||||
recordId: record.id,
|
||||
insight,
|
||||
notificationEnabled: insightNotificationEnabled
|
||||
}
|
||||
} catch (e) {
|
||||
insightDebugSection(
|
||||
'ERROR',
|
||||
@@ -1284,6 +1724,7 @@ ${topMentionText}
|
||||
`错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}`
|
||||
)
|
||||
insightLog('ERROR', `API 调用失败 (${resolvedDisplayName}): ${(e as Error).message}`)
|
||||
return { success: false, message: `生成失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1329,5 +1770,3 @@ ${topMentionText}
|
||||
}
|
||||
|
||||
export const insightService = new InsightService()
|
||||
|
||||
|
||||
+190
-65
@@ -10,6 +10,10 @@ const execFileAsync = promisify(execFile)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
type DbKeyPollResult =
|
||||
| { status: 'success'; key: string; loginRequiredDetected: boolean }
|
||||
| { status: 'process-ended'; loginRequiredDetected: boolean }
|
||||
| { status: 'timeout'; loginRequiredDetected: boolean }
|
||||
|
||||
export class KeyService {
|
||||
private readonly isMac = process.platform === 'darwin'
|
||||
@@ -58,6 +62,7 @@ export class KeyService {
|
||||
private readonly HKEY_CURRENT_USER = 0x80000001
|
||||
private readonly ERROR_SUCCESS = 0
|
||||
private readonly WM_CLOSE = 0x0010
|
||||
private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
@@ -342,30 +347,169 @@ export class KeyService {
|
||||
return null
|
||||
}
|
||||
|
||||
private async findPidByImageName(imageName: string): Promise<number | null> {
|
||||
private async findPidsByImageName(imageName: string): Promise<number[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'])
|
||||
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
||||
const pids: number[] = []
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('INFO:')) continue
|
||||
const parts = line.split('","').map((p) => p.replace(/^"|"$/g, ''))
|
||||
if (parts[0]?.toLowerCase() === imageName.toLowerCase()) {
|
||||
const pid = Number(parts[1])
|
||||
if (!Number.isNaN(pid)) return pid
|
||||
if (!Number.isNaN(pid)) pids.push(pid)
|
||||
}
|
||||
}
|
||||
return null
|
||||
return pids
|
||||
} catch (e) {
|
||||
return null
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async findWeChatPid(): Promise<number | null> {
|
||||
const names = ['Weixin.exe', 'WeChat.exe']
|
||||
for (const name of names) {
|
||||
const pid = await this.findPidByImageName(name)
|
||||
private async findWeChatPids(): Promise<number[]> {
|
||||
const pids: number[] = []
|
||||
const pushUnique = (pid: number | null | undefined) => {
|
||||
if (!pid || pids.includes(pid)) return
|
||||
pids.push(pid)
|
||||
}
|
||||
|
||||
for (const name of ['Weixin.exe', 'WeChat.exe']) {
|
||||
const found = await this.findPidsByImageName(name)
|
||||
found.forEach(pushUnique)
|
||||
}
|
||||
return pids
|
||||
}
|
||||
|
||||
private async isWeChatPidActive(pid: number): Promise<boolean> {
|
||||
const pids = await this.findWeChatPids()
|
||||
if (pids.includes(pid)) return true
|
||||
|
||||
const fallbackPid = await this.waitForWeChatWindow(250)
|
||||
return fallbackPid === pid
|
||||
}
|
||||
|
||||
private async waitForWeChatPid(timeoutMs: number): Promise<number | null> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const pids = await this.findWeChatPids()
|
||||
if (pids.length > 0) return pids[0]
|
||||
|
||||
const fallbackPid = await this.waitForWeChatWindow(250)
|
||||
if (fallbackPid) return fallbackPid
|
||||
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private getRemainingMs(deadline: number): number {
|
||||
return Math.max(0, deadline - Date.now())
|
||||
}
|
||||
|
||||
private async pollDbKeyFromHook(
|
||||
pid: number,
|
||||
deadline: number,
|
||||
logs: string[],
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyPollResult> {
|
||||
const keyBuffer = Buffer.alloc(128)
|
||||
let loginRequiredDetected = false
|
||||
let nextProcessCheckAt = 0
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const now = Date.now()
|
||||
if (now >= nextProcessCheckAt) {
|
||||
nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
return { status: 'process-ended', loginRequiredDetected }
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pollKeyData(keyBuffer, keyBuffer.length)) {
|
||||
const key = this.decodeUtf8(keyBuffer)
|
||||
if (key.length === 64) {
|
||||
onStatus?.('密钥获取成功', 1)
|
||||
return { status: 'success', key, loginRequiredDetected }
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
const levelOut = [0]
|
||||
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break
|
||||
const msg = this.decodeUtf8(statusBuffer)
|
||||
const level = levelOut[0] ?? 0
|
||||
if (msg) {
|
||||
logs.push(msg)
|
||||
if (this.isLoginRelatedText(msg)) {
|
||||
loginRequiredDetected = true
|
||||
}
|
||||
onStatus?.(msg, level)
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
}
|
||||
|
||||
return { status: 'timeout', loginRequiredDetected }
|
||||
}
|
||||
|
||||
private cleanupDbKeyHook(): void {
|
||||
try {
|
||||
this.cleanupHook()
|
||||
} catch { }
|
||||
}
|
||||
|
||||
private buildInitHookError(): string {
|
||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||
if (error) {
|
||||
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
const levelOut = [0]
|
||||
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
|
||||
? this.decodeUtf8(statusBuffer)
|
||||
: ''
|
||||
return status || '初始化失败'
|
||||
}
|
||||
|
||||
private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
|
||||
while (this.getRemainingMs(deadline) > 0) {
|
||||
onStatus?.('正在查找微信进程...', 0)
|
||||
const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000))
|
||||
if (pid) return pid
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private shouldRetryAfterProcessLost(deadline: number): boolean {
|
||||
return this.getRemainingMs(deadline) > 1000
|
||||
}
|
||||
|
||||
private async delayBeforeRetry(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
|
||||
if (!this.shouldRetryAfterProcessLost(deadline)) return null
|
||||
onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0)
|
||||
await this.delayBeforeRetry()
|
||||
return this.waitForNextDbKeyPid(deadline, onStatus)
|
||||
}
|
||||
|
||||
private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise<boolean> {
|
||||
if (loginRequiredDetected) return true
|
||||
if (!pid) return false
|
||||
if (!await this.isWeChatPidActive(pid)) return false
|
||||
return await this.detectWeChatLoginRequired(pid)
|
||||
}
|
||||
|
||||
private async findWeChatPid(): Promise<number | null> {
|
||||
const pids = await this.findWeChatPids()
|
||||
if (pids.length > 0) return pids[0]
|
||||
const fallbackPid = await this.waitForWeChatWindow(5000)
|
||||
return fallbackPid ?? null
|
||||
}
|
||||
@@ -373,9 +517,8 @@ export class KeyService {
|
||||
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const weixinPid = await this.findPidByImageName('Weixin.exe')
|
||||
const wechatPid = await this.findPidByImageName('WeChat.exe')
|
||||
if (!weixinPid && !wechatPid) return true
|
||||
const runningPids = await this.findWeChatPids()
|
||||
if (runningPids.length === 0) return true
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
return false
|
||||
@@ -604,7 +747,7 @@ export class KeyService {
|
||||
return true
|
||||
}
|
||||
|
||||
// --- DB Key Logic (Unchanged core flow) ---
|
||||
// --- DB Key Logic (core hook/poll flow unchanged) ---
|
||||
|
||||
async autoGetDbKey(
|
||||
timeoutMs = 60_000,
|
||||
@@ -615,74 +758,56 @@ export class KeyService {
|
||||
if (!this.ensureKernel32()) return { success: false, error: 'Kernel32 Init Failed' }
|
||||
|
||||
const logs: string[] = []
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
onStatus?.('正在查找微信进程...', 0)
|
||||
const pid = await this.findWeChatPid()
|
||||
let pid = await this.findWeChatPid()
|
||||
if (!pid) {
|
||||
const err = '未找到微信进程,请先启动微信'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
let lastAttemptLoginRequiredDetected = false
|
||||
|
||||
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
|
||||
onStatus?.('正在检测微信界面组件...', 0)
|
||||
await this.waitForWeChatWindowComponents(pid, 15000)
|
||||
while (pid && this.getRemainingMs(deadline) > 0) {
|
||||
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
|
||||
onStatus?.('正在检测微信界面组件...', 0)
|
||||
await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline)))
|
||||
|
||||
const ok = this.initHook(pid)
|
||||
if (!ok) {
|
||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||
if (error) {
|
||||
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
return { success: false, error: friendlyError }
|
||||
}
|
||||
return { success: false, error }
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||
continue
|
||||
}
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
const levelOut = [0]
|
||||
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
|
||||
? this.decodeUtf8(statusBuffer)
|
||||
: ''
|
||||
return { success: false, error: status || '初始化失败' }
|
||||
}
|
||||
|
||||
const keyBuffer = Buffer.alloc(128)
|
||||
const start = Date.now()
|
||||
let loginRequiredDetected = false
|
||||
|
||||
try {
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (this.pollKeyData(keyBuffer, keyBuffer.length)) {
|
||||
const key = this.decodeUtf8(keyBuffer)
|
||||
if (key.length === 64) {
|
||||
onStatus?.('密钥获取成功', 1)
|
||||
return { success: true, key, logs }
|
||||
}
|
||||
const ok = this.initHook(pid)
|
||||
if (!ok) {
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
this.cleanupDbKeyHook()
|
||||
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
const levelOut = [0]
|
||||
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break
|
||||
const msg = this.decodeUtf8(statusBuffer)
|
||||
const level = levelOut[0] ?? 0
|
||||
if (msg) {
|
||||
logs.push(msg)
|
||||
if (this.isLoginRelatedText(msg)) {
|
||||
loginRequiredDetected = true
|
||||
}
|
||||
onStatus?.(msg, level)
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
return { success: false, error: this.buildInitHookError(), logs }
|
||||
}
|
||||
} finally {
|
||||
|
||||
let pollResult: DbKeyPollResult
|
||||
try {
|
||||
this.cleanupHook()
|
||||
} catch { }
|
||||
pollResult = await this.pollDbKeyFromHook(pid, deadline, logs, onStatus)
|
||||
} finally {
|
||||
this.cleanupDbKeyHook()
|
||||
}
|
||||
|
||||
lastAttemptLoginRequiredDetected = pollResult.loginRequiredDetected
|
||||
if (pollResult.status === 'success') {
|
||||
return { success: true, key: pollResult.key, logs }
|
||||
}
|
||||
if (pollResult.status === 'process-ended') {
|
||||
lastAttemptLoginRequiredDetected = false
|
||||
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const loginRequired = loginRequiredDetected || await this.detectWeChatLoginRequired(pid)
|
||||
const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected)
|
||||
if (loginRequired) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { execFile, exec, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const require = createRequire(__filename);
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SnsLivePhoto {
|
||||
thumb: string
|
||||
md5?: string
|
||||
token?: string
|
||||
thumbToken?: string
|
||||
key?: string
|
||||
encIdx?: string
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export interface SnsMedia {
|
||||
thumb: string
|
||||
md5?: string
|
||||
token?: string
|
||||
thumbToken?: string
|
||||
key?: string
|
||||
encIdx?: string
|
||||
livePhoto?: SnsLivePhoto
|
||||
@@ -126,12 +128,22 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
|
||||
|
||||
let fixedUrl = url.replace('http://', 'https://')
|
||||
|
||||
// 只有非视频(即图片)才需要处理 /150 变 /0
|
||||
// 只有非视频(即图片)才需要处理路径末尾的尺寸标识(/150、/200等)变为 /0
|
||||
if (!isVideo) {
|
||||
fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1')
|
||||
const [pathPart, queryPart] = fixedUrl.split('?')
|
||||
const fixedPath = pathPart.replace(/\/(150|200|480)($|\?)/, '/0$2')
|
||||
fixedUrl = queryPart ? `${fixedPath}?${queryPart}` : fixedPath
|
||||
}
|
||||
|
||||
if (!token || fixedUrl.includes('token=')) return fixedUrl
|
||||
// 如果没有提供新token,直接返回
|
||||
if (!token) return fixedUrl
|
||||
|
||||
// 移除已有的token和idx参数
|
||||
const [pathPart, queryPart] = fixedUrl.split('?')
|
||||
if (queryPart) {
|
||||
const params = queryPart.split('&').filter(p => !p.startsWith('token=') && !p.startsWith('idx='))
|
||||
fixedUrl = params.length > 0 ? `${pathPart}?${params.join('&')}` : pathPart
|
||||
}
|
||||
|
||||
// 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数
|
||||
if (isVideo) {
|
||||
@@ -704,6 +716,7 @@ class SnsService {
|
||||
url: urlMatch ? urlMatch[1].trim() : '',
|
||||
thumb: thumbMatch ? thumbMatch[1].trim() : '',
|
||||
token: urlToken || thumbToken,
|
||||
thumbToken: thumbToken,
|
||||
key: urlKey || thumbKey,
|
||||
md5: urlMd5,
|
||||
encIdx: urlEncIdx || thumbEncIdx
|
||||
@@ -716,19 +729,24 @@ class SnsService {
|
||||
const lpUrlTag = lx.match(/<url([^>]*)>/i)
|
||||
const lpThumb = lx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
|
||||
const lpThumbTag = lx.match(/<thumb([^>]*)>/i)
|
||||
let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined
|
||||
let lpUrlToken: string | undefined, lpThumbToken: string | undefined
|
||||
let lpKey: string | undefined, lpEncIdx: string | undefined
|
||||
if (lpUrlTag?.[1]) {
|
||||
const a = lpUrlTag[1]
|
||||
lpToken = a.match(/token="([^"]+)"/i)?.[1]
|
||||
lpUrlToken = a.match(/token="([^"]+)"/i)?.[1]
|
||||
lpKey = a.match(/key="([^"]+)"/i)?.[1]
|
||||
lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1]
|
||||
}
|
||||
if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1]
|
||||
if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1]
|
||||
if (lpThumbTag?.[1]) {
|
||||
const a = lpThumbTag[1]
|
||||
lpThumbToken = a.match(/token="([^"]+)"/i)?.[1]
|
||||
if (!lpKey) lpKey = a.match(/key="([^"]+)"/i)?.[1]
|
||||
}
|
||||
item.livePhoto = {
|
||||
url: lpUrl ? lpUrl[1].trim() : '',
|
||||
thumb: lpThumb ? lpThumb[1].trim() : '',
|
||||
token: lpToken,
|
||||
token: lpUrlToken || lpThumbToken,
|
||||
thumbToken: lpThumbToken,
|
||||
key: lpKey,
|
||||
encIdx: lpEncIdx
|
||||
}
|
||||
@@ -1181,16 +1199,18 @@ class SnsService {
|
||||
|
||||
const fixedMedia = (post.media || []).map((m: any) => ({
|
||||
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
||||
thumb: fixSnsUrl(m.thumb, m.token, false),
|
||||
thumb: fixSnsUrl(m.thumb, m.thumbToken || m.token, false),
|
||||
md5: m.md5,
|
||||
token: m.token,
|
||||
thumbToken: m.thumbToken,
|
||||
key: isVideoPost ? (videoKey || m.key) : m.key,
|
||||
encIdx: m.encIdx || m.enc_idx,
|
||||
livePhoto: m.livePhoto ? {
|
||||
...m.livePhoto,
|
||||
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
||||
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
||||
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.thumbToken || m.livePhoto.token, false),
|
||||
token: m.livePhoto.token,
|
||||
thumbToken: m.livePhoto.thumbToken,
|
||||
key: videoKey || m.livePhoto.key || m.key,
|
||||
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
||||
} : undefined
|
||||
@@ -2060,6 +2080,8 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
const zlib = require('zlib')
|
||||
const urlObj = new URL(url)
|
||||
|
||||
console.log(`[SnsService] 开始下载图片: url=${url.substring(0, 100)}..., key=${key || 'undefined'}`)
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
@@ -2074,7 +2096,9 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
}
|
||||
|
||||
const req = https.request(options, (res: any) => {
|
||||
console.log(`[SnsService] CDN 响应: statusCode=${res.statusCode}, x-enc=${res.headers['x-enc']}, content-type=${res.headers['content-type']}`)
|
||||
if (res.statusCode !== 200 && res.statusCode !== 206) {
|
||||
console.error(`[SnsService] CDN 请求失败: HTTP ${res.statusCode}`)
|
||||
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
||||
return
|
||||
}
|
||||
@@ -2094,9 +2118,11 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
|
||||
let decoded = raw
|
||||
const rawMagicMime = detectImageMime(raw, '')
|
||||
console.log(`[SnsService] 原始数据: size=${raw.length}, mime=${rawMagicMime}, xEnc=${xEnc}`)
|
||||
|
||||
// 图片逻辑
|
||||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||
console.log(`[SnsService] 解密判断: shouldDecrypt=${shouldDecrypt}, key=${key || 'undefined'}`)
|
||||
if (shouldDecrypt) {
|
||||
try {
|
||||
const keyStr = String(key).trim()
|
||||
@@ -2112,6 +2138,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
}
|
||||
|
||||
const decryptedMagicMime = detectImageMime(decrypted, '')
|
||||
console.log(`[SnsService] 解密后: mime=${decryptedMagicMime}`)
|
||||
if (decryptedMagicMime.startsWith('image/')) {
|
||||
decoded = decrypted
|
||||
} else if (!rawMagicMime.startsWith('image/')) {
|
||||
@@ -2124,7 +2151,9 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
}
|
||||
|
||||
const decodedMagicMime = detectImageMime(decoded, '')
|
||||
console.log(`[SnsService] 最终结果: mime=${decodedMagicMime}, isImage=${decodedMagicMime.startsWith('image/')}`)
|
||||
if (!decodedMagicMime.startsWith('image/')) {
|
||||
console.error(`[SnsService] 图片解密失败: 原始mime=${rawMagicMime}, 解密后mime=${decodedMagicMime}, key=${key}`)
|
||||
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -94,6 +94,7 @@ function App() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false)
|
||||
const [canMinimizeToTray, setCanMinimizeToTray] = useState(false)
|
||||
const [closeRestoreMethod, setCloseRestoreMethod] = useState<'tray' | 'dock'>('tray')
|
||||
|
||||
// 锁定状态
|
||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||
@@ -120,6 +121,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
|
||||
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
|
||||
setCloseRestoreMethod(payload.restoreMethod === 'dock' ? 'dock' : 'tray')
|
||||
setShowCloseDialog(true)
|
||||
})
|
||||
|
||||
@@ -685,6 +687,7 @@ function App() {
|
||||
<WindowCloseDialog
|
||||
open={showCloseDialog}
|
||||
canMinimizeToTray={canMinimizeToTray}
|
||||
restoreMethod={closeRestoreMethod}
|
||||
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||
onCancel={() => handleWindowCloseAction('cancel')}
|
||||
/>
|
||||
|
||||
@@ -551,8 +551,13 @@ export function ExportDateRangeDialog({
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="export-date-range-dialog-overlay" onClick={onClose}>
|
||||
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
className="export-date-range-dialog-overlay"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
> <div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="export-date-range-dialog-header">
|
||||
<h4>{title}</h4>
|
||||
<button
|
||||
|
||||
@@ -231,7 +231,8 @@
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
@@ -239,11 +240,55 @@
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin: 0;
|
||||
accent-color: var(--primary);
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .media-default-check {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 88%, #fff);
|
||||
}
|
||||
|
||||
&:checked + .media-default-check::after {
|
||||
opacity: 1;
|
||||
transform: rotate(-45deg) scale(1);
|
||||
}
|
||||
|
||||
&:focus-visible + .media-default-check {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.media-default-check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, var(--text-tertiary));
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 4px;
|
||||
border-left: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
opacity: 0;
|
||||
transform: rotate(-45deg) scale(0.72);
|
||||
transform-origin: center;
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,15 +608,69 @@
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--text-tertiary) 4%, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-dropdown-floating {
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
|
||||
.select-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.select-option.active .option-desc {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.export-defaults-settings-form.layout-split {
|
||||
.form-group {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import * as configService from '../../services/config'
|
||||
import { ExportDateRangeDialog } from './ExportDateRangeDialog'
|
||||
@@ -56,6 +58,48 @@ const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
interface SelectDropdownPlacement {
|
||||
left: number
|
||||
width: number
|
||||
maxHeight: number
|
||||
top?: number
|
||||
bottom?: number
|
||||
}
|
||||
|
||||
const resolveSelectDropdownPlacement = (anchor: HTMLElement | null): SelectDropdownPlacement | null => {
|
||||
if (!anchor || typeof window === 'undefined') return null
|
||||
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const viewportMargin = 12
|
||||
const dropdownGap = 6
|
||||
const minDropdownHeight = 128
|
||||
const availableWidth = Math.max(160, viewportWidth - viewportMargin * 2)
|
||||
const width = Math.min(Math.max(rect.width, 220), availableWidth)
|
||||
const left = Math.max(viewportMargin, Math.min(rect.left, viewportWidth - width - viewportMargin))
|
||||
const spaceBelow = Math.max(0, viewportHeight - rect.bottom - viewportMargin - dropdownGap)
|
||||
const spaceAbove = Math.max(0, rect.top - viewportMargin - dropdownGap)
|
||||
const shouldOpenAbove = spaceBelow < minDropdownHeight && spaceAbove > spaceBelow
|
||||
const availableHeight = shouldOpenAbove ? spaceAbove : spaceBelow
|
||||
const maxHeight = Math.max(96, Math.min(320, availableHeight))
|
||||
|
||||
return shouldOpenAbove
|
||||
? { left, width, maxHeight, bottom: viewportHeight - rect.top + dropdownGap }
|
||||
: { left, width, maxHeight, top: rect.bottom + dropdownGap }
|
||||
}
|
||||
|
||||
const getSelectDropdownStyle = (placement: SelectDropdownPlacement): CSSProperties => ({
|
||||
position: 'fixed',
|
||||
top: placement.top,
|
||||
bottom: placement.bottom,
|
||||
left: placement.left,
|
||||
right: 'auto',
|
||||
width: placement.width,
|
||||
maxHeight: placement.maxHeight,
|
||||
zIndex: 9300
|
||||
})
|
||||
|
||||
export function ExportDefaultsSettingsForm({
|
||||
onNotify,
|
||||
onDefaultsChanged,
|
||||
@@ -66,6 +110,10 @@ export function ExportDefaultsSettingsForm({
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportExcelColumnsMenuRef = useRef<HTMLDivElement>(null)
|
||||
const exportFileNamingModeMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [exportExcelColumnsPlacement, setExportExcelColumnsPlacement] = useState<SelectDropdownPlacement | null>(null)
|
||||
const [exportFileNamingModePlacement, setExportFileNamingModePlacement] = useState<SelectDropdownPlacement | null>(null)
|
||||
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||
@@ -122,10 +170,20 @@ export function ExportDefaultsSettingsForm({
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
if (
|
||||
showExportExcelColumnsSelect &&
|
||||
exportExcelColumnsDropdownRef.current &&
|
||||
!exportExcelColumnsDropdownRef.current.contains(target) &&
|
||||
!exportExcelColumnsMenuRef.current?.contains(target)
|
||||
) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
|
||||
if (
|
||||
showExportFileNamingModeSelect &&
|
||||
exportFileNamingModeDropdownRef.current &&
|
||||
!exportFileNamingModeDropdownRef.current.contains(target) &&
|
||||
!exportFileNamingModeMenuRef.current?.contains(target)
|
||||
) {
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}
|
||||
}
|
||||
@@ -134,6 +192,30 @@ export function ExportDefaultsSettingsForm({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||
|
||||
const updateSelectDropdownPlacements = useCallback(() => {
|
||||
if (showExportExcelColumnsSelect) {
|
||||
setExportExcelColumnsPlacement(resolveSelectDropdownPlacement(exportExcelColumnsDropdownRef.current))
|
||||
}
|
||||
if (showExportFileNamingModeSelect) {
|
||||
setExportFileNamingModePlacement(resolveSelectDropdownPlacement(exportFileNamingModeDropdownRef.current))
|
||||
}
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showExportExcelColumnsSelect) setExportExcelColumnsPlacement(null)
|
||||
if (!showExportFileNamingModeSelect) setExportFileNamingModePlacement(null)
|
||||
if (!showExportExcelColumnsSelect && !showExportFileNamingModeSelect) return
|
||||
|
||||
updateSelectDropdownPlacements()
|
||||
window.addEventListener('resize', updateSelectDropdownPlacements)
|
||||
document.addEventListener('scroll', updateSelectDropdownPlacements, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSelectDropdownPlacements)
|
||||
document.removeEventListener('scroll', updateSelectDropdownPlacements, true)
|
||||
}
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect, updateSelectDropdownPlacements])
|
||||
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||
@@ -143,6 +225,73 @@ export function ExportDefaultsSettingsForm({
|
||||
onNotify?.(text, success)
|
||||
}
|
||||
|
||||
const fileNamingModeDropdown = showExportFileNamingModeSelect && exportFileNamingModePlacement
|
||||
? createPortal(
|
||||
<div
|
||||
ref={exportFileNamingModeMenuRef}
|
||||
className="select-dropdown select-dropdown-floating"
|
||||
role="listbox"
|
||||
style={getSelectDropdownStyle(exportFileNamingModePlacement)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{exportFileNamingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={exportDefaultFileNamingMode === option.value}
|
||||
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFileNamingMode(option.value)
|
||||
await configService.setExportDefaultFileNamingMode(option.value)
|
||||
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||
notify('已更新导出文件命名方式', true)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null
|
||||
|
||||
const excelColumnsDropdown = showExportExcelColumnsSelect && exportExcelColumnsPlacement
|
||||
? createPortal(
|
||||
<div
|
||||
ref={exportExcelColumnsMenuRef}
|
||||
className="select-dropdown select-dropdown-floating"
|
||||
role="listbox"
|
||||
style={getSelectDropdownStyle(exportExcelColumnsPlacement)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{exportExcelColumnOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={exportExcelColumnsValue === option.value}
|
||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
const compact = option.value === 'compact'
|
||||
setExportDefaultExcelCompactColumns(compact)
|
||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||
onDefaultsChanged?.({ excelCompactColumns: compact })
|
||||
notify(compact ? '已启用精简列' : '已启用完整列', true)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={`export-defaults-settings-form ${layout === 'split' ? 'layout-split' : 'layout-stacked'}`}>
|
||||
<div className="form-group">
|
||||
@@ -273,6 +422,8 @@ export function ExportDefaultsSettingsForm({
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={showExportFileNamingModeSelect}
|
||||
onClick={() => {
|
||||
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
@@ -282,27 +433,7 @@ export function ExportDefaultsSettingsForm({
|
||||
<span className="select-value">{exportFileNamingModeLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportFileNamingModeSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFileNamingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFileNamingMode(option.value)
|
||||
await configService.setExportDefaultFileNamingMode(option.value)
|
||||
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||
notify('已更新导出文件命名方式', true)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fileNamingModeDropdown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,6 +448,8 @@ export function ExportDefaultsSettingsForm({
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={showExportExcelColumnsSelect}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
@@ -326,28 +459,7 @@ export function ExportDefaultsSettingsForm({
|
||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportExcelColumnsSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportExcelColumnOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
const compact = option.value === 'compact'
|
||||
setExportDefaultExcelCompactColumns(compact)
|
||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||
onDefaultsChanged?.({ excelCompactColumns: compact })
|
||||
notify(compact ? '已启用精简列' : '已启用完整列', true)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{excelColumnsDropdown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,7 +483,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出图片`, true)
|
||||
}}
|
||||
/>
|
||||
图片
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>图片</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -385,7 +498,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出语音`, true)
|
||||
}}
|
||||
/>
|
||||
语音
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>语音</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -399,7 +513,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出视频`, true)
|
||||
}}
|
||||
/>
|
||||
视频
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>视频</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -413,7 +528,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出表情包`, true)
|
||||
}}
|
||||
/>
|
||||
表情包
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>表情包</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -427,7 +543,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
|
||||
}}
|
||||
/>
|
||||
文件
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>文件</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface JumpToDatePopoverProps {
|
||||
messageDateCounts?: Record<string, number>
|
||||
loadingDates?: boolean
|
||||
loadingDateCounts?: boolean
|
||||
maxDate?: Date
|
||||
}
|
||||
|
||||
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
@@ -29,7 +30,8 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
hasLoadedMessageDates = false,
|
||||
messageDateCounts,
|
||||
loadingDates = false,
|
||||
loadingDateCounts = false
|
||||
loadingDateCounts = false,
|
||||
maxDate
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
@@ -73,6 +75,14 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
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 today = new Date()
|
||||
return day === today.getDate()
|
||||
@@ -102,6 +112,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
if (hasLoadedMessageDates && !hasMessage(day)) return
|
||||
if (isAfterMaxDate(day)) return
|
||||
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||
setSelectedDate(targetDate)
|
||||
onSelect(targetDate)
|
||||
@@ -113,7 +124,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
const classes = ['day-cell']
|
||||
if (isToday(day)) classes.push('today')
|
||||
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(' ')
|
||||
}
|
||||
|
||||
@@ -225,6 +236,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
if (day === null) return <div key={index} className="day-cell empty" />
|
||||
const dateKey = toDateKey(day)
|
||||
const hasMessageOnDay = hasMessage(day)
|
||||
const isDisabled = (hasLoadedMessageDates && !hasMessageOnDay) || isAfterMaxDate(day)
|
||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||
const showCount = count > 0
|
||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||
@@ -233,7 +245,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
key={index}
|
||||
className={getDayClassName(day)}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
>
|
||||
<span className="day-number">{day}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import './WindowCloseDialog.scss'
|
||||
interface WindowCloseDialogProps {
|
||||
open: boolean
|
||||
canMinimizeToTray: boolean
|
||||
restoreMethod?: 'tray' | 'dock'
|
||||
onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
@@ -12,10 +13,12 @@ interface WindowCloseDialogProps {
|
||||
export default function WindowCloseDialog({
|
||||
open,
|
||||
canMinimizeToTray,
|
||||
restoreMethod = 'tray',
|
||||
onSelect,
|
||||
onCancel
|
||||
}: WindowCloseDialogProps) {
|
||||
const [rememberChoice, setRememberChoice] = useState(false)
|
||||
const isDockRestore = restoreMethod === 'dock'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -57,7 +60,9 @@ export default function WindowCloseDialog({
|
||||
<h2 id="window-close-dialog-title">关闭 WeFlow</h2>
|
||||
<p>
|
||||
{canMinimizeToTray
|
||||
? '你可以保留后台进程与本地 API,或者直接完全退出应用。'
|
||||
? isDockRestore
|
||||
? '你可以隐藏主窗口并保留后台进程与本地 API,稍后可从 Dock 或重新打开应用恢复。'
|
||||
: '你可以保留后台进程与本地 API,或者直接完全退出应用。'
|
||||
: '当前系统托盘不可用,本次只能完全退出应用。'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -73,8 +78,12 @@ export default function WindowCloseDialog({
|
||||
<Minimize2 size={18} />
|
||||
</span>
|
||||
<span className="window-close-dialog-option-text">
|
||||
<strong>最小化到系统托盘</strong>
|
||||
<span>继续保留后台进程和本地 API,稍后可从托盘恢复。</span>
|
||||
<strong>{isDockRestore ? '隐藏主窗口' : '最小化到系统托盘'}</strong>
|
||||
<span>
|
||||
{isDockRestore
|
||||
? '继续保留后台进程和本地 API,稍后可从 Dock 或重新打开应用恢复。'
|
||||
: '继续保留后台进程和本地 API,稍后可从托盘恢复。'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -184,6 +184,88 @@
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.chart-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0 0 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-note {
|
||||
margin: -4px 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.self-sent-ratio-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--primary-rgb), 0.045), transparent 34%),
|
||||
var(--card-bg);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 1px 0 rgba(var(--primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.chart-title-row {
|
||||
align-items: flex-start;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 4px var(--primary-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 72%, var(--primary-light));
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.14);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-note {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
margin: -2px 0 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, var(--card-bg));
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rankings
|
||||
|
||||
+242
-18
@@ -36,8 +36,20 @@ function AnalyticsPage() {
|
||||
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
|
||||
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
|
||||
|
||||
const themeMode = useThemeStore((state) => state.themeMode)
|
||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
|
||||
const chartThemeSignature = useThemeStore((state) => `${state.currentTheme}-${state.themeMode}`)
|
||||
const {
|
||||
statistics,
|
||||
rankings,
|
||||
timeDistribution,
|
||||
selfSentDailyDistribution,
|
||||
isLoaded,
|
||||
setStatistics,
|
||||
setRankings,
|
||||
setTimeDistribution,
|
||||
setSelfSentDailyDistribution,
|
||||
markLoaded,
|
||||
clearCache
|
||||
} = useAnalyticsStore()
|
||||
|
||||
const loadExcludedUsernames = useCallback(async () => {
|
||||
try {
|
||||
@@ -54,7 +66,14 @@ function AnalyticsPage() {
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async (forceRefresh = false) => {
|
||||
if (isLoaded && !forceRefresh) return
|
||||
const currentAnalyticsState = useAnalyticsStore.getState()
|
||||
if (
|
||||
currentAnalyticsState.isLoaded &&
|
||||
!forceRefresh &&
|
||||
currentAnalyticsState.statistics &&
|
||||
currentAnalyticsState.timeDistribution &&
|
||||
currentAnalyticsState.selfSentDailyDistribution
|
||||
) return
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'analytics',
|
||||
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
|
||||
@@ -128,6 +147,22 @@ function AnalyticsPage() {
|
||||
if (timeResult.success && timeResult.data) {
|
||||
setTimeDistribution(timeResult.data)
|
||||
}
|
||||
setLoadingStatus('正在统计每日发送分布...')
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在统计每日发送分布',
|
||||
progressText: '每日发送'
|
||||
})
|
||||
const selfSentDailyResult = await window.electronAPI.analytics.getSelfSentDailyDistribution(0, 0, forceRefresh)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,每日发送分布结果未继续写入'
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if (selfSentDailyResult.success && selfSentDailyResult.data) {
|
||||
setSelfSentDailyDistribution(selfSentDailyResult.data)
|
||||
}
|
||||
markLoaded()
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: '分析看板数据加载完成',
|
||||
@@ -142,7 +177,7 @@ function AnalyticsPage() {
|
||||
setIsLoading(false)
|
||||
if (removeListener) removeListener()
|
||||
}
|
||||
}, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
|
||||
}, [markLoaded, setRankings, setSelfSentDailyDistribution, setStatistics, setTimeDistribution])
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
@@ -276,17 +311,42 @@ function AnalyticsPage() {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
const getChartLabelColors = () => {
|
||||
const getChartTheme = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { text: '#333333', line: '#999999' }
|
||||
return {
|
||||
text: '#333333',
|
||||
secondaryText: '#666666',
|
||||
mutedText: '#999999',
|
||||
line: '#e5e5e5',
|
||||
surface: '#ffffff',
|
||||
border: '#e5e5e5',
|
||||
primary: '#10a37f',
|
||||
primaryLight: 'rgba(16, 163, 127, 0.1)',
|
||||
danger: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
success: '#10a37f',
|
||||
info: '#3b82f6'
|
||||
}
|
||||
}
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
const text = styles.getPropertyValue('--text-primary').trim() || '#333333'
|
||||
const line = styles.getPropertyValue('--text-tertiary').trim() || '#999999'
|
||||
return { text, line }
|
||||
const cssVar = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback
|
||||
return {
|
||||
text: cssVar('--text-primary', '#333333'),
|
||||
secondaryText: cssVar('--text-secondary', '#666666'),
|
||||
mutedText: cssVar('--text-tertiary', '#999999'),
|
||||
line: cssVar('--border-color', '#e5e5e5'),
|
||||
surface: cssVar('--card-inner-bg', '#ffffff'),
|
||||
border: cssVar('--border-color', '#e5e5e5'),
|
||||
primary: cssVar('--primary', '#10a37f'),
|
||||
primaryLight: cssVar('--primary-light', 'rgba(16, 163, 127, 0.1)'),
|
||||
danger: cssVar('--danger', '#ef4444'),
|
||||
warning: cssVar('--warning', '#f59e0b'),
|
||||
success: cssVar('--primary', '#10a37f'),
|
||||
info: '#3b82f6'
|
||||
}
|
||||
}
|
||||
|
||||
const chartLabelColors = getChartLabelColors()
|
||||
const chartTheme = getChartTheme()
|
||||
|
||||
const getTypeChartOption = () => {
|
||||
if (!statistics) return {}
|
||||
@@ -309,7 +369,7 @@ function AnalyticsPage() {
|
||||
show: true,
|
||||
formatter: '{b}\n{d}%',
|
||||
textStyle: {
|
||||
color: chartLabelColors.text,
|
||||
color: chartTheme.text,
|
||||
textShadowBlur: 0,
|
||||
textShadowColor: 'transparent',
|
||||
textShadowOffsetX: 0,
|
||||
@@ -320,7 +380,7 @@ function AnalyticsPage() {
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: chartLabelColors.line,
|
||||
color: chartTheme.mutedText,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
@@ -332,7 +392,7 @@ function AnalyticsPage() {
|
||||
shadowOffsetY: 0,
|
||||
},
|
||||
label: {
|
||||
color: chartLabelColors.text,
|
||||
color: chartTheme.text,
|
||||
textShadowBlur: 0,
|
||||
textShadowColor: 'transparent',
|
||||
textBorderWidth: 0,
|
||||
@@ -340,7 +400,7 @@ function AnalyticsPage() {
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: chartLabelColors.line,
|
||||
color: chartTheme.mutedText,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
@@ -364,7 +424,7 @@ function AnalyticsPage() {
|
||||
show: true,
|
||||
formatter: '{b}: {c}',
|
||||
textStyle: {
|
||||
color: chartLabelColors.text,
|
||||
color: chartTheme.text,
|
||||
textShadowBlur: 0,
|
||||
textShadowColor: 'transparent',
|
||||
textShadowOffsetX: 0,
|
||||
@@ -375,7 +435,7 @@ function AnalyticsPage() {
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: chartLabelColors.line,
|
||||
color: chartTheme.mutedText,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
@@ -387,7 +447,7 @@ function AnalyticsPage() {
|
||||
shadowOffsetY: 0,
|
||||
},
|
||||
label: {
|
||||
color: chartLabelColors.text,
|
||||
color: chartTheme.text,
|
||||
textShadowBlur: 0,
|
||||
textShadowColor: 'transparent',
|
||||
textBorderWidth: 0,
|
||||
@@ -395,7 +455,7 @@ function AnalyticsPage() {
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: chartLabelColors.line,
|
||||
color: chartTheme.mutedText,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
@@ -417,6 +477,160 @@ function AnalyticsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const getSelfSentDailyRatioData = () => {
|
||||
const entries = Object.entries(selfSentDailyDistribution?.dailyDistribution || {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
const days = entries.map(([day]) => day)
|
||||
const counts = entries.map(([, count]) => count)
|
||||
const totalDays = Math.max(days.length, 1)
|
||||
const total = counts.reduce((sum, count) => sum + count, 0)
|
||||
const baseline = total > 0 ? total / totalDays : 0
|
||||
const ratios = counts.map((count) => baseline > 0 ? Number((count / baseline * 100).toFixed(1)) : 0)
|
||||
const movingAverage = ratios.map((_, index) => {
|
||||
const start = Math.max(0, index - 6)
|
||||
const windowValues = ratios.slice(start, index + 1)
|
||||
const sum = windowValues.reduce((total, value) => total + value, 0)
|
||||
return Number((sum / windowValues.length).toFixed(1))
|
||||
})
|
||||
return { days, counts, ratios, movingAverage, baseline, total }
|
||||
}
|
||||
|
||||
const getSelfSentDailyRatioOption = () => {
|
||||
if (!selfSentDailyDistribution) return {}
|
||||
const { days, counts, ratios, movingAverage, baseline } = getSelfSentDailyRatioData()
|
||||
const showZoom = days.length > 31
|
||||
|
||||
const zoomStart = showZoom ? Math.max(0, 100 - Math.min(100, 31 / days.length * 100)) : 0
|
||||
const ratioBarColors = {
|
||||
normal: chartTheme.primary,
|
||||
high: chartTheme.warning,
|
||||
spike: chartTheme.danger,
|
||||
trend: chartTheme.secondaryText,
|
||||
baseline: chartTheme.mutedText
|
||||
}
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: chartTheme.surface,
|
||||
borderColor: chartTheme.border,
|
||||
textStyle: { color: chartTheme.text },
|
||||
extraCssText: 'box-shadow: var(--shadow-md); border-radius: 8px;',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
shadowStyle: { color: chartTheme.primaryLight }
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const items = Array.isArray(params) ? params : [params]
|
||||
const first = items[0]
|
||||
const index = Number(first?.dataIndex || 0)
|
||||
const lines = [
|
||||
`${first?.axisValue || ''}`,
|
||||
`当日发送:${formatNumber(counts[index] || 0)} 条`,
|
||||
`相对日均:${formatNumber(ratios[index] || 0)}%`,
|
||||
`7日均线:${formatNumber(movingAverage[index] || 0)}%`,
|
||||
`全期日均:${baseline.toFixed(1)} 条/天`
|
||||
]
|
||||
return lines.join('<br/>')
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['单日比例', '7日均线'],
|
||||
top: 0,
|
||||
textStyle: { color: chartTheme.secondaryText }
|
||||
},
|
||||
grid: { left: 56, right: 40, top: 42, bottom: showZoom ? 58 : 32 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: days,
|
||||
axisLine: { lineStyle: { color: chartTheme.line } },
|
||||
axisTick: { lineStyle: { color: chartTheme.line } },
|
||||
axisLabel: {
|
||||
color: chartTheme.mutedText,
|
||||
hideOverlap: true,
|
||||
formatter: (value: string) => value.slice(5)
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '相对日均',
|
||||
nameTextStyle: { color: chartTheme.mutedText },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: chartTheme.mutedText,
|
||||
formatter: '{value}%'
|
||||
},
|
||||
splitLine: { lineStyle: { color: chartTheme.line, type: 'dashed' } }
|
||||
},
|
||||
dataZoom: showZoom ? [
|
||||
{ type: 'inside', start: zoomStart, end: 100 },
|
||||
{
|
||||
type: 'slider',
|
||||
height: 18,
|
||||
bottom: 16,
|
||||
start: zoomStart,
|
||||
end: 100,
|
||||
borderColor: chartTheme.border,
|
||||
fillerColor: chartTheme.primaryLight,
|
||||
handleStyle: { color: chartTheme.primary, borderColor: chartTheme.primary },
|
||||
moveHandleStyle: { color: chartTheme.primaryLight },
|
||||
dataBackground: {
|
||||
lineStyle: { color: chartTheme.mutedText },
|
||||
areaStyle: { color: chartTheme.primaryLight }
|
||||
},
|
||||
selectedDataBackground: {
|
||||
lineStyle: { color: chartTheme.primary },
|
||||
areaStyle: { color: chartTheme.primaryLight }
|
||||
},
|
||||
textStyle: { color: chartTheme.mutedText }
|
||||
}
|
||||
] : undefined,
|
||||
series: [
|
||||
{
|
||||
name: '单日比例',
|
||||
type: 'bar',
|
||||
data: ratios,
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
const value = Number(params?.value || 0)
|
||||
if (value >= 200) return ratioBarColors.spike
|
||||
if (value >= 150) return ratioBarColors.high
|
||||
return ratioBarColors.normal
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
markLine: {
|
||||
symbol: 'none',
|
||||
data: [{ yAxis: 100, name: '日均基线' }],
|
||||
label: {
|
||||
position: 'middle',
|
||||
formatter: '日均基线',
|
||||
color: chartTheme.secondaryText,
|
||||
backgroundColor: chartTheme.surface,
|
||||
borderColor: chartTheme.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
padding: [2, 6]
|
||||
},
|
||||
lineStyle: { type: 'dashed', color: ratioBarColors.baseline }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '7日均线',
|
||||
type: 'line',
|
||||
data: movingAverage,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2, color: ratioBarColors.trend },
|
||||
itemStyle: { color: ratioBarColors.trend }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const selfSentDailyRatioData = getSelfSentDailyRatioData()
|
||||
|
||||
const renderPageShell = (content: ReactNode) => (
|
||||
<div className="analytics-page-shell">
|
||||
<ChatAnalysisHeader currentMode="private" />
|
||||
@@ -521,6 +735,16 @@ function AnalyticsPage() {
|
||||
<div className="chart-card"><h3>消息类型分布</h3><ReactECharts option={getTypeChartOption()} style={{ height: 300 }} /></div>
|
||||
<div className="chart-card"><h3>发送/接收比例</h3><ReactECharts option={getSendReceiveOption()} style={{ height: 300 }} /></div>
|
||||
<div className="chart-card wide"><h3>每小时消息分布</h3><ReactECharts option={getHourlyOption()} style={{ height: 250 }} /></div>
|
||||
<div className="chart-card wide self-sent-ratio-card">
|
||||
<div className="chart-title-row">
|
||||
<h3>每日自身发送强度比例</h3>
|
||||
<span>范围:全部 · 基线:{selfSentDailyRatioData.baseline.toFixed(1)} 条/天 · 共 {formatNumber(selfSentDailyDistribution?.totalMessages || 0)} 条</span>
|
||||
</div>
|
||||
<div className="chart-note">
|
||||
比例 = 当日自身发送量 ÷ 全期每日平均自身发送量。超过 100% 表示高于本人基线
|
||||
</div>
|
||||
<ReactECharts key={chartThemeSignature} option={getSelfSentDailyRatioOption()} style={{ height: 320 }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="page-section">
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
Info,
|
||||
Loader2,
|
||||
Mic,
|
||||
Newspaper,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
Users
|
||||
} from 'lucide-react'
|
||||
import { Avatar } from '../../components/Avatar'
|
||||
@@ -21,9 +23,11 @@ export interface ChatHeaderProps {
|
||||
isGroupChat: boolean
|
||||
standaloneSessionWindow: boolean
|
||||
showGroupMembersPanel: boolean
|
||||
showGroupSummaryPanel: boolean
|
||||
showJumpPopover: boolean
|
||||
showInSessionSearch: boolean
|
||||
showDetailPanel: boolean
|
||||
aiGroupSummaryEnabled: boolean
|
||||
shouldHideStandaloneDetailButton: boolean
|
||||
isPrivateSnsSupported: boolean
|
||||
isExportActionBusy: boolean
|
||||
@@ -32,10 +36,13 @@ export interface ChatHeaderProps {
|
||||
isBatchTranscribing: boolean
|
||||
runningBatchVoiceTaskType?: BatchVoiceTaskType
|
||||
isBatchDecrypting: boolean
|
||||
isTriggeringSessionInsight: boolean
|
||||
isRefreshingMessages: boolean
|
||||
isLoadingMessages: boolean
|
||||
currentSessionId?: string | null
|
||||
jumpCalendarWrapRef: React.RefObject<HTMLDivElement | null>
|
||||
onTriggerSessionInsight: () => void
|
||||
onToggleGroupSummaryPanel: () => void
|
||||
onGroupAnalytics: () => void
|
||||
onToggleGroupMembersPanel: () => void
|
||||
onExportCurrentSession: () => void
|
||||
@@ -53,9 +60,11 @@ function ChatHeader({
|
||||
isGroupChat,
|
||||
standaloneSessionWindow,
|
||||
showGroupMembersPanel,
|
||||
showGroupSummaryPanel,
|
||||
showJumpPopover,
|
||||
showInSessionSearch,
|
||||
showDetailPanel,
|
||||
aiGroupSummaryEnabled,
|
||||
shouldHideStandaloneDetailButton,
|
||||
isPrivateSnsSupported,
|
||||
isExportActionBusy,
|
||||
@@ -64,10 +73,13 @@ function ChatHeader({
|
||||
isBatchTranscribing,
|
||||
runningBatchVoiceTaskType,
|
||||
isBatchDecrypting,
|
||||
isTriggeringSessionInsight,
|
||||
isRefreshingMessages,
|
||||
isLoadingMessages,
|
||||
currentSessionId,
|
||||
jumpCalendarWrapRef,
|
||||
onTriggerSessionInsight,
|
||||
onToggleGroupSummaryPanel,
|
||||
onGroupAnalytics,
|
||||
onToggleGroupMembersPanel,
|
||||
onExportCurrentSession,
|
||||
@@ -102,6 +114,26 @@ function ChatHeader({
|
||||
{isGroupChat && <div className="header-subtitle">群聊</div>}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className={`icon-btn session-insight-btn${isTriggeringSessionInsight ? ' triggering' : ''}`}
|
||||
onClick={onTriggerSessionInsight}
|
||||
disabled={!currentSessionId || isTriggeringSessionInsight}
|
||||
title={isTriggeringSessionInsight ? '正在生成 AI 见解' : '立即触发当前聊天 AI 见解'}
|
||||
aria-label="立即触发当前聊天 AI 见解"
|
||||
>
|
||||
{isTriggeringSessionInsight ? <Loader2 size={18} className="spin" /> : <Sparkles size={18} />}
|
||||
</button>
|
||||
{isGroupChat && aiGroupSummaryEnabled && (
|
||||
<button
|
||||
className={`icon-btn group-summary-btn ${showGroupSummaryPanel ? 'active' : ''}`}
|
||||
onClick={onToggleGroupSummaryPanel}
|
||||
disabled={!currentSessionId}
|
||||
title="AI 群聊总结"
|
||||
aria-label="AI 群聊总结"
|
||||
>
|
||||
<Newspaper size={18} />
|
||||
</button>
|
||||
)}
|
||||
{!standaloneSessionWindow && isGroupChat && (
|
||||
<button className="icon-btn group-analytics-btn" onClick={onGroupAnalytics} title="群聊分析">
|
||||
<BarChart3 size={18} />
|
||||
@@ -203,9 +235,11 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
|
||||
prev.isGroupChat === next.isGroupChat &&
|
||||
prev.standaloneSessionWindow === next.standaloneSessionWindow &&
|
||||
prev.showGroupMembersPanel === next.showGroupMembersPanel &&
|
||||
prev.showGroupSummaryPanel === next.showGroupSummaryPanel &&
|
||||
prev.showJumpPopover === next.showJumpPopover &&
|
||||
prev.showInSessionSearch === next.showInSessionSearch &&
|
||||
prev.showDetailPanel === next.showDetailPanel &&
|
||||
prev.aiGroupSummaryEnabled === next.aiGroupSummaryEnabled &&
|
||||
prev.shouldHideStandaloneDetailButton === next.shouldHideStandaloneDetailButton &&
|
||||
prev.isPrivateSnsSupported === next.isPrivateSnsSupported &&
|
||||
prev.isExportActionBusy === next.isExportActionBusy &&
|
||||
@@ -214,10 +248,13 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
|
||||
prev.isBatchTranscribing === next.isBatchTranscribing &&
|
||||
prev.runningBatchVoiceTaskType === next.runningBatchVoiceTaskType &&
|
||||
prev.isBatchDecrypting === next.isBatchDecrypting &&
|
||||
prev.isTriggeringSessionInsight === next.isTriggeringSessionInsight &&
|
||||
prev.isRefreshingMessages === next.isRefreshingMessages &&
|
||||
prev.isLoadingMessages === next.isLoadingMessages &&
|
||||
prev.currentSessionId === next.currentSessionId &&
|
||||
prev.jumpCalendarWrapRef === next.jumpCalendarWrapRef &&
|
||||
prev.onTriggerSessionInsight === next.onTriggerSessionInsight &&
|
||||
prev.onToggleGroupSummaryPanel === next.onToggleGroupSummaryPanel &&
|
||||
prev.onGroupAnalytics === next.onGroupAnalytics &&
|
||||
prev.onToggleGroupMembersPanel === next.onToggleGroupMembersPanel &&
|
||||
prev.onExportCurrentSession === next.onExportCurrentSession &&
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ChatMessageBubbleProps {
|
||||
isSelected?: boolean
|
||||
onContextMenu?: (event: React.MouseEvent, message: Message) => void
|
||||
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
|
||||
actionNode?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
portal?: React.ReactNode
|
||||
}
|
||||
@@ -57,6 +58,7 @@ function ChatMessageBubble({
|
||||
isSelected,
|
||||
onContextMenu,
|
||||
onToggleSelection,
|
||||
actionNode,
|
||||
children,
|
||||
portal
|
||||
}: ChatMessageBubbleProps) {
|
||||
@@ -92,12 +94,20 @@ function ChatMessageBubble({
|
||||
</div>
|
||||
<div className="bubble-body">
|
||||
{isGroupChat && !isSent && (
|
||||
<div className="sender-name">
|
||||
{resolvedSenderName || '群成员'}
|
||||
<div className="sender-line">
|
||||
<div className="sender-name">
|
||||
{resolvedSenderName || '群成员'}
|
||||
</div>
|
||||
{actionNode}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{!isGroupChat && !isSent && actionNode ? (
|
||||
<div className="message-action-inline">
|
||||
{actionNode}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSelectionMode && isSent && <SelectionCheckbox checked={isSelected} side="right" />}
|
||||
@@ -131,6 +141,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
|
||||
prev.isSelected === next.isSelected &&
|
||||
prev.onContextMenu === next.onContextMenu &&
|
||||
prev.onToggleSelection === next.onToggleSelection &&
|
||||
prev.actionNode === next.actionNode &&
|
||||
prev.children === next.children &&
|
||||
prev.portal === next.portal
|
||||
)
|
||||
|
||||
@@ -1747,6 +1747,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.session-insight-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
&.success {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -1922,6 +1945,10 @@
|
||||
|
||||
.message-wrapper.new-message {
|
||||
animation: messagePop 0.35s ease-out;
|
||||
|
||||
.message-bubble:not(.system) .bubble-content {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes messagePop {
|
||||
@@ -3542,6 +3569,271 @@
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-panel {
|
||||
width: clamp(320px, 30vw, 420px);
|
||||
min-width: 320px;
|
||||
max-width: 420px;
|
||||
|
||||
.group-summary-controls {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.group-summary-date-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
.group-summary-date-trigger {
|
||||
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-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-code-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
background: var(--bg-hover);
|
||||
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-range-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
|
||||
button {
|
||||
height: 30px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&.active {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--card-bg));
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-generate-btn {
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-rule-hint,
|
||||
.group-summary-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.group-summary-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-summary-record {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 74%, transparent);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-summary-record-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-summary-period {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.group-summary-meta {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.group-summary-topic-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-summary-topic {
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
padding: 9px;
|
||||
|
||||
h5 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-topic-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
|
||||
span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-log-modal {
|
||||
width: min(860px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
|
||||
.detail-content {
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-log-pre {
|
||||
margin: 0;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--card-bg) 86%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@keyframes detailCardEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -5828,6 +6120,245 @@
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.sender-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
min-height: 18px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.sender-name {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message-action-inline {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 6px 0 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-insight-trigger {
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 5px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
transform: translateX(3px);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.16s ease, color 0.16s ease, background 0.16s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
}
|
||||
|
||||
&.compact {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
transform: none;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrapper-with-selection:hover .message-insight-trigger,
|
||||
.message-insight-trigger:focus-visible {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.message-wrapper-with-selection:hover .message-insight-trigger.compact,
|
||||
.message-insight-trigger.compact:focus-visible {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.message-insight-trigger.compact:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.message-insight-trigger:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.message-insight-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4100;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.message-insight-card {
|
||||
position: fixed;
|
||||
z-index: 4101;
|
||||
width: min(336px, calc(100vw - 16px));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
animation: messageInsightPop 0.14s ease-out;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.message-insight-card-header {
|
||||
height: 38px;
|
||||
padding: 0 10px 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-insight-refresh {
|
||||
margin-left: auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
}
|
||||
|
||||
.message-insight-card-body {
|
||||
min-height: 132px;
|
||||
padding: 13px 14px 14px;
|
||||
}
|
||||
|
||||
.message-insight-loading,
|
||||
.message-insight-error {
|
||||
min-height: 104px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-insight-error {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--primary);
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.message-insight-text {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.62;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-insight-divider {
|
||||
height: 1px;
|
||||
margin: 12px 0;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.message-insight-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.message-insight-tag {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
|
||||
&.mood {
|
||||
color: #8a5a00;
|
||||
background: rgba(245, 158, 11, 0.13);
|
||||
}
|
||||
|
||||
&.intent {
|
||||
color: #225f5c;
|
||||
background: rgba(91, 147, 144, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes messageInsightPop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Ambient Reply dark mode / alternate adjustments handled via CSS variables
|
||||
|
||||
.link-message,
|
||||
|
||||
+823
-4
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper, Star, Sparkles, Code2 } from 'lucide-react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
@@ -8,6 +8,7 @@ import { useChatStore } from '../stores/chatStore'
|
||||
import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore'
|
||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||
import type { ChatRecordItem, ChatSession, Message } from '../types/models'
|
||||
import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||
@@ -68,6 +69,7 @@ interface QuotedMessageJumpTarget {
|
||||
|
||||
type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done'
|
||||
type GlobalMsgSearchResult = Message & { sessionId: string }
|
||||
type GroupSummaryRangeMode = 1 | 2 | 4 | 8 | 12 | 24
|
||||
|
||||
interface GlobalMsgPrefixCacheEntry {
|
||||
keyword: string
|
||||
@@ -762,6 +764,17 @@ function formatYmdHmDateTime(timestamp?: number): string {
|
||||
return `${y}-${m}-${day} ${h}:${min}`
|
||||
}
|
||||
|
||||
function formatDateInputLocal(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
function formatSummaryPeriod(start: number, end: number): string {
|
||||
return `${formatYmdHmDateTime(start * 1000)} - ${formatYmdHmDateTime(end * 1000)}`
|
||||
}
|
||||
|
||||
interface ChatPageProps {
|
||||
standaloneSessionWindow?: boolean
|
||||
initialSessionId?: string | null
|
||||
@@ -1476,6 +1489,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||
const jumpCalendarWrapRef = useRef<HTMLDivElement>(null)
|
||||
const jumpPopoverPortalRef = useRef<HTMLDivElement>(null)
|
||||
const groupSummaryDateWrapRef = useRef<HTMLDivElement>(null)
|
||||
const [currentOffset, setCurrentOffset] = useState(0)
|
||||
const [jumpStartTime, setJumpStartTime] = useState(0)
|
||||
const [jumpEndTime, setJumpEndTime] = useState(0)
|
||||
@@ -1537,6 +1551,18 @@ function ChatPage(props: ChatPageProps) {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null)
|
||||
const [showMessageInfo, setShowMessageInfo] = useState<Message | null>(null)
|
||||
const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null)
|
||||
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
|
||||
const [showGroupSummaryPanel, setShowGroupSummaryPanel] = useState(false)
|
||||
const [groupSummaryRecords, setGroupSummaryRecords] = useState<GroupSummaryRecordSummary[]>([])
|
||||
const [groupSummaryTotal, setGroupSummaryTotal] = useState(0)
|
||||
const [groupSummaryLoading, setGroupSummaryLoading] = useState(false)
|
||||
const [groupSummaryError, setGroupSummaryError] = useState<string | null>(null)
|
||||
const [groupSummaryDateFilter, setGroupSummaryDateFilter] = useState(() => formatDateInputLocal(new Date()))
|
||||
const [groupSummaryRangeMode, setGroupSummaryRangeMode] = useState<GroupSummaryRangeMode>(4)
|
||||
const [showGroupSummaryDatePopover, setShowGroupSummaryDatePopover] = useState(false)
|
||||
const [isTriggeringGroupSummary, setIsTriggeringGroupSummary] = useState(false)
|
||||
const [groupSummaryHint, setGroupSummaryHint] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [groupSummaryLogRecord, setGroupSummaryLogRecord] = useState<GroupSummaryRecord | null>(null)
|
||||
|
||||
// 多选模式
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||
@@ -1637,6 +1663,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
|
||||
|
||||
const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys])
|
||||
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
|
||||
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
|
||||
const [isTriggeringSessionInsight, setIsTriggeringSessionInsight] = useState(false)
|
||||
const [sessionInsightHint, setSessionInsightHint] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const sessionInsightHintTimerRef = useRef<number | null>(null)
|
||||
const messageKeySetRef = useRef<Set<string>>(new Set())
|
||||
const lastMessageTimeRef = useRef(0)
|
||||
const isMessageListAtBottomRef = useRef(true)
|
||||
@@ -1856,6 +1887,130 @@ function ChatPage(props: ChatPageProps) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const getGroupSummaryDateRangeSeconds = useCallback((dateValue = groupSummaryDateFilter) => {
|
||||
const date = dateValue || formatDateInputLocal(new Date())
|
||||
const start = new Date(`${date}T00:00:00`)
|
||||
if (!Number.isFinite(start.getTime())) {
|
||||
const fallback = new Date()
|
||||
fallback.setHours(0, 0, 0, 0)
|
||||
const fallbackEnd = new Date(fallback)
|
||||
fallbackEnd.setHours(23, 59, 59, 999)
|
||||
return { startTime: Math.floor(fallback.getTime() / 1000), endTime: Math.floor(fallbackEnd.getTime() / 1000) }
|
||||
}
|
||||
const end = new Date(start)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
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])
|
||||
|
||||
const loadGroupSummaryRecords = useCallback(async (sessionId?: string) => {
|
||||
const targetSessionId = String(sessionId || currentSessionRef.current || '').trim()
|
||||
if (!targetSessionId || !targetSessionId.endsWith('@chatroom')) return
|
||||
const { startTime, endTime } = getGroupSummaryDateRangeSeconds()
|
||||
setGroupSummaryLoading(true)
|
||||
setGroupSummaryError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.groupSummary.listRecords({
|
||||
sessionId: targetSessionId,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: 100
|
||||
})
|
||||
if (currentSessionRef.current !== targetSessionId) return
|
||||
if (!result.success) {
|
||||
setGroupSummaryRecords([])
|
||||
setGroupSummaryTotal(0)
|
||||
setGroupSummaryError(result.error || '读取群聊总结失败')
|
||||
return
|
||||
}
|
||||
setGroupSummaryRecords(result.records || [])
|
||||
setGroupSummaryTotal(result.total || 0)
|
||||
} catch (error) {
|
||||
if (currentSessionRef.current !== targetSessionId) return
|
||||
setGroupSummaryRecords([])
|
||||
setGroupSummaryTotal(0)
|
||||
setGroupSummaryError((error as Error).message || String(error))
|
||||
} finally {
|
||||
if (currentSessionRef.current === targetSessionId) {
|
||||
setGroupSummaryLoading(false)
|
||||
}
|
||||
}
|
||||
}, [getGroupSummaryDateRangeSeconds])
|
||||
|
||||
const resolveTodayGroupSummaryManualRange = useCallback(() => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const hours = Number(groupSummaryRangeMode)
|
||||
return { startTime: nowSeconds - hours * 60 * 60, endTime: nowSeconds }
|
||||
}, [groupSummaryRangeMode])
|
||||
|
||||
const triggerManualGroupSummary = useCallback(async () => {
|
||||
const sessionId = String(currentSessionId || '').trim()
|
||||
if (!sessionId || !sessionId.endsWith('@chatroom')) return
|
||||
const sessionInfo = sessionMapRef.current.get(sessionId)
|
||||
const selectedDate = groupSummaryDateFilter || formatDateInputLocal(new Date())
|
||||
const today = formatDateInputLocal(new Date())
|
||||
|
||||
setIsTriggeringGroupSummary(true)
|
||||
setGroupSummaryHint({ success: true, message: '正在生成群聊总结...' })
|
||||
try {
|
||||
if (selectedDate === today) {
|
||||
const { startTime, endTime } = resolveTodayGroupSummaryManualRange()
|
||||
if (startTime <= 0 || endTime <= startTime) {
|
||||
setGroupSummaryHint({ success: false, message: '请选择有效的总结时段' })
|
||||
return
|
||||
}
|
||||
const result = await window.electronAPI.groupSummary.triggerManual({
|
||||
sessionId,
|
||||
displayName: sessionInfo?.displayName || sessionId,
|
||||
avatarUrl: sessionInfo?.avatarUrl,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
if (result.success) {
|
||||
setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' })
|
||||
if (!result.skipped) {
|
||||
await loadGroupSummaryRecords(sessionId)
|
||||
}
|
||||
} else {
|
||||
setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' })
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) })
|
||||
} finally {
|
||||
setIsTriggeringGroupSummary(false)
|
||||
}
|
||||
}, [currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, resolveTodayGroupSummaryManualRange])
|
||||
|
||||
const openGroupSummaryLog = useCallback(async (recordId: string) => {
|
||||
try {
|
||||
const result = await window.electronAPI.groupSummary.getRecord(recordId)
|
||||
if (!result.success || !result.record) {
|
||||
setGroupSummaryHint({ success: false, message: result.error || '读取总结日志失败' })
|
||||
return
|
||||
}
|
||||
setGroupSummaryLogRecord(result.record)
|
||||
} catch (error) {
|
||||
setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToggleJumpPopover = useCallback(() => {
|
||||
if (!currentSessionId) return
|
||||
if (showJumpPopover) {
|
||||
@@ -2898,9 +3053,21 @@ function ChatPage(props: ChatPageProps) {
|
||||
return
|
||||
}
|
||||
setShowDetailPanel(false)
|
||||
setShowGroupSummaryPanel(false)
|
||||
setShowGroupMembersPanel(true)
|
||||
}, [currentSessionId, showGroupMembersPanel, isGroupChatSession])
|
||||
|
||||
const toggleGroupSummaryPanel = useCallback(() => {
|
||||
if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) return
|
||||
if (showGroupSummaryPanel) {
|
||||
setShowGroupSummaryPanel(false)
|
||||
return
|
||||
}
|
||||
setShowDetailPanel(false)
|
||||
setShowGroupMembersPanel(false)
|
||||
setShowGroupSummaryPanel(true)
|
||||
}, [aiGroupSummaryEnabled, currentSessionId, showGroupSummaryPanel, isGroupChatSession])
|
||||
|
||||
// 切换详情面板
|
||||
const toggleDetailPanel = useCallback(() => {
|
||||
if (showDetailPanel) {
|
||||
@@ -2908,6 +3075,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
return
|
||||
}
|
||||
setShowGroupMembersPanel(false)
|
||||
setShowGroupSummaryPanel(false)
|
||||
setShowDetailPanel(true)
|
||||
if (currentSessionId) {
|
||||
void loadSessionDetail(currentSessionId)
|
||||
@@ -2924,6 +3092,15 @@ function ChatPage(props: ChatPageProps) {
|
||||
void loadGroupMembersPanel(currentSessionId)
|
||||
}, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showGroupSummaryPanel) return
|
||||
if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) {
|
||||
setShowGroupSummaryPanel(false)
|
||||
return
|
||||
}
|
||||
void loadGroupSummaryRecords(currentSessionId)
|
||||
}, [aiGroupSummaryEnabled, currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, showGroupSummaryPanel, isGroupChatSession])
|
||||
|
||||
useEffect(() => {
|
||||
const chatroomId = String(sessionDetail?.wxid || '').trim()
|
||||
if (!chatroomId || !chatroomId.includes('@chatroom')) return
|
||||
@@ -3005,6 +3182,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
setIsLoadingRelationStats(false)
|
||||
setShowDetailPanel(false)
|
||||
setShowGroupMembersPanel(false)
|
||||
setShowGroupSummaryPanel(false)
|
||||
setGroupSummaryRecords([])
|
||||
setGroupSummaryError(null)
|
||||
setGroupSummaryHint(null)
|
||||
setGroupPanelMembers([])
|
||||
setGroupMembersError(null)
|
||||
setGroupMembersLoadingHint('')
|
||||
@@ -3095,6 +3276,36 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false
|
||||
|
||||
const loadMessageInsightConfig = () => {
|
||||
void Promise.all([
|
||||
configService.getAiMessageInsightEnabled(),
|
||||
configService.getAiMessageInsightContextCount()
|
||||
])
|
||||
.then(([enabled, contextCount]) => {
|
||||
if (canceled) return
|
||||
setAiMessageInsightEnabled(enabled)
|
||||
setAiMessageInsightContextCount(contextCount)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('加载消息解析配置失败:', error)
|
||||
if (canceled) return
|
||||
setAiMessageInsightEnabled(false)
|
||||
setAiMessageInsightContextCount(50)
|
||||
})
|
||||
}
|
||||
|
||||
loadMessageInsightConfig()
|
||||
const handleFocus = () => loadMessageInsightConfig()
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => {
|
||||
canceled = true
|
||||
window.removeEventListener('focus', handleFocus)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -3111,6 +3322,13 @@ function ChatPage(props: ChatPageProps) {
|
||||
// 同步 currentSessionId 到 ref
|
||||
useEffect(() => {
|
||||
currentSessionRef.current = currentSessionId
|
||||
messageInsightMemoryCache.clear()
|
||||
setSessionInsightHint(null)
|
||||
setIsTriggeringSessionInsight(false)
|
||||
if (sessionInsightHintTimerRef.current !== null) {
|
||||
window.clearTimeout(sessionInsightHintTimerRef.current)
|
||||
sessionInsightHintTimerRef.current = null
|
||||
}
|
||||
isMessageListAtBottomRef.current = true
|
||||
topRangeLoadLockRef.current = false
|
||||
bottomRangeLoadLockRef.current = false
|
||||
@@ -4363,6 +4581,9 @@ function ChatPage(props: ChatPageProps) {
|
||||
setShowJumpPopover(false)
|
||||
setShowDetailPanel(false)
|
||||
setShowGroupMembersPanel(false)
|
||||
setShowGroupSummaryPanel(false)
|
||||
setGroupSummaryError(null)
|
||||
setGroupSummaryHint(null)
|
||||
setGroupMemberSearchKeyword('')
|
||||
setGroupMembersError(null)
|
||||
setGroupMembersLoadingHint('')
|
||||
@@ -5470,6 +5691,20 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!showJumpPopover) return
|
||||
const syncPosition = () => {
|
||||
@@ -5807,6 +6042,50 @@ function ChatPage(props: ChatPageProps) {
|
||||
})
|
||||
}, [currentSession, isCurrentSessionPrivateSnsSupported])
|
||||
|
||||
const showSessionInsightHint = useCallback((hint: { success: boolean; message: string }) => {
|
||||
if (sessionInsightHintTimerRef.current !== null) {
|
||||
window.clearTimeout(sessionInsightHintTimerRef.current)
|
||||
sessionInsightHintTimerRef.current = null
|
||||
}
|
||||
setSessionInsightHint(hint)
|
||||
sessionInsightHintTimerRef.current = window.setTimeout(() => {
|
||||
setSessionInsightHint(null)
|
||||
sessionInsightHintTimerRef.current = null
|
||||
}, 5000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sessionInsightHintTimerRef.current !== null) {
|
||||
window.clearTimeout(sessionInsightHintTimerRef.current)
|
||||
sessionInsightHintTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false
|
||||
|
||||
const loadGroupSummaryConfig = () => {
|
||||
void configService.getAiGroupSummaryEnabled()
|
||||
.then((enabled) => {
|
||||
if (!canceled) setAiGroupSummaryEnabled(enabled)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('加载群聊总结配置失败:', error)
|
||||
if (!canceled) setAiGroupSummaryEnabled(false)
|
||||
})
|
||||
}
|
||||
|
||||
loadGroupSummaryConfig()
|
||||
const handleFocus = () => loadGroupSummaryConfig()
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => {
|
||||
canceled = true
|
||||
window.removeEventListener('focus', handleFocus)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!standaloneSessionWindow) return
|
||||
setStandaloneInitialLoadRequested(false)
|
||||
@@ -5868,7 +6147,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
selectSessionById
|
||||
])
|
||||
|
||||
// 监听 URL 参数中的会话/锚点(通知跳转 + 足迹锚点定位)
|
||||
// 监听 URL 参数中的会话/锚点(通知跳转 + 足迹/深度解析锚点定位)
|
||||
useEffect(() => {
|
||||
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
|
||||
const params = new URLSearchParams(location.search)
|
||||
@@ -5883,6 +6162,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
&& jumpLocalId > 0
|
||||
&& Number.isFinite(jumpCreateTime)
|
||||
&& jumpCreateTime > 0
|
||||
const hasMessageAnalysisAnchor = jumpSource === 'messageAnalysis'
|
||||
&& Number.isFinite(jumpLocalId)
|
||||
&& jumpLocalId > 0
|
||||
&& Number.isFinite(jumpCreateTime)
|
||||
&& jumpCreateTime > 0
|
||||
|
||||
if (hasFootprintAnchor) {
|
||||
pendingFootprintJumpRef.current = {
|
||||
@@ -5912,6 +6196,34 @@ function ChatPage(props: ChatPageProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasMessageAnalysisAnchor) {
|
||||
pendingFootprintJumpRef.current = {
|
||||
sessionId: urlSessionId,
|
||||
localId: jumpLocalId,
|
||||
createTime: jumpCreateTime
|
||||
}
|
||||
if (currentSessionId !== urlSessionId) {
|
||||
selectSessionById(urlSessionId)
|
||||
return
|
||||
}
|
||||
const messageStub: Message = {
|
||||
messageKey: `footprint:${urlSessionId}:${jumpCreateTime}:${jumpLocalId}`,
|
||||
localId: jumpLocalId,
|
||||
serverId: 0,
|
||||
localType: 0,
|
||||
createTime: jumpCreateTime,
|
||||
sortSeq: jumpCreateTime,
|
||||
isSend: null,
|
||||
senderUsername: null,
|
||||
parsedContent: '',
|
||||
rawContent: ''
|
||||
}
|
||||
handleInSessionResultJump(messageStub)
|
||||
pendingFootprintJumpRef.current = null
|
||||
navigate('/chat', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
pendingFootprintJumpRef.current = null
|
||||
if (currentSessionId !== urlSessionId) {
|
||||
selectSessionById(urlSessionId)
|
||||
@@ -6115,6 +6427,41 @@ function ChatPage(props: ChatPageProps) {
|
||||
})
|
||||
}, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog])
|
||||
|
||||
const handleTriggerSessionInsight = useCallback(async () => {
|
||||
const session = currentSession
|
||||
const sessionId = String(session?.username || currentSessionId || '').trim()
|
||||
if (!sessionId || isTriggeringSessionInsight) return
|
||||
|
||||
setIsTriggeringSessionInsight(true)
|
||||
if (sessionInsightHintTimerRef.current !== null) {
|
||||
window.clearTimeout(sessionInsightHintTimerRef.current)
|
||||
sessionInsightHintTimerRef.current = null
|
||||
}
|
||||
setSessionInsightHint({ success: true, message: '正在生成当前聊天的 AI 见解...' })
|
||||
try {
|
||||
const result = await window.electronAPI.insight.triggerSessionInsight({
|
||||
sessionId,
|
||||
displayName: session?.displayName || sessionId,
|
||||
avatarUrl: session?.avatarUrl
|
||||
})
|
||||
if (currentSessionRef.current !== sessionId) return
|
||||
showSessionInsightHint({
|
||||
success: result.success,
|
||||
message: result.message || (result.success ? 'AI 见解已生成' : 'AI 见解生成失败')
|
||||
})
|
||||
} catch (error) {
|
||||
if (currentSessionRef.current !== sessionId) return
|
||||
showSessionInsightHint({
|
||||
success: false,
|
||||
message: `触发失败:${(error as Error).message || String(error)}`
|
||||
})
|
||||
} finally {
|
||||
if (currentSessionRef.current === sessionId) {
|
||||
setIsTriggeringSessionInsight(false)
|
||||
}
|
||||
}
|
||||
}, [currentSession, currentSessionId, isTriggeringSessionInsight, showSessionInsightHint])
|
||||
|
||||
const handleGroupAnalytics = useCallback(() => {
|
||||
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
|
||||
navigate('/analytics/group', {
|
||||
@@ -6887,6 +7234,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
messageKey={messageKey}
|
||||
isSelected={selectedMessages.has(messageKey)}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
aiMessageInsightEnabled={aiMessageInsightEnabled}
|
||||
aiMessageInsightContextCount={aiMessageInsightContextCount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -6906,7 +7255,9 @@ function ChatPage(props: ChatPageProps) {
|
||||
handleJumpToQuotedMessage,
|
||||
isSelectionMode,
|
||||
selectedMessages,
|
||||
handleToggleSelection
|
||||
handleToggleSelection,
|
||||
aiMessageInsightEnabled,
|
||||
aiMessageInsightContextCount
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -7240,9 +7591,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
isGroupChat={isCurrentSessionGroup}
|
||||
standaloneSessionWindow={standaloneSessionWindow}
|
||||
showGroupMembersPanel={showGroupMembersPanel}
|
||||
showGroupSummaryPanel={showGroupSummaryPanel}
|
||||
showJumpPopover={showJumpPopover}
|
||||
showInSessionSearch={showInSessionSearch}
|
||||
showDetailPanel={showDetailPanel}
|
||||
aiGroupSummaryEnabled={aiGroupSummaryEnabled}
|
||||
shouldHideStandaloneDetailButton={shouldHideStandaloneDetailButton}
|
||||
isPrivateSnsSupported={isCurrentSessionPrivateSnsSupported}
|
||||
isExportActionBusy={isExportActionBusy}
|
||||
@@ -7251,10 +7604,13 @@ function ChatPage(props: ChatPageProps) {
|
||||
isBatchTranscribing={isBatchTranscribing}
|
||||
runningBatchVoiceTaskType={runningBatchVoiceTaskType}
|
||||
isBatchDecrypting={isBatchDecrypting}
|
||||
isTriggeringSessionInsight={isTriggeringSessionInsight}
|
||||
isRefreshingMessages={isRefreshingMessages}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
currentSessionId={currentSessionId}
|
||||
jumpCalendarWrapRef={jumpCalendarWrapRef}
|
||||
onTriggerSessionInsight={handleTriggerSessionInsight}
|
||||
onToggleGroupSummaryPanel={toggleGroupSummaryPanel}
|
||||
onGroupAnalytics={handleGroupAnalytics}
|
||||
onToggleGroupMembersPanel={toggleGroupMembersPanel}
|
||||
onExportCurrentSession={handleExportCurrentSession}
|
||||
@@ -7299,6 +7655,20 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionInsightHint && (
|
||||
<div className={`session-insight-hint ${sessionInsightHint.success ? 'success' : 'error'}`} role="status" aria-live="polite">
|
||||
{isTriggeringSessionInsight ? <Loader2 size={14} className="spin" /> : <Sparkles size={14} />}
|
||||
<span>{sessionInsightHint.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupSummaryHint && (
|
||||
<div className={`session-insight-hint ${groupSummaryHint.success ? 'success' : 'error'}`} role="status" aria-live="polite">
|
||||
{isTriggeringGroupSummary ? <Loader2 size={14} className="spin" /> : <Newspaper size={14} />}
|
||||
<span>{groupSummaryHint.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContactSnsTimelineDialog
|
||||
target={chatSnsTimelineTarget}
|
||||
onClose={() => setChatSnsTimelineTarget(null)}
|
||||
@@ -7539,6 +7909,138 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGroupSummaryPanel && isCurrentSessionGroup && (
|
||||
<div className="detail-panel group-summary-panel">
|
||||
<div className="detail-header">
|
||||
<div className="detail-title-wrap">
|
||||
<h4>AI 群聊总结</h4>
|
||||
<span className="detail-title-sub">{currentSession?.displayName || currentSessionId}</span>
|
||||
</div>
|
||||
<button className="close-btn" onClick={() => setShowGroupSummaryPanel(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="group-summary-controls">
|
||||
<div className="group-summary-date-row">
|
||||
<label>日期</label>
|
||||
<div className="group-summary-date-picker" ref={groupSummaryDateWrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
type="button"
|
||||
className="group-summary-icon-btn"
|
||||
onClick={() => void loadGroupSummaryRecords(currentSessionId || undefined)}
|
||||
title="刷新总结"
|
||||
>
|
||||
<RefreshCw size={14} className={groupSummaryLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isGroupSummaryToday ? (
|
||||
<div className="group-summary-range-tabs">
|
||||
{([1, 2, 4, 8, 12, 24] as const).map((hours) => (
|
||||
<button
|
||||
key={hours}
|
||||
type="button"
|
||||
className={groupSummaryRangeMode === hours ? 'active' : ''}
|
||||
onClick={() => setGroupSummaryRangeMode(hours)}
|
||||
>
|
||||
{hours}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="group-summary-rule-hint">将按设置里的自动总结间隔切分选中日期的完整聊天记录。</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group-summary-generate-btn"
|
||||
onClick={() => void triggerManualGroupSummary()}
|
||||
disabled={isTriggeringGroupSummary}
|
||||
>
|
||||
{isTriggeringGroupSummary ? <Loader2 size={14} className="spin" /> : <Sparkles size={14} />}
|
||||
<span>生成总结</span>
|
||||
</button>
|
||||
<div className="group-summary-rule-hint">
|
||||
{isGroupSummaryToday ? '少于 5 条可总结消息会自动跳过。' : '每个切片少于 5 条可总结消息会自动跳过。'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group-summary-list">
|
||||
{groupSummaryLoading ? (
|
||||
<div className="detail-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>加载总结中...</span>
|
||||
</div>
|
||||
) : groupSummaryError ? (
|
||||
<div className="detail-empty">{groupSummaryError}</div>
|
||||
) : groupSummaryRecords.length === 0 ? (
|
||||
<div className="detail-empty">当前日期暂无群聊总结</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="group-summary-count">共 {groupSummaryTotal} 条总结</div>
|
||||
{groupSummaryRecords.map((record) => (
|
||||
<div key={record.id} className="group-summary-record">
|
||||
<div className="group-summary-record-head">
|
||||
<div>
|
||||
<span className="group-summary-period">{formatSummaryPeriod(record.periodStart, record.periodEnd)}</span>
|
||||
<span className="group-summary-meta">
|
||||
{record.triggerType === 'manual' ? '手动' : '自动'} · {record.readableMessageCount} 条消息
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="group-summary-code-btn"
|
||||
onClick={() => void openGroupSummaryLog(record.id)}
|
||||
title="查看完整日志"
|
||||
>
|
||||
<Code2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="group-summary-topic-list">
|
||||
{record.topics.map((topic, topicIndex) => (
|
||||
<div key={`${record.id}-${topicIndex}`} className="group-summary-topic">
|
||||
<h5>{topic.title}</h5>
|
||||
<div className="group-summary-topic-row">
|
||||
<span>参与者</span>
|
||||
<p>{topic.participants.length > 0 ? topic.participants.join('、') : '未明确'}</p>
|
||||
</div>
|
||||
<div className="group-summary-topic-row">
|
||||
<span>关键/矛盾点</span>
|
||||
<p>{topic.keyPoints.length > 0 ? topic.keyPoints.join(';') : '无'}</p>
|
||||
</div>
|
||||
<div className="group-summary-topic-row">
|
||||
<span>结论</span>
|
||||
<p>{topic.conclusion || '暂无明确结论'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 会话详情面板 */}
|
||||
{showDetailPanel && (
|
||||
<div className="detail-panel session-detail-panel">
|
||||
@@ -8195,6 +8697,79 @@ function ChatPage(props: ChatPageProps) {
|
||||
document.body
|
||||
)}
|
||||
|
||||
{groupSummaryLogRecord && createPortal(
|
||||
<div className="message-info-overlay" onClick={() => setGroupSummaryLogRecord(null)}>
|
||||
<div className="message-info-modal group-summary-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="detail-header">
|
||||
<h4>群聊总结日志</h4>
|
||||
<button className="close-btn" onClick={() => setGroupSummaryLogRecord(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="detail-content">
|
||||
<div className="detail-section">
|
||||
<div className="detail-item">
|
||||
<span className="label">群聊</span>
|
||||
<span className="value">{groupSummaryLogRecord.displayName}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">时段</span>
|
||||
<span className="value">{formatSummaryPeriod(groupSummaryLogRecord.periodStart, groupSummaryLogRecord.periodEnd)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">触发</span>
|
||||
<span className="value">{groupSummaryLogRecord.triggerType === 'manual' ? '手动' : '自动'}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">模型</span>
|
||||
<span className="value">{groupSummaryLogRecord.log.model}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">消息数</span>
|
||||
<span className="value">{groupSummaryLogRecord.log.readableMessageCount} / {groupSummaryLogRecord.log.messageCount}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">JSON Mode</span>
|
||||
<span className="value">
|
||||
{groupSummaryLogRecord.log.responseFormatJson ? '启用' : '未启用'}
|
||||
{groupSummaryLogRecord.log.responseFormatFallback ? `,已降级:${groupSummaryLogRecord.log.responseFormatFallbackReason || '未知原因'}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<div className="section-title">
|
||||
<Code2 size={14} />
|
||||
<span>系统提示词</span>
|
||||
</div>
|
||||
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.systemPrompt}</pre>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<div className="section-title">
|
||||
<Code2 size={14} />
|
||||
<span>用户提示词与完整记录</span>
|
||||
</div>
|
||||
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.userPrompt}</pre>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<div className="section-title">
|
||||
<Code2 size={14} />
|
||||
<span>模型输出原文</span>
|
||||
</div>
|
||||
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.rawOutput}</pre>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<div className="section-title">
|
||||
<Newspaper size={14} />
|
||||
<span>最终总结</span>
|
||||
</div>
|
||||
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.finalSummary}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 修改消息弹窗 */}
|
||||
{editingMessage && createPortal(
|
||||
<div className="modal-overlay">
|
||||
@@ -8401,6 +8976,32 @@ const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?:
|
||||
})
|
||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||
|
||||
type MessageInsightAnalysis = {
|
||||
explicitText: string
|
||||
emotion: string
|
||||
intent: string
|
||||
topic: string
|
||||
}
|
||||
|
||||
type MessageInsightState = {
|
||||
status: 'idle' | 'loading' | 'success' | 'error'
|
||||
data?: MessageInsightAnalysis
|
||||
error?: string
|
||||
cached?: boolean
|
||||
recordId?: string
|
||||
}
|
||||
|
||||
const messageInsightMemoryCache = new Map<string, MessageInsightState>()
|
||||
|
||||
function buildMessageInsightCacheKey(sessionId: string, message: Message, messageKey: string): string {
|
||||
return [
|
||||
String(sessionId || '').trim(),
|
||||
Math.floor(Number(message.localId || 0)),
|
||||
Math.floor(Number(message.createTime || 0)),
|
||||
messageKey
|
||||
].join(':')
|
||||
}
|
||||
|
||||
function getSharedImageDecryptTask(
|
||||
key: string,
|
||||
createTask: () => Promise<SharedImageDecryptResult>
|
||||
@@ -8456,6 +9057,190 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
||||
}
|
||||
|
||||
// 消息气泡组件
|
||||
function MessageInsightControl({
|
||||
message,
|
||||
messageKey,
|
||||
session,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
senderName,
|
||||
targetText,
|
||||
contextCount,
|
||||
compact
|
||||
}: {
|
||||
message: Message
|
||||
messageKey: string
|
||||
session: ChatSession
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
senderName?: string
|
||||
targetText: string
|
||||
contextCount: number
|
||||
compact?: boolean
|
||||
}) {
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null)
|
||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||
const cacheKey = useMemo(() => buildMessageInsightCacheKey(session.username, message, messageKey), [message, messageKey, session.username])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [state, setState] = useState<MessageInsightState>(() => messageInsightMemoryCache.get(cacheKey) || { status: 'idle' })
|
||||
const [position, setPosition] = useState<{ top: number; left: number; placement: 'top' | 'bottom' }>({ top: 0, left: 0, placement: 'top' })
|
||||
|
||||
useEffect(() => {
|
||||
setState(messageInsightMemoryCache.get(cacheKey) || { status: 'idle' })
|
||||
setOpen(false)
|
||||
}, [cacheKey])
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const anchor = anchorRef.current
|
||||
if (!anchor) return
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const cardWidth = cardRef.current?.offsetWidth || 320
|
||||
const cardHeight = cardRef.current?.offsetHeight || 190
|
||||
const gap = 10
|
||||
const preferredTop = rect.top - cardHeight - gap
|
||||
const placement: 'top' | 'bottom' = preferredTop < 8 ? 'bottom' : 'top'
|
||||
const top = placement === 'top' ? preferredTop : rect.bottom + gap
|
||||
const left = Math.min(Math.max(8, rect.left + 20), Math.max(8, window.innerWidth - cardWidth - 8))
|
||||
setPosition({
|
||||
top: Math.min(Math.max(8, top), Math.max(8, window.innerHeight - cardHeight - 8)),
|
||||
left,
|
||||
placement
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
updatePosition()
|
||||
const handle = () => updatePosition()
|
||||
window.addEventListener('resize', handle)
|
||||
window.addEventListener('scroll', handle, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handle)
|
||||
window.removeEventListener('scroll', handle, true)
|
||||
}
|
||||
}, [open, updatePosition])
|
||||
|
||||
const requestInsight = useCallback(async (forceRefresh = false) => {
|
||||
if (!forceRefresh) {
|
||||
const cached = messageInsightMemoryCache.get(cacheKey)
|
||||
if (cached?.status === 'success') {
|
||||
setState(cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
setState({ status: 'loading' })
|
||||
try {
|
||||
const result = await window.electronAPI.insight.generateMessageInsight({
|
||||
sessionId: session.username,
|
||||
displayName: displayName || session.displayName || session.username,
|
||||
avatarUrl: avatarUrl || session.avatarUrl,
|
||||
targetLocalId: message.localId,
|
||||
targetCreateTime: message.createTime,
|
||||
targetMessageKey: messageKey,
|
||||
targetText,
|
||||
targetSenderName: senderName || displayName || session.displayName || session.username,
|
||||
contextCount,
|
||||
forceRefresh
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
const nextState: MessageInsightState = {
|
||||
status: 'success',
|
||||
data: result.data,
|
||||
cached: result.cached === true,
|
||||
recordId: result.recordId
|
||||
}
|
||||
messageInsightMemoryCache.set(cacheKey, nextState)
|
||||
setState(nextState)
|
||||
} else {
|
||||
setState({ status: 'error', error: result.message || '解析失败' })
|
||||
}
|
||||
} catch (error) {
|
||||
setState({ status: 'error', error: (error as Error).message || '解析失败' })
|
||||
}
|
||||
}, [avatarUrl, cacheKey, contextCount, displayName, message.createTime, message.localId, messageKey, senderName, session.avatarUrl, session.displayName, session.username, targetText])
|
||||
|
||||
const handleOpen = useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
setOpen(true)
|
||||
window.setTimeout(updatePosition, 0)
|
||||
const cached = messageInsightMemoryCache.get(cacheKey)
|
||||
if (cached?.status === 'success') {
|
||||
setState(cached)
|
||||
return
|
||||
}
|
||||
void requestInsight(false)
|
||||
}, [cacheKey, requestInsight, updatePosition])
|
||||
|
||||
const card = open ? createPortal(
|
||||
<>
|
||||
<button className="message-insight-backdrop" type="button" aria-label="关闭深度解析" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`message-insight-card open placement-${position.placement}`}
|
||||
style={{ top: position.top, left: position.left }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="message-insight-card-header">
|
||||
<Star size={14} />
|
||||
<span>深度解析</span>
|
||||
<button
|
||||
type="button"
|
||||
className="message-insight-refresh"
|
||||
title="重新解析"
|
||||
onClick={() => void requestInsight(true)}
|
||||
disabled={state.status === 'loading'}
|
||||
>
|
||||
{state.status === 'loading' ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="message-insight-card-body">
|
||||
{state.status === 'loading' && (
|
||||
<div className="message-insight-loading">
|
||||
<Loader2 size={15} className="spin" />
|
||||
<span>解析中...</span>
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'error' && (
|
||||
<div className="message-insight-error">
|
||||
<span>{state.error || '解析失败'}</span>
|
||||
<button type="button" onClick={() => void requestInsight(true)}>重试</button>
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'success' && state.data && (
|
||||
<>
|
||||
<p className="message-insight-text">{state.data.explicitText}</p>
|
||||
<div className="message-insight-divider" />
|
||||
<div className="message-insight-tags">
|
||||
<span className="message-insight-tag mood">情绪:{state.data.emotion}</span>
|
||||
<span className="message-insight-tag intent">意图:{state.data.intent}</span>
|
||||
<span className="message-insight-tag">话题:{state.data.topic}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
className={`message-insight-trigger ${compact ? 'compact' : ''}`}
|
||||
onClick={handleOpen}
|
||||
title="深度解析"
|
||||
aria-label="深度解析"
|
||||
>
|
||||
<Star size={12} />
|
||||
{!compact && <span>深度解析</span>}
|
||||
</button>
|
||||
{card}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
messageKey,
|
||||
@@ -8471,7 +9256,9 @@ function MessageBubble({
|
||||
onJumpToQuotedMessage,
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onToggleSelection
|
||||
onToggleSelection,
|
||||
aiMessageInsightEnabled,
|
||||
aiMessageInsightContextCount
|
||||
}: {
|
||||
message: Message;
|
||||
messageKey: string;
|
||||
@@ -8488,6 +9275,8 @@ function MessageBubble({
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
|
||||
aiMessageInsightEnabled?: boolean;
|
||||
aiMessageInsightContextCount?: number;
|
||||
}) {
|
||||
const isSystem = isSystemMessage(message.localType)
|
||||
const isEmoji = message.localType === 47
|
||||
@@ -9706,6 +10495,33 @@ function MessageBubble({
|
||||
const avatarUrl = isSent
|
||||
? (myAvatarUrl || resolvedSenderAvatarUrl)
|
||||
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
|
||||
const canShowMessageInsight = Boolean(
|
||||
aiMessageInsightEnabled &&
|
||||
!isSent &&
|
||||
!isSystem &&
|
||||
!isImage &&
|
||||
!isVideo &&
|
||||
!isVoice &&
|
||||
!isEmoji &&
|
||||
!isCard &&
|
||||
!isCall &&
|
||||
!isType49 &&
|
||||
message.localType === 1 &&
|
||||
cleanedParsedContent.trim()
|
||||
)
|
||||
const messageInsightControl = canShowMessageInsight ? (
|
||||
<MessageInsightControl
|
||||
message={message}
|
||||
messageKey={messageKey}
|
||||
session={session}
|
||||
displayName={session.displayName || session.username}
|
||||
avatarUrl={avatarUrl}
|
||||
senderName={resolvedSenderName || session.displayName || session.username}
|
||||
targetText={cleanedParsedContent}
|
||||
contextCount={aiMessageInsightContextCount || 50}
|
||||
compact={!isGroupChat}
|
||||
/>
|
||||
) : null
|
||||
|
||||
// 是否有引用消息
|
||||
const hasQuote = quotedContent.length > 0
|
||||
@@ -11051,6 +11867,7 @@ function MessageBubble({
|
||||
isSelected={isSelected}
|
||||
onContextMenu={onContextMenu}
|
||||
onToggleSelection={onToggleSelection}
|
||||
actionNode={messageInsightControl}
|
||||
portal={systemAlertPortal}
|
||||
>
|
||||
{renderContent()}
|
||||
@@ -11073,6 +11890,8 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
|
||||
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
|
||||
if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false
|
||||
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
|
||||
if (prevProps.aiMessageInsightEnabled !== nextProps.aiMessageInsightEnabled) return false
|
||||
if (prevProps.aiMessageInsightContextCount !== nextProps.aiMessageInsightContextCount) return false
|
||||
|
||||
return (
|
||||
prevProps.session.username === nextProps.session.username &&
|
||||
|
||||
+101
-3
@@ -1177,10 +1177,18 @@
|
||||
}
|
||||
|
||||
.export-defaults-modal-actions {
|
||||
padding: 0 18px 16px;
|
||||
padding: 12px 18px 16px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 64%, transparent);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.export-defaults-close-action {
|
||||
min-width: 96px;
|
||||
min-height: 38px;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.task-center-card-label {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
@@ -1932,10 +1940,11 @@
|
||||
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
|
||||
--contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
|
||||
--contacts-message-col-width: 94px;
|
||||
--contacts-latest-time-col-width: 128px;
|
||||
--contacts-media-col-width: 58px;
|
||||
--contacts-action-col-width: 126px;
|
||||
--contacts-actions-sticky-width: 160px;
|
||||
--contacts-table-min-width: 1120px;
|
||||
--contacts-table-min-width: 1248px;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -2184,6 +2193,58 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.contacts-list-header-latest-time {
|
||||
width: var(--contacts-latest-time-col-width);
|
||||
min-width: var(--contacts-latest-time-col-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.contacts-list-header-sortable {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
gap: 4px;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list-header-sort-icon {
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.muted {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list-header-media {
|
||||
width: var(--contacts-media-col-width);
|
||||
min-width: var(--contacts-media-col-width);
|
||||
@@ -2501,6 +2562,37 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.row-latest-time {
|
||||
width: var(--contacts-latest-time-col-width);
|
||||
min-width: var(--contacts-latest-time-col-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.row-latest-time-value {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
|
||||
&.muted {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.row-media-metric {
|
||||
width: var(--contacts-media-col-width);
|
||||
min-width: var(--contacts-media-col-width);
|
||||
@@ -5027,6 +5119,7 @@
|
||||
--contacts-name-text-width: 10em;
|
||||
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
|
||||
--contacts-message-col-width: 94px;
|
||||
--contacts-latest-time-col-width: 120px;
|
||||
--contacts-media-col-width: 56px;
|
||||
--contacts-action-col-width: 126px;
|
||||
}
|
||||
@@ -5054,6 +5147,10 @@
|
||||
min-width: var(--contacts-message-col-width);
|
||||
}
|
||||
|
||||
.table-wrap .row-latest-time {
|
||||
min-width: var(--contacts-latest-time-col-width);
|
||||
}
|
||||
|
||||
.table-wrap .row-media-metric {
|
||||
min-width: var(--contacts-media-col-width);
|
||||
}
|
||||
@@ -5753,7 +5850,8 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.close-icon-btn {
|
||||
.automation-modal .close-icon-btn,
|
||||
.automation-editor-modal .close-icon-btn {
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
color: var(--text-secondary);
|
||||
|
||||
+165
-25
@@ -4,6 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Aperture,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckSquare,
|
||||
@@ -656,6 +659,41 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
|
||||
return `${y}-${m}-${day} ${h}:${min}`
|
||||
}
|
||||
|
||||
const formatLatestMessageTimeFromSeconds = (
|
||||
timestamp?: number,
|
||||
now: number = Date.now()
|
||||
): { text: string; title: string } => {
|
||||
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return { text: '--', title: '' }
|
||||
}
|
||||
const ms = timestamp * 1000
|
||||
const absolute = formatYmdHmDateTime(ms)
|
||||
const diff = Math.max(0, now - ms)
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diff < minute) {
|
||||
return { text: '刚刚', title: absolute }
|
||||
}
|
||||
if (diff < hour) {
|
||||
const minutes = Math.max(1, Math.floor(diff / minute))
|
||||
return { text: `${minutes} 分钟前`, title: absolute }
|
||||
}
|
||||
if (diff < day) {
|
||||
const hours = Math.max(1, Math.floor(diff / hour))
|
||||
return { text: `${hours} 小时前`, title: absolute }
|
||||
}
|
||||
return { text: absolute, title: absolute }
|
||||
}
|
||||
|
||||
type ContactsSortKey = 'messageCount' | 'latestMessageTime'
|
||||
type ContactsSortOrder = 'desc' | 'asc'
|
||||
interface ContactsSortConfig {
|
||||
key: ContactsSortKey | null
|
||||
order: ContactsSortOrder | null
|
||||
}
|
||||
const DEFAULT_CONTACTS_SORT_CONFIG: ContactsSortConfig = { key: null, order: null }
|
||||
|
||||
const isSingleContactSession = (sessionId: string): boolean => {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
if (!normalized) return false
|
||||
@@ -2269,6 +2307,18 @@ function ExportPage() {
|
||||
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
||||
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
|
||||
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
|
||||
const [contactsSortConfig, setContactsSortConfig] = useState<ContactsSortConfig>(DEFAULT_CONTACTS_SORT_CONFIG)
|
||||
|
||||
const toggleContactsSort = useCallback((key: ContactsSortKey) => {
|
||||
setContactsSortConfig(prev => {
|
||||
if (prev.key !== key) {
|
||||
return { key, order: 'desc' }
|
||||
}
|
||||
if (prev.order === 'desc') return { key, order: 'asc' }
|
||||
if (prev.order === 'asc') return DEFAULT_CONTACTS_SORT_CONFIG
|
||||
return { key, order: 'desc' }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [exportFolder, setExportFolder] = useState('')
|
||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
||||
@@ -4364,7 +4414,7 @@ function ExportPage() {
|
||||
try {
|
||||
if (prioritizedSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
|
||||
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
|
||||
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds, { bypassSessionCache: true, preferHintCache: false })
|
||||
if (isStale()) return { ...accumulatedCounts }
|
||||
if (priorityResult.success) {
|
||||
applyCounts(priorityResult.counts)
|
||||
@@ -4381,7 +4431,7 @@ function ExportPage() {
|
||||
|
||||
if (remainingSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
|
||||
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
|
||||
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds, { bypassSessionCache: true, preferHintCache: false })
|
||||
if (isStale()) return { ...accumulatedCounts }
|
||||
if (remainingResult.success) {
|
||||
applyCounts(remainingResult.counts)
|
||||
@@ -6661,34 +6711,47 @@ function ExportPage() {
|
||||
)
|
||||
})
|
||||
|
||||
const indexedContacts = contacts.map((contact, index) => ({
|
||||
contact,
|
||||
index,
|
||||
count: (() => {
|
||||
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||
if (typeof counted === 'number') return counted
|
||||
const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint)
|
||||
return hinted
|
||||
})()
|
||||
}))
|
||||
const indexedContacts = contacts.map((contact, index) => {
|
||||
const sessionRow = sessionRowByUsername.get(contact.username)
|
||||
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||
const hinted = normalizeMessageCount(sessionRow?.messageCountHint)
|
||||
const count = typeof counted === 'number' ? counted : hinted
|
||||
const rowTs = sessionRow?.lastTimestamp || sessionRow?.sortTimestamp
|
||||
const latestTime = typeof rowTs === 'number' && rowTs > 0 ? rowTs : undefined
|
||||
return { contact, index, count, latestTime }
|
||||
})
|
||||
|
||||
const compareNullable = (a: number | undefined, b: number | undefined, order: ContactsSortOrder): number => {
|
||||
const aHas = typeof a === 'number' && Number.isFinite(a)
|
||||
const bHas = typeof b === 'number' && Number.isFinite(b)
|
||||
if (aHas && bHas) {
|
||||
const diff = (a as number) - (b as number)
|
||||
return order === 'desc' ? -diff : diff
|
||||
}
|
||||
if (aHas) return -1
|
||||
if (bHas) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
const sortKey = contactsSortConfig.key
|
||||
const sortOrder = contactsSortConfig.order ?? 'desc'
|
||||
|
||||
indexedContacts.sort((a, b) => {
|
||||
const aHasCount = typeof a.count === 'number'
|
||||
const bHasCount = typeof b.count === 'number'
|
||||
if (aHasCount && bHasCount) {
|
||||
const diff = (b.count as number) - (a.count as number)
|
||||
if (sortKey === 'latestMessageTime') {
|
||||
const diff = compareNullable(a.latestTime, b.latestTime, sortOrder)
|
||||
if (diff !== 0) return diff
|
||||
} else if (sortKey === 'messageCount') {
|
||||
const diff = compareNullable(a.count, b.count, sortOrder)
|
||||
if (diff !== 0) return diff
|
||||
} else {
|
||||
const diff = compareNullable(a.count, b.count, 'desc')
|
||||
if (diff !== 0) return diff
|
||||
} else if (aHasCount) {
|
||||
return -1
|
||||
} else if (bHasCount) {
|
||||
return 1
|
||||
}
|
||||
// 无统计值或同分时保持原顺序,避免列表频繁跳动。
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
return indexedContacts.map(item => item.contact)
|
||||
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
|
||||
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername, contactsSortConfig])
|
||||
|
||||
const keywordMatchedContactUsernameSet = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
@@ -6897,7 +6960,7 @@ function ExportPage() {
|
||||
useEffect(() => {
|
||||
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
|
||||
setIsContactsListAtTop(true)
|
||||
}, [activeTab, searchKeyword])
|
||||
}, [activeTab, searchKeyword, contactsSortConfig])
|
||||
|
||||
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
|
||||
if (sourceContacts.length === 0) return []
|
||||
@@ -7613,12 +7676,29 @@ function ExportPage() {
|
||||
scheduleSessionMutualFriendsWorker()
|
||||
}
|
||||
|
||||
// 记录刷新前的会话时间戳
|
||||
const oldTimestamps = new Map(
|
||||
sessionsRef.current.map(s => [s.username, s.lastTimestamp || s.sortTimestamp || 0])
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
loadContactsList({ scopeKey }),
|
||||
loadSnsStats({ full: true }),
|
||||
loadSnsUserPostCounts({ force: true })
|
||||
])
|
||||
|
||||
// 找出有变动的会话(最后消息时间变化)
|
||||
const changedSessions = sessionsRef.current.filter(session => {
|
||||
const oldTs = oldTimestamps.get(session.username) || 0
|
||||
const newTs = session.lastTimestamp || session.sortTimestamp || 0
|
||||
return newTs > oldTs
|
||||
})
|
||||
|
||||
// 只对有变动的会话重新加载消息数量
|
||||
if (changedSessions.length > 0) {
|
||||
await loadSessionMessageCounts(changedSessions, activeTabRef.current, { scopeKey })
|
||||
}
|
||||
|
||||
const currentDetailSessionId = showSessionDetailPanel
|
||||
? String(sessionDetail?.wxid || '').trim()
|
||||
: ''
|
||||
@@ -8391,6 +8471,15 @@ function ExportPage() {
|
||||
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
||||
const displayedMessageCount = countedMessages ?? hintedMessages
|
||||
const mediaMetric = sessionContentMetrics[contact.username]
|
||||
const rowLatestTs = matchedSession?.lastTimestamp || matchedSession?.sortTimestamp
|
||||
const resolvedLatestTs = typeof rowLatestTs === 'number' && rowLatestTs > 0 ? rowLatestTs : undefined
|
||||
const latestTimeInfo = formatLatestMessageTimeFromSeconds(resolvedLatestTs, nowTick)
|
||||
const latestTimeState: { state: 'value'; text: string; title: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
|
||||
!canExport
|
||||
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
|
||||
: (typeof resolvedLatestTs === 'number' && resolvedLatestTs > 0
|
||||
? { state: 'value', text: latestTimeInfo.text, title: latestTimeInfo.title }
|
||||
: { state: 'na', text: '--' })
|
||||
const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
|
||||
!canExport
|
||||
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
|
||||
@@ -8506,6 +8595,18 @@ function ExportPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-latest-time">
|
||||
{latestTimeState.state === 'loading'
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="最新消息时间加载中" />
|
||||
: (
|
||||
<span
|
||||
className={`row-latest-time-value ${latestTimeState.state === 'value' ? '' : 'muted'}`}
|
||||
title={latestTimeState.state === 'value' ? latestTimeState.title : undefined}
|
||||
>
|
||||
{latestTimeState.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-media-metric">
|
||||
<strong className="row-media-metric-value">
|
||||
{emojiMetric.state === 'loading'
|
||||
@@ -9250,7 +9351,7 @@ function ExportPage() {
|
||||
<div className="export-defaults-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-btn"
|
||||
className="secondary-btn export-defaults-close-action"
|
||||
onClick={() => setIsExportDefaultsModalOpen(false)}
|
||||
>
|
||||
关闭
|
||||
@@ -9454,7 +9555,46 @@ function ExportPage() {
|
||||
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="contacts-list-header-count">总消息数</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`contacts-list-header-count contacts-list-header-sortable ${contactsSortConfig.key === 'messageCount' ? 'is-active' : ''}`}
|
||||
onClick={() => toggleContactsSort('messageCount')}
|
||||
title={
|
||||
contactsSortConfig.key !== 'messageCount'
|
||||
? '按总消息数降序排列'
|
||||
: contactsSortConfig.order === 'desc'
|
||||
? '切换为按总消息数升序'
|
||||
: '取消排序(恢复默认)'
|
||||
}
|
||||
>
|
||||
<span>总消息数</span>
|
||||
{contactsSortConfig.key === 'messageCount'
|
||||
? (contactsSortConfig.order === 'asc'
|
||||
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
|
||||
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
|
||||
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`contacts-list-header-latest-time contacts-list-header-sortable ${contactsSortConfig.key === 'latestMessageTime' ? 'is-active' : ''}`}
|
||||
onClick={() => toggleContactsSort('latestMessageTime')}
|
||||
title={
|
||||
contactsSortConfig.key !== 'latestMessageTime'
|
||||
? '按最新消息时间降序排列'
|
||||
: contactsSortConfig.order === 'desc'
|
||||
? '切换为按最新消息时间升序'
|
||||
: '取消排序(恢复默认)'
|
||||
}
|
||||
>
|
||||
<span>最新消息时间</span>
|
||||
{contactsSortConfig.key === 'latestMessageTime'
|
||||
? (contactsSortConfig.order === 'asc'
|
||||
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
|
||||
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
|
||||
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
|
||||
}
|
||||
</button>
|
||||
<span className="contacts-list-header-media">表情包</span>
|
||||
<span className="contacts-list-header-media">语音</span>
|
||||
<span className="contacts-list-header-media">图片</span>
|
||||
|
||||
@@ -265,6 +265,25 @@
|
||||
color: #5b55a0;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
&.manual {
|
||||
color: #0f766e;
|
||||
background: rgba(20, 184, 166, 0.13);
|
||||
}
|
||||
}
|
||||
|
||||
.insight-source-pill {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(91, 147, 144, 0.1);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.message_analysis {
|
||||
background: rgba(245, 158, 11, 0.13);
|
||||
color: #8a5a00;
|
||||
}
|
||||
}
|
||||
|
||||
.insight-time {
|
||||
@@ -282,6 +301,43 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-analysis-target {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.message-analysis-target-label {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-analysis-target-text {
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-analysis-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
|
||||
span {
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.insight-filter-panel {
|
||||
width: var(--insight-panel-width);
|
||||
flex-shrink: 0;
|
||||
@@ -376,6 +432,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.insight-source-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: rgba(91, 147, 144, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.insight-custom-dates {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
InsightRecordContactFacet,
|
||||
InsightRecordFilters,
|
||||
InsightRecordListResult,
|
||||
InsightRecordSourceType,
|
||||
InsightRecordSummary,
|
||||
InsightRecordTriggerReason
|
||||
} from '../types/electron'
|
||||
@@ -15,6 +16,7 @@ import './InsightInboxPage.scss'
|
||||
const INSIGHT_AVATAR_URL = './assets/insight/AI_Insight.png'
|
||||
|
||||
type DateFilterMode = 'all' | 'today' | 'week' | 'custom'
|
||||
type SourceFilterMode = InsightRecordSourceType | 'all'
|
||||
|
||||
function getStartOfDay(date: Date): number {
|
||||
const next = new Date(date)
|
||||
@@ -62,16 +64,23 @@ function formatGroupDate(timestamp: number): string {
|
||||
}
|
||||
|
||||
function getTriggerLabel(reason: InsightRecordTriggerReason): string {
|
||||
if (reason === 'message_analysis') return '深度解析'
|
||||
if (reason === 'silence') return '沉默提醒'
|
||||
if (reason === 'test') return '测试见解'
|
||||
if (reason === 'manual') return '手动触发'
|
||||
return '活跃分析'
|
||||
}
|
||||
|
||||
function getSourceLabel(sourceType?: InsightRecordSourceType): string {
|
||||
return sourceType === 'message_analysis' ? '深度解析' : 'AI 见解'
|
||||
}
|
||||
|
||||
function buildLogText(record: InsightRecord): string {
|
||||
const log = record.log
|
||||
return [
|
||||
const lines = [
|
||||
`时间:${new Date(record.createdAt).toLocaleString('zh-CN')}`,
|
||||
`联系人:${record.displayName} (${record.sessionId})`,
|
||||
`来源:${getSourceLabel(record.sourceType)}`,
|
||||
`触发类型:${getTriggerLabel(record.triggerReason)}`,
|
||||
`接口地址:${log.endpoint}`,
|
||||
`模型:${log.model}`,
|
||||
@@ -90,7 +99,23 @@ function buildLogText(record: InsightRecord): string {
|
||||
'',
|
||||
'最终见解:',
|
||||
log.finalInsight
|
||||
].join('\n')
|
||||
]
|
||||
|
||||
if (record.sourceType === 'message_analysis') {
|
||||
lines.splice(8, 0,
|
||||
`JSON Mode:${log.responseFormatJson ? '启用' : '未启用'}`,
|
||||
`JSON Mode 降级:${log.responseFormatFallback ? '是' : '否'}`,
|
||||
`降级原因:${log.responseFormatFallbackReason || '无'}`,
|
||||
`上下文:请求 ${log.contextStats?.requested ?? log.contextCount} 条,前 ${log.contextStats?.beforeTarget ?? 0} 条,后 ${log.contextStats?.afterTarget ?? 0} 条`,
|
||||
`上下文读取异常:${log.contextStats?.readError || '无'}`
|
||||
)
|
||||
lines.splice(4, 0,
|
||||
`目标消息:${record.messageInsight?.targetSenderName || log.targetMessage?.senderName || ''}:${record.messageInsight?.targetTextPreview || log.targetMessage?.textPreview || ''}`,
|
||||
`目标定位:localId=${record.messageInsight?.targetLocalId || log.targetMessage?.localId || 0}, createTime=${record.messageInsight?.targetCreateTime || log.targetMessage?.createTime || 0}, key=${record.messageInsight?.targetMessageKey || log.targetMessage?.messageKey || ''}`
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export default function InsightInboxPage() {
|
||||
@@ -101,6 +126,7 @@ export default function InsightInboxPage() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [contactSearch, setContactSearch] = useState('')
|
||||
const [selectedSessionId, setSelectedSessionId] = useState('')
|
||||
const [sourceType, setSourceType] = useState<SourceFilterMode>('all')
|
||||
const [dateMode, setDateMode] = useState<DateFilterMode>('all')
|
||||
const [customStart, setCustomStart] = useState(formatDateInput(new Date()))
|
||||
const [customEnd, setCustomEnd] = useState(formatDateInput(new Date()))
|
||||
@@ -133,11 +159,12 @@ export default function InsightInboxPage() {
|
||||
const filters = useMemo<InsightRecordFilters>(() => ({
|
||||
keyword: keyword.trim() || undefined,
|
||||
sessionId: selectedSessionId || undefined,
|
||||
sourceType,
|
||||
startTime: dateRange.startTime,
|
||||
endTime: dateRange.endTime,
|
||||
limit: 200,
|
||||
offset: 0
|
||||
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId])
|
||||
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId, sourceType])
|
||||
|
||||
const loadRecords = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -200,6 +227,16 @@ export default function InsightInboxPage() {
|
||||
}, [contactSearch, contacts])
|
||||
|
||||
const openChat = (record: InsightRecordSummary) => {
|
||||
if (record.sourceType === 'message_analysis' && record.messageInsight) {
|
||||
const query = new URLSearchParams({
|
||||
sessionId: record.sessionId,
|
||||
jumpSource: 'messageAnalysis',
|
||||
jumpLocalId: String(record.messageInsight.targetLocalId || 0),
|
||||
jumpCreateTime: String(record.messageInsight.targetCreateTime || 0)
|
||||
})
|
||||
navigate(`/chat?${query.toString()}`)
|
||||
return
|
||||
}
|
||||
navigate(`/chat?sessionId=${encodeURIComponent(record.sessionId)}`)
|
||||
}
|
||||
|
||||
@@ -305,6 +342,7 @@ export default function InsightInboxPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="insight-card-actions">
|
||||
<span className={`insight-source-pill ${record.sourceType || 'insight'}`}>{getSourceLabel(record.sourceType)}</span>
|
||||
<span className={`insight-trigger-pill ${record.triggerReason}`}>{getTriggerLabel(record.triggerReason)}</span>
|
||||
<span className="insight-time">{formatRecordTime(record.createdAt)}</span>
|
||||
<button className="insight-action-btn" onClick={() => openChat(record)} title="打开聊天">
|
||||
@@ -318,7 +356,22 @@ export default function InsightInboxPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{record.sourceType === 'message_analysis' && record.messageInsight && (
|
||||
<div className="message-analysis-target">
|
||||
<span className="message-analysis-target-label">目标消息</span>
|
||||
<span className="message-analysis-target-text">
|
||||
{record.messageInsight.targetSenderName}:{record.messageInsight.targetTextPreview}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="insight-body">{record.insight}</p>
|
||||
{record.sourceType === 'message_analysis' && record.messageInsight && (
|
||||
<div className="message-analysis-tags">
|
||||
<span>情绪:{record.messageInsight.analysis.emotion}</span>
|
||||
<span>意图:{record.messageInsight.analysis.intent}</span>
|
||||
<span>话题:{record.messageInsight.analysis.topic}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -347,6 +400,28 @@ export default function InsightInboxPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="insight-filter-widget">
|
||||
<div className="insight-widget-title">
|
||||
<Sparkles size={14} />
|
||||
<span>来源类型</span>
|
||||
</div>
|
||||
<div className="insight-source-tabs">
|
||||
{[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'insight', label: 'AI 见解' },
|
||||
{ value: 'message_analysis', label: '深度解析' }
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={sourceType === option.value ? 'active' : ''}
|
||||
onClick={() => setSourceType(option.value as SourceFilterMode)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="insight-filter-widget">
|
||||
<div className="insight-widget-title">
|
||||
<CalendarDays size={14} />
|
||||
@@ -440,9 +515,44 @@ export default function InsightInboxPage() {
|
||||
`Max Tokens: ${logRecord.log.maxTokens}`,
|
||||
`Temperature: ${logRecord.log.temperature}`,
|
||||
`Duration: ${logRecord.log.durationMs}ms`,
|
||||
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`
|
||||
`Source: ${getSourceLabel(logRecord.sourceType)}`,
|
||||
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`,
|
||||
...(logRecord.sourceType === 'message_analysis'
|
||||
? [
|
||||
`JSON Mode: ${logRecord.log.responseFormatJson ? 'enabled' : 'disabled'}`,
|
||||
`JSON Fallback: ${logRecord.log.responseFormatFallback ? 'yes' : 'no'}`,
|
||||
`Fallback Reason: ${logRecord.log.responseFormatFallbackReason || 'none'}`
|
||||
]
|
||||
: [])
|
||||
].join('\n')}</pre>
|
||||
</section>
|
||||
{logRecord.sourceType === 'message_analysis' && (
|
||||
<section>
|
||||
<h4>深度解析目标</h4>
|
||||
<pre>{[
|
||||
`Sender: ${logRecord.messageInsight?.targetSenderName || logRecord.log.targetMessage?.senderName || ''}`,
|
||||
`Preview: ${logRecord.messageInsight?.targetTextPreview || logRecord.log.targetMessage?.textPreview || ''}`,
|
||||
`LocalId: ${logRecord.messageInsight?.targetLocalId || logRecord.log.targetMessage?.localId || 0}`,
|
||||
`CreateTime: ${logRecord.messageInsight?.targetCreateTime || logRecord.log.targetMessage?.createTime || 0}`,
|
||||
`MessageKey: ${logRecord.messageInsight?.targetMessageKey || logRecord.log.targetMessage?.messageKey || ''}`,
|
||||
`Context Requested: ${logRecord.log.contextStats?.requested ?? logRecord.log.contextCount}`,
|
||||
`Context Before: ${logRecord.log.contextStats?.beforeTarget ?? 0}`,
|
||||
`Context After: ${logRecord.log.contextStats?.afterTarget ?? 0}`,
|
||||
`Context Error: ${logRecord.log.contextStats?.readError || 'none'}`
|
||||
].join('\n')}</pre>
|
||||
</section>
|
||||
)}
|
||||
{logRecord.sourceType === 'message_analysis' && logRecord.log.parsedAnalysis && (
|
||||
<section>
|
||||
<h4>解析字段</h4>
|
||||
<pre>{[
|
||||
`explicitText: ${logRecord.log.parsedAnalysis.explicitText}`,
|
||||
`emotion: ${logRecord.log.parsedAnalysis.emotion}`,
|
||||
`intent: ${logRecord.log.parsedAnalysis.intent}`,
|
||||
`topic: ${logRecord.log.parsedAnalysis.topic}`
|
||||
].join('\n')}</pre>
|
||||
</section>
|
||||
)}
|
||||
<section>
|
||||
<h4>System Prompt</h4>
|
||||
<pre>{logRecord.log.systemPrompt}</pre>
|
||||
|
||||
@@ -770,12 +770,12 @@ function MyFootprintPage() {
|
||||
<>
|
||||
<section className="kpi-grid">
|
||||
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
|
||||
<span className="kpi-label">有聊天的人数</span>
|
||||
<span className="kpi-label">收到消息的人数</span>
|
||||
<strong>{data.summary.private_inbound_people}</strong>
|
||||
<small>回复了其中 {data.summary.private_replied_people} 人</small>
|
||||
</button>
|
||||
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
|
||||
<span className="kpi-label">我有回复的人数</span>
|
||||
<span className="kpi-label">发送消息的人数</span>
|
||||
<strong>{data.summary.private_outbound_people}</strong>
|
||||
<small>回复率 {formatPercent(data.summary.private_reply_rate)}</small>
|
||||
</button>
|
||||
|
||||
+139
-13
@@ -3390,14 +3390,17 @@
|
||||
}
|
||||
|
||||
&.insight-social-tab {
|
||||
--insight-moments-column-width: 76px;
|
||||
--insight-social-column-width: minmax(220px, 300px);
|
||||
--insight-status-column-width: 82px;
|
||||
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
|
||||
--insight-moments-column-width: 44px;
|
||||
--insight-profile-column-width: 78px;
|
||||
--insight-social-column-width: minmax(184px, 230px);
|
||||
--insight-status-column-width: 70px;
|
||||
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-profile-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
|
||||
|
||||
.anti-revoke-list-header {
|
||||
grid-template-columns: var(--insight-social-list-grid);
|
||||
gap: 14px;
|
||||
gap: 8px;
|
||||
padding-left: 14px;
|
||||
padding-right: 10px;
|
||||
|
||||
.insight-moments-column-title {
|
||||
display: flex;
|
||||
@@ -3405,6 +3408,12 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.insight-profile-column-title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.insight-social-column-title {
|
||||
min-width: 0;
|
||||
color: var(--text-tertiary);
|
||||
@@ -3420,11 +3429,19 @@
|
||||
display: grid;
|
||||
grid-template-columns: var(--insight-social-list-grid);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 8px;
|
||||
padding-left: 14px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.anti-revoke-row-main {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
|
||||
.anti-revoke-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.insight-moments-cell {
|
||||
@@ -3435,6 +3452,92 @@
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.insight-profile-cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.insight-profile-status-btn {
|
||||
position: relative;
|
||||
width: 74px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, opacity 0.16s ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
&:not(:disabled)::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
z-index: 8;
|
||||
max-width: 180px;
|
||||
width: max-content;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--text-primary) 92%, #000 8%);
|
||||
color: var(--bg-primary);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s ease;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover::after,
|
||||
&:not(:disabled):focus-visible::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.profile-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.ready {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&.running {
|
||||
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&.failed {
|
||||
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
|
||||
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
.insight-moments-toggle {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
@@ -3489,24 +3592,33 @@
|
||||
}
|
||||
|
||||
.insight-social-binding-cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.insight-social-binding-controls {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px 10px;
|
||||
grid-template-columns: minmax(92px, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.insight-social-binding-input-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.binding-platform-chip {
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
@@ -3536,12 +3648,18 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: flex-end;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
|
||||
.btn {
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.insight-social-binding-feedback {
|
||||
grid-column: 1 / span 2;
|
||||
min-height: 18px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.binding-feedback {
|
||||
@@ -3563,6 +3681,11 @@
|
||||
align-items: flex-end;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
|
||||
.status-badge {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3605,6 +3728,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
|
||||
.insight-moments-column-title,
|
||||
.insight-profile-column-title,
|
||||
.insight-social-column-title {
|
||||
display: none;
|
||||
}
|
||||
@@ -3617,12 +3741,14 @@
|
||||
}
|
||||
|
||||
.insight-moments-cell,
|
||||
.insight-profile-cell,
|
||||
.insight-social-binding-cell,
|
||||
.anti-revoke-row-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.insight-moments-cell {
|
||||
.insight-moments-cell,
|
||||
.insight-profile-cell {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
+552
-56
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json'
|
||||
import type { ChatSession, ContactInfo } from '../types/models'
|
||||
import type { InsightProfileStatus } from '../types/electron'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
@@ -32,9 +35,11 @@ type SettingsTab =
|
||||
| 'aiCommon'
|
||||
| 'insight'
|
||||
| 'aiFootprint'
|
||||
| 'aiGroupSummary'
|
||||
| 'aiMessageInsight'
|
||||
| 'autoDownload'
|
||||
|
||||
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
|
||||
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint' | 'aiMessageInsight'>; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'notification', label: '通知', icon: Bell },
|
||||
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
|
||||
@@ -56,16 +61,19 @@ const filteredTabs = tabs.filter(tab => {
|
||||
return true
|
||||
})
|
||||
|
||||
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
|
||||
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint' | 'aiGroupSummary' | 'aiMessageInsight'>; label: string }> = [
|
||||
{ id: 'aiCommon', label: '基础配置' },
|
||||
{ id: 'insight', label: 'AI 见解' },
|
||||
{ id: 'aiFootprint', label: 'AI 足迹' }
|
||||
{ id: 'aiFootprint', label: 'AI 足迹' },
|
||||
{ id: 'aiGroupSummary', label: '群聊总结' },
|
||||
{ id: 'aiMessageInsight', label: '消息解析' }
|
||||
]
|
||||
|
||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||
const isWindows = !isMac && !isLinux
|
||||
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 dbPathPlaceholder = isMac
|
||||
@@ -325,8 +333,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [weiboBindingDrafts, setWeiboBindingDrafts] = useState<Record<string, string>>({})
|
||||
const [weiboBindingErrors, setWeiboBindingErrors] = useState<Record<string, string>>({})
|
||||
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
|
||||
const [aiInsightProfileStatuses, setAiInsightProfileStatuses] = useState<Record<string, InsightProfileStatus>>({})
|
||||
const [aiInsightProfileActiveSessionId, setAiInsightProfileActiveSessionId] = useState<string | null>(null)
|
||||
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
|
||||
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
|
||||
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
|
||||
const [aiGroupSummaryIntervalHours, setAiGroupSummaryIntervalHours] = useState(4)
|
||||
const [aiGroupSummarySystemPrompt, setAiGroupSummarySystemPrompt] = useState('')
|
||||
const [aiGroupSummaryFilterList, setAiGroupSummaryFilterList] = useState<string[]>([])
|
||||
const [aiGroupSummaryFilterSearchKeyword, setAiGroupSummaryFilterSearchKeyword] = useState('')
|
||||
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
|
||||
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
|
||||
const [aiMessageInsightSystemPrompt, setAiMessageInsightSystemPrompt] = useState('')
|
||||
|
||||
// 自动下载图片
|
||||
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
|
||||
@@ -372,7 +390,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}, [location.state])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') {
|
||||
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') {
|
||||
setAiGroupExpanded(true)
|
||||
}
|
||||
}, [activeTab])
|
||||
@@ -590,6 +608,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
|
||||
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
|
||||
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
|
||||
const savedAiGroupSummaryEnabled = await configService.getAiGroupSummaryEnabled()
|
||||
const savedAiGroupSummaryIntervalHours = await configService.getAiGroupSummaryIntervalHours()
|
||||
const savedAiGroupSummarySystemPrompt = await configService.getAiGroupSummarySystemPrompt()
|
||||
const savedAiGroupSummaryFilterList = await configService.getAiGroupSummaryFilterList()
|
||||
const savedAiMessageInsightEnabled = await configService.getAiMessageInsightEnabled()
|
||||
const savedAiMessageInsightContextCount = await configService.getAiMessageInsightContextCount()
|
||||
const savedAiMessageInsightSystemPrompt = await configService.getAiMessageInsightSystemPrompt()
|
||||
|
||||
setAiInsightEnabled(savedAiInsightEnabled)
|
||||
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
|
||||
@@ -616,6 +641,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
|
||||
setAiFootprintEnabled(savedAiFootprintEnabled)
|
||||
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
|
||||
setAiGroupSummaryEnabled(savedAiGroupSummaryEnabled)
|
||||
setAiGroupSummaryIntervalHours(savedAiGroupSummaryIntervalHours)
|
||||
setAiGroupSummarySystemPrompt(savedAiGroupSummarySystemPrompt)
|
||||
setAiGroupSummaryFilterList(savedAiGroupSummaryFilterList)
|
||||
setAiMessageInsightEnabled(savedAiMessageInsightEnabled)
|
||||
setAiMessageInsightContextCount(savedAiMessageInsightContextCount)
|
||||
setAiMessageInsightSystemPrompt(savedAiMessageInsightSystemPrompt)
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('加载配置失败:', e)
|
||||
@@ -2833,37 +2865,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
showMessage('已清空主动推送过滤列表', true)
|
||||
}
|
||||
|
||||
const sessionFilterOptionMap = new Map<string, SessionFilterOption>()
|
||||
const { sessionFilterOptionMap, sessionFilterOptions } = useMemo(() => {
|
||||
const optionMap = new Map<string, SessionFilterOption>()
|
||||
|
||||
for (const session of chatSessions) {
|
||||
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||
sessionFilterOptionMap.set(session.username, {
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
avatarUrl: session.avatarUrl,
|
||||
type: getSessionFilterType(session)
|
||||
})
|
||||
}
|
||||
for (const session of chatSessions) {
|
||||
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||
optionMap.set(session.username, {
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
avatarUrl: session.avatarUrl,
|
||||
type: getSessionFilterType(session)
|
||||
})
|
||||
}
|
||||
|
||||
for (const contact of messagePushContactOptions) {
|
||||
if (!contact.username) continue
|
||||
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
|
||||
const existing = sessionFilterOptionMap.get(contact.username)
|
||||
sessionFilterOptionMap.set(contact.username, {
|
||||
username: contact.username,
|
||||
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
|
||||
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
|
||||
type: getSessionFilterType(contact)
|
||||
})
|
||||
}
|
||||
for (const contact of messagePushContactOptions) {
|
||||
if (!contact.username) continue
|
||||
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
|
||||
const existing = optionMap.get(contact.username)
|
||||
optionMap.set(contact.username, {
|
||||
username: contact.username,
|
||||
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
|
||||
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
|
||||
type: getSessionFilterType(contact)
|
||||
})
|
||||
}
|
||||
|
||||
const sessionFilterOptions = Array.from(sessionFilterOptionMap.values())
|
||||
.sort((a, b) => {
|
||||
const aSession = chatSessions.find(session => session.username === a.username)
|
||||
const bSession = chatSessions.find(session => session.username === b.username)
|
||||
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
|
||||
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
|
||||
})
|
||||
const options = Array.from(optionMap.values())
|
||||
.sort((a, b) => {
|
||||
const aSession = chatSessions.find(session => session.username === a.username)
|
||||
const bSession = chatSessions.find(session => session.username === b.username)
|
||||
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
|
||||
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
|
||||
})
|
||||
|
||||
return { sessionFilterOptionMap: optionMap, sessionFilterOptions: options }
|
||||
}, [chatSessions, messagePushContactOptions])
|
||||
|
||||
const getSessionFilterOptionInfo = (username: string) => {
|
||||
return sessionFilterOptionMap.get(username) || {
|
||||
@@ -2903,6 +2939,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
messagePushFilterSearchKeyword
|
||||
)
|
||||
|
||||
const groupSummaryFilterOptions = sessionFilterOptions.filter((session) => session.type === 'group')
|
||||
const groupSummaryAvailableSessions = groupSummaryFilterOptions.filter((session) => {
|
||||
const keyword = aiGroupSummaryFilterSearchKeyword.trim().toLowerCase()
|
||||
if (aiGroupSummaryFilterList.includes(session.username)) return false
|
||||
if (!keyword) return true
|
||||
return String(session.displayName || '').toLowerCase().includes(keyword) ||
|
||||
session.username.toLowerCase().includes(keyword)
|
||||
})
|
||||
|
||||
const handleAddAllNotificationFilterSessions = async () => {
|
||||
const usernames = notificationAvailableSessions.map(session => session.username)
|
||||
if (usernames.length === 0) return
|
||||
@@ -3231,6 +3276,120 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
await configService.setAiInsightWeiboBindings(nextBindings)
|
||||
if (!silent) showMessage('已清除微博绑定', true)
|
||||
}
|
||||
|
||||
const refreshAiInsightProfileStatuses = async (sessionIds?: string[]) => {
|
||||
const ids = normalizeSessionIds(
|
||||
sessionIds || sessionFilterOptions
|
||||
.filter((session) => session.type === 'private')
|
||||
.map((session) => session.username)
|
||||
)
|
||||
if (ids.length === 0) {
|
||||
setAiInsightProfileStatuses({})
|
||||
setAiInsightProfileActiveSessionId(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await window.electronAPI.insight.listProfileStatuses(ids)
|
||||
if (!result.success) return
|
||||
setAiInsightProfileStatuses(result.statuses || {})
|
||||
setAiInsightProfileActiveSessionId(result.activeTask?.sessionId || null)
|
||||
} catch (error) {
|
||||
console.warn('刷新 AI 画像状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateInsightProfile = async (session: SessionFilterOption) => {
|
||||
const sessionId = session.username
|
||||
const currentStatus = aiInsightProfileStatuses[sessionId]
|
||||
if (currentStatus?.status === 'running') {
|
||||
try {
|
||||
const result = await window.electronAPI.insight.cancelProfile(sessionId)
|
||||
showMessage(result.message || '已请求取消画像任务', result.success)
|
||||
} catch (e: any) {
|
||||
showMessage(`取消画像失败:${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setTimeout(() => { void refreshAiInsightProfileStatuses() }, 500)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId) return
|
||||
|
||||
setAiInsightProfileStatuses((prev) => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
sessionId,
|
||||
status: 'running',
|
||||
phase: '正在初始化画像...',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
}))
|
||||
setAiInsightProfileActiveSessionId(sessionId)
|
||||
try {
|
||||
const result = await window.electronAPI.insight.generateProfile({
|
||||
sessionId,
|
||||
displayName: session.displayName || session.username,
|
||||
avatarUrl: session.avatarUrl
|
||||
})
|
||||
showMessage(result.message || (result.success ? '画像完成' : '画像失败'), result.success)
|
||||
} catch (e: any) {
|
||||
showMessage(`画像失败:${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
await refreshAiInsightProfileStatuses()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'insight') return
|
||||
const ids = sessionFilterOptions
|
||||
.filter((session) => session.type === 'private')
|
||||
.map((session) => session.username)
|
||||
if (ids.length === 0) return
|
||||
void refreshAiInsightProfileStatuses(ids)
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshAiInsightProfileStatuses(ids)
|
||||
}, 2500)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [activeTab, sessionFilterOptions])
|
||||
|
||||
const getInsightProfileButtonMeta = (sessionId: string) => {
|
||||
const status = aiInsightProfileStatuses[sessionId]
|
||||
const activeOther = Boolean(aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId)
|
||||
if (status?.status === 'running') {
|
||||
return {
|
||||
className: 'running',
|
||||
label: '取消',
|
||||
title: status.phase || '画像生成中,点击取消',
|
||||
disabled: false,
|
||||
icon: <Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
}
|
||||
if (status?.status === 'ready') {
|
||||
return {
|
||||
className: 'ready',
|
||||
label: '已画像',
|
||||
title: activeOther ? '其他联系人正在画像中' : '点击以重新画像',
|
||||
disabled: activeOther,
|
||||
icon: <Check size={13} />
|
||||
}
|
||||
}
|
||||
if (status?.status === 'failed') {
|
||||
return {
|
||||
className: 'failed',
|
||||
label: '失败',
|
||||
title: activeOther ? '其他联系人正在画像中' : (status.error || '画像失败,点击重试'),
|
||||
disabled: activeOther,
|
||||
icon: <XCircle size={13} />
|
||||
}
|
||||
}
|
||||
return {
|
||||
className: 'none',
|
||||
label: '未画像',
|
||||
title: activeOther ? '其他联系人正在画像中' : '点击进行画像',
|
||||
disabled: activeOther,
|
||||
icon: <i className="profile-status-dot" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
const renderInsightTab = () => (
|
||||
<div className="tab-content">
|
||||
{/* 总开关 */}
|
||||
@@ -3805,6 +3964,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="anti-revoke-list-header">
|
||||
<span>对话({filteredSessions.length})</span>
|
||||
<span className="insight-moments-column-title">朋友圈</span>
|
||||
<span className="insight-profile-column-title">画像</span>
|
||||
<span className="insight-social-column-title">社交平台(微博)</span>
|
||||
<span className="anti-revoke-status-column-title">状态</span>
|
||||
</div>
|
||||
@@ -3816,6 +3976,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
|
||||
const isBindingLoading = weiboBindingLoadingSessionId === session.username
|
||||
const weiboBindingError = weiboBindingErrors[session.username]
|
||||
const profileButtonMeta = getInsightProfileButtonMeta(session.username)
|
||||
return (
|
||||
<div
|
||||
key={session.username}
|
||||
@@ -3866,37 +4027,56 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span className="binding-feedback muted">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-profile-cell">
|
||||
{isPrivateSession ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`insight-profile-status-btn ${profileButtonMeta.className}`}
|
||||
title={profileButtonMeta.title}
|
||||
data-tooltip={profileButtonMeta.title}
|
||||
disabled={profileButtonMeta.disabled}
|
||||
onClick={() => void handleGenerateInsightProfile(session)}
|
||||
>
|
||||
{profileButtonMeta.icon}
|
||||
<span>{profileButtonMeta.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="binding-feedback muted">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-social-binding-cell">
|
||||
{isPrivateSession ? (
|
||||
<>
|
||||
<div className="insight-social-binding-input-wrap">
|
||||
<span className="binding-platform-chip">微博</span>
|
||||
<input
|
||||
type="text"
|
||||
className="insight-social-binding-input"
|
||||
value={weiboDraftValue}
|
||||
placeholder="填写数字 UID"
|
||||
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="insight-social-binding-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
|
||||
disabled={isBindingLoading || !weiboDraftValue.trim()}
|
||||
>
|
||||
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
|
||||
</button>
|
||||
{weiboBinding && (
|
||||
<div className="insight-social-binding-controls">
|
||||
<div className="insight-social-binding-input-wrap">
|
||||
<span className="binding-platform-chip">微博</span>
|
||||
<input
|
||||
type="text"
|
||||
className="insight-social-binding-input"
|
||||
value={weiboDraftValue}
|
||||
placeholder="填写数字 UID"
|
||||
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="insight-social-binding-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleClearWeiboBinding(session.username)}
|
||||
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
|
||||
disabled={isBindingLoading || !weiboDraftValue.trim()}
|
||||
>
|
||||
清除
|
||||
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
|
||||
</button>
|
||||
)}
|
||||
{weiboBinding && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleClearWeiboBinding(session.username)}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="insight-social-binding-feedback">
|
||||
{weiboBindingError ? (
|
||||
@@ -4021,6 +4201,320 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAiGroupSummaryTab = () => {
|
||||
const groupSummaryPromptDisplayValue = aiGroupSummarySystemPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT
|
||||
|
||||
const addToFilterList = async (username: string) => {
|
||||
if (!username.endsWith('@chatroom') || aiGroupSummaryFilterList.includes(username)) return
|
||||
const next = [...aiGroupSummaryFilterList, username]
|
||||
setAiGroupSummaryFilterList(next)
|
||||
await configService.setAiGroupSummaryFilterList(next)
|
||||
showMessage('已添加到群聊总结作用域', true)
|
||||
}
|
||||
|
||||
const removeFromFilterList = async (username: string) => {
|
||||
const next = aiGroupSummaryFilterList.filter((item) => item !== username)
|
||||
setAiGroupSummaryFilterList(next)
|
||||
await configService.setAiGroupSummaryFilterList(next)
|
||||
showMessage('已从群聊总结作用域移除', true)
|
||||
}
|
||||
|
||||
const addAllFiltered = async () => {
|
||||
const usernames = groupSummaryAvailableSessions.map((session) => session.username)
|
||||
if (usernames.length === 0) return
|
||||
const next = Array.from(new Set([...aiGroupSummaryFilterList, ...usernames]))
|
||||
setAiGroupSummaryFilterList(next)
|
||||
await configService.setAiGroupSummaryFilterList(next)
|
||||
showMessage(`已添加 ${usernames.length} 个群聊`, true)
|
||||
}
|
||||
|
||||
const clearFilterList = async () => {
|
||||
if (aiGroupSummaryFilterList.length === 0) return
|
||||
setAiGroupSummaryFilterList([])
|
||||
await configService.setAiGroupSummaryFilterList([])
|
||||
showMessage('已清空群聊总结作用域', true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>AI 群聊总结</label>
|
||||
<span className="form-hint">
|
||||
开启后,群聊页顶部会显示 AI 总结按钮;自动总结只对下面作用域内的群聊生效。未选择任何群聊时不会自动消耗 token。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{aiGroupSummaryEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiGroupSummaryEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiGroupSummaryEnabled(val)
|
||||
await configService.setAiGroupSummaryEnabled(val)
|
||||
showMessage(val ? 'AI 群聊总结已开启' : 'AI 群聊总结已关闭', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>自动总结间隔</label>
|
||||
<span className="form-hint">
|
||||
按本地系统时间从当天 00:00 开始切分完整时间段,到点总结上一段。时段内可总结消息少于 5 条时会跳过。
|
||||
</span>
|
||||
<div className="push-filter-type-tabs" style={{ marginTop: 10 }}>
|
||||
{[1, 2, 4, 8, 12, 24].map((hours) => (
|
||||
<button
|
||||
key={hours}
|
||||
type="button"
|
||||
className={`push-filter-type-tab ${aiGroupSummaryIntervalHours === hours ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setAiGroupSummaryIntervalHours(hours)
|
||||
scheduleConfigSave('aiGroupSummaryIntervalHours', () => configService.setAiGroupSummaryIntervalHours(hours))
|
||||
}}
|
||||
>
|
||||
{hours} 小时
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<label style={{ marginBottom: 0 }}>群聊总结提示词</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={async () => {
|
||||
setAiGroupSummarySystemPrompt('')
|
||||
await configService.setAiGroupSummarySystemPrompt('')
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
<span className="form-hint">
|
||||
群聊总结专用提示词。留空时使用内置默认提示词。
|
||||
</span>
|
||||
<textarea
|
||||
className="field-input ai-prompt-textarea"
|
||||
rows={10}
|
||||
style={{ width: '100%', resize: 'vertical', marginTop: 8 }}
|
||||
value={groupSummaryPromptDisplayValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setAiGroupSummarySystemPrompt(val)
|
||||
scheduleConfigSave('aiGroupSummarySystemPrompt', () => configService.setAiGroupSummarySystemPrompt(val))
|
||||
}}
|
||||
/>
|
||||
<span className="form-hint" style={{ color: 'var(--danger, #ef4444)', marginTop: 8, display: 'block' }}>
|
||||
该提示词控制 JSON 输出结构和总结解析路径,不建议随意修改,否则可能导致总结失败或内容错位。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>自动总结作用域群聊</label>
|
||||
<span className="form-hint">
|
||||
仅控制自动总结范围。手动点击群聊页 AI 总结按钮不受作用域限制;未选择任何群聊时自动总结不会运行。
|
||||
</span>
|
||||
|
||||
{aiGroupSummaryFilterList.length === 0 && (
|
||||
<div className="api-docs" style={{ marginTop: 12 }}>
|
||||
<div className="api-item">
|
||||
<p className="api-desc">当前未选择作用域群聊,自动群聊总结不会触发。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="notification-filter-container" style={{ marginTop: 12 }}>
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>可选群聊</span>
|
||||
{groupSummaryAvailableSessions.length > 0 && (
|
||||
<button type="button" className="filter-panel-action" onClick={() => { void addAllFiltered() }}>
|
||||
全选当前
|
||||
</button>
|
||||
)}
|
||||
<div className="filter-search-box">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索群聊..."
|
||||
value={aiGroupSummaryFilterSearchKeyword}
|
||||
onChange={(e) => setAiGroupSummaryFilterSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{groupSummaryAvailableSessions.length > 0 ? (
|
||||
groupSummaryAvailableSessions.map(session => (
|
||||
<div
|
||||
key={session.username}
|
||||
className="filter-panel-item"
|
||||
onClick={() => { void addToFilterList(session.username) }}
|
||||
>
|
||||
<Avatar src={session.avatarUrl} name={session.displayName || session.username} size={28} />
|
||||
<span className="filter-item-name">{session.displayName || session.username}</span>
|
||||
<span className="filter-item-type">群聊</span>
|
||||
<span className="filter-item-action">+</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="filter-panel-empty">
|
||||
{aiGroupSummaryFilterSearchKeyword ? '没有匹配的群聊' : '暂无可添加的群聊'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>作用域群聊</span>
|
||||
{aiGroupSummaryFilterList.length > 0 && (
|
||||
<span className="filter-panel-count">{aiGroupSummaryFilterList.length}</span>
|
||||
)}
|
||||
{aiGroupSummaryFilterList.length > 0 && (
|
||||
<button type="button" className="filter-panel-action" onClick={() => { void clearFilterList() }}>
|
||||
全不选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{aiGroupSummaryFilterList.length > 0 ? (
|
||||
aiGroupSummaryFilterList.map(username => {
|
||||
const info = getSessionFilterOptionInfo(username)
|
||||
return (
|
||||
<div
|
||||
key={username}
|
||||
className="filter-panel-item selected"
|
||||
onClick={() => { void removeFromFilterList(username) }}
|
||||
>
|
||||
<Avatar src={info.avatarUrl} name={info.displayName} size={28} />
|
||||
<span className="filter-item-name">{info.displayName}</span>
|
||||
<span className="filter-item-type">群聊</span>
|
||||
<span className="filter-item-action">×</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="filter-panel-empty">尚未添加任何群聊</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderAiMessageInsightTab = () => (
|
||||
<div className="tab-content">
|
||||
{(() => {
|
||||
const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。
|
||||
|
||||
严格要求:
|
||||
1. 必须且只能输出合法的纯 JSON。
|
||||
2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。
|
||||
3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。
|
||||
4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。
|
||||
5. emotion、intent、topic 必须是短标签。
|
||||
|
||||
JSON 输出格式:
|
||||
{
|
||||
"explicit_text": "暗示转明示,80字以内",
|
||||
"emotion": "2-6字情绪标签",
|
||||
"intent": "2-8字意图标签",
|
||||
"topic": "2-8字话题标签"
|
||||
}`
|
||||
const displayValue = aiMessageInsightSystemPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>消息深度解析</label>
|
||||
<span className="form-hint">
|
||||
开启后,在聊天页悬停对方文本消息时显示深度解析入口。点击后按需调用 AI,解析结果会保存到灵感信箱。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{aiMessageInsightEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiMessageInsightEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiMessageInsightEnabled(val)
|
||||
await configService.setAiMessageInsightEnabled(val)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>发送上下文对话条数</label>
|
||||
<span className="form-hint">
|
||||
围绕选中消息向前、向后各取一半;一侧不足时自动由另一侧补齐。条数越多分析越准确,token 消耗也越多。
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={aiMessageInsightContextCount}
|
||||
min={1}
|
||||
max={200}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 50))
|
||||
setAiMessageInsightContextCount(val)
|
||||
scheduleConfigSave('aiMessageInsightContextCount', () => configService.setAiMessageInsightContextCount(val))
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ marginBottom: 0 }}>消息解析提示词</label>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={async () => {
|
||||
setAiMessageInsightSystemPrompt('')
|
||||
await configService.setAiMessageInsightSystemPrompt('')
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
<span className="form-hint">
|
||||
消息解析专用提示词。留空时使用内置默认提示词。
|
||||
</span>
|
||||
<textarea
|
||||
className="field-input ai-prompt-textarea"
|
||||
rows={10}
|
||||
style={{ width: '100%', resize: 'vertical' }}
|
||||
value={displayValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setAiMessageInsightSystemPrompt(val)
|
||||
scheduleConfigSave('aiMessageInsightSystemPrompt', () => configService.setAiMessageInsightSystemPrompt(val))
|
||||
}}
|
||||
/>
|
||||
<span className="form-hint" style={{ color: 'var(--danger, #ef4444)', marginTop: 8, display: 'block' }}>
|
||||
该提示词控制 JSON 输出结构和解析口径,不建议随意修改,否则可能导致解析失败或内容错位。
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderApiTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -5049,7 +5543,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
row.push(
|
||||
<div key="ai-settings-group" className={`tab-group ${aiGroupExpanded ? 'expanded' : ''}`}>
|
||||
<button
|
||||
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') ? 'active' : ''}`}
|
||||
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') ? 'active' : ''}`}
|
||||
onClick={() => setAiGroupExpanded((prev) => !prev)}
|
||||
aria-expanded={aiGroupExpanded}
|
||||
>
|
||||
@@ -5091,6 +5585,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
{activeTab === 'aiCommon' && renderAiCommonTab()}
|
||||
{activeTab === 'insight' && renderInsightTab()}
|
||||
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
|
||||
{activeTab === 'aiGroupSummary' && renderAiGroupSummaryTab()}
|
||||
{activeTab === 'aiMessageInsight' && renderAiMessageInsightTab()}
|
||||
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
|
||||
{activeTab === 'updates' && renderUpdatesTab()}
|
||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
padding: 12px 16px;
|
||||
margin-left: -4px;
|
||||
margin-right: -4px;
|
||||
overflow: hidden;
|
||||
|
||||
// 遮挡流程线
|
||||
&::before {
|
||||
@@ -212,7 +213,7 @@
|
||||
width: 20px;
|
||||
background: var(--primary);
|
||||
border-radius: 12px 0 0 12px;
|
||||
z-index: -1;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +235,8 @@
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
[data-mode="dark"] .welcome-sidebar & {
|
||||
border-color: rgba(255, 255, 255, 0.2); // 稍微调亮边框
|
||||
@@ -273,6 +276,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
|
||||
@@ -120,6 +120,14 @@ export const CONFIG_KEYS = {
|
||||
// AI 足迹
|
||||
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
|
||||
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
|
||||
AI_GROUP_SUMMARY_ENABLED: 'aiGroupSummaryEnabled',
|
||||
AI_GROUP_SUMMARY_INTERVAL_HOURS: 'aiGroupSummaryIntervalHours',
|
||||
AI_GROUP_SUMMARY_SYSTEM_PROMPT: 'aiGroupSummarySystemPrompt',
|
||||
AI_GROUP_SUMMARY_FILTER_MODE: 'aiGroupSummaryFilterMode',
|
||||
AI_GROUP_SUMMARY_FILTER_LIST: 'aiGroupSummaryFilterList',
|
||||
AI_MESSAGE_INSIGHT_ENABLED: 'aiMessageInsightEnabled',
|
||||
AI_MESSAGE_INSIGHT_CONTEXT_COUNT: 'aiMessageInsightContextCount',
|
||||
AI_MESSAGE_INSIGHT_SYSTEM_PROMPT: 'aiMessageInsightSystemPrompt',
|
||||
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled',
|
||||
AUTO_DOWNLOAD_HIGH_RES: 'autoDownloadHighRes',
|
||||
AUTO_DOWNLOAD_WHITELIST: 'autoDownloadWhitelist'
|
||||
@@ -2175,6 +2183,97 @@ export async function setAiFootprintSystemPrompt(prompt: string): Promise<void>
|
||||
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
|
||||
}
|
||||
|
||||
// Legacy only: 群聊总结现在只使用 aiGroupSummaryFilterList 作为作用域白名单。
|
||||
export type AiGroupSummaryFilterMode = 'whitelist' | 'blacklist'
|
||||
|
||||
const AI_GROUP_SUMMARY_INTERVALS = new Set([1, 2, 4, 8, 12, 24])
|
||||
|
||||
const normalizeAiGroupSummaryFilterList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(new Set(
|
||||
value
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter((item) => item.endsWith('@chatroom'))
|
||||
))
|
||||
}
|
||||
|
||||
export async function getAiGroupSummaryEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_ENABLED)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAiGroupSummaryEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getAiGroupSummaryIntervalHours(): Promise<number> {
|
||||
const value = Number(await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_INTERVAL_HOURS))
|
||||
const normalized = Number.isFinite(value) ? Math.floor(value) : 4
|
||||
return AI_GROUP_SUMMARY_INTERVALS.has(normalized) ? normalized : 4
|
||||
}
|
||||
|
||||
export async function setAiGroupSummaryIntervalHours(hours: number): Promise<void> {
|
||||
const normalized = Math.floor(Number(hours) || 4)
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_INTERVAL_HOURS, AI_GROUP_SUMMARY_INTERVALS.has(normalized) ? normalized : 4)
|
||||
}
|
||||
|
||||
export async function getAiGroupSummarySystemPrompt(): Promise<string> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_SYSTEM_PROMPT)
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export async function setAiGroupSummarySystemPrompt(prompt: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_SYSTEM_PROMPT, prompt)
|
||||
}
|
||||
|
||||
export async function getAiGroupSummaryFilterMode(): Promise<AiGroupSummaryFilterMode> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_MODE)
|
||||
return value === 'blacklist' ? 'blacklist' : 'whitelist'
|
||||
}
|
||||
|
||||
export async function setAiGroupSummaryFilterMode(mode: AiGroupSummaryFilterMode): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_MODE, mode === 'blacklist' ? 'blacklist' : 'whitelist')
|
||||
}
|
||||
|
||||
export async function getAiGroupSummaryFilterList(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_LIST)
|
||||
return normalizeAiGroupSummaryFilterList(value)
|
||||
}
|
||||
|
||||
export async function setAiGroupSummaryFilterList(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_LIST, normalizeAiGroupSummaryFilterList(list))
|
||||
}
|
||||
|
||||
export async function getAiMessageInsightEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_ENABLED)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAiMessageInsightEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getAiMessageInsightContextCount(): Promise<number> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_CONTEXT_COUNT)
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return 50
|
||||
return Math.max(1, Math.min(200, Math.floor(numeric)))
|
||||
}
|
||||
|
||||
export async function setAiMessageInsightContextCount(count: number): Promise<void> {
|
||||
const normalized = Number.isFinite(count) ? Math.max(1, Math.min(200, Math.floor(count))) : 50
|
||||
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_CONTEXT_COUNT, normalized)
|
||||
}
|
||||
|
||||
export async function getAiMessageInsightSystemPrompt(): Promise<string> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_SYSTEM_PROMPT)
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export async function setAiMessageInsightSystemPrompt(prompt: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_SYSTEM_PROMPT, prompt)
|
||||
}
|
||||
|
||||
export async function getAiInsightDebugLogEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED)
|
||||
return value === true
|
||||
|
||||
@@ -32,11 +32,22 @@ interface TimeDistribution {
|
||||
monthlyDistribution: Record<string, number>
|
||||
}
|
||||
|
||||
interface SelfSentDailyDistribution {
|
||||
unit: 'day'
|
||||
dailyDistribution: Record<string, number>
|
||||
totalMessages: number
|
||||
firstMessageTime: number | null
|
||||
lastMessageTime: number | null
|
||||
beginTimestamp: number
|
||||
endTimestamp: number
|
||||
}
|
||||
|
||||
interface AnalyticsState {
|
||||
// 数据
|
||||
statistics: ChatStatistics | null
|
||||
rankings: ContactRanking[]
|
||||
timeDistribution: TimeDistribution | null
|
||||
selfSentDailyDistribution: SelfSentDailyDistribution | null
|
||||
|
||||
// 状态
|
||||
isLoaded: boolean
|
||||
@@ -46,6 +57,7 @@ interface AnalyticsState {
|
||||
setStatistics: (data: ChatStatistics) => void
|
||||
setRankings: (data: ContactRanking[]) => void
|
||||
setTimeDistribution: (data: TimeDistribution) => void
|
||||
setSelfSentDailyDistribution: (data: SelfSentDailyDistribution) => void
|
||||
markLoaded: () => void
|
||||
clearCache: () => void
|
||||
}
|
||||
@@ -56,17 +68,20 @@ export const useAnalyticsStore = create<AnalyticsState>()(
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
selfSentDailyDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null,
|
||||
|
||||
setStatistics: (data) => set({ statistics: data }),
|
||||
setRankings: (data) => set({ rankings: data }),
|
||||
setTimeDistribution: (data) => set({ timeDistribution: data }),
|
||||
setSelfSentDailyDistribution: (data) => set({ selfSentDailyDistribution: data }),
|
||||
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
|
||||
clearCache: () => set({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
selfSentDailyDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null
|
||||
}),
|
||||
|
||||
Vendored
+201
-3
@@ -21,7 +21,24 @@ export interface SocialSaveWeiboCookieResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
|
||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis'
|
||||
export type InsightRecordSourceType = 'insight' | 'message_analysis'
|
||||
|
||||
export interface MessageInsightAnalysis {
|
||||
explicitText: string
|
||||
emotion: string
|
||||
intent: string
|
||||
topic: string
|
||||
}
|
||||
|
||||
export interface MessageInsightTarget {
|
||||
targetLocalId: number
|
||||
targetCreateTime: number
|
||||
targetMessageKey: string
|
||||
targetSenderName: string
|
||||
targetTextPreview: string
|
||||
analysis: MessageInsightAnalysis
|
||||
}
|
||||
|
||||
export interface InsightRecordLog {
|
||||
endpoint: string
|
||||
@@ -37,10 +54,28 @@ export interface InsightRecordLog {
|
||||
finalInsight: string
|
||||
durationMs: number
|
||||
createdAt: number
|
||||
responseFormatJson?: boolean
|
||||
responseFormatFallback?: boolean
|
||||
responseFormatFallbackReason?: string
|
||||
targetMessage?: {
|
||||
localId: number
|
||||
createTime: number
|
||||
messageKey: string
|
||||
senderName: string
|
||||
textPreview: string
|
||||
}
|
||||
contextStats?: {
|
||||
requested: number
|
||||
beforeTarget: number
|
||||
afterTarget: number
|
||||
readError?: string
|
||||
}
|
||||
parsedAnalysis?: MessageInsightAnalysis
|
||||
}
|
||||
|
||||
export interface InsightRecordSummary {
|
||||
id: string
|
||||
sourceType: InsightRecordSourceType
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
@@ -48,6 +83,7 @@ export interface InsightRecordSummary {
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
read: boolean
|
||||
messageInsight?: MessageInsightTarget
|
||||
}
|
||||
|
||||
export interface InsightRecord extends InsightRecordSummary {
|
||||
@@ -67,6 +103,7 @@ export interface InsightRecordFilters {
|
||||
sessionId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
sourceType?: InsightRecordSourceType | 'all'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
@@ -87,6 +124,108 @@ export interface InsightRecordResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type InsightProfileStatusValue = 'none' | 'ready' | 'running' | 'failed'
|
||||
|
||||
export interface InsightProfileStatus {
|
||||
sessionId: string
|
||||
status: InsightProfileStatusValue
|
||||
updatedAt?: number
|
||||
error?: string
|
||||
phase?: string
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
export interface InsightProfileStatusListResult {
|
||||
success: boolean
|
||||
statuses: Record<string, InsightProfileStatus>
|
||||
activeTask?: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
phase: string
|
||||
startedAt: number
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface InsightProfileGenerateResult {
|
||||
success: boolean
|
||||
message: string
|
||||
cancelled?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type GroupSummaryTriggerType = 'auto' | 'manual'
|
||||
|
||||
export interface GroupSummaryTopic {
|
||||
title: string
|
||||
participants: string[]
|
||||
keyPoints: string[]
|
||||
conclusion: string
|
||||
}
|
||||
|
||||
export interface GroupSummaryLog {
|
||||
endpoint: string
|
||||
model: string
|
||||
temperature: number
|
||||
triggerType: GroupSummaryTriggerType
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
messageCount: number
|
||||
readableMessageCount: number
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
rawOutput: string
|
||||
finalSummary: string
|
||||
durationMs: number
|
||||
createdAt: number
|
||||
responseFormatJson?: boolean
|
||||
responseFormatFallback?: boolean
|
||||
responseFormatFallbackReason?: string
|
||||
parsedTopics?: GroupSummaryTopic[]
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecordSummary {
|
||||
id: string
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
triggerType: GroupSummaryTriggerType
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
messageCount: number
|
||||
readableMessageCount: number
|
||||
topics: GroupSummaryTopic[]
|
||||
summaryText: string
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecord extends GroupSummaryRecordSummary {
|
||||
accountScope: string
|
||||
rawOutput: string
|
||||
log: GroupSummaryLog
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecordFilters {
|
||||
sessionId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecordListResult {
|
||||
success: boolean
|
||||
records: GroupSummaryRecordSummary[]
|
||||
total: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecordResult {
|
||||
success: boolean
|
||||
record?: GroupSummaryRecord
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BackupProgress {
|
||||
phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
||||
message: string
|
||||
@@ -169,6 +308,11 @@ export interface BackupManifest {
|
||||
}
|
||||
}
|
||||
|
||||
export type CloseConfirmPayload = {
|
||||
canMinimizeToTray: boolean
|
||||
restoreMethod?: 'tray' | 'dock'
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
minimize: () => void
|
||||
@@ -176,7 +320,7 @@ export interface ElectronAPI {
|
||||
isMaximized: () => Promise<boolean>
|
||||
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
||||
close: () => void
|
||||
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void
|
||||
onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => () => void
|
||||
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise<boolean>
|
||||
openAgreementWindow: () => Promise<boolean>
|
||||
completeOnboarding: () => Promise<boolean>
|
||||
@@ -365,7 +509,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getSessionMessageCounts: (sessionIds: string[]) => Promise<{
|
||||
getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => Promise<{
|
||||
success: boolean
|
||||
counts?: Record<string, number>
|
||||
error?: string
|
||||
@@ -785,6 +929,19 @@ export interface ElectronAPI {
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) => Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
unit: 'day'
|
||||
dailyDistribution: Record<string, number>
|
||||
totalMessages: number
|
||||
firstMessageTime: number | null
|
||||
lastMessageTime: number | null
|
||||
beginTimestamp: number
|
||||
endTimestamp: number
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getExcludedUsernames: () => Promise<{
|
||||
success: boolean
|
||||
data?: string[]
|
||||
@@ -1307,6 +1464,18 @@ export interface ElectronAPI {
|
||||
markRecordRead: (id: string) => Promise<{ success: boolean; error?: string }>
|
||||
clearRecords: (filters?: InsightRecordFilters) => Promise<{ success: boolean; removed: number; error?: string }>
|
||||
triggerTest: () => Promise<{ success: boolean; message: string }>
|
||||
triggerSessionInsight: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => Promise<{ success: boolean; message: string; recordId?: string; insight?: string; skipped?: boolean; notificationEnabled?: boolean }>
|
||||
listProfileStatuses: (sessionIds: string[]) => Promise<InsightProfileStatusListResult>
|
||||
generateProfile: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => Promise<InsightProfileGenerateResult>
|
||||
cancelProfile: (sessionId?: string) => Promise<{ success: boolean; message: string }>
|
||||
generateFootprintInsight: (payload: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
@@ -1320,6 +1489,35 @@ export interface ElectronAPI {
|
||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}) => Promise<{ success: boolean; message: string; insight?: string }>
|
||||
generateMessageInsight: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
targetLocalId?: number
|
||||
targetCreateTime?: number
|
||||
targetMessageKey?: string
|
||||
targetText: string
|
||||
targetSenderName?: string
|
||||
contextCount?: number
|
||||
forceRefresh?: boolean
|
||||
}) => Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }>
|
||||
}
|
||||
groupSummary: {
|
||||
listRecords: (filters?: GroupSummaryRecordFilters) => Promise<GroupSummaryRecordListResult>
|
||||
getRecord: (id: string) => Promise<GroupSummaryRecordResult>
|
||||
triggerManual: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}) => Promise<{ success: boolean; message: string; recordId?: string; record?: 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/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "shared/**/*.json"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
+3
-1
@@ -4,6 +4,7 @@
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
@@ -14,6 +15,7 @@
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"electron/**/*.ts",
|
||||
"electron/**/*.d.ts"
|
||||
"electron/**/*.d.ts",
|
||||
"shared/**/*.json"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user