Compare commits

...

8 Commits

Author SHA1 Message Date
H3CoF6
75136ad834 Merge pull request #977 from TMYTiMidlY/fix/linux-key-service-packaged-build
fix(linux): initialize key service in packaged builds
2026-05-18 02:03:11 +08:00
cc
1d7bed8434 Merge pull request #979 from Jasonzhu1207/main
feat: Add Chat Analysis
2026-05-17 23:30:20 +08:00
Jason
0cf338b94c fix: Chat Analysis 2026-05-17 23:17:34 +08:00
Jason
a07a6de645 fix: Mailbox Message Location 2026-05-17 22:45:27 +08:00
Jason
a300d3c5d3 fix: Chat Analysis 2026-05-17 21:32:31 +08:00
Jason
1df4f0e523 feat: Add Chat Analysis 2026-05-17 21:04:14 +08:00
TMYTiMidlY
7eeec7d930 fix(linux): initialize key service in packaged builds 2026-05-17 14:11:55 +08:00
cc
d008359d70 修复:足迹页面分段失效的问题;#972 #974 所提到的问题;数据库备份中目录错误的问题;
优化:足迹页面的索引扫描性能;导出页面的消息缓存逻辑
2026-05-17 11:54:42 +08:00
21 changed files with 1571 additions and 72 deletions

View File

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

View File

@@ -397,13 +397,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()
}
@@ -1792,6 +1786,7 @@ function registerIpcHandlers() {
sessionId?: string
startTime?: number
endTime?: number
sourceType?: 'insight' | 'message_analysis' | 'all'
limit?: number
offset?: number
}) => {
@@ -1834,6 +1829,21 @@ 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('social:saveWeiboCookie', async (_, rawInput: string) => {
try {
if (!configService) {
@@ -2349,8 +2359,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 +3223,8 @@ function registerIpcHandlers() {
imageAesKey: imageKeys.aesKey,
resourcesPath,
userDataPath,
logEnabled
logEnabled,
isPackaged: app.isPackaged
}
})
@@ -3344,7 +3355,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 +3423,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
}
})

View File

