mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-06-01 07:36:47 +00:00
Merge branch 'dev' into fix/sns-thumb-and-backup-v2
This commit is contained in:
@@ -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 || ''),
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
@@ -1818,6 +1813,14 @@ function registerIpcHandlers() {
|
||||
return insightService.triggerTest()
|
||||
})
|
||||
|
||||
ipcMain.handle('insight:triggerSessionInsight', async (_, payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => {
|
||||
return insightService.triggerSessionInsight(payload)
|
||||
})
|
||||
|
||||
ipcMain.handle('insight:generateFootprintInsight', async (_, payload: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
@@ -1834,6 +1837,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 +2367,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 +3231,8 @@ function registerIpcHandlers() {
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath,
|
||||
userDataPath,
|
||||
logEnabled
|
||||
logEnabled,
|
||||
isPackaged: app.isPackaged
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3344,7 +3363,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 +3431,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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -583,6 +583,11 @@ 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),
|
||||
generateFootprintInsight: (payload: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
@@ -595,7 +600,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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -79,8 +84,29 @@ interface SharedAiModelConfig {
|
||||
maxTokens: number
|
||||
}
|
||||
|
||||
interface SessionInsightTriggerResult {
|
||||
success: boolean
|
||||
message: string
|
||||
recordId?: string
|
||||
insight?: string
|
||||
skipped?: boolean
|
||||
notificationEnabled?: boolean
|
||||
}
|
||||
|
||||
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 +187,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 +243,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 +256,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 +282,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()) {
|
||||
@@ -465,11 +546,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 +566,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()
|
||||
@@ -590,6 +715,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 +1425,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 +1446,7 @@ ${topMentionText}
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成')
|
||||
return
|
||||
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||
}
|
||||
|
||||
// ── 构建 prompt ────────────────────────────────────────────────────────────
|
||||
@@ -1210,9 +1536,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 +1603,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 +1619,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}` }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +128,22 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
|
||||
|
||||
let fixedUrl = url.replace('http://', 'https://')
|
||||
|
||||
// 只有非视频(即图片)才需要处理缩略图路径变 /0(获取原图)
|
||||
// 支持 /150、/200、/480 等常见的缩略图尺寸
|
||||
// 只有非视频(即图片)才需要处理路径末尾的尺寸标识(/150、/200等)变为 /0
|
||||
if (!isVideo) {
|
||||
fixedUrl = fixedUrl.replace(/\/(150|200|480)($|\?)/, '/0$2')
|
||||
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) {
|
||||
@@ -705,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
|
||||
@@ -717,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
|
||||
}
|
||||
@@ -1182,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
|
||||
|
||||
Reference in New Issue
Block a user