From 52ba55ee801ea408567e293e7411c1e8e5385c93 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 21 May 2026 00:10:17 +0800 Subject: [PATCH 1/2] feat: Add manually trigger AI insights in conversations --- electron/main.ts | 8 ++ electron/preload.ts | 5 ++ electron/services/insightRecordService.ts | 2 +- electron/services/insightService.ts | 77 +++++++++++++++++-- .../Export/ExportDateRangeDialog.tsx | 3 +- src/pages/Chat/ChatHeader.tsx | 16 ++++ src/pages/ChatPage.scss | 23 ++++++ src/pages/ChatPage.tsx | 76 +++++++++++++++++- src/pages/InsightInboxPage.scss | 5 ++ src/pages/InsightInboxPage.tsx | 1 + src/types/electron.d.ts | 7 +- 11 files changed, 211 insertions(+), 12 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index ff1d5f3..fdc8fc8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1819,6 +1819,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: { diff --git a/electron/preload.ts b/electron/preload.ts index 3df0c44..6d85b53 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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: { diff --git a/electron/services/insightRecordService.ts b/electron/services/insightRecordService.ts index e2a049b..b36b203 100644 --- a/electron/services/insightRecordService.ts +++ b/electron/services/insightRecordService.ts @@ -4,7 +4,7 @@ import path from 'path' import { createHash, randomUUID } from 'crypto' import { ConfigService } from './config' -export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'message_analysis' +export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis' export type InsightRecordSourceType = 'insight' | 'message_analysis' export interface MessageInsightAnalysis { diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index 1934e1c..ed48173 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -84,6 +84,15 @@ 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 { @@ -537,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, @@ -554,6 +566,47 @@ class InsightService { } } + /** + * 手动对指定会话立即触发一次 AI 见解。 + * 只新增触发入口;实际上下文、朋友圈/微博拼接、prompt 和入库仍走 generateInsightForSession。 + */ + async triggerSessionInsight(params: { + sessionId: string + displayName?: string + avatarUrl?: string + }): Promise { + 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() @@ -1372,10 +1425,10 @@ ${afterText} displayName: string triggerReason: InsightRecordTriggerReason silentDays?: number - }): Promise { + }): Promise { 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 @@ -1393,7 +1446,7 @@ ${afterText} if (!apiBaseUrl || !apiKey) { insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成') - return + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } } // ── 构建 prompt ──────────────────────────────────────────────────────────── @@ -1483,9 +1536,9 @@ ${afterText} // 模型主动选择跳过 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}` @@ -1550,6 +1603,15 @@ ${afterText} 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', @@ -1557,6 +1619,7 @@ ${afterText} `错误信息:${(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}` } } } diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index 497251c..0bde562 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -557,8 +557,7 @@ export function ExportDateRangeDialog({ event.stopPropagation() onClose() }} - > -
event.stopPropagation()}> + >
event.stopPropagation()}>

{title}