@@ -195,7 +195,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 }
@@ -595,7 +595,19 @@ 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)
},
social: {

View File

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

View File

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

View File

@@ -129,6 +129,9 @@ interface ConfigSchema {
// AI 足迹
aiFootprintEnabled: boolean
aiFootprintSystemPrompt: string
aiMessageInsightEnabled: boolean
aiMessageInsightContextCount: number
aiMessageInsightSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean
autoDownloadHighRes: boolean
@@ -252,6 +255,9 @@ export class ConfigService {
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiMessageInsightEnabled: false,
aiMessageInsightContextCount: 50,
aiMessageInsightSystemPrompt: '',
aiInsightDebugLogEnabled: false,
autoDownloadHighRes: false,
autoDownloadWhitelist: []

View File

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

View File

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

View File

@@ -21,7 +21,12 @@ 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 {
insightRecordService,
type InsightRecordLog,
type InsightRecordTriggerReason,
type MessageInsightAnalysis
} from './insightRecordService'
// ─── 常量 ────────────────────────────────────────────────────────────────────
@@ -81,6 +86,18 @@ interface SharedAiModelConfig {
type InsightFilterMode = 'whitelist' | 'blacklist'
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 +178,52 @@ function normalizeSessionIdList(value: unknown): string[] {
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
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 +234,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?: { responseFormatJson?: boolean }
): Promise<string> {
return new Promise((resolve, reject) => {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
@@ -183,15 +247,19 @@ function callApi(
return
}
const body = JSON.stringify({
const payload: Record<string, unknown> = {
model,
messages,
max_tokens: normalizeApiMaxTokens(maxTokens),
temperature: API_TEMPERATURE,
stream: 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,11 +273,15 @@ 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()) {
@@ -590,6 +662,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 {

View File

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

View File

@@ -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(/\/\d+$/, '/0')
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

View File

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

View File

@@ -1922,6 +1922,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 {
@@ -5828,6 +5832,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,

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } 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 } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
@@ -1637,6 +1637,8 @@ function ChatPage(props: ChatPageProps) {
const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys])
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
const messageKeySetRef = useRef<Set<string>>(new Set())
const lastMessageTimeRef = useRef(0)
const isMessageListAtBottomRef = useRef(true)
@@ -3095,6 +3097,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 +3143,7 @@ function ChatPage(props: ChatPageProps) {
// 同步 currentSessionId 到 ref
useEffect(() => {
currentSessionRef.current = currentSessionId
messageInsightMemoryCache.clear()
isMessageListAtBottomRef.current = true
topRangeLoadLockRef.current = false
bottomRangeLoadLockRef.current = false
@@ -5868,7 +5901,7 @@ function ChatPage(props: ChatPageProps) {
selectSessionById
])
// 监听 URL 参数中的会话/锚点(通知跳转 + 足迹锚点定位)
// 监听 URL 参数中的会话/锚点(通知跳转 + 足迹/深度解析锚点定位)
useEffect(() => {
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
const params = new URLSearchParams(location.search)
@@ -5883,6 +5916,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 +5950,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)
@@ -6887,6 +6953,8 @@ function ChatPage(props: ChatPageProps) {
messageKey={messageKey}
isSelected={selectedMessages.has(messageKey)}
onToggleSelection={handleToggleSelection}
aiMessageInsightEnabled={aiMessageInsightEnabled}
aiMessageInsightContextCount={aiMessageInsightContextCount}
/>
</div>
)
@@ -6906,7 +6974,9 @@ function ChatPage(props: ChatPageProps) {
handleJumpToQuotedMessage,
isSelectionMode,
selectedMessages,
handleToggleSelection
handleToggleSelection,
aiMessageInsightEnabled,
aiMessageInsightContextCount
])
return (
@@ -8401,6 +8471,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 +8552,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 +8751,9 @@ function MessageBubble({
onJumpToQuotedMessage,
isSelectionMode,
isSelected,
onToggleSelection
onToggleSelection,
aiMessageInsightEnabled,
aiMessageInsightContextCount
}: {
message: Message;
messageKey: string;
@@ -8488,6 +8770,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 +9990,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 +11362,7 @@ function MessageBubble({
isSelected={isSelected}
onContextMenu={onContextMenu}
onToggleSelection={onToggleSelection}
actionNode={messageInsightControl}
portal={systemAlertPortal}
>
{renderContent()}
@@ -11073,6 +11385,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 &&

View File

@@ -4364,7 +4364,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 +4381,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)
@@ -7613,12 +7613,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()
: ''

View File

@@ -267,6 +267,20 @@
}
}
.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 {
font-size: 12px;
color: var(--text-tertiary);
@@ -282,6 +296,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 +427,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;

View File

@@ -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,22 @@ function formatGroupDate(timestamp: number): string {
}
function getTriggerLabel(reason: InsightRecordTriggerReason): string {
if (reason === 'message_analysis') return '深度解析'
if (reason === 'silence') return '沉默提醒'
if (reason === 'test') 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 +98,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 +125,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 +158,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 +226,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 +341,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 +355,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 +399,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 +514,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>

View File

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

View File

@@ -32,9 +32,10 @@ type SettingsTab =
| 'aiCommon'
| 'insight'
| 'aiFootprint'
| '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,10 +57,11 @@ 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' | 'aiMessageInsight'>; label: string }> = [
{ id: 'aiCommon', label: '基础配置' },
{ id: 'insight', label: 'AI 见解' },
{ id: 'aiFootprint', label: 'AI 足迹' }
{ id: 'aiFootprint', label: 'AI 足迹' },
{ id: 'aiMessageInsight', label: '消息解析' }
]
const isMac = navigator.userAgent.toLowerCase().includes('mac')
@@ -327,6 +329,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = 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 +377,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}, [location.state])
useEffect(() => {
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') {
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiMessageInsight') {
setAiGroupExpanded(true)
}
}, [activeTab])
@@ -590,6 +595,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
const savedAiMessageInsightEnabled = await configService.getAiMessageInsightEnabled()
const savedAiMessageInsightContextCount = await configService.getAiMessageInsightContextCount()
const savedAiMessageInsightSystemPrompt = await configService.getAiMessageInsightSystemPrompt()
setAiInsightEnabled(savedAiInsightEnabled)
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
@@ -616,6 +624,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
setAiFootprintEnabled(savedAiFootprintEnabled)
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
setAiMessageInsightEnabled(savedAiMessageInsightEnabled)
setAiMessageInsightContextCount(savedAiMessageInsightContextCount)
setAiMessageInsightSystemPrompt(savedAiMessageInsightSystemPrompt)
} catch (e: any) {
console.error('加载配置失败:', e)
@@ -4021,6 +4032,107 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</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 +5161,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 === 'aiMessageInsight') ? 'active' : ''}`}
onClick={() => setAiGroupExpanded((prev) => !prev)}
aria-expanded={aiGroupExpanded}
>
@@ -5091,6 +5203,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{activeTab === 'aiCommon' && renderAiCommonTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
{activeTab === 'aiMessageInsight' && renderAiMessageInsightTab()}
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
{activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}

View File

@@ -120,6 +120,9 @@ export const CONFIG_KEYS = {
// AI 足迹
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
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 +2178,36 @@ export async function setAiFootprintSystemPrompt(prompt: string): Promise<void>
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
}
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

View File

@@ -21,7 +21,24 @@ export interface SocialSaveWeiboCookieResult {
error?: string
}
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | '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
}
@@ -1320,6 +1357,18 @@ 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 }>
}
}