From 1b7197cdef21cac0424228afcf7da86c1aa09c9c Mon Sep 17 00:00:00 2001 From: chadwbur Date: Sun, 19 Apr 2026 21:08:50 +0800 Subject: [PATCH 01/22] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=E8=81=8A=E5=A4=A9=E5=88=97=E8=A1=A8=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E6=B4=BB=E8=B7=83=E6=97=B6=E9=97=B4=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E6=8C=89=E8=AE=B0=E5=BD=95=E6=95=B0?= =?UTF-8?q?&=E8=81=8A=E5=A4=A9=E6=8E=92=E5=BA=8F=E3=80=82=E6=96=B0?= =?UTF-8?q?=E5=A2=9EAI=E7=94=9F=E6=88=90=E7=9A=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=88=86=E6=9E=90=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- docs/ARCHITECTURE.md | 596 ++++++++++++++++++++++++++++++++++++++ src/pages/ExportPage.scss | 92 +++++- src/pages/ExportPage.tsx | 184 ++++++++++-- 4 files changed, 852 insertions(+), 24 deletions(-) create mode 100644 docs/ARCHITECTURE.md diff --git a/.gitignore b/.gitignore index 25fdeab..787e866 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,6 @@ pnpm-lock.yaml wechat-research-site .codex weflow-web-offical -/Wedecrypt \ No newline at end of file +/Wedecrypt +.codebuddy/ +.DS_Store \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3c9b02b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,596 @@ +# WeFlow 项目蓝图与架构文档 + +> 版本:对应 `package.json` v4.3.0 · 生成时间:2026-04-17 +> 适用于开发者、新成员上手、Agent(CodeBuddy 等)自动化协作 + +--- + +## 1. 项目定位 + +**WeFlow** 是一个**完全本地**的微信 4.0+ 聊天记录查看、分析与导出的桌面应用。 + +| 维度 | 说明 | +|------|------| +| 产品形态 | Electron 桌面应用(Windows / macOS / Linux) | +| 核心诉求 | 实时查看 & 解密本地微信数据库、生成聊天分析 / 年度报告 / 双人报告、导出多格式、朋友圈解密 | +| 数据边界 | 全部本地运行,无云端上传;可选开放本地 HTTP API(端口 5031) | +| 许可 | 见 `LICENSE` | +| 版本策略 | electron-updater + GitHub Releases,支持自动更新与差分包 | + +### 1.1 关键功能矩阵 + +| 模块 | 能力概述 | 主要代码落点 | +|------|---------|-------------| +| 实时聊天查看 | 消息列表、撤回防护、实时刷新 | [ChatPage.tsx](src/pages/ChatPage.tsx) + [chatService.ts](electron/services/chatService.ts) | +| 图片/视频/实况解密 | XOR / AES + ffmpeg 转码 | [imageDecryptService.ts](electron/services/imageDecryptService.ts), [videoService.ts](electron/services/videoService.ts) | +| 私聊/群聊分析 | 统计消息、时段、画像 | [analyticsService.ts](electron/services/analyticsService.ts), [groupAnalyticsService.ts](electron/services/groupAnalyticsService.ts) | +| 年度 / 双人报告 | 跨年数据生成、可视化 | [annualReportService.ts](electron/services/annualReportService.ts), [dualReportService.ts](electron/services/dualReportService.ts) + 对应 Worker | +| 导出 | JSON / HTML / TXT / Excel / CSV / ChatLab | [exportService.ts](electron/services/exportService.ts) + [exportWorker.ts](electron/exportWorker.ts) | +| 朋友圈 | 图片/视频/实况解密、时间突破 | [snsService.ts](electron/services/snsService.ts) + [SnsPage.tsx](src/pages/SnsPage.tsx) | +| HTTP API | 本地消息 API 服务 | [httpService.ts](electron/services/httpService.ts) + [docs/HTTP-API.md](docs/HTTP-API.md) | +| 语音转写 | sherpa-onnx ASR + silk-wasm | [voiceTranscribeService.ts](electron/services/voiceTranscribeService.ts) + [transcribeWorker.ts](electron/transcribeWorker.ts) | +| 通知与防撤回 | 桌面弹窗、黑白名单 | [messagePushService.ts](electron/services/messagePushService.ts), [notificationWindow.ts](electron/windows/notificationWindow.ts) | +| 应用锁 | Windows Hello / 系统凭据 | [windowsHelloService.ts](electron/services/windowsHelloService.ts) + [LockScreen.tsx](src/components/LockScreen.tsx) | + +--- + +## 2. 技术栈总览 + +### 2.1 运行时 & 构建 + +| 层 | 技术 | 说明 | +|----|------|------| +| Shell | Electron 41 | 主进程 + 渲染进程分离 | +| 渲染进程 | React 19 + TypeScript 6 | 使用 `react-router-dom@7` 路由 | +| 构建 | Vite 7 + `vite-plugin-electron` + `vite-plugin-electron-renderer` | 一次构建产出主/渲染/Worker | +| 样式 | SCSS(`sass`) + 组件局部样式 | 深浅色 + 主题色切换(`data-theme` / `data-mode`) | +| 打包 | electron-builder 26 | Win `.exe`(NSIS)、macOS `.dmg/.zip`、Linux `AppImage/tar.gz` | +| 自动更新 | electron-updater + GitHub provider | 差分包关闭,支持强制更新 | + +### 2.2 核心依赖 + +| 类别 | 库 | 用途 | +|------|----|------| +| 状态管理 | `zustand` | 轻量全局 store,见 `src/stores/` | +| UI 图标 | `lucide-react` | 图标系统 | +| 图表 | `echarts` + `echarts-for-react` | 分析与报告可视化 | +| 长列表 | `react-virtuoso` | 聊天消息虚拟滚动 | +| Markdown | `react-markdown` + `remark-gfm` | 报告与富文本 | +| 数据库 | `wcdb` 原生 + `koffi` FFI | 微信 SQLite/WCDB 加密数据库读取 | +| 多媒体 | `ffmpeg-static`、`silk-wasm`、`sherpa-onnx-node` | 视频解码、silk 语音、ASR | +| 中文分词 | `jieba-wasm` | 词云与分析 | +| 配置 | `electron-store` | JSON 持久化 | +| 导出 | `exceljs`、`jszip`、`html2canvas` | 多格式产物 | +| 辅助 | `fzstd`、`wechat-emojis`、`sudo-prompt` | zstd 解压、表情、提权 | + +--- + +## 3. 目录蓝图 + +``` +WeFlow/ +├── electron/ # 主进程与 Worker(Node.js 环境) +│ ├── main.ts # 主进程入口(≈122KB,IPC 汇聚点) +│ ├── preload.ts # 预加载脚本,暴露 window.electronAPI +│ ├── preload-env.ts # 环境预加载(启动前) +│ ├── annualReportWorker.ts # 年度报告工作线程 +│ ├── dualReportWorker.ts # 双人报告工作线程 +│ ├── exportWorker.ts # 导出工作线程 +│ ├── imageSearchWorker.ts # 图像检索/遍历工作线程 +│ ├── transcribeWorker.ts # 语音转写工作线程 +│ ├── wcdbWorker.ts # WCDB 读取工作线程 +│ ├── services/ # 业务服务(按域拆分) +│ ├── windows/notificationWindow.ts # 独立通知窗口 +│ ├── utils/LRUCache.ts # 主进程工具 +│ ├── assets/wasm/ # wasm 资源(jieba、silk 等) +│ └── types/ # 原生模块类型声明 +│ +├── src/ # 渲染进程(React) +│ ├── main.tsx / App.tsx / App.scss # 入口与根路由 +│ ├── pages/ # 页面级组件(大文件集中地) +│ ├── components/ # 通用/业务组件 +│ │ ├── Export/ # 导出子组件 +│ │ └── Sns/ # 朋友圈子组件 +│ ├── services/ # 渲染层服务(通过 IPC 调用主进程) +│ ├── stores/ # Zustand 全局状态 +│ ├── styles/ # 全局样式与主题 +│ ├── types/ # 渲染层类型 +│ └── utils/ # 工具函数 +│ +├── docs/ # 项目文档(HTTP-API、架构文档等) +├── resources/ # 平台资源:icons / runtime / wcdb / key / installer +├── public/ # 前端静态资源 + splash +├── .github/workflows/ # CI:release、nightly、security-scan 等 +├── AGENTS.md # Agent 协作规则 +├── README.md # 用户说明 +├── package.json # 依赖 + electron-builder 配置 +├── vite.config.ts # 构建管线(1 主 + 7 Worker + preload) +├── tsconfig.json / tsconfig.node.json +├── installer.nsh # NSIS 安装脚本 +└── .gitleaks.toml # 密钥扫描配置 +``` + +--- + +## 4. 架构总览 + +### 4.1 高层架构图 + +```mermaid +flowchart TB + subgraph User["用户"] + U([👤]) + end + + subgraph Renderer["渲染进程 (Chromium + React 19)"] + UI[["Pages / Components"]] + Stores[(Zustand Stores)] + RSvc["渲染层服务\n(src/services)"] + UI <--> Stores + UI --> RSvc + end + + subgraph Preload["preload.ts\n(contextBridge)"] + API["window.electronAPI"] + end + + subgraph Main["主进程 (Node.js)"] + IPC[["ipcMain\nhandlers (main.ts)"]] + subgraph Services["electron/services/*"] + Chat[chatService] + Export[exportService] + Image[imageDecryptService] + Sns[snsService] + Analytics[analyticsService] + GAnalytics[groupAnalyticsService] + Annual[annualReportService] + Dual[dualReportService] + Http[httpService] + Key[keyService + Mac/Linux/WindowsHello] + Wcdb[wcdbService / wcdbCore] + Voice[voiceTranscribeService] + Msg[messagePushService] + Cfg[config] + end + subgraph Workers["Utility Workers (vite 独立打包)"] + W1[wcdbWorker] + W2[exportWorker] + W3[annualReportWorker] + W4[dualReportWorker] + W5[transcribeWorker] + W6[imageSearchWorker] + end + IPC --> Services + Services --> Workers + end + + subgraph OS["操作系统 & 外部资源"] + Fs[(本地文件系统\n微信数据目录)] + NativeLibs[(原生库\nkoffi / sherpa-onnx / ffmpeg / wcdb)] + Store[(electron-store\nJSON 配置)] + HttpClient[(外部 HTTP 客户端\nChatLab / 脚本)] + Updater[(GitHub Releases\nelectron-updater)] + end + + U --> UI + RSvc --> API + API <--> IPC + Services --> Fs + Services --> NativeLibs + Services --> Store + Http -. 暴露 5031 .-> HttpClient + Main -. 自动更新 .-> Updater +``` + +### 4.2 进程与线程模型 + +```mermaid +flowchart LR + Main[[主进程\nelectron/main.ts]] + Pre[[Preload\nelectron/preload.ts]] + R1[渲染:主窗口\nindex.html + App.tsx] + R2[渲染:通知窗口] + R3[渲染:视频/图片独立窗口] + R4[渲染:聊天记录窗口] + R5[渲染:年度/双人报告窗口] + W1((wcdbWorker)) + W2((exportWorker)) + W3((annualReportWorker)) + W4((dualReportWorker)) + W5((transcribeWorker)) + W6((imageSearchWorker)) + + Main --- Pre --- R1 + Main --- R2 + Main --- R3 + Main --- R4 + Main --- R5 + Main -. Worker_threads/child_process .-> W1 + Main -. 同上 .-> W2 + Main -. 同上 .-> W3 + Main -. 同上 .-> W4 + Main -. 同上 .-> W5 + Main -. 同上 .-> W6 +``` + +> 关键设计:**CPU 密集或长耗任务全部外包到独立 Worker**,通过 `vite.config.ts` 的多 entry 独立打包。`inlineDynamicImports: true` 保证 worker 单文件产物可 `new Worker(path)` 直接加载。 + +### 4.3 通信契约(IPC 命名空间) + +`electron/main.ts` 中 `ipcMain.handle` / `ipcMain.on` 采用 **前缀:动作** 命名空间,统一走 [preload.ts](electron/preload.ts) 暴露的 `window.electronAPI..`: + +| 命名空间 | 典型通道 | 所属服务 | +|---------|---------|---------| +| `config:*` | `get` / `set` / `clear` | `config.ts` | +| `dialog:*` | `openFile` / `openDirectory` / `saveFile` | Electron `dialog` | +| `shell:*` | `openPath` / `openExternal` | Electron `shell` | +| `app:*` | `getVersion` / `checkForUpdates` / `downloadAndInstall` / `ignoreUpdate` / `getLaunchAtStartupStatus` | 主进程 + updater | +| `window:*` | `minimize` / `maximize` / `close` / `isMaximized` / `openVideoPlayerWindow` / `openChatHistoryWindow` / `openSessionChatWindow` / `respondCloseConfirm` / `setTitleBarOverlay` | 主窗口管理 | +| `log:*` / `diagnostics:*` | 日志读取、导出卡片诊断 | 日志系统 | +| `cloud:*` | `init` / `recordPage` / `getLogs` | `cloudControlService` | +| `insight:*` | `testConnection` / `getTodayStats` / `triggerTest` / `generateFootprintInsight` | `insightService` | +| `video:*` | `getVideoInfo` / `parseVideoMd5` | `videoService` | +| `dbpath:*` | `autoDetect` / `scanWxids` / `scanWxidCandidates` / `getDefault` | `dbPathService` | +| `wcdb:*` | `testConnection` / `open` / `close` | `wcdbService` | +| `chat:*` | 会话、消息、联系人等(见 `preload.ts`) | `chatService` | +| `export:*` | 导出任务、进度、取消 | `exportService` | +| `sns:*` | 朋友圈列表、解密 | `snsService` | +| `analytics:*` / `group-analytics:*` | 统计查询 | 对应 Service | +| `annual-report:*` / `dual-report:*` | 报告生成 | 对应 Service | +| `voice:*` | 语音转写 | `voiceTranscribeService` | +| `auth:*` | 应用锁状态 | `keyService*` / `windowsHelloService` | +| `notification:*` | 新消息通知与跳转 | `messagePushService` + `notificationWindow` | + +> 上述仅为骨架,完整 IPC 契约以 [preload.ts](electron/preload.ts) 为唯一来源(≈28KB,包含完整白名单与类型)。 + +--- + +## 5. 渲染进程结构 + +### 5.1 路由蓝图 + +基于 [App.tsx](src/App.tsx) 的实际路由: + +```mermaid +flowchart TB + Start((启动)) --> Gate{路由判定} + Gate -->|"/agreement-window"| AgreeWin[AgreementPage · 独立窗口] + Gate -->|"/onboarding-window"| Welcome[WelcomePage · standalone] + Gate -->|"/video-player-window"| VideoWin[VideoWindow] + Gate -->|"/image-viewer-window"| ImgWin[ImageWindow] + Gate -->|"/chat-history/..."| HistWin[ChatHistoryPage] + Gate -->|"/chat-window"| ChatStandalone[ChatPage · standalone] + Gate -->|"/notification-window"| NotifWin[NotificationWindow] + + Gate -->|主窗口| Main[[主布局:\nTitleBar + Sidebar + Content]] + Main --> Home[/home: HomePage/] + Main --> AcctMgmt[/account-management/] + Main --> Chat[/chat: ChatPage/] + Main --> AnalyticsHub[/analytics: ChatAnalyticsHubPage/] + AnalyticsHub --> PrivAnaWel[/analytics/private: Welcome/] + AnalyticsHub --> PrivAna[/analytics/private/view/] + AnalyticsHub --> GroupAna[/analytics/group/] + Main --> Annual[/annual-report + /view/] + Main --> Dual[/dual-report + /view/] + Main --> Footprint[/footprint/] + Main --> Export[/export: ExportPage · keepalive/] + Main --> Sns[/sns: SnsPage/] + Main --> Biz[/biz: BizPage/] + Main --> Contacts[/contacts: ContactsPage/] + Main --> Res[/resources: ResourcesPage/] + + Main -. 叠加 .-> Settings[/settings: SettingsPage · 背景路由切换/] +``` + +**亮点**: +- **ExportPage 采用 keep-alive 模式**:用 `export-keepalive-page` DOM 容器常驻,仅切换 `active/hidden` class,避免长任务重置。 +- **Settings 路由叠加**:通过 `location.state.backgroundLocation` 实现在任意页面上浮设置面板。 +- **多独立窗口**:视频、图片、通知、聊天记录、会话聊天、年度/双人报告均有独立 `BrowserWindow`。 + +### 5.2 Zustand Stores + +| Store | 文件 | 职责 | +|-------|------|------| +| `useAppStore` | `src/stores/appStore.ts` | 数据库连接状态、更新信息、锁屏 | +| `useThemeStore` | `src/stores/themeStore.ts` | 主题 ID / 模式(light/dark/system) | +| `useChatStore` | `src/stores/chatStore.ts` | 当前会话、选中消息 | +| `useAnalyticsStore` | `src/stores/analyticsStore.ts` | 分析页参数与缓存 | +| `useImageStore` | `src/stores/imageStore.ts` | 图片解密结果缓存 | +| `useBatchImageDecryptStore` | `src/stores/batchImageDecryptStore.ts` | 全局批量解密进度 | +| `useBatchTranscribeStore` | `src/stores/batchTranscribeStore.ts` | 全局批量转写进度 | +| `useContactTypeCountsStore` | `src/stores/contactTypeCountsStore.ts` | 联系人分类计数缓存 | + +### 5.3 渲染层服务 + +| 文件 | 角色 | +|------|------| +| `src/services/ipc.ts` | IPC 桥接基础(与 preload 对齐) | +| `src/services/config.ts` | 配置封装(73KB,大量常量与 setter/getter) | +| `src/services/exportBridge.ts` | 导出事件桥(主→渲染进度广播) | +| `src/services/cloudControl.ts` | 云控 / 统计上报(仅在用户同意后启用) | +| `src/services/backgroundTaskMonitor.ts` | 后台任务监测(解密、转写、导出) | + +--- + +## 6. 主进程服务层(`electron/services/`) + +### 6.1 服务地图(按规模分层) + +```mermaid +flowchart LR + subgraph Core["核心数据层 (超大)"] + ChatService["chatService.ts\n389KB"] + WcdbCore["wcdbCore.ts\n177KB"] + ExportService["exportService.ts\n343KB"] + SnsService["snsService.ts\n105KB"] + ImageDecrypt["imageDecryptService.ts\n75KB"] + GroupAnalytics["groupAnalyticsService.ts\n74KB"] + AnnualReport["annualReportService.ts\n58KB"] + KeyMac["keyServiceMac.ts\n51KB"] + end + + subgraph Feature["功能层 (中大)"] + HttpSvc["httpService.ts\n62KB"] + InsightSvc["insightService.ts\n44KB"] + KeyService["keyService.ts\n39KB"] + Config["config.ts\n30KB"] + DualReport["dualReportService.ts\n30KB"] + AnalyticsSvc["analyticsService.ts\n24KB"] + WcdbService["wcdbService.ts\n24KB"] + VideoSvc["videoService.ts\n25KB"] + VoiceSvc["voiceTranscribeService.ts\n17KB"] + MessagePush["messagePushService.ts\n16KB"] + KeyLinux["keyServiceLinux.ts\n16KB"] + end + + subgraph Util["工具与缓存 (小)"] + DbPath[dbPathService] + BizSvc[bizService] + Cloud[cloudControlService] + Wasm[wasmService] + Isaac[isaac64] + Avatar[avatarFileCacheService] + Contact[contactCacheService] + ContactExp[contactExportService] + GMsgCnt[groupMyMessageCountCacheService] + Session[sessionStatsCacheService] + Stats[exportContentStatsCacheService] + Rec[exportRecordService] + ImgPre[imagePreloadService] + MsgCache[messageCacheService] + Linux[linuxNotificationService] + Hello[windowsHelloService] + Styles[exportHtmlStyles] + CardDiag[exportCardDiagnosticsService] + Region[contactRegionLookupData] + end + + ChatService --> WcdbCore + ExportService --> ChatService + SnsService --> WcdbCore + ImageDecrypt --> WcdbCore + GroupAnalytics --> ChatService + AnnualReport --> ChatService + DualReport --> ChatService + AnalyticsSvc --> ChatService + HttpSvc --> ChatService + InsightSvc --> ChatService +``` + +### 6.2 关键服务职责 + +| 服务 | 核心职责 | 特别说明 | +|------|---------|---------| +| `chatService` | 会话列表、消息读取、媒体定位、实时刷新 | 超大文件,必须用 `codebase_search` / `view_code_item` 定位后再改 | +| `wcdbCore` / `wcdbService` | 基于 `koffi` FFI 调用 WCDB 原生库,解密读取微信 SQLite | 跨平台原生库放在 `resources/wcdb//` | +| `keyService*` | 获取微信解密密钥(Win/Mac/Linux) | Mac 51KB:涉及内存扫描;Linux 独立实现;Win 通过 `windowsHelloService` 辅助 | +| `imageDecryptService` | XOR / AES 解密图片、实况图片 | LRU 缓存 + 批量调度(配合 `batchImageDecryptStore`) | +| `videoService` | 视频 md5 解析、ffmpeg 转码、生成封面 | 依赖 `ffmpeg-static` + `asarUnpack` | +| `voiceTranscribeService` | silk→wav→sherpa-onnx ASR | 通过 `transcribeWorker` 异步执行 | +| `snsService` | 朋友圈解密、导出、时间限制突破 | 单独的 `Sns/` 组件族对应 | +| `analyticsService` / `groupAnalyticsService` | 私聊/群聊统计分析、排行、时段分布 | 依赖 `jieba-wasm` 分词 | +| `annualReportService` / `dualReportService` | 年度/双人报告生成 | 将重算任务派给对应 Worker | +| `exportService` + `exportWorker` | 多格式导出,分任务并发 + 进度广播 | HTML 导出样式来自 [exportHtml.css](electron/services/exportHtml.css) | +| `httpService` | 本地 HTTP API 服务(默认 5031) | 详见 [docs/HTTP-API.md](docs/HTTP-API.md) | +| `messagePushService` | 新消息监听 + 通知窗口推送 | 黑白名单、防撤回 | +| `insightService` | 「我的足迹」洞察 / AI 辅助洞察 | 支持 Footprint 生成 | +| `config` | 使用 `electron-store` 的 JSON 配置中台 | 多 wxid 配置、密钥、主题、协议同意等 | +| `cloudControlService` | 开关/页面统计(用户同意后) | 完全本地化的云控模型 | + +--- + +## 7. 核心数据流 + +### 7.1 启动 & 连接数据库流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant App as App.tsx + participant Cfg as configService + participant IPC as window.electronAPI + participant Main as main.ts + participant Wcdb as wcdbService + wcdbCore + + U->>App: 打开应用 + App->>Cfg: getAgreementAccepted + alt 未同意 + App-->>U: 显示协议弹窗 + else 已同意 + App->>Cfg: 读取 dbPath / decryptKey / wxid + alt 配置完整 + App->>IPC: chat.connect() + IPC->>Main: ipcMain.handle('chat:connect') + Main->>Wcdb: open(dbPath, hexKey, wxid) + Wcdb-->>Main: 成功/失败 + Main-->>IPC: { success } + IPC-->>App: setDbConnected(true) + App->>App: navigate('/home') + else 配置缺失 + App->>App: 引导 WelcomePage + end + end +``` + +### 7.2 聊天消息读取与图片解密 + +```mermaid +sequenceDiagram + participant UI as ChatPage + participant Store as chatStore + participant IPC as electronAPI.chat + participant Chat as chatService + participant WCore as wcdbCore(FFI) + participant Img as imageDecryptService + participant W as imageSearchWorker + + UI->>IPC: listMessages(sessionId, range) + IPC->>Chat: 查询消息 + Chat->>WCore: SQL via koffi + WCore-->>Chat: 原始消息 + Chat-->>UI: 消息列表(含图片引用) + UI->>Store: 写入消息 + UI->>IPC: decryptImage(md5/hash) + IPC->>Img: 解密请求 + alt 需要遍历目录 + Img->>W: postMessage(search) + W-->>Img: 文件路径 + end + Img-->>IPC: 解密后的路径/blob + IPC-->>UI: 图片数据 +``` + +### 7.3 导出任务生命周期 + +```mermaid +stateDiagram-v2 + [*] --> Queued: 用户点击导出 + Queued --> Running: exportService 派发 + Running --> WorkerRun: exportWorker 执行 + WorkerRun --> Progress: 进度广播(exportBridge) + Progress --> WorkerRun + WorkerRun --> Success: 成功 + WorkerRun --> Failed: 异常/取消 + Success --> [*] + Failed --> [*] +``` + +**进度通道**:`exportService` → 主进程事件 → `exportBridge.ts` → 渲染层订阅者(`ExportPage`)。 + +--- + +## 8. 构建与发布 + +### 8.1 Vite 多入口 + +[vite.config.ts](vite.config.ts) 声明 **8 个 entry**: + +| Entry | 产物 | 说明 | +|-------|------|------| +| `electron/main.ts` | `dist-electron/main.js` | 主进程 | +| `electron/preload.ts` | `dist-electron/preload.js` | 预加载 | +| `electron/annualReportWorker.ts` | `dist-electron/annualReportWorker.js` | 年度报告 worker | +| `electron/dualReportWorker.ts` | `dist-electron/dualReportWorker.js` | 双人报告 worker | +| `electron/imageSearchWorker.ts` | `dist-electron/imageSearchWorker.js` | 图像搜索 worker | +| `electron/wcdbWorker.ts` | `dist-electron/wcdbWorker.js` | WCDB worker | +| `electron/transcribeWorker.ts` | `dist-electron/transcribeWorker.js` | 语音转写 worker | +| `electron/exportWorker.ts` | `dist-electron/exportWorker.js` | 导出 worker | + +- `react(), renderer()` 插件处理渲染进程;`inlineDynamicImports: true` 确保 worker 单文件。 +- `external`:`koffi` / `better-sqlite3` / `sherpa-onnx-node` / `exceljs` / `ffmpeg-static` 不打包进 bundle,asar 外存放。 + +### 8.2 打包策略(electron-builder) + +| 平台 | Target | 关键配置 | +|------|--------|---------| +| Windows | `nsis` | `installer.nsh`、多语言安装器、VC++ 运行库随包 | +| macOS | `dmg` + `zip` | `hardenedRuntime: false`、`entitlements.mac.plist` | +| Linux | `AppImage` + `tar.gz` | 附带 `resources/linux/install.sh` | +| `asarUnpack` | `silk-wasm` / `sherpa-onnx-*` / `ffmpeg-static` | 原生/二进制模块不能进 asar | +| `extraResources` | `resources/**` + `public/icon.*` + `electron/assets/wasm/` | 运行时资源 | +| `publish` | GitHub `Jasonzhu1207/WeFlow` | 配合 `electron-updater` | + +### 8.3 CI 流水线(`.github/workflows/`) + +| 文件 | 作用 | +|------|------| +| `release.yml` | 发布打包 | +| `preview-nightly-main.yml` | Nightly 构建 | +| `dev-daily-fixed.yml` | Dev 日常 | +| `security-scan.yml` | 安全扫描(含 gitleaks) | +| `anti-spam.yml` | Issue 反垃圾 | +| `issue-auto-assign.yml` | Issue 自动分派 | + +--- + +## 9. 安全与合规设计 + +1. **数据本地化**:全部解密、分析、导出均在本地执行,不上传任何聊天内容(协议与隐私弹窗双重同意)。 +2. **密钥保护**: + - 微信 key 通过平台特定 `keyService*` 动态获取,不落盘; + - 应用锁可选 Windows Hello / 系统凭据; + - `.gitleaks.toml` 扫描源码防止密钥入库。 +3. **参数化查询**:所有 SQLite 查询通过 WCDB 参数化接口,避免拼接。 +4. **更新通道**:仅从 GitHub Releases 拉取,支持强制更新(`minimumVersion`)。 +5. **云控与统计**:`cloudControlService` 完全可选,用户可拒绝;默认不采集任何聊天内容。 +6. **IPC 白名单**:`preload.ts` 通过 `contextBridge` 仅暴露有限 API,渲染层无法直接访问 Node。 + +--- + +## 10. 性能关键点 + +| 热点 | 设计 | +|------|------| +| 消息列表(聊天动辄百万级) | `react-virtuoso` 虚拟滚动 + `chatService` 分页 + `LRUCache` | +| 图片解密批量(几千张) | `imageDecryptService` + `imageSearchWorker` + 全局进度 store | +| 年度/双人报告(跨年聚合) | 独立 Worker + 分块流式 + 缓存(`groupMyMessageCountCacheService`、`sessionStatsCacheService` 等) | +| 导出大体量 HTML/Excel | `exportWorker` + `jszip` 流式写入 + 进度广播 | +| 主题切换 & 样式 | CSS 变量 + `data-theme` / `data-mode` 根属性切换,无重渲染 | +| 启动速度 | `splash.html` 早期显示 + 主题预读 + DB 异步连接 | + +--- + +## 11. 扩展点(二开指南) + +| 场景 | 落点 | +|------|------| +| 新增页面 | `src/pages/` 新建 `XxxPage.tsx` + `.scss`,在 [App.tsx](src/App.tsx) 路由和 [Sidebar.tsx](src/components/Sidebar.tsx) 菜单登记 | +| 新增主进程能力 | `electron/services/` 新建 service,在 `main.ts` 添加 `ipcMain.handle('ns:action', ...)`,在 [preload.ts](electron/preload.ts) 暴露 → 渲染层新增对应 `src/services/` 或直接 `window.electronAPI.ns.action()` 调用 | +| 新增 Worker | `electron/xxxWorker.ts` 建立,在 [vite.config.ts](vite.config.ts) 添加 entry | +| 新增导出格式 | 扩展 `exportService` + `exportWorker`,UI 在 `src/components/Export/` 与 `ExportPage.tsx` 挂接 | +| 新增 HTTP API | `httpService.ts` 注册路由,同步更新 [docs/HTTP-API.md](docs/HTTP-API.md) | +| 新增主题 | `src/stores/themeStore.ts` 的 themes 列表 + `src/styles/` 主题 SCSS | +| 新语言支持 | 当前未接入 i18n,需要新增语言时优先引入 `react-i18next`(评估风险) | + +--- + +## 12. 已知风险与技术债 + +| 风险 | 说明 | 缓解建议 | +|------|------|---------| +| 超大文件 | `ChatPage.tsx` 397KB、`ExportPage.tsx` 402KB、`chatService.ts` 389KB、`exportService.ts` 343KB、`SettingsPage.tsx` 174KB | 新增内容尽量独立成文件;只在必须时才对这些文件进行精细化 diff,禁止盲目整读 | +| 原生模块兼容 | `koffi`、`sherpa-onnx-node`、`wcdb` 随平台/Electron 版本变化 | 升级前先 `npm run rebuild` 并跑三端冒烟 | +| 配置 migrate | `electron-store` schema 无正式迁移框架 | 新字段默认可 Optional;破坏性变更需加版本号判定 | +| 单一 `preload.ts` | 28KB,IPC 全部集中 | 保持字段分组;考虑按域拆分 preload 模块(需评估 `contextBridge` 成本) | +| 无自动化测试 | 缺少单元/集成测试 | 新功能按 AGENTS.md "Level 2 TDD" 策略补齐关键分支 | + +--- + +## 13. 快速上手路径(推荐) + +1. 通读 [README.md](README.md) + 本架构文档 +2. 阅读 [AGENTS.md](AGENTS.md) 了解协作与门禁 +3. 顺序浏览:[App.tsx](src/App.tsx) → [Sidebar.tsx](src/components/Sidebar.tsx) → [preload.ts](electron/preload.ts) → [main.ts](electron/main.ts)(搜索 `ipcMain.handle`) +4. 按域选择 Service 细读:聊天链路入口从 `chat:connect` / `chat:listSessions` 反查 [chatService.ts](electron/services/chatService.ts) +5. 运行 `npm install && npm run dev` 启动联调;`npm run typecheck` 验证 + +--- + +## 14. 参考文档 + +- [README.md](README.md) +- [docs/HTTP-API.md](docs/HTTP-API.md) +- [AGENTS.md](AGENTS.md) +- [vite.config.ts](vite.config.ts) +- [package.json](package.json) + diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 28e4f31..bb4237f 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1920,10 +1920,11 @@ --contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width)); --contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap)); --contacts-message-col-width: 120px; + --contacts-latest-time-col-width: 150px; --contacts-media-col-width: 72px; --contacts-action-col-width: 140px; --contacts-actions-sticky-width: 240px; - --contacts-table-min-width: 1240px; + --contacts-table-min-width: 1400px; overflow: hidden; border: none; border-radius: 12px; @@ -2174,6 +2175,59 @@ box-sizing: border-box; } + .contacts-list-header-latest-time { + width: var(--contacts-latest-time-col-width); + min-width: var(--contacts-latest-time-col-width); + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + text-align: center; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + + // 可排序的表头按钮通用样式 + .contacts-list-header-sortable { + background: transparent; + border: none; + padding: 4px 6px; + margin: 0; + color: inherit; + font: inherit; + cursor: pointer; + border-radius: 6px; + gap: 4px; + transition: background-color 0.12s ease, color 0.12s ease; + + &:hover { + background: color-mix(in srgb, var(--primary) 10%, transparent); + color: var(--primary); + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; + } + + &.is-active { + color: var(--primary); + } + } + + .contacts-list-header-sort-icon { + color: inherit; + flex-shrink: 0; + + &.muted { + color: var(--text-tertiary); + opacity: 0.6; + } + } + .contacts-list-header-media { width: var(--contacts-media-col-width); min-width: var(--contacts-media-col-width); @@ -2508,6 +2562,37 @@ box-sizing: border-box; } + .row-latest-time { + width: var(--contacts-latest-time-col-width); + min-width: var(--contacts-latest-time-col-width); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + text-align: center; + box-sizing: border-box; + } + + .row-latest-time-value { + margin: 0; + font-size: 12px; + line-height: 1.2; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + + &.muted { + color: var(--text-tertiary); + } + } + .row-media-metric { width: var(--contacts-media-col-width); min-width: var(--contacts-media-col-width); @@ -5058,6 +5143,7 @@ --contacts-name-text-width: 10em; --contacts-main-col-width: calc(44px + 10px + var(--contacts-name-text-width)); --contacts-message-col-width: 104px; + --contacts-latest-time-col-width: 128px; --contacts-media-col-width: 62px; --contacts-action-col-width: 140px; } @@ -5085,6 +5171,10 @@ min-width: var(--contacts-message-col-width); } + .table-wrap .row-latest-time { + min-width: var(--contacts-latest-time-col-width); + } + .table-wrap .row-media-metric { min-width: var(--contacts-media-col-width); } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 362960a..8c96b1f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -4,6 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { createPortal } from 'react-dom' import { Aperture, + ArrowDown, + ArrowUp, + ArrowUpDown, Calendar, Check, CheckSquare, @@ -638,6 +641,44 @@ const formatYmdHmDateTime = (timestamp?: number): string => { return `${y}-${m}-${day} ${h}:${min}` } +// 将秒级时间戳格式化为最新消息时间:24h 内显示相对时间,超过则显示 YYYY-MM-DD HH:mm。 +// 返回 { text, title } 以便上层 title 属性展示完整绝对时间。 +const formatLatestMessageTimeFromSeconds = ( + timestamp?: number, + now: number = Date.now() +): { text: string; title: string } => { + if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) { + return { text: '--', title: '' } + } + const ms = timestamp * 1000 + const absolute = formatYmdHmDateTime(ms) + const diff = Math.max(0, now - ms) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (diff < minute) { + return { text: '刚刚', title: absolute } + } + if (diff < hour) { + const minutes = Math.max(1, Math.floor(diff / minute)) + return { text: `${minutes} 分钟前`, title: absolute } + } + if (diff < day) { + const hours = Math.max(1, Math.floor(diff / hour)) + return { text: `${hours} 小时前`, title: absolute } + } + return { text: absolute, title: absolute } +} + +// 导出列表支持的排序维度。 +type ContactsSortKey = 'messageCount' | 'latestMessageTime' +type ContactsSortOrder = 'desc' | 'asc' +interface ContactsSortConfig { + key: ContactsSortKey | null + order: ContactsSortOrder | null +} +const DEFAULT_CONTACTS_SORT_CONFIG: ContactsSortConfig = { key: null, order: null } + const isSingleContactSession = (sessionId: string): boolean => { const normalized = String(sessionId || '').trim() if (!normalized) return false @@ -2184,6 +2225,21 @@ function ExportPage() { const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState(null) const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('') const [backgroundTasks, setBackgroundTasks] = useState([]) + // 会话列表排序状态:key=null 时回退默认顺序(按消息数降序)。 + const [contactsSortConfig, setContactsSortConfig] = useState(DEFAULT_CONTACTS_SORT_CONFIG) + + const toggleContactsSort = useCallback((key: ContactsSortKey) => { + setContactsSortConfig(prev => { + if (prev.key !== key) { + // 切换到新列:从降序开始。 + return { key, order: 'desc' } + } + // 同列循环:desc -> asc -> null (默认) + if (prev.order === 'desc') return { key, order: 'asc' } + if (prev.order === 'asc') return DEFAULT_CONTACTS_SORT_CONFIG + return { key, order: 'desc' } + }) + }, []) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -6456,34 +6512,54 @@ function ExportPage() { ) }) - const indexedContacts = contacts.map((contact, index) => ({ - contact, - index, - count: (() => { - const counted = normalizeMessageCount(sessionMessageCounts[contact.username]) - if (typeof counted === 'number') return counted - const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint) - return hinted - })() - })) + const indexedContacts = contacts.map((contact, index) => { + const sessionRow = sessionRowByUsername.get(contact.username) + const counted = normalizeMessageCount(sessionMessageCounts[contact.username]) + const hinted = normalizeMessageCount(sessionRow?.messageCountHint) + const count = typeof counted === 'number' ? counted : hinted + // 最新消息时间:优先使用 sessionContentMetrics 中的 lastTimestamp(更精确), + // 其次使用 SessionRow 的 sortTimestamp/lastTimestamp(通讯录加载即有)。 + const metricTs = sessionContentMetrics[contact.username]?.lastTimestamp + const rowTs = sessionRow?.sortTimestamp || sessionRow?.lastTimestamp + const latestTime = (typeof metricTs === 'number' && metricTs > 0) + ? metricTs + : (typeof rowTs === 'number' && rowTs > 0 ? rowTs : undefined) + return { contact, index, count, latestTime } + }) + + // 比较器:空值稳定排在末尾;相等时按原始下标稳定兜底。 + const compareNullable = (a: number | undefined, b: number | undefined, order: ContactsSortOrder): number => { + const aHas = typeof a === 'number' && Number.isFinite(a) + const bHas = typeof b === 'number' && Number.isFinite(b) + if (aHas && bHas) { + const diff = (a as number) - (b as number) + return order === 'desc' ? -diff : diff + } + if (aHas) return -1 + if (bHas) return 1 + return 0 + } + + const sortKey = contactsSortConfig.key + const sortOrder = contactsSortConfig.order ?? 'desc' indexedContacts.sort((a, b) => { - const aHasCount = typeof a.count === 'number' - const bHasCount = typeof b.count === 'number' - if (aHasCount && bHasCount) { - const diff = (b.count as number) - (a.count as number) + if (sortKey === 'latestMessageTime') { + const diff = compareNullable(a.latestTime, b.latestTime, sortOrder) + if (diff !== 0) return diff + } else if (sortKey === 'messageCount') { + const diff = compareNullable(a.count, b.count, sortOrder) + if (diff !== 0) return diff + } else { + // 默认(key===null):保持旧有按消息数降序的行为,避免改变现有视觉顺序。 + const diff = compareNullable(a.count, b.count, 'desc') if (diff !== 0) return diff - } else if (aHasCount) { - return -1 - } else if (bHasCount) { - return 1 } - // 无统计值或同分时保持原顺序,避免列表频繁跳动。 return a.index - b.index }) return indexedContacts.map(item => item.contact) - }, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername]) + }, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername, sessionContentMetrics, contactsSortConfig]) const keywordMatchedContactUsernameSet = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase() @@ -6692,7 +6768,7 @@ function ExportPage() { useEffect(() => { contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' }) setIsContactsListAtTop(true) - }, [activeTab, searchKeyword]) + }, [activeTab, searchKeyword, contactsSortConfig]) const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => { if (sourceContacts.length === 0) return [] @@ -8075,6 +8151,19 @@ function ExportPage() { const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages const mediaMetric = sessionContentMetrics[contact.username] + // 最新消息时间:优先取 metric.lastTimestamp(最精确),退回到 SessionRow 的 sortTimestamp/lastTimestamp。 + const metricLatestTs = mediaMetric?.lastTimestamp + const rowLatestTs = matchedSession?.sortTimestamp || matchedSession?.lastTimestamp + const resolvedLatestTs = (typeof metricLatestTs === 'number' && metricLatestTs > 0) + ? metricLatestTs + : (typeof rowLatestTs === 'number' && rowLatestTs > 0 ? rowLatestTs : undefined) + const latestTimeInfo = formatLatestMessageTimeFromSeconds(resolvedLatestTs, nowTick) + const latestTimeState: { state: 'value'; text: string; title: string } | { state: 'loading' } | { state: 'na'; text: '--' } = + !canExport + ? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' }) + : (typeof resolvedLatestTs === 'number' && resolvedLatestTs > 0 + ? { state: 'value', text: latestTimeInfo.text, title: latestTimeInfo.title } + : { state: 'na', text: '--' }) const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } = !canExport ? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' }) @@ -8190,6 +8279,18 @@ function ExportPage() { )} +
+ {latestTimeState.state === 'loading' + ? + : ( + + {latestTimeState.text} + + )} +
{emojiMetric.state === 'loading' @@ -9135,7 +9236,46 @@ function ExportPage() { {contactsHeaderMainLabel} - 总消息数 + + 表情包 语音 图片 From 09c83ae993659a5025bd7fd96e1029ef6500c3e8 Mon Sep 17 00:00:00 2001 From: DestinyleSnowy Date: Thu, 14 May 2026 19:24:46 +0800 Subject: [PATCH 02/22] fix(api): refresh image export cache when larger file is available --- electron/services/httpService.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 1e9b014..c72f811 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -1417,6 +1417,20 @@ class HttpService { return '.jpg' } + private writeFileIfLarger(fullPath: string, data: Buffer): void { + if (fs.existsSync(fullPath)) { + try { + const stat = fs.statSync(fullPath) + if (!stat.isFile()) return + if (data.length <= stat.size) return + } catch { + // If the existing export cannot be inspected, overwrite it below. + } + } + + fs.writeFileSync(fullPath, data) + } + private async exportMediaForMessages( messages: Message[], talker: string, @@ -1516,9 +1530,7 @@ class HttpService { const targetDir = path.join(sessionDir, 'images') const fullPath = path.join(targetDir, fileName) this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.writeFileSync(fullPath, imageBuffer) - } + this.writeFileIfLarger(fullPath, imageBuffer) const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` return { kind: 'image', fileName, fullPath, relativePath } } @@ -1531,9 +1543,7 @@ class HttpService { const targetDir = path.join(sessionDir, 'images') const fullPath = path.join(targetDir, fileName) this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.copyFileSync(imagePath, fullPath) - } + this.writeFileIfLarger(fullPath, imageBuffer) const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` return { kind: 'image', fileName, fullPath, relativePath } } From d008359d7017b13e600d3ee3b71e2b143c92af27 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Sun, 17 May 2026 11:54:42 +0800 Subject: [PATCH 03/22] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=B6=B3?= =?UTF-8?q?=E8=BF=B9=E9=A1=B5=E9=9D=A2=E5=88=86=E6=AE=B5=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9B#972=20#974=20=E6=89=80?= =?UTF-8?q?=E6=8F=90=E5=88=B0=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=A4=87=E4=BB=BD=E4=B8=AD=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9B=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E8=B6=B3=E8=BF=B9=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=E7=B4=A2=E5=BC=95=E6=89=AB=E6=8F=8F=E6=80=A7=E8=83=BD?= =?UTF-8?q?=EF=BC=9B=E5=AF=BC=E5=87=BA=E9=A1=B5=E9=9D=A2=E7=9A=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=BC=93=E5=AD=98=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/exportWorker.ts | 11 +++++-- electron/main.ts | 13 +++++--- electron/preload.ts | 2 +- electron/services/backupService.ts | 1 + electron/services/chatService.ts | 50 +++++++++++++++++++++--------- electron/services/exportService.ts | 12 +++++-- electron/services/snsService.ts | 40 ++++++++++++++++++------ src/pages/ExportPage.tsx | 21 +++++++++++-- src/pages/MyFootprintPage.tsx | 4 +-- 9 files changed, 116 insertions(+), 38 deletions(-) diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index 60f896e..dd55157 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -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 || ''), diff --git a/electron/main.ts b/electron/main.ts index cf80daf..e5631b3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2349,8 +2349,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 +3213,8 @@ function registerIpcHandlers() { imageAesKey: imageKeys.aesKey, resourcesPath, userDataPath, - logEnabled + logEnabled, + isPackaged: app.isPackaged } }) @@ -3344,7 +3345,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 +3413,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 } }) diff --git a/electron/preload.ts b/electron/preload.ts index bb175c0..ba48c62 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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 } diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts index e26a4a4..bdb2488 100644 --- a/electron/services/backupService.ts +++ b/electron/services/backupService.ts @@ -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, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index b827d41..dd2111b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -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 } @@ -8613,13 +8614,17 @@ class ChatService { private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise { 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 +8632,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 +9463,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 +9569,7 @@ class ChatService { myWxid: string privateSessionIds: string[] }): Promise { - const sessionGapSeconds = 10 * 60 + const sessionGapSeconds = 5 * 60 const segments: MyFootprintPrivateSegment[] = [] type WorkingSegment = { @@ -9579,14 +9587,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 +9631,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>) { 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 diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 72198df..cd88bb0 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -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( diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 65d4941..1de4612 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -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(/]*)>/i) const lpThumb = lx.match(/]*>([^<]+)<\/thumb>/i) const lpThumbTag = lx.match(/]*)>/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 diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 02e1d8e..eb4f79a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -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() : '' diff --git a/src/pages/MyFootprintPage.tsx b/src/pages/MyFootprintPage.tsx index ff7918d..cb4c194 100644 --- a/src/pages/MyFootprintPage.tsx +++ b/src/pages/MyFootprintPage.tsx @@ -770,12 +770,12 @@ function MyFootprintPage() { <>
From 7eeec7d930e64137bde673bf087afa242974ed12 Mon Sep 17 00:00:00 2001 From: TMYTiMidlY <93774927+TMYTiMidlY@users.noreply.github.com> Date: Sun, 17 May 2026 14:11:55 +0800 Subject: [PATCH 04/22] fix(linux): initialize key service in packaged builds --- electron/main.ts | 8 +------- electron/services/keyServiceLinux.ts | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index cf80daf..1ebb03c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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() } diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index b67a73b..6cc46a5 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -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) From 1df4f0e523d4f3bf1c09c93a3402f3e0edb2b160 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 17 May 2026 21:04:14 +0800 Subject: [PATCH 05/22] feat: Add Chat Analysis --- electron/main.ts | 16 + electron/preload.ts | 14 +- electron/services/chatService.ts | 87 +++++ electron/services/config.ts | 6 + electron/services/insightRecordService.ts | 94 +++++- electron/services/insightService.ts | 281 +++++++++++++++- src/pages/Chat/ChatMessageBubble.tsx | 15 +- src/pages/ChatPage.scss | 215 ++++++++++++ src/pages/ChatPage.tsx | 392 +++++++++++++++++++++- src/pages/InsightInboxPage.scss | 73 ++++ src/pages/InsightInboxPage.tsx | 117 ++++++- src/pages/SettingsPage.tsx | 123 ++++++- src/services/config.ts | 33 ++ src/types/electron.d.ts | 51 ++- 14 files changed, 1493 insertions(+), 24 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index cf80daf..ff1d5f3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1792,6 +1792,7 @@ function registerIpcHandlers() { sessionId?: string startTime?: number endTime?: number + sourceType?: 'insight' | 'message_analysis' | 'all' limit?: number offset?: number }) => { @@ -1834,6 +1835,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) { diff --git a/electron/preload.ts b/electron/preload.ts index bb175c0..3df0c44 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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: { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index b827d41..85d0f95 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -2585,6 +2585,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 => { + 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() diff --git a/electron/services/config.ts b/electron/services/config.ts index 618d908..c2148bc 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -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: [] diff --git a/electron/services/insightRecordService.ts b/electron/services/insightRecordService.ts index 762b372..e2a049b 100644 --- a/electron/services/insightRecordService.ts +++ b/electron/services/insightRecordService.ts @@ -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() diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index bb0ea57..f003bb3 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -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 + 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 { return new Promise((resolve, reject) => { const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') @@ -183,13 +247,17 @@ function callApi( return } - const body = JSON.stringify({ + const payload: Record = { 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 = { hostname: urlObj.hostname, @@ -210,6 +278,10 @@ function callApi( 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 { diff --git a/src/pages/Chat/ChatMessageBubble.tsx b/src/pages/Chat/ChatMessageBubble.tsx index e413979..59deed5 100644 --- a/src/pages/Chat/ChatMessageBubble.tsx +++ b/src/pages/Chat/ChatMessageBubble.tsx @@ -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,10 +94,18 @@ function ChatMessageBubble({
{isGroupChat && !isSent && ( -
- {resolvedSenderName || '群成员'} +
+
+ {resolvedSenderName || '群成员'} +
+ {actionNode}
)} + {!isGroupChat && !isSent && actionNode ? ( +
+ {actionNode} +
+ ) : null} {children}
@@ -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 ) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 11651ef..4bf7481 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -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,217 @@ 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-floating { + height: 18px; + margin: 0 0 3px 12px; + display: flex; + align-items: center; + justify-content: flex-start; +} + +.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); + } +} + +.message-wrapper-with-selection:hover .message-insight-trigger, +.message-insight-trigger:focus-visible { + opacity: 0.78; +} + +.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, diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index c2c341a..6589299 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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' @@ -55,6 +55,12 @@ interface PendingFootprintJumpPayload { createTime: number } +interface PendingMessageAnalysisJumpPayload { + sessionId: string + localId: number + createTime: number +} + interface QuotedMessageJumpTarget { sourceMessageKey: string sourceCreateTime: number @@ -1613,6 +1619,8 @@ function ChatPage(props: ChatPageProps) { const [globalMsgSearchError, setGlobalMsgSearchError] = useState(null) const pendingInSessionSearchRef = useRef(null) const pendingFootprintJumpRef = useRef(null) + const pendingMessageAnalysisJumpRef = useRef(null) + const messageAnalysisJumpLoadKeyRef = useRef(null) const pendingQuotedMessageJumpRef = useRef(null) const loadMessagesRef = useRef(null) const pendingGlobalMsgSearchReplayRef = useRef(null) @@ -1637,6 +1645,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>(new Set()) const lastMessageTimeRef = useRef(0) const isMessageListAtBottomRef = useRef(true) @@ -3095,6 +3105,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 +3151,7 @@ function ChatPage(props: ChatPageProps) { // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId + messageInsightMemoryCache.clear() isMessageListAtBottomRef.current = true topRangeLoadLockRef.current = false bottomRangeLoadLockRef.current = false @@ -5115,6 +5156,66 @@ function ChatPage(props: ChatPageProps) { }, 220) }, [currentSessionId, flashNewMessages, getMessageKey, loadMessages]) + const findMessageAnalysisTargetInMessages = useCallback((target: PendingMessageAnalysisJumpPayload): { index: number; message: Message } | null => { + if (messages.length === 0) return null + const targetLocalId = Math.floor(Number(target.localId || 0)) + const targetCreateTime = Math.floor(Number(target.createTime || 0)) + if (targetLocalId <= 0 && targetCreateTime <= 0) return null + + let bestIndex = -1 + let bestMessage: Message | null = null + let bestScore = -1 + for (let index = 0; index < messages.length; index++) { + const item = messages[index] + const localId = Math.floor(Number(item.localId || 0)) + const createTime = Math.floor(Number(item.createTime || 0)) + const localIdMatch = targetLocalId > 0 && localId === targetLocalId + const exactTimeMatch = targetCreateTime > 0 && createTime === targetCreateTime + const nearTimeMatch = targetCreateTime > 0 && Math.abs(createTime - targetCreateTime) <= 1 + if (!localIdMatch && !exactTimeMatch && !nearTimeMatch) continue + + const score = (localIdMatch ? 100 : 0) + (exactTimeMatch ? 40 : (nearTimeMatch ? 20 : 0)) + if (score > bestScore) { + bestIndex = index + bestMessage = item + bestScore = score + } + } + return bestMessage ? { index: bestIndex, message: bestMessage } : null + }, [messages]) + + const jumpToMessageAnalysisTarget = useCallback((target: PendingMessageAnalysisJumpPayload, behavior: 'auto' | 'smooth' = 'auto') => { + const resolved = findMessageAnalysisTargetInMessages(target) + if (resolved) { + pendingMessageAnalysisJumpRef.current = null + messageAnalysisJumpLoadKeyRef.current = null + scrollToResolvedMessage(resolved, behavior) + navigate('/chat', { replace: true }) + return true + } + return false + }, [findMessageAnalysisTargetInMessages, navigate, scrollToResolvedMessage]) + + const requestMessageAnalysisWindowLoad = useCallback((target: PendingMessageAnalysisJumpPayload) => { + const targetSessionId = String(target.sessionId || '').trim() + const targetTime = Math.floor(Number(target.createTime || 0)) + if (!targetSessionId || targetTime <= 0) return + const loadKey = `${targetSessionId}:${Math.floor(Number(target.localId || 0))}:${targetTime}` + if (messageAnalysisJumpLoadKeyRef.current === loadKey) return + messageAnalysisJumpLoadKeyRef.current = loadKey + + const requestSeq = inSessionResultJumpRequestSeqRef.current + 1 + inSessionResultJumpRequestSeqRef.current = requestSeq + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(targetTime + 1) + suppressAutoLoadLaterRef.current = true + void loadMessagesRef.current?.(targetSessionId, 0, 0, targetTime + 1, false, { + forceInitialLimit: 120, + inSessionJumpRequestSeq: requestSeq + }) + }, []) + // 滚动到底部 const scrollToBottom = useCallback(() => { suppressScrollToBottomButton(220) @@ -5868,7 +5969,7 @@ function ChatPage(props: ChatPageProps) { selectSessionById ]) - // 监听 URL 参数中的会话/锚点(通知跳转 + 足迹锚点定位) + // 监听 URL 参数中的会话/锚点(通知跳转 + 足迹/深度解析锚点定位) useEffect(() => { if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理 const params = new URLSearchParams(location.search) @@ -5883,6 +5984,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,7 +6018,27 @@ function ChatPage(props: ChatPageProps) { return } + if (hasMessageAnalysisAnchor) { + const pendingTarget = { + sessionId: urlSessionId, + localId: jumpLocalId, + createTime: jumpCreateTime + } + messageAnalysisJumpLoadKeyRef.current = null + pendingMessageAnalysisJumpRef.current = pendingTarget + if (currentSessionId !== urlSessionId) { + selectSessionById(urlSessionId) + return + } + if (!jumpToMessageAnalysisTarget(pendingTarget, 'auto')) { + requestMessageAnalysisWindowLoad(pendingTarget) + } + return + } + pendingFootprintJumpRef.current = null + pendingMessageAnalysisJumpRef.current = null + messageAnalysisJumpLoadKeyRef.current = null if (currentSessionId !== urlSessionId) { selectSessionById(urlSessionId) } @@ -5926,6 +6052,8 @@ function ChatPage(props: ChatPageProps) { currentSessionId, selectSessionById, handleInSessionResultJump, + jumpToMessageAnalysisTarget, + requestMessageAnalysisWindowLoad, navigate ]) @@ -5952,6 +6080,24 @@ function ChatPage(props: ChatPageProps) { navigate('/chat', { replace: true }) }, [isConnected, isConnecting, currentSessionId, handleInSessionResultJump, navigate]) + useEffect(() => { + const pending = pendingMessageAnalysisJumpRef.current + if (!pending) return + if (!isConnected || isConnecting) return + if (currentSessionId !== pending.sessionId) return + if (jumpToMessageAnalysisTarget(pending, 'auto')) return + if (isLoadingMessages || isSessionSwitching) return + requestMessageAnalysisWindowLoad(pending) + }, [ + isConnected, + isConnecting, + currentSessionId, + isLoadingMessages, + isSessionSwitching, + jumpToMessageAnalysisTarget, + requestMessageAnalysisWindowLoad + ]) + useEffect(() => { if (!standaloneSessionWindow || !normalizedInitialSessionId) return if (!isConnected || isConnecting) { @@ -6887,6 +7033,8 @@ function ChatPage(props: ChatPageProps) { messageKey={messageKey} isSelected={selectedMessages.has(messageKey)} onToggleSelection={handleToggleSelection} + aiMessageInsightEnabled={aiMessageInsightEnabled} + aiMessageInsightContextCount={aiMessageInsightContextCount} /> ) @@ -6906,7 +7054,9 @@ function ChatPage(props: ChatPageProps) { handleJumpToQuotedMessage, isSelectionMode, selectedMessages, - handleToggleSelection + handleToggleSelection, + aiMessageInsightEnabled, + aiMessageInsightContextCount ]) return ( @@ -8401,6 +8551,32 @@ const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: }) const senderAvatarLoading = new Map>() +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() + +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 @@ -8456,6 +8632,181 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { } // 消息气泡组件 +function MessageInsightControl({ + message, + messageKey, + session, + displayName, + avatarUrl, + senderName, + targetText, + contextCount +}: { + message: Message + messageKey: string + session: ChatSession + displayName?: string + avatarUrl?: string + senderName?: string + targetText: string + contextCount: number +}) { + const anchorRef = useRef(null) + const cardRef = useRef(null) + const cacheKey = useMemo(() => buildMessageInsightCacheKey(session.username, message, messageKey), [message, messageKey, session.username]) + const [open, setOpen] = useState(false) + const [state, setState] = useState(() => 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( + <> + + +
+ {state.status === 'loading' && ( +
+ + 解析中... +
+ )} + {state.status === 'error' && ( +
+ {state.error || '解析失败'} + +
+ )} + {state.status === 'success' && state.data && ( + <> +

{state.data.explicitText}

+
+
+ 情绪:{state.data.emotion} + 意图:{state.data.intent} + 话题:{state.data.topic} +
+ + )} +
+
+ , + document.body + ) : null + + return ( + <> + + {card} + + ) +} + function MessageBubble({ message, messageKey, @@ -8471,7 +8822,9 @@ function MessageBubble({ onJumpToQuotedMessage, isSelectionMode, isSelected, - onToggleSelection + onToggleSelection, + aiMessageInsightEnabled, + aiMessageInsightContextCount }: { message: Message; messageKey: string; @@ -8488,6 +8841,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 +10061,32 @@ 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 ? ( + + ) : null // 是否有引用消息 const hasQuote = quotedContent.length > 0 @@ -11051,6 +11432,7 @@ function MessageBubble({ isSelected={isSelected} onContextMenu={onContextMenu} onToggleSelection={onToggleSelection} + actionNode={messageInsightControl} portal={systemAlertPortal} > {renderContent()} @@ -11073,6 +11455,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 && diff --git a/src/pages/InsightInboxPage.scss b/src/pages/InsightInboxPage.scss index b2c5484..289a3c7 100644 --- a/src/pages/InsightInboxPage.scss +++ b/src/pages/InsightInboxPage.scss @@ -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; diff --git a/src/pages/InsightInboxPage.tsx b/src/pages/InsightInboxPage.tsx index 6466fce..0a08ff4 100644 --- a/src/pages/InsightInboxPage.tsx +++ b/src/pages/InsightInboxPage.tsx @@ -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('all') const [dateMode, setDateMode] = useState('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(() => ({ 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() {
+ {getSourceLabel(record.sourceType)} {getTriggerLabel(record.triggerReason)} {formatRecordTime(record.createdAt)}
+ {record.sourceType === 'message_analysis' && record.messageInsight && ( +
+ 目标消息 + + {record.messageInsight.targetSenderName}:{record.messageInsight.targetTextPreview} + +
+ )}

{record.insight}

+ {record.sourceType === 'message_analysis' && record.messageInsight && ( +
+ 情绪:{record.messageInsight.analysis.emotion} + 意图:{record.messageInsight.analysis.intent} + 话题:{record.messageInsight.analysis.topic} +
+ )} ))} @@ -347,6 +399,28 @@ export default function InsightInboxPage() { +
+
+ + 来源类型 +
+
+ {[ + { value: 'all', label: '全部' }, + { value: 'insight', label: 'AI 见解' }, + { value: 'message_analysis', label: '深度解析' } + ].map((option) => ( + + ))} +
+
+
@@ -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')} + {logRecord.sourceType === 'message_analysis' && ( +
+

深度解析目标

+
{[
+                    `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')}
+
+ )} + {logRecord.sourceType === 'message_analysis' && logRecord.log.parsedAnalysis && ( +
+

解析字段

+
{[
+                    `explicitText: ${logRecord.log.parsedAnalysis.explicitText}`,
+                    `emotion: ${logRecord.log.parsedAnalysis.emotion}`,
+                    `intent: ${logRecord.log.parsedAnalysis.intent}`,
+                    `topic: ${logRecord.log.parsedAnalysis.topic}`
+                  ].join('\n')}
+
+ )}

System Prompt

{logRecord.log.systemPrompt}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 92c0f78..0b51991 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -32,9 +32,10 @@ type SettingsTab = | 'aiCommon' | 'insight' | 'aiFootprint' + | 'aiMessageInsight' | 'autoDownload' -const tabs: { id: Exclude; label: string; icon: React.ElementType }[] = [ +const tabs: { id: Exclude; 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; label: string }> = [ +const aiTabs: Array<{ id: Extract; 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(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 = {}) {
) + const renderAiMessageInsightTab = () => ( +
+ {(() => { + 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 ( + <> +
+ + + 开启后,在聊天页悬停对方文本消息时显示深度解析入口。点击后按需调用 AI,解析结果会保存到灵感信箱。 + +
+ {aiMessageInsightEnabled ? '已开启' : '已关闭'} + +
+
+ +
+ + + 围绕选中消息向前、向后各取一半;一侧不足时自动由另一侧补齐。条数越多分析越准确,token 消耗也越多。 + + { + const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 50)) + setAiMessageInsightContextCount(val) + scheduleConfigSave('aiMessageInsightContextCount', () => configService.setAiMessageInsightContextCount(val)) + }} + style={{ width: 100 }} + /> +
+ +
+
+ + +
+ + 消息解析专用提示词。留空时使用内置默认提示词。 + +