+ {!standaloneSessionWindow && isGroupChat && (
)} + {sessionInsightHint && ( +
+ {isTriggeringSessionInsight ? : } + {sessionInsightHint.message} +
+ )} + setChatSnsTimelineTarget(null)} diff --git a/src/pages/InsightInboxPage.scss b/src/pages/InsightInboxPage.scss index 289a3c7..764ab48 100644 --- a/src/pages/InsightInboxPage.scss +++ b/src/pages/InsightInboxPage.scss @@ -265,6 +265,11 @@ color: #5b55a0; background: rgba(99, 102, 241, 0.12); } + + &.manual { + color: #0f766e; + background: rgba(20, 184, 166, 0.13); + } } .insight-source-pill { diff --git a/src/pages/InsightInboxPage.tsx b/src/pages/InsightInboxPage.tsx index 0a08ff4..5310039 100644 --- a/src/pages/InsightInboxPage.tsx +++ b/src/pages/InsightInboxPage.tsx @@ -67,6 +67,7 @@ function getTriggerLabel(reason: InsightRecordTriggerReason): string { if (reason === 'message_analysis') return '深度解析' if (reason === 'silence') return '沉默提醒' if (reason === 'test') return '测试见解' + if (reason === 'manual') return '手动触发' return '活跃分析' } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 2c8807a..ecfddc8 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -21,7 +21,7 @@ export interface SocialSaveWeiboCookieResult { error?: string } -export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'message_analysis' +export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis' export type InsightRecordSourceType = 'insight' | 'message_analysis' export interface MessageInsightAnalysis { @@ -1344,6 +1344,11 @@ export interface ElectronAPI { markRecordRead: (id: string) => Promise<{ success: boolean; error?: string }> clearRecords: (filters?: InsightRecordFilters) => Promise<{ success: boolean; removed: number; error?: string }> triggerTest: () => Promise<{ success: boolean; message: string }> + triggerSessionInsight: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + }) => Promise<{ success: boolean; message: string; recordId?: string; insight?: string; skipped?: boolean; notificationEnabled?: boolean }> generateFootprintInsight: (payload: { rangeLabel: string summary: { From 89eef5a9229d3d001fbc84c6e1f997c559353386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=B4=9B=E6=B4=9B?= <2963144227@qq.com> Date: Fri, 22 May 2026 22:14:53 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9C=8B=E5=8F=8B?= =?UTF-8?q?=E5=9C=88=E5=9B=BE=E7=89=87=E6=98=BE=E7=A4=BA"=E5=B7=B2?= =?UTF-8?q?=E5=88=A0=E9=99=A4"=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E5=8D=A1=E4=BD=8F=E7=9A=84=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=20Bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. snsService.ts: fixSnsUrl 正则只处理 /150 缩略图路径, 实际 CDN 返回 /200 导致 HTTP 400,改为支持 /150|200|480 2. backupService.ts: inspectBackup 缺少完成事件导致界面卡死, ensureConnected 中 accountDirName 变量名未定义 --- electron/services/backupService.ts | 7 +++++-- electron/services/snsService.ts | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts index e26a4a4..d3d0072 100644 --- a/electron/services/backupService.ts +++ b/electron/services/backupService.ts @@ -467,10 +467,10 @@ export class BackupService { ) if (!opened) { const detail = await wcdbService.getLastInitError().catch(() => null) - return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` } + return { success: false, error: detail || `目标账号 ${accountDir} 数据库连接失败` } } - return { success: true, wxid: accountDirName, dbPath, dbStorage } + return { success: true, wxid: accountDir, dbPath, dbStorage } } private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string { @@ -857,10 +857,13 @@ export class BackupService { if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' } const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) { + emitBackupProgress({ phase: 'failed', message: '不支持的备份包格式' }) return { success: false, error: '不支持的备份包格式' } } + emitBackupProgress({ phase: 'done', message: '备份包已读取' }) return { success: true, manifest } } catch (e) { + emitBackupProgress({ phase: 'failed', message: e instanceof Error ? e.message : String(e) }) return { success: false, error: e instanceof Error ? e.message : String(e) } } finally { if (extractDir) { diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 65d4941..9cde3e1 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -126,9 +126,10 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { let fixedUrl = url.replace('http://', 'https://') - // 只有非视频(即图片)才需要处理 /150 变 /0 + // 只有非视频(即图片)才需要处理缩略图路径变 /0(获取原图) + // 支持 /150、/200、/480 等常见的缩略图尺寸 if (!isVideo) { - fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1') + fixedUrl = fixedUrl.replace(/\/(150|200|480)($|\?)/, '/0$2') } if (!token || fixedUrl.includes('token=')) return fixedUrl @@ -2060,6 +2061,8 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class const zlib = require('zlib') const urlObj = new URL(url) + console.log(`[SnsService] 开始下载图片: url=${url.substring(0, 100)}..., key=${key || 'undefined'}`) + const options = { hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search, @@ -2074,7 +2077,9 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } const req = https.request(options, (res: any) => { + console.log(`[SnsService] CDN 响应: statusCode=${res.statusCode}, x-enc=${res.headers['x-enc']}, content-type=${res.headers['content-type']}`) if (res.statusCode !== 200 && res.statusCode !== 206) { + console.error(`[SnsService] CDN 请求失败: HTTP ${res.statusCode}`) resolve({ success: false, error: `HTTP ${res.statusCode}` }) return } @@ -2094,9 +2099,11 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class let decoded = raw const rawMagicMime = detectImageMime(raw, '') + console.log(`[SnsService] 原始数据: size=${raw.length}, mime=${rawMagicMime}, xEnc=${xEnc}`) // 图片逻辑 const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0 + console.log(`[SnsService] 解密判断: shouldDecrypt=${shouldDecrypt}, key=${key || 'undefined'}`) if (shouldDecrypt) { try { const keyStr = String(key).trim() @@ -2112,6 +2119,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } const decryptedMagicMime = detectImageMime(decrypted, '') + console.log(`[SnsService] 解密后: mime=${decryptedMagicMime}`) if (decryptedMagicMime.startsWith('image/')) { decoded = decrypted } else if (!rawMagicMime.startsWith('image/')) { @@ -2124,7 +2132,9 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } const decodedMagicMime = detectImageMime(decoded, '') + console.log(`[SnsService] 最终结果: mime=${decodedMagicMime}, isImage=${decodedMagicMime.startsWith('image/')}`) if (!decodedMagicMime.startsWith('image/')) { + console.error(`[SnsService] 图片解密失败: 原始mime=${rawMagicMime}, 解密后mime=${decodedMagicMime}, key=${key}`) resolve({ success: false, error: '图片解密失败:无法识别图片格式' }) return }