Initial upload for secondary development

This commit is contained in:
2026-06-08 19:00:03 +08:00
commit b913b8c78c
81 changed files with 27139 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Runtime/private data
.env
*.env
knowledge*.db
__pycache__/
# Dependencies
node_modules/
# Code signing certificates and private keys
certs/
*.pfx
*.p12
*.pvk
*.key
*.cer
*.crt
# Build outputs
$dest/
.backend-*/
electron-launcher/dist/
electron-launcher/build-resources/
electron-launcher/build/
chatlab-web/frontend/dist/
chatlog_fastAPI/build/
chatlog_fastAPI/dist/
# Keep only the latest shipped installer artifacts in release/
release/*
!release/ChatLab-Setup-1.0.1-202605310641.exe
!release/ChatLab-Setup-1.0.1-202605310641.exe.blockmap
!release/manifest.txt

397
CHANGELOG.md Normal file
View File

@@ -0,0 +1,397 @@
# 修改日志 CHANGELOG
本文件记录每次代码修改的内容、原因与时间,精确到分钟,便于回溯和审查。
**规则:每次修复 Bug 或新增功能,必须在此文件追加一条记录。**
---
## [2026-05-11 01:15] 项目瘦身:清理分发包多余文件
### 背景
项目原始大小 2.3 GB包含构建产物、Go SDK、PyInstaller 输出等开发专用文件,发送给用户体积过大。清理后保留"无痕启动控制台"完整运行所需的最小文件集。
### 删除内容
| 路径 | 大小 | 原因 |
|------|------|------|
| `chatlog_fastAPI/dist/` | 653 MB | PyInstaller 打包输出,不走此 exe 启动 |
| `chatlog_fastAPI/build/` | 206 MB | PyInstaller 中间产物 |
| `electron-launcher/dist/` | 349 MB | electron-builder 安装包,不走此包启动 |
| `go_env/` | 276 MB | 完整 Go SDKchatlog.exe 已编译完毕 |
| `chatlab-web/frontend/dist/` | ~1 MB | Vite 构建输出,走 npm run dev |
| `cmd/` `internal/` `pkg/` `bin/` | <2 MB | Go 源码,不重新编译则无用 |
| `chatlab-web/backend/` | 49 KB | 废弃旧版 Python 后端 |
| `scripts/` `.github/` `.vscode/` | 小 | 开发专用 |
| `main.go` `go.mod` `go.sum` `Makefile` `.goreleaser.yaml` `.gitignore` | 小 | Go 构建文件 |
| `lib/windows_x64/wx_key.exp/.lib/.pdb` | 小 | 链接/调试符号,运行只需 .dll |
| `chatlog_fastAPI/__pycache__/` 等 | 28 KB | Python 字节码缓存,运行时自动重建 |
| `chatlog_fastAPI/chatlog.db` `knowledge.db` `*.spec` `test_wxid.py` `backend_design.md` | 小 | 空文件/调试/设计文档 |
| `test_audio*.py` `test_upload.go.bak` `launcher_error.log` | 小 | 临时调试文件 |
| `README.md` `chatlab.md` `dll调用指南.md` `启动与编译命令指南.md` | 小 | 开发参考文档 |
| `chatlog_fastAPI/knowledge_wxid_*.db` | ~224 KB | 个人账号知识库,用户登录后自动生成 |
**节省:~1.49 GB2.3 GB → 863 MB**
### 保留内容(最小运行集)
```
get_wechat_me/
├── 无痕启动控制台.vbs 启动入口
├── chatlog.exe Go 后端63 MB
├── CHANGELOG.md / README.md / DISCLAIMER.md / LICENSE
├── lib/windows_x64/wx_key.dll
├── electron-launcher/
│ ├── main.js / preload.js / index.html / package.json / package-lock.json
│ └── node_modules/650 MBelectron 二进制)
├── chatlog_fastAPI/
│ ├── main.py / config.py / database.py / scheduler.py
│ ├── .env / requirements.txt
│ ├── routers/ / services/
└── chatlab-web/frontend/
├── src/ / public/ / index.html / vite.config.js / package.json
└── node_modules/150 MBVite + React
```
## [2026-05-11 01:05] Bug 修复:可搜索下拉浮层背景透明
### 问题描述
上一轮新增的"添加群聊"可搜索下拉浮层背景完全透明,条目文字叠在底层内容上,可读性极差。
### 根因
代码中使用了 `var(--surface)``var(--surface-2)` 两个 CSS 变量,但 `index.css``:root` 从未定义这两个变量,浏览器 fallback 为 `transparent`
这两个变量在整个项目(`TopicsPage.jsx``KnowledgePage.jsx``MessageBubble.jsx``SettingsPage.jsx``App.jsx`)中被广泛引用。
### 修改文件明细
#### 1. `chatlab-web/frontend/src/index.css`
**改动**:在 `:root` 的颜色变量区域追加两行别名定义:
```css
/* 别名:兼容组件中使用的 var(--surface) / var(--surface-2) */
--surface: #ffffff; /* 等同于 --bg-surface */
--surface-2: #f0f2f5; /* 等同于 --bg-overlay */
```
**原因**:项目现有的背景变量命名为 `--bg-surface``--bg-overlay` 等,但大量组件使用了更简短的 `--surface`/`--surface-2` 命名。根本修复是在 CSS 中统一补充定义这两个别名,一次修复所有引用,避免逐文件替换。
```
位置chatlab-web/frontend/src/index.css:root 第 6 行后
新增 3 行
```
---
#### 2. `chatlab-web/frontend/src/pages/TopicsPage.jsx`
**改动**:将浮层容器和条目中仍残留的错误变量名手动替换为正确值:
- 浮层容器:`var(--surface)``var(--bg-surface)`
- 条目默认背景:`transparent``var(--bg-surface)`(使其与浮层容器背景一致)
- 条目选中态:`var(--surface-2)``var(--bg-overlay)`
- `onMouseEnter``var(--surface-2)``var(--bg-hover)`
- `onMouseLeave``var(--surface-2)``var(--bg-overlay)`
**原因**:在 CSS 补充别名定义之外,同步将浮层的引用改为语义更明确的 `--bg-*` 系列变量,确保悬停/选中状态的颜色层次感正确(白底 → 悬停浅灰 → 选中深灰)。
```
位置showGroupDropdown 浮层(第 315-340 行)
修改 5 处变量名
```
---
### 总结
| 文件 | 改动性质 | 解决的问题 |
|------|----------|-----------|
| `chatlab-web/frontend/src/index.css` | Bug 修复 | 全局补充 `--surface`/`--surface-2` CSS 变量定义,消除所有相关透明背景问题 |
| `chatlab-web/frontend/src/pages/TopicsPage.jsx` | 变量名规范化 | 浮层下拉列表背景透明,条目悬停/选中状态无法区分 |
---
## [2026-05-10 15:42] Bug 修复:新账号首次解密引导 + 账号切换前端数据刷新
### 问题描述
1. **新账号无法在启动器一键解密**:切换到从未登录过的新微信账号时,需要手动进入 chatlog.exe 的 TUI 界面执行解密,启动器无法自动引导。
2. **账号切换后前端消息不刷新**切换微信账号并重启控制台后React 前端仍然显示上一个账号的会话列表和消息,没有切换到新账号的数据。
3. **FastAPI 层账号数据库不切换**`_resolved_wxid` 全局变量一旦解析成功后永不刷新,账号切换后 Python 业务层仍读取旧账号的知识库数据库。
---
### 修改文件明细
#### 1. `electron-launcher/main.js`
**改动**:在 `keyProcess.on('close')` 回调中,读取完 `savedWorkDir` 后,新增对工作目录的检测逻辑:
- 若工作目录不存在或目录内无 `.db` 文件 → 判定为"新账号首次解密",向渲染进程发送 `show-decrypt-dialog` IPC 事件,弹出引导弹窗。
- 若工作目录已存在 `.db` 文件 → 判定为"已解密账号",直接启动 server不弹窗。
**原因**`chatlog.exe key` 命令执行完毕后会更新 `~/.chatlog/chatlog.json``last_account`,此时读取的 `savedWorkDir` 才是当前账号对应的工作目录。通过检测该目录是否含有解密产物(`.db` 文件),即可区分新旧账号。
```
位置electron-launcher/main.jskeyProcess.on('close') 内,第 251 行附近
新增约 20 行代码
```
---
#### 2. `electron-launcher/preload.js`
**改动**:在 `contextBridge.exposeInMainWorld` 中新增一条 IPC 监听:
```js
onShowDecryptDialog: (callback) => ipcRenderer.on('show-decrypt-dialog', () => callback())
```
**原因**Electron 的 contextIsolation 模式下,渲染进程无法直接访问 `ipcRenderer`,必须通过 `preload.js``contextBridge` 安全暴露。新增的 `show-decrypt-dialog` 事件需要在这里注册后,`index.html` 中的 JS 才能通过 `window.electronAPI.onShowDecryptDialog` 收到通知。
```
位置electron-launcher/preload.js第 19-20 行
新增 1 行
```
---
#### 3. `electron-launcher/index.html`
**改动CSS 部分)**:新增模态弹窗相关样式(`.modal-overlay``.modal-box``.modal-title``.modal-body``.modal-steps``.modal-footer``.modal-spinner` 及其动画)。
**改动HTML 部分)**:在 `<body>` 顶部新增 `#decrypt-dialog` 模态弹窗元素,包含:
- 标题:"🔓 检测到新账号,正在解密数据"
- 操作步骤引导(切换到微信、点击聊天窗口、翻看历史消息)
- 与 chatlog.exe 一致的提示说明
- 底部旋转 spinner + 计时显示
**改动JS 部分)**
- 新增 `decryptDialog``decryptDialogStatus``decryptDialogElapsed` DOM 引用。
- `onDecryptStatus` 回调中额外同步更新弹窗计时器(`decryptDialogElapsed`)。
- `onDecryptReady` 回调中增加 `decryptDialog.classList.remove('show')` 关闭弹窗。
- 新增 `window.electronAPI.onShowDecryptDialog` 监听,收到事件时显示弹窗。
**原因**:用户切换新账号时需要按照 chatlog.exe TUI 中相同的操作提示(点击微信聊天界面)才能完成密钥提取。弹窗承担了原来需要手动进入 TUI 界面的提示职责,实现"一键解密"体验。
```
位置electron-launcher/index.html
新增 ~90 行 CSS + HTML + JS
```
---
#### 4. `chatlab-web/frontend/src/App.jsx`
**改动**
1. **去掉 localStorage 冷加载旧账号数据**:将 `useState(() => loadSessionCache())` 改为 `useState([])`,避免启动时直接显示上一个账号的旧会话列表。
2. **新增账号指纹机制**
- `calcFingerprint(sessions)` —— 取前 3 个会话 id 拼接成指纹字符串。
- `loadAccountFingerprint()` / `saveAccountFingerprint(fp)` —— 读写 localStorage 中的 `chatlab_account_fingerprint` 键。
3. **`getSessions` 返回后检测账号切换**`loadSessions` 函数(封装了原 `getSessions` 调用)在拿到新会话列表后,对比新旧指纹。若指纹不同,说明账号已切换,立即执行:
- `localStorage.removeItem(CACHE_KEY)` 清除旧缓存。
- `setSelectedRoom(null)` 重置当前选中群(否则会拿旧群 id 去查询新账号的消息)。
4. **每 30 秒轮询一次**:新增 `useEffect` 设置 30 秒定时器,静默调用 `loadSessions(true)`(轮询模式不显示 loading 也不报错),以便在用户不刷新页面的情况下自动感知账号切换。
**原因**
- 旧代码的 `getSessions()` 只在组件挂载时执行一次,账号切换不触发重新加载。
- 旧代码从 localStorage 取缓存作为初始 state导致旧账号数据在新账号登录时"闪现"。
- 两处修复合力确保前端始终显示当前微信账号的真实数据。
```
位置chatlab-web/frontend/src/App.jsx
修改约 40 行,新增约 30 行
```
---
#### 5. `chatlog_fastAPI/database.py`
**改动**
1. 新增 `import time`
2. 新增全局变量 `_wxid_last_resolved: float = 0.0` 和常量 `_WXID_TTL = 60.0`
3. `get_current_wxid()` 函数:在命中现有缓存时增加时间判断 `(now - _wxid_last_resolved) < _WXID_TTL`,超过 60 秒强制重新解析。
4. 在两处成功解析到 wxid 时,均更新 `_wxid_last_resolved = time.time()`
**原因**:原代码中 `_resolved_wxid` 一旦解析成功就永久缓存,账号切换后 Python 层仍用旧 wxid 路由到旧知识库数据库文件(`knowledge_<旧wxid>.db`),导致 AI 话题分析、知识库搜索等功能数据错误。60 秒 TTL 平衡了性能与准确性。
```
位置chatlog_fastAPI/database.py第 1-53 行
修改约 10 行,新增约 5 行
```
---
#### 6. `chatlog_fastAPI/main.py`
**改动**
1. 新增 `import asyncio``import logging`
2. 新增后台协程 `_account_watch_loop()`:每 60 秒调用一次 `update_db_path()`,静默捕获异常。
3.`lifespan` 上下文管理器中,`yield` 前用 `asyncio.create_task` 启动该协程,`yield` 后取消任务并等待清理。
**原因**:仅靠 `database.py` 的 TTL 机制还不够——TTL 只在被调用时才重新检测,如果 FastAPI 长时间运行无请求则无法触发。后台轮询任务确保即使 FastAPI 在后台静默运行,账号切换也能在约 60 秒内被感知并切换数据库。
```
位置chatlog_fastAPI/main.py全文
修改约 5 行,新增约 15 行
```
---
### 总结
| 文件 | 改动性质 | 解决的问题 |
|------|----------|-----------|
| `electron-launcher/main.js` | 功能新增 | 新账号自动检测 + 弹窗触发 |
| `electron-launcher/preload.js` | IPC 暴露 | 渲染进程收到新账号通知 |
| `electron-launcher/index.html` | UI 新增 | 首次解密引导弹窗,等同 chatlog.exe TUI 提示 |
| `chatlab-web/frontend/src/App.jsx` | Bug 修复 + 功能增强 | 账号切换后前端立即显示新账号数据 |
| `chatlog_fastAPI/database.py` | Bug 修复 | 账号切换后 Python 层自动切换知识库数据库 |
| `chatlog_fastAPI/main.py` | 功能新增 | 后台定期检测账号变化,无需依赖请求触发 |
---
## [2026-05-11 00:32] Bug 修复AI话题分析 & 知识库 4 个 UI 问题
### 问题描述
1. **AI话题分析"添加群聊"无搜索**:群聊列表使用原生 `<select>`,群多时只能逐条翻找,体验差。
2. **知识库文档无分组**:文档平铺展示,无法区分属于哪个群聊/话题。
3. **AI分析无进度条**:点击"AI 分析"或"AI 生成知识文档"后,用户不知道当前执行到哪一步。
4. **管理消息弹窗"已添加"不可操作**:已加入话题的消息显示静态文字"已添加",无法直接点击移除。
---
### 修改文件明细
#### 1. `chatlab-web/frontend/src/pages/TopicsPage.jsx`
**Bug 1 修复 — 群聊选择改为可搜索下拉**
- 将"添加群聊"面板内的原生 `<select>` 替换为自定义可搜索组件:`<input>` 搜索框 + 绝对定位浮层列表。
- 新增 state`groupSearchKeyword``showGroupDropdown`
- 浮层列表实时过滤(同时匹配群聊名称和群 ID点击某项后填充输入框并收起浮层。
```
位置showAddGroup 块内(第 268 行附近)
新增约 45 行 JSX
```
**Bug 3A 修复 — AI分析进度条**
- `handleInitGroup` 轮询中新增 `JSON.parse(task.progress)` 解析,将 `{processed, total}` 赋值到 `initProgress` state初始化/完成时重置为 null
- 在"AI 分析"按钮下方插入进度条 UI显示 `processed/total` 数值 + 彩色进度条宽度由百分比驱动0.5s 过渡动画)。
- 新增 state`initProgress`
```
位置handleInitGroup 函数(第 117 行)+ 按钮区域(第 336 行)
修改约 15 行,新增约 22 行
```
**Bug 3B 修复 — AI生成知识文档进度条**
- `handleSummarize` 改为接收后端返回的 `task_id``res.data.task_id`),若存在则用 `getTask(task_id)` 每 3 秒轮询,解析 `progress` 字段,更新 `summarizeProgress` state若无 task_id 则 fallback 5 秒后刷新。
- 在"AI 生成知识文档"按钮下方插入进度条 UI与 3A 样式一致,宽度 180px
- 新增导入:`getTask`(已在 `api/index.js` 中定义)。
- 新增 state`summarizeProgress`
```
位置handleSummarize 函数(第 241 行)+ 按钮区域(第 422 行)
修改约 12 行,新增约 40 行
```
**Bug 4 修复 — "已添加"改为红色"移除"按钮**
- 管理消息弹窗左栏:将 `topicSeqs.has(Number(m.id))` 为真时显示的 `<span>已添加</span>` 改为红色 `<button> 移除</button>`,点击调用已有的 `handleRemoveMsg(Number(m.id))`
- 样式:`color: 'var(--danger)'``borderColor: 'rgba(243,139,168,0.4)'`
```
位置:管理消息弹窗左栏消息列表(第 531 行)
修改 1 处,约 6 行
```
---
#### 2. `chatlog_fastAPI/routers/knowledge.py`
**Bug 2 后端 — SQL 联表返回 group_name**
- 两处 `SELECT`(普通查询 + FTS 关键词查询)均扩展为:
- 新增 `LEFT JOIN groups g ON t.group_id=g.id`
- 新增返回字段:`t.group_id``g.name as group_name`
- 普通查询的 `ORDER BY` 改为 `ORDER BY g.name, k.updated_at DESC`,使同一群聊的文档聚在一起。
```
位置chatlog_fastAPI/routers/knowledge.py第 18-33 行
修改约 8 行
```
---
#### 3. `chatlab-web/frontend/src/pages/KnowledgePage.jsx`
**Bug 2 前端 — 文档列表按群聊分组展示**
- 将原来平铺的 `docs.map(...)` 改为先按 `doc.group_name`(后端新增字段)分组,渲染时在每个群聊前加粘性标题头(灰色小号大写文字,背景 `var(--surface-2)``position: sticky`)。
- `group_name` 为空时归入"未知群聊"分组。
```
位置KnowledgePage.jsx第 96-114 行
修改约 20 行,新增约 30 行
```
---
#### 4. `chatlog_fastAPI/routers/topics.py`
**Bug 3B 后端 — summarize 接口返回 task_id**
- `POST /api/topics/{id}/summarize` 在调用 `run_summarize` 前,先向 `ai_tasks` 表插入一条类型为 `'summarize'`、状态为 `'running'` 的记录,并把 `task_id` 随响应返回:`{"ok": True, "task_id": task_id}`
- 前端可用 `getTask(task_id)` 轮询该记录的 `status`/`progress` 字段以展示进度。
```
位置chatlog_fastAPI/routers/topics.py第 142-151 行
修改约 10 行
```
---
#### 5. `chatlog_fastAPI/services/summary_engine.py`
**Bug 3B 后端 — run_summarize 接收 task_id完成/出错时更新状态**
- 函数签名新增可选参数 `task_id: int | None = None`
- 新增内部辅助函数 `_update_task(status, processed, total)`打开独立数据库连接UPDATE `ai_tasks` 记录的 `status``progress`
- 在以下关键节点调用 `_update_task`
- 任务开始:`running, 0, 1`
- 无消息/无有效消息/LLM 失败:`error, 0, 1`
- 生成完成:`done, 1, 1`
- 新增 `import json`(用于序列化 progress 字段)。
```
位置chatlog_fastAPI/services/summary_engine.py第 65-190 行
修改约 8 行,新增约 20 行
```
---
### 总结
| 文件 | 改动性质 | 解决的问题 |
|------|----------|-----------|
| `TopicsPage.jsx` | UI 替换 + 功能新增 | Bug1可搜索下拉+ Bug3A初始化进度条+ Bug3B生成知识文档进度条+ Bug4移除按钮 |
| `KnowledgePage.jsx` | UI 重构 | Bug2知识库按群聊分组展示 |
| `chatlog_fastAPI/routers/knowledge.py` | SQL 扩展 | Bug2返回 group_name 字段) |
| `chatlog_fastAPI/routers/topics.py` | 功能新增 | Bug3Bsummarize 接口返回 task_id |
| `chatlog_fastAPI/services/summary_engine.py` | 功能新增 | Bug3B运行时更新 ai_tasks 进度状态) |

121
DISCLAIMER.md Normal file
View File

@@ -0,0 +1,121 @@
# Chatlog 免责声明
## 1. 定义
在本免责声明中,除非上下文另有说明,下列术语应具有以下含义:
- **"本项目"或"Chatlog"**:指本开源软件项目,包括其源代码、可执行程序、文档及相关资源。
- **"开发者"**:指本项目的创建者、维护者及代码贡献者。
- **"用户"**:指下载、安装、使用或以任何方式接触本项目的个人或实体。
- **"聊天数据"**:指通过各类即时通讯软件生成的对话内容及相关元数据。
- **"合法授权"**:指根据适用法律法规,由数据所有者或数据主体明确授予的处理其聊天数据的权限。
- **"第三方服务"**:指由非本项目开发者提供的外部服务,如大型语言模型(LLM) API 服务。
## 2. 使用目的与法律遵守
本项目仅供学习、研究和个人合法使用。用户须严格遵守所在国家/地区的法律法规使用本工具。任何违反法律法规、侵犯他人合法权益的行为,均与本项目及其开发者无关,相关法律责任由用户自行承担。
⚠️ **用户应自行了解并遵守当地有关数据访问、隐私保护、计算机安全和网络安全的法律法规。不同司法管辖区对数据处理有不同的法律要求,用户有责任确保其使用行为符合所有适用法规。**
## 3. 授权范围与隐私保护
- 本工具仅限于处理用户自己合法拥有的聊天数据,或已获得数据所有者明确授权的数据。
- 严禁将本工具用于未经授权获取、查看或分析他人聊天记录,或侵犯他人隐私权。
- 用户应采取适当措施保护通过本工具获取和处理的聊天数据安全,包括但不限于加密存储、限制访问权限、定期删除不必要数据等。
- 用户应确保其处理的聊天数据符合相关数据保护法规,包括但不限于获得必要的同意、保障数据主体权利、遵守数据最小化原则等。
## 4. 使用限制
- 本项目仅允许在合法授权情况下对聊天数据库进行备份与查看。
- 未经明确授权,严禁将本项目用于访问、查看、分析或处理任何第三方聊天数据。
- 使用第三方 LLM 服务时,用户应遵守相关服务提供商的服务条款和使用政策。
- 用户不得规避本项目中的任何技术限制,或尝试反向工程、反编译或反汇编本项目,除非适用法律明确允许此类活动。
## 5. 技术风险声明
⚠️ **使用本项目存在以下技术风险,用户应充分了解并自行承担:**
- 本工具需要访问聊天软件的数据库文件,可能因聊天软件版本更新导致功能失效或数据不兼容。
- 在 macOS 系统上使用时,需要临时关闭 SIP 安全机制,这可能降低系统安全性,用户应了解相关风险并自行决定是否使用。
- 本项目可能存在未知的技术缺陷或安全漏洞,可能导致数据损坏、丢失或泄露。
- 使用本项目处理大量数据可能导致系统性能下降或资源占用过高。
- 第三方依赖库或 API 的变更可能影响本项目的功能或安全性。
## 6. 禁止非法用途
严禁将本项目用于以下用途:
- 从事任何形式的非法活动,包括但不限于未授权系统测试、网络渗透或其他违反法律法规的行为。
- 监控、窃取或未经授权获取他人聊天记录或个人信息。
- 将获取的数据用于骚扰、诈骗、敲诈、威胁或其他侵害他人合法权益的行为。
- 规避任何安全措施或访问控制机制。
- 传播虚假信息、仇恨言论或违反公序良俗的内容。
- 侵犯任何第三方的知识产权、隐私权或其他合法权益。
**违反上述规定的,用户应自行承担全部法律责任,并赔偿因此给开发者或第三方造成的全部损失。**
## 7. 第三方服务集成
- 用户将聊天数据与第三方 LLM 服务(如 OpenAI、Claude 等)结合使用时,应仔细阅读并遵守这些服务的使用条款、隐私政策和数据处理协议。
- 用户应了解,向第三方服务传输数据可能导致数据离开用户控制范围,并受第三方服务条款约束。
- 本项目开发者不对第三方服务的可用性、安全性、准确性或数据处理行为负责,用户应自行评估相关风险。
- 用户应确保其向第三方服务传输数据的行为符合适用的数据保护法规和第三方服务条款。
## 8. 责任限制
**在法律允许的最大范围内:**
- 本项目按"原样"和"可用"状态提供,不对功能的适用性、可靠性、准确性、完整性或及时性做任何明示或暗示的保证。
- 开发者明确否认对适销性、特定用途适用性、不侵权以及任何其他明示或暗示的保证。
- 本项目开发者和贡献者不对用户使用本工具的行为及后果承担任何法律责任。
- 对于因使用本工具而可能导致的任何直接、间接、附带、特殊、惩罚性或后果性损失,包括但不限于数据丢失、业务中断、隐私泄露、声誉损害、利润损失、法律纠纷等,本项目开发者概不负责,即使开发者已被告知此类损失的可能性。
- 在任何情况下,开发者对用户的全部责任累计不超过用户为获取本软件实际支付的金额(如为免费获取则为零)。
## 9. 知识产权声明
- 本项目基于 Apache-2.0 许可证开源,用户在使用、修改和分发时应严格遵守该许可证的所有条款。
- 本项目的名称"Chatlog"、相关标识及商标权(如有)归开发者所有,未经明确授权,用户不得以任何方式使用这些标识进行商业活动。
- 根据 Apache-2.0 许可证,用户可自由使用、修改和分发本项目代码,但须遵守许可证规定的归属声明等要求。
- 用户对其修改版本自行承担全部责任,且不得以原项目名义发布,必须明确标明其为修改版本并与原项目区分。
- 用户不得移除或更改本项目中的版权声明、商标或其他所有权声明。
## 10. 数据处理合规性
- 用户在使用本项目处理个人数据时,应遵守适用的数据保护法规,包括但不限于《中华人民共和国个人信息保护法》、《通用数据保护条例》(GDPR)等。
- 用户应确保其具有处理相关数据的合法依据,如获得数据主体的明确同意。
- 用户应实施适当的技术和组织措施,确保数据安全,防止未授权访问、意外丢失或泄露。
- 在跨境传输数据时,用户应确保符合相关法律对数据出境的要求。
- 用户应尊重数据主体权利,包括访问权、更正权、删除权等。
## 11. 免责声明接受
下载、安装、使用本项目,表示用户已阅读、理解并同意遵守本免责声明的所有条款。如不同意,请立即停止使用本工具并删除相关代码和程序。
**用户确认:**
- 已完整阅读并理解本免责声明的全部内容
- 自愿接受本免责声明的全部条款
- 具有完全民事行为能力,能够理解并承担使用本项目的风险和责任
- 将遵守本免责声明中规定的所有义务和限制
## 12. 免责声明修改与通知
- 本免责声明可能根据项目发展和法律法规变化进行修改和调整,修改后的声明将在项目官方仓库页面公布。
- 开发者没有义务个别通知用户免责声明的变更,用户应定期查阅最新版本。
- 重大变更将通过项目仓库的 Release Notes 或 README 文件更新进行通知。
- 在免责声明更新后继续使用本项目,即视为接受修改后的条款。
## 13. 法律适用与管辖
- 本免责声明受中华人民共和国法律管辖,并按其解释。
- 任何与本免责声明有关的争议,应首先通过友好协商解决;协商不成的,提交至本项目开发者所在地有管辖权的人民法院诉讼解决。
- 对于中国境外用户,如本免责声明与用户所在地强制性法律规定冲突,应以不违反该强制性规定的方式解释和适用本声明,但本声明的其余部分仍然有效。
## 14. 可分割性
如本免责声明中的任何条款被有管辖权的法院或其他权威机构认定为无效、不合法或不可执行,不影响其余条款的有效性和可执行性。无效条款应被视为从本声明中分割,并在法律允许的最大范围内由最接近原条款意图的有效条款替代。
## 15. 完整协议
本免责声明构成用户与开发者之间关于本项目使用的完整协议,取代先前或同时期关于本项目的所有口头或书面协议、提议和陈述。本声明的任何豁免、修改或补充均应以书面形式作出并经开发者签署方为有效。

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Alibaba Cloud
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

114
README.md Normal file
View File

@@ -0,0 +1,114 @@
# 微信知识库 - 使用说明
## 环境要求
| 软件 | 版本要求 |
|---|---|
| Windows | 10/11 64位 |
| 微信 | v4推荐 4.1.5.30 |
| Node.js | 20+ LTS |
| Python | 3.10+ |
---
## 一次性配置(首次使用)
### 第一步:安装 Node.js
前往 https://nodejs.org 下载 LTS 版本安装,安装时勾选 **"Add to PATH"**。
验证安装:
```
node -v
```
### 第二步:安装 Python
前往 https://www.python.org/downloads 下载 3.10 或更高版本。
安装时务必勾选 **"Add Python to PATH"**。
验证安装:
```
python --version
```
### 第三步:安装 Python 依赖
打开命令提示符CMD进入 `chatlog_fastAPI` 目录执行:
```
cd 解压路径\get_wechat_me\chatlog_fastAPI
python -m pip install -r requirements.txt
```
## 启动方式
配置完成后,每次使用只需:
**双击 `无痕启动控制台.vbs`**
控制台窗口打开后,依次点击启动三个服务:
1. **chatlog**(微信数据服务,端口 5030
2. **FastAPI**(后端,端口 8000
3. **前端**(端口 5173
三个服务全部启动后,点击"进入系统"即可使用。
---
## 注意事项
- 启动前请确保微信已登录
- 若 chatlog 服务启动失败,尝试以**管理员身份**运行 `无痕启动控制台.vbs`
- 微信版本过新或过旧可能导致密钥提取失败,推荐使用 4.1.5.30
---
## 桌面版构建与代码签名
桌面版构建入口:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
```
默认构建允许未签名安装包,适合本机测试。未签名包在客户电脑上可能触发 Windows SmartScreen 或杀毒软件提示,这是 Windows 对未知发布者程序的常见提示。
如已有 Windows 代码签名 PFX/P12 证书,可在构建时启用签名:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 `
-Sign `
-CertificateFile "D:\certs\ChatLab-CodeSigning.pfx" `
-CertificatePassword "证书密码" `
-PublisherName "证书中的发布者名称" `
-ForceSign
```
也可以使用环境变量,避免把证书密码写进命令历史:
```powershell
$env:CHATLAB_PFX_FILE = "D:\certs\ChatLab-CodeSigning.pfx"
$env:CHATLAB_PFX_PASSWORD = "证书密码"
$env:CHATLAB_CERT_PUBLISHER_NAME = "证书中的发布者名称"
$env:CHATLAB_FORCE_SIGN = "1"
powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
```
签名相关环境变量:
| 变量 | 说明 |
|---|---|
| `CHATLAB_PFX_FILE` | PFX/P12 证书完整路径 |
| `CHATLAB_PFX_PASSWORD` | 证书密码 |
| `CHATLAB_CERT_PUBLISHER_NAME` | 可选,证书发布者名称 |
| `CHATLAB_TIMESTAMP_SERVER` | 可选,默认 `http://timestamp.digicert.com` |
| `CHATLAB_FORCE_SIGN` | 设为 `1` 时签名失败会中断构建 |
安全要求:
- 不要把 `.pfx``.p12``.pvk``.key``.cer``.crt``certs/` 放进项目或安装包资源目录。
- 构建脚本会阻止证书、私钥、`.env``knowledge*.db``__pycache__` 进入发布资源。
- 证书只通过本机路径参与签名,不会复制到客户安装包。

87
chatlab-web/README.md Normal file
View File

@@ -0,0 +1,87 @@
# ChatLab Web MVP
> 设备售后微信知识库 — Web 端 MVP
## 目录结构
```
chatlab-web/
├── frontend/ # React (Vite) 前端
└── backend/ # Python FastAPI 后端
```
> ⚠️ 此目录与 get_wechat 主项目完全独立,不修改任何现有文件
---
## 快速启动
### 1. 启动前端(含 Mock 数据,无需后端)
```bash
cd chatlab-web/frontend
npm run dev
# 浏览器访问 http://localhost:5173
```
### 2. 启动后端(接入 chatlog API
```bash
cd chatlab-web/backend
pip install -r requirements.txt
cp .env .env.local # 按需修改 CHATLOG_API 地址
python main.py
# 后端运行在 http://localhost:8000
```
### 3. 前端切换到真实后端
编辑 `frontend/src/api/index.js`,将 `USE_MOCK = true` 改为 `false`
---
## 接口说明
### 存量查询(前端主动拉取)
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/chatroom` | GET | 获取群聊列表 |
| `/api/v1/chatlog` | GET | 拉取存量聊天记录(分页) |
| `/api/session` | GET | 最近会话列表 |
| `/api/contact` | GET | 联系人搜索 |
### 增量推送Webhook → SSE
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/webhook` | POST | chatlog 推送新消息(配置到 chatlog |
| `/api/sse/chatlog?talker=群ID` | GET (SSE) | 前端订阅实时推送 |
| `/api/health` | GET | 健康检查 |
---
## chatlog API 对接
后端代理到 `http://127.0.0.1:5030`,参考接口:
```
GET /api/v1/chatlog?talker={群ID}&time={start,end}&limit={n}&offset={n}&format=json
POST {本后端 webhook URL} ← chatlog 配置 Webhook 推送地址
```
具体入参出参待确认后更新 `backend/main.py` 中的代理逻辑。
---
## MVP 功能范围
- [x] 深色主题 UI + 完整设计系统
- [x] 群聊列表 Sidebar
- [x] 聊天记录检索(时间范围 / 发送人多选 / 关键词)
- [x] 消息气泡展示(按天分组)
- [x] 关键词高亮
- [x] Webhook 增量接收 + SSE 实时推送
- [x] Mock 数据层(可一键切换真实 API
- [ ] AI 话题分类P0待接入
- [ ] 知识库P0待接入

24
chatlab-web/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/company-logo.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatLab — 售后知识库</title>
<meta name="description" content="设备售后微信群聊天记录智能分析与知识库管理平台" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4470
chatlab-web/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.99.2",
"axios": "^1.15.2",
"dayjs": "^1.11.20",
"docx": "^9.6.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"vite": "^8.0.10"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,336 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { MessageSquare, BookOpen, Bot, Settings, Wifi, Users, Search } from 'lucide-react'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
import ChatlogPage from './pages/ChatlogPage'
import TopicsPage from './pages/TopicsPage'
import KnowledgePage from './pages/KnowledgePage'
import SettingsPage from './pages/SettingsPage'
import { getSessions } from './api'
import './index.css'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const NAV_ITEMS = [
{ id: 'chatlog', label: '聊天记录', icon: MessageSquare },
{ id: 'topics', label: 'AI 话题分析', icon: Bot },
{ id: 'knowledge', label: '报告库', icon: BookOpen },
{ id: 'settings', label: '设置', icon: Settings },
]
// ── 持久化缓存localStorage用于账号指纹检测 ──────
const CACHE_KEY = 'chatlab_sessions_v3'
const ACCOUNT_KEY = 'chatlab_account_fingerprint'
function loadSessionCache() {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (!raw) return []
return JSON.parse(raw)
} catch { return [] }
}
function saveSessionCache(sessions) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(sessions))
} catch {}
}
// 账号指纹取前3个会话 id 拼接,用于检测账号是否切换
function calcFingerprint(sessions) {
return sessions.slice(0, 3).map(s => s.id).join('|')
}
function loadAccountFingerprint() {
try { return localStorage.getItem(ACCOUNT_KEY) || '' } catch { return '' }
}
function saveAccountFingerprint(fp) {
try { localStorage.setItem(ACCOUNT_KEY, fp) } catch {}
}
export default function App() {
const [activeNav, setActiveNav] = useState('chatlog')
// sessions 初始为空数组,不从 localStorage 冷加载,避免旧账号数据闪现
const [sessions, setSessions] = useState([])
const [selectedRoom, setSelectedRoom] = useState(null)
const [toasts, setToasts] = useState([])
const [loadError, setLoadError] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(false)
const [, setTick] = useState(0)
// ── 加载会话列表(含账号指纹检测,账号切换时清空旧缓存) ──
const loadSessions = useCallback((isPolling = false) => {
if (!isPolling) setLoading(true)
getSessions()
.then((res) => {
const list = res?.data || []
list.sort((a, b) => b.lastTime - a.lastTime)
// 账号指纹检测:若与上次不同说明账号已切换,清空旧数据
const newFp = calcFingerprint(list)
const oldFp = loadAccountFingerprint()
if (oldFp && newFp && oldFp !== newFp) {
// 账号已切换:清空缓存、重置选中群
localStorage.removeItem(CACHE_KEY)
setSelectedRoom(null)
}
saveAccountFingerprint(newFp)
setSessions(list)
saveSessionCache(list)
// 首次加载(非轮询)且尚未选中群时,选中第一个
if (!isPolling && list.length > 0 && !selectedRoom) setSelectedRoom(list[0])
})
.catch((e) => {
if (isPolling) return // 轮询失败静默处理
const status = e?.response?.status
const detail = e?.response?.data?.detail || e?.response?.data?.error || ''
if (!e?.response) {
setLoadError('无法连接服务,请确认已启动 chatlog server端口 5030和 FastAPI端口 8000')
} else if (status >= 500) {
setLoadError(detail || `FastAPI 服务内部错误 (${status}),请检查后端日志`)
} else {
setLoadError(detail || `请求失败:${e.message || status || '未知错误'}`)
}
console.error('[App] getSessions failed', e)
})
.finally(() => { if (!isPolling) setLoading(false) })
}, []) // eslint-disable-line
useEffect(() => {
loadSessions(false)
}, []) // eslint-disable-line
// ── 每 30 秒轮询一次,检测账号是否切换 ──
useEffect(() => {
const pollId = setInterval(() => loadSessions(true), 30_000)
return () => clearInterval(pollId)
}, [loadSessions])
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 60_000)
return () => clearInterval(id)
}, [])
// ── 显示列表:搜索过滤 ──
const displaySessions = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return sessions
return sessions.filter(s =>
s.name?.toLowerCase().includes(q) ||
s.id?.toLowerCase().includes(q)
)
}, [sessions, searchQuery])
const addToast = (message, type = 'success') => {
const id = Date.now()
setToasts((t) => [...t, { id, message, type }])
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3000)
}
// SSE 新消息到达时更新对应会话的 lastTime/lastContent
const handleNewMessage = useCallback((roomId, msg) => {
setSessions(prev => {
const next = prev.map(s =>
s.id === roomId
? { ...s, lastTime: msg.timestamp, lastContent: msg.content || '' }
: s
)
next.sort((a, b) => b.lastTime - a.lastTime)
saveSessionCache(next)
return next
})
}, [])
return (
<div className="app-shell">
{/* ─── Sidebar ─────────────────────────────── */}
<aside className="sidebar">
{/* Logo */}
<div className="sidebar-logo">
<div className="sidebar-logo-icon">
<img src="/company-logo.jpg" alt="" />
</div>
<span className="sidebar-logo-text">灵泽万川ChatLab</span>
<span className="sidebar-logo-version">MVP</span>
</div>
{/* Nav */}
<nav className="sidebar-nav">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
return (
<div
key={item.id}
className={`nav-item ${activeNav === item.id ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`}
onClick={() => !item.disabled && setActiveNav(item.id)}
style={item.disabled ? { opacity: 0.4, cursor: 'not-allowed' } : {}}
title={item.disabled ? '开发中...' : ''}
>
<Icon size={16} />
{item.label}
{item.badge && (
<span style={{
marginLeft: 'auto', fontSize: 10,
padding: '1px 5px', background: 'var(--warning-dim)',
color: 'var(--warning)', borderRadius: 6, fontWeight: 600,
}}>
{item.badge}
</span>
)}
</div>
)
})}
</nav>
{/* Session / Room List */}
<div className="room-list">
<div className="room-list-header">最近会话</div>
{/* 搜索框 */}
<div style={{
padding: '6px 10px 8px',
borderBottom: '1px solid var(--border)',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'var(--surface-2)',
border: '1px solid var(--border)',
borderRadius: 7, padding: '4px 8px',
}}>
<Search size={12} color="var(--text-muted)" style={{ flexShrink: 0 }} />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索群 / 联系人..."
style={{
flex: 1, background: 'transparent', border: 'none',
outline: 'none', fontSize: 12,
color: 'var(--text-primary)',
}}
/>
{searchQuery && (
<span
onClick={() => setSearchQuery('')}
style={{ cursor: 'pointer', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1 }}
>×</span>
)}
</div>
</div>
{loadError && (
<div style={{ padding: '12px', fontSize: 12, color: 'var(--danger)', lineHeight: 1.5 }}>
{loadError}
</div>
)}
{!loadError && sessions.length === 0 && loading && (
<div style={{ padding: '12px', fontSize: 12, color: 'var(--text-muted)' }}>
加载中...
</div>
)}
{!loadError && sessions.length > 0 && displaySessions.length === 0 && (
<div style={{ padding: '16px 12px', fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
未找到匹配的会话
</div>
)}
{displaySessions.map((room) => (
<div
key={room.id}
className={`room-item ${selectedRoom?.id === room.id ? 'active' : ''}`}
onClick={() => { setSelectedRoom(room); setActiveNav('chatlog') }}
id={`room-${room.id}`}
>
<div className="room-avatar" style={{ background: getRoomColor(room.id) }}>
{room.isGroup ? <Users size={14} /> : (room.name?.slice(0, 2) || '??')}
</div>
<div className="room-info">
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 4 }}>
<div className="room-name" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{room.name}
</div>
{room.lastTime > 0 && (
<div style={{ fontSize: 10, color: 'var(--text-muted)', flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs.unix(room.lastTime).fromNow()}
</div>
)}
</div>
<div className="room-meta" style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>
{room.lastContent
? truncate(room.lastContent, 18)
: (room.isGroup ? '群聊' : '私聊')}
</div>
</div>
</div>
))}
</div>
</aside>
{/* ─── Main ─────────────────────────────────── */}
<div className="main-content">
{/* Topbar */}
<div className="topbar">
<div>
<div className="topbar-title">
{selectedRoom ? selectedRoom.name : '聊天记录检索'}
</div>
{selectedRoom && (
<div className="topbar-subtitle">
{selectedRoom.isGroup ? '微信群聊' : '私聊'} · {selectedRoom.id}
</div>
)}
</div>
<div className="topbar-actions">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-muted)' }}>
<Wifi size={13} />
chatlog API: 127.0.0.1:5030
</div>
</div>
</div>
{/* Page Content */}
{activeNav === 'chatlog' && (
<ChatlogPage room={selectedRoom} onToast={addToast} onNewMessage={handleNewMessage} />
)}
{activeNav === 'topics' && (
<TopicsPage sessions={sessions} onToast={addToast} />
)}
{activeNav === 'knowledge' && (
<KnowledgePage onToast={addToast} />
)}
{activeNav === 'settings' && (
<SettingsPage />
)}
</div>
{/* ─── Toasts ──────────────────────────────── */}
<div className="toast-container">
{toasts.map((t) => (
<div key={t.id} className={`toast ${t.type}`}>
{t.type === 'success' ? '✅' : '⚠️'} {t.message}
</div>
))}
</div>
</div>
)
}
const ROOM_COLORS = ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#3b82f6']
function getRoomColor(id) {
if (!id) return ROOM_COLORS[0]
let hash = 0
for (let c of id) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff
return ROOM_COLORS[Math.abs(hash) % ROOM_COLORS.length]
}
function truncate(str, maxLen) {
if (!str) return ''
const clean = str.replace(/\n/g, ' ')
return clean.length > maxLen ? clean.slice(0, maxLen) + '...' : clean
}

View File

@@ -0,0 +1,279 @@
/**
* API 接口层 — 对接 chatlog_fastAPI 业务层8000 端口,通过 vite proxy 转发)
*
* FastAPI 路由routers/search.py
* GET /api/search/chatrooms?keyword=&limit=&offset=
* GET /api/search/members?talker=&time=
* GET /api/search?talker=&time=&sender=&keyword=&page=&page_size=
*
* chatlog 底层字段说明FastAPI 透传不做字段转换):
* 群聊 : name / nickName / remark / owner / users
* 消息 : seq / time(ISO) / sender / senderName / talker / talkerName / type / subType / content
* 成员 : userName / displayName / msgCount / lastSpeakTime
*/
import axios from 'axios'
import dayjs from 'dayjs'
const api = axios.create({ timeout: 30000 })
api.interceptors.response.use(
(res) => res.data,
(err) => {
console.error('[API Error]', err.response?.data || err.message)
return Promise.reject(err)
}
)
// ─────────────────────────────────────────────
// 群聊列表
// FastAPI: GET /api/search/chatrooms?keyword=&limit=&offset=
// 返回: { total, items: [ { Name, NickName, Remark, Owner, Users } ] }
// ─────────────────────────────────────────────
export async function getChatrooms(keyword = '') {
const raw = await api.get('/api/search/chatrooms', { params: { keyword, limit: 100, offset: 0 } })
const items = Array.isArray(raw) ? raw : (raw.items || [])
const rooms = items.map((r) => ({
id: r.name || r.Name,
name: r.nickName || r.NickName || r.remark || r.Remark || r.name || r.Name,
memberCount: (r.users || r.Users)?.length ?? 0,
platform: 'wechat',
}))
return { data: rooms }
}
// ─────────────────────────────────────────────
// 会话列表(含最新消息预览和时间,来自微信原生 Session 表)
// FastAPI: GET /api/search/sessions?limit=500
// 返回: [{ userName, nickName, remark, content, nTime, nOrder }]
// ─────────────────────────────────────────────
export async function getSessions(keyword = '') {
const raw = await api.get('/api/search/sessions', { params: { keyword, limit: 500 } })
const items = Array.isArray(raw) ? raw : (raw.items || [])
const sessions = items.map((r) => ({
id: r.userName,
name: r.nickName || r.remark || r.userName,
platform: 'wechat',
lastContent: r.content || '',
lastTime: r.nTime ? dayjs(r.nTime).unix() : 0,
nOrder: r.nOrder || 0,
isGroup: r.userName?.endsWith('@chatroom'),
}))
return { data: sessions.filter(s => s.isGroup) }
}
// ─────────────────────────────────────────────
// 群成员列表(含发言统计)
// FastAPI: GET /api/search/members?talker=&time=
// 返回: { members: [{userName, displayName, msgCount, lastSpeakTime}], total }
// ─────────────────────────────────────────────
export async function getChatroomMembers(roomId) {
const raw = await api.get('/api/search/members', { params: { talker: roomId } })
const members = (raw.members || []).map((m) => ({
platformId: m.userName,
accountName: m.displayName || m.userName,
groupNickname: m.displayName,
}))
return { data: members }
}
// ─────────────────────────────────────────────
// 存量聊天记录(核心接口)
// FastAPI: GET /api/search?talker=&time=&sender=&keyword=&page=&page_size=
// time 格式: "YYYY-MM-DD,YYYY-MM-DD"(逗号分隔)
// limit/offset → page/page_sizepage 从 1 开始)
// 返回: { total, items: [ { Seq, Time, Sender, SenderName, Talker, TalkerName, Type, Content } ] }
// ─────────────────────────────────────────────
export async function getChatlog({
talker,
startTime,
endTime,
senders = [],
keyword = '',
limit = 100,
offset = 0,
}) {
let time = ''
if (startTime && endTime) {
const fmt = (unix) => dayjs.unix(unix).format('YYYY-MM-DD')
time = `${fmt(startTime)},${fmt(endTime)}`
}
const page = Math.floor(offset / limit) + 1
const page_size = limit
const params = {
talker,
page,
page_size,
...(time ? { time } : {}),
...(keyword ? { keyword } : {}),
...(senders.length > 0 ? { sender: senders.join(',') } : {}),
}
const raw = await api.get('/api/search', { params })
const items = raw.items || []
const messages = items.map((m) => {
const isFile = Number(m.type) === 49 && Number(m.subType) === 6
const fileMd5 = isFile ? (m.contents?.md5 || '') : ''
const fileName = isFile ? (m.contents?.title || m.contents?.fileName || m.contents?.filename || '') : ''
return {
id: String(m.seq),
sender: m.sender || '',
accountName: m.senderName || m.sender || '',
groupNickname: m.senderName || '',
timestamp: m.time ? dayjs(m.time).unix() : 0,
type: convertMsgType(m.type, m.subType),
content: m.content || '',
subType: m.subType,
talker: m.talker,
talkerName: m.talkerName,
// 媒体文件标识chatlog contents 字段各类型 key 不同)
// 图片: md5/rawmd5 → chatlog 按 md5 查库
// 视频: pathWindows 反斜杠需转成正斜杠让 handleMedia 走 findPath 分支)
// 语音: contents.voice = ServerID → /voice/{serverid}
// 文件: contents.md5 → /file/{md5}
// 表情包: contents.cdnurl = 外部 CDN 直链
mediaKey: m.contents?.rawmd5 || m.contents?.md5 || m.contents?.path?.replace(/\\/g, '/') || '',
voiceKey: m.contents?.voice || '', // 语音专用 ServerID key
mediaMd5: m.contents?.md5 || '',
mediaPath: (m.contents?.path || '').replace(/\\/g, '/'),
emojiUrl: m.contents?.cdnurl || '', // 表情包 CDN 直链
linkTitle: isFile ? '' : (m.contents?.title || ''), // 链接/公众号卡片
linkDesc: isFile ? '' : (m.contents?.desc || ''),
linkUrl: isFile ? '' : (m.contents?.url || m.content || ''),
linkThumb: isFile ? '' : (m.contents?.thumbUrl || ''),
linkSource: isFile ? '' : (m.contents?.sourceName || ''),
quote: m.quote || null,
isFile,
fileName,
fileMd5,
fileUrl: fileMd5 ? `/api/files/${encodeURIComponent(fileMd5)}?filename=${encodeURIComponent(fileName || fileMd5)}` : '',
}
})
return {
data: {
messages,
total: raw.total ?? messages.length,
hasMore: (offset + limit) < (raw.total ?? 0),
},
}
}
// ─────────────────────────────────────────────
// Webhook 实时推送SSE 方式)
// ─────────────────────────────────────────────
export function subscribeWebhook(talker, callback) {
const url = `/api/sse/chatlog?talker=${encodeURIComponent(talker)}`
let es
try {
es = new EventSource(url)
es.onmessage = (e) => {
try {
const raw = JSON.parse(e.data)
const isFile = Number(raw.type) === 49 && Number(raw.subType) === 6
const fileMd5 = isFile ? (raw.contents?.md5 || '') : ''
const fileName = isFile ? (raw.contents?.title || raw.contents?.fileName || raw.contents?.filename || '') : ''
const msg = {
id: String(raw.seq || Date.now()),
sender: raw.sender || '',
accountName: raw.senderName || raw.sender || '',
groupNickname: raw.senderName || '',
timestamp: raw.time ? dayjs(raw.time).unix() : dayjs().unix(),
type: convertMsgType(raw.type, raw.subType),
content: raw.content || '',
subType: raw.subType,
talker: raw.talker,
talkerName: raw.talkerName,
mediaKey: raw.contents?.rawmd5 || raw.contents?.md5 || raw.contents?.path?.replace(/\\/g, '/') || '',
voiceKey: raw.contents?.voice || '',
mediaMd5: raw.contents?.md5 || '',
mediaPath: (raw.contents?.path || '').replace(/\\/g, '/'),
emojiUrl: raw.contents?.cdnurl || '',
linkTitle: isFile ? '' : (raw.contents?.title || ''),
linkDesc: isFile ? '' : (raw.contents?.desc || ''),
linkUrl: isFile ? '' : (raw.contents?.url || raw.content || ''),
linkThumb: isFile ? '' : (raw.contents?.thumbUrl || ''),
linkSource: isFile ? '' : (raw.contents?.sourceName || ''),
quote: raw.quote || null,
isFile,
fileName,
fileMd5,
fileUrl: fileMd5 ? `/api/files/${encodeURIComponent(fileMd5)}?filename=${encodeURIComponent(fileName || fileMd5)}` : '',
}
callback(msg)
} catch {
// 忽略解析错误
}
}
es.onerror = () => {
console.warn('[SSE] 连接失败或断开Webhook 实时推送不可用')
}
} catch {
console.warn('[SSE] EventSource 创建失败')
}
return () => {
es?.close()
}
}
export function triggerMockWebhook() {
console.info('[Webhook] 真实模式下无法模拟推送,请通过 chatlog 发送新消息触发')
}
// ─────────────────────────────────────────────
// Groups监控群组管理
// ─────────────────────────────────────────────
export const getGroups = () => api.get('/api/groups')
export const createGroup = (talker, name) => api.post('/api/groups', { talker, name })
export const patchGroup = (groupId, body) => api.patch(`/api/groups/${groupId}`, body)
export const initGroup = (groupId, { startTime, endTime }) =>
api.post(`/api/groups/${groupId}/init`, { start_time: startTime, end_time: endTime })
export const getGroupTask = (groupId) => api.get(`/api/groups/${groupId}/task`)
export const deleteGroup = (groupId) => api.delete(`/api/groups/${groupId}`)
// ─────────────────────────────────────────────
// TopicsAI 话题)
// ─────────────────────────────────────────────
export const getTopics = (params) => api.get('/api/topics', { params })
export const getTopic = (id) => api.get(`/api/topics/${id}`)
export const createTopic = (group_id, title) => api.post('/api/topics', { group_id, title })
export const patchTopic = (id, body) => api.patch(`/api/topics/${id}`, body)
export const deleteTopic = (id) => api.delete(`/api/topics/${id}`)
export const summarizeTopic = (id) => api.post(`/api/topics/${id}/summarize`)
export const addTopicMessage = (id, msg_seq, talker) => api.post(`/api/topics/${id}/messages`, { msg_seq, talker })
export const removeTopicMessage = (id, seq) => api.delete(`/api/topics/${id}/messages/${seq}`)
// ─────────────────────────────────────────────
// Knowledge知识库
// ─────────────────────────────────────────────
export const getKnowledge = (keyword) => api.get('/api/knowledge', { params: keyword ? { keyword } : {} })
export const getKnowledgeDoc = (id) => api.get(`/api/knowledge/${id}`)
export const patchKnowledge = (id, content) => api.patch(`/api/knowledge/${id}`, { content })
// ─────────────────────────────────────────────
// Tasks
// ─────────────────────────────────────────────
export const getTask = (id) => api.get(`/api/tasks/${id}`)
// ─────────────────────────────────────────────
// 消息类型映射
// chatlog Type: 1=文字 3=图片 34=语音 43=视频 49=分享 10000=系统
// ─────────────────────────────────────────────
function convertMsgType(rawType, subType) {
if (Number(rawType) === 49 && Number(subType) === 62) return 82 // 拍了拍
const map = {
1: 0, // 文字
3: 1, // 图片
34: 2, // 语音
43: 3, // 视频
47: 5, // 表情包
49: 7, // 分享/链接/文件
10000: 80, // 系统消息
10002: 81, // 撤回
}
return map[rawType] ?? 99
}

View File

@@ -0,0 +1,119 @@
/**
* Mock 数据层
* MVP 阶段使用此文件模拟后端 API等真实接口文档确认后替换 api/client.js 即可
*/
import dayjs from 'dayjs'
// ── Mock 群聊列表 ──────────────────────────────
export const MOCK_CHATROOMS = [
{ id: 'room_001', name: '设备售后技术群', memberCount: 47, platform: 'wechat' },
{ id: 'room_002', name: '弯管机项目组', memberCount: 12, platform: 'wechat' },
{ id: 'room_003', name: '华南区售后', memberCount: 23, platform: 'wechat' },
{ id: 'room_004', name: '客服协调群', memberCount: 8, platform: 'wechat' },
]
// ── Mock 成员列表 ──────────────────────────────
export const MOCK_MEMBERS = {
room_001: [
{ platformId: 'u001', accountName: '张三', groupNickname: '调机师傅-张三' },
{ platformId: 'u002', accountName: '李四', groupNickname: '售后一组' },
{ platformId: 'u003', accountName: '王五', groupNickname: '王五' },
{ platformId: 'u004', accountName: '赵六', groupNickname: '技术总监' },
{ platformId: 'u005', accountName: '孙七', groupNickname: '孙工' },
{ platformId: 'u006', accountName: '周八', groupNickname: '周老板' },
{ platformId: 'me', accountName: '我', groupNickname: '(本机账号)' },
],
room_002: [
{ platformId: 'u001', accountName: '张三', groupNickname: '张三' },
{ platformId: 'u004', accountName: '赵六', groupNickname: '赵工' },
{ platformId: 'me', accountName: '我', groupNickname: '我' },
],
room_003: [
{ platformId: 'u002', accountName: '李四', groupNickname: '华南李四' },
{ platformId: 'u005', accountName: '孙七', groupNickname: '孙七' },
{ platformId: 'me', accountName: '我', groupNickname: '我' },
],
room_004: [
{ platformId: 'u006', accountName: '周八', groupNickname: '周总' },
{ platformId: 'me', accountName: '我', groupNickname: '我' },
],
}
// ── Mock 消息生成 ──────────────────────────────
const now = dayjs()
function makeMsg(sender, accountName, groupNickname, minutesAgo, content, type = 0) {
return {
id: `${sender}_${minutesAgo}`,
sender,
accountName,
groupNickname,
timestamp: now.subtract(minutesAgo, 'minute').unix(),
type,
content,
}
}
export const MOCK_MESSAGES = {
room_001: [
makeMsg('u001', '张三', '调机师傅-张三', 180, '那台弯管机又报警了,客户那边急着要货'),
makeMsg('u002', '李四', '售后一组', 178, '我这边也遇到了,注塑机模具对不上,顶针位置偏了'),
makeMsg('u001', '张三', '调机师傅-张三', 177, '报的是什么错误码?'),
makeMsg('u003', '王五', '王五', 176, '@李四 你先检查一下导柱有没有松动'),
makeMsg('u001', '张三', '调机师傅-张三', 174, 'E-1023伺服过载报警'),
makeMsg('u002', '李四', '售后一组', 173, '导柱没问题啊,刚紧过'),
makeMsg('u003', '王五', '王五', 171, '那看看顶针有没有弯,之前有台机子就是这样'),
makeMsg('u004', '赵六', '技术总监', 168, '@张三 E-1023 是伺服过载,你先检查一下电机温度,再看看机械负载有没有异常'),
makeMsg('u001', '张三', '调机师傅-张三', 165, '温度正常36度我再看看机械那边'),
makeMsg('u004', '赵六', '技术总监', 162, '重点检查导轨润滑这个型号的机器用的是23号导轨油很多现场都缺油'),
makeMsg('u001', '张三', '调机师傅-张三', 158, '找到了!导轨这段完全干了,润滑油嘴堵了,加完油重启好了'),
makeMsg('u004', '赵六', '技术总监', 155, '好这个问题做个记录。E-1023 80%是润滑问题,以后优先检查这里'),
makeMsg('u002', '李四', '售后一组', 150, '顶针换了,位置好了,谢谢王五'),
makeMsg('u003', '王五', '王五', 148, '好的好的,这个顶针弯了之后看起来不明显,要拿千分表测'),
makeMsg('u005', '孙七', '孙工', 120, '今天那个广州客户发来消息说液压系统压力不够标准是16MPa实测只有11MPa'),
makeMsg('u004', '赵六', '技术总监', 118, '检查溢流阀,旋钮可能被碰到调小了。调回去之前先确认液压油液位'),
makeMsg('u005', '孙七', '孙工', 115, '液压油液位正常溢流阀找到了调回16MPa好了'),
makeMsg('u006', '周八', '周老板', 90, '大家注意,下周一华东区有个验厂,需要两个技术人员配合,谁有空?'),
makeMsg('u001', '张三', '调机师傅-张三', 88, '我可以,周一下午没安排'),
makeMsg('u004', '赵六', '技术总监', 85, '我跟张三去,先联系对方工厂准备材料'),
makeMsg('me', '我', '(本机账号)', 60, '刚查了下那个广州客户的机器是定制款液压系统压力设定是18MPa不是16MPa是客户特殊要求的'),
makeMsg('u004', '赵六', '技术总监', 58, '对的,这个机器台账里有注记。要存到知识库里'),
makeMsg('u002', '李四', '售后一组', 30, '收到,我来整理一下今天几个问题的解决记录'),
makeMsg('u001', '张三', '调机师傅-张三', 15, '好,我补充一下弯管机那台的细节'),
makeMsg('me', '我', '(本机账号)', 5, '刚又来一个新报警WG-50CNC 出现 F-2055有人知道这个码吗'),
],
room_002: [
makeMsg('u001', '张三', '张三', 300, 'WG-38CNC 那台调好了弯管角度偏差在±0.3度以内'),
makeMsg('u004', '赵六', '赵工', 295, '好,符合客户要求。出货前再跑一遍程序确认'),
makeMsg('u001', '张三', '张三', 290, '已经跑了,没问题了'),
makeMsg('me', '我', '我', 60, '客户那边反馈说回弹补偿不够120度弯出来实际是122度'),
makeMsg('u004', '赵六', '赵工', 55, '增加2度补偿试试在参数页面改 K02 参数'),
makeMsg('me', '我', '我', 50, '改完了现在是121度还差一点'),
makeMsg('u004', '赵六', '赵工', 45, '再加0.5度,如果还不够这批料可能弹性模量偏高,要换料'),
],
room_003: [
makeMsg('u002', '李四', '华南李四', 240, '深圳那个客户要下周来验收,设备已经调好了'),
makeMsg('u005', '孙七', '孙七', 235, '验收要准备什么材料?'),
makeMsg('u002', '李四', '华南李四', 230, '合格证、操作手册、维修手册、参数表,还有现场演示视频'),
makeMsg('me', '我', '我', 45, '广州那个液压问题解决了吗?'),
makeMsg('u002', '李四', '华南李四', 40, '解决了溢流阀调回来就好了。那个机器是定制款压力18MPa'),
],
room_004: [
makeMsg('u006', '周八', '周总', 180, '下周一验厂,张三和赵工去,行程确认一下'),
makeMsg('me', '我', '我', 175, '好的,已经记下来了'),
makeMsg('u006', '周八', '周总', 50, '还有季度报告,这周五之前交'),
makeMsg('me', '我', '我', 45, '收到,我来汇总'),
],
}
// ── Webhook 增量消息池 ─────────────────────────
// 模拟 webhook 可能推入的增量消息
export const MOCK_WEBHOOK_MESSAGES = {
room_001: [
{ sender: 'u003', accountName: '王五', groupNickname: '王五', content: '这个需求挺有意思的F-2055 我查过,是刀具磨损检测报警' },
{ sender: 'u004', accountName: '赵六', groupNickname: '技术总监', content: 'F-2055 对,检查一下刀具刃口,如果磨损超标就换刀' },
{ sender: 'u001', accountName: '张三', groupNickname: '调机师傅-张三', content: '换完刀清一下报警就好了,这个很常见' },
{ sender: 'u005', accountName: '孙七', groupNickname: '孙工', content: '好的,我来处理' },
],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,337 @@
import { useState, useRef, useEffect } from 'react'
import { Sparkles, X, Copy, Check, Loader, Download } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const MEDIA_TYPE_MAP = { 1: 'image', 2: 'voice', 3: 'video' }
function quoteContext(msg) {
if (!msg.quote?.content) return ''
const sender = msg.quote.sender_name || msg.quote.sender || '未知'
const seq = msg.quote.seq ? ` seq=${msg.quote.seq}` : ''
return `[引用消息${seq}] ${sender}: ${msg.quote.content}`
}
// 调用 chatlog 内置 AI 解析单条媒体消息,返回文字描述,失败则返回 null
async function parseOneMedia(msg) {
const aiType = MEDIA_TYPE_MAP[msg.type]
// 语音用 voiceKeyServerID图片/视频用 mediaKeymd5
const key = msg.type === 2
? (msg.voiceKey || msg.mediaKey || '')
: (msg.mediaKey || '')
if (!aiType || !key) return null
try {
const res = await fetch('/api/ai/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: aiType, key }),
})
if (!res.ok) return null
const data = await res.json()
return data.text || data.result || null
} catch {
return null
}
}
// 把 messages 列表转成发给 AI 的纯文本(含媒体解析结果)
async function buildContext(messages, onProgress) {
const lines = []
let mediaCount = 0
const mediaMessages = messages.filter(m => {
if (!MEDIA_TYPE_MAP[m.type]) return false
return m.type === 2 ? (m.voiceKey || m.mediaKey) : m.mediaKey
})
// 先解析所有媒体(并发,最多 5 个同时)
const mediaResults = new Map()
const chunks = []
for (let i = 0; i < mediaMessages.length; i += 5) chunks.push(mediaMessages.slice(i, i + 5))
for (const chunk of chunks) {
const results = await Promise.all(chunk.map(m => parseOneMedia(m)))
chunk.forEach((m, i) => { if (results[i]) mediaResults.set(m.id, results[i]) })
mediaCount += chunk.length
onProgress(Math.min(90, Math.round((mediaCount / Math.max(mediaMessages.length, 1)) * 80)))
}
// 组装上下文文本
for (const m of messages) {
const sender = m.accountName || m.sender || '未知'
const typeLabel = { 0: '', 1: '[图片]', 2: '[语音]', 3: '[视频]', 5: '[表情]', 7: '[链接]', 80: '[系统]', 81: '[撤回]' }[m.type] || `[类型${m.type}]`
const mediaDesc = mediaResults.get(m.id)
const quoteText = quoteContext(m)
if (m.type === 0 && m.content) {
lines.push(`${sender}: ${m.content}${quoteText}`)
} else if (mediaDesc) {
lines.push(`${sender} ${typeLabel}: ${mediaDesc}${quoteText}`)
} else if (m.content && m.type !== 0) {
lines.push(`${sender} ${typeLabel}: ${m.content}${quoteText}`)
} else {
lines.push(`${sender} ${typeLabel}${quoteText}`)
}
}
return { context: lines.join('\n'), parsedMedia: mediaResults.size }
}
/**
* AI 总结面板
* 先解析媒体(图片/语音/视频),再把全部内容一起送给 AI 总结
*/
export default function AISummaryPanel({ messages, roomName, onClose }) {
const [phase, setPhase] = useState('idle') // idle | parsing | loading | streaming | done | error
const [parseProgress, setParseProgress] = useState(0)
const [parsedMedia, setParsedMedia] = useState(0)
const [content, setContent] = useState('')
const [copied, setCopied] = useState(false)
const abortRef = useRef(null)
useEffect(() => {
handleGenerate()
return () => abortRef.current?.abort()
}, []) // eslint-disable-line
const handleGenerate = async () => {
setContent('')
setParseProgress(0)
setParsedMedia(0)
const controller = new AbortController()
abortRef.current = controller
try {
// ── 第一步:解析媒体 ──
const mediaMessages = messages.filter(m => {
if (!MEDIA_TYPE_MAP[m.type]) return false
return m.type === 2 ? (m.voiceKey || m.mediaKey) : m.mediaKey
})
if (mediaMessages.length > 0) {
setPhase('parsing')
const { context, parsedMedia: count } = await buildContext(messages, setParseProgress)
setParsedMedia(count)
setParseProgress(100)
// 稍等一下让用户看到 100%
await new Promise(r => setTimeout(r, 300))
await streamSummary(context, roomName, controller, setPhase, setContent)
} else {
// 没有媒体,直接总结文本
const textContext = messages
.filter(m => (m.type === 0 && m.content) || m.quote?.content)
.map(m => `${m.accountName || m.sender}: ${m.content || ''}${quoteContext(m)}`)
.join('\n')
await streamSummary(textContext, roomName, controller, setPhase, setContent)
}
} catch (e) {
if (e.name === 'AbortError') return
setContent(`**生成失败**: ${e.message}`)
setPhase('error')
}
}
const handleCopy = () => {
navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const mediaTotal = messages.filter(m => {
if (!MEDIA_TYPE_MAP[m.type]) return false
return m.type === 2 ? (m.voiceKey || m.mediaKey) : m.mediaKey
}).length
return (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 200, backdropFilter: 'blur(4px)',
}}>
<div style={{
width: 720, maxWidth: '92vw', maxHeight: '82vh',
background: 'var(--bg-elevated)', border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-lg)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '14px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0,
}}>
<Sparkles size={16} color="var(--accent-light)" />
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
AI 知识总结
</span>
<span style={{
fontSize: 11, color: 'var(--text-muted)',
background: 'var(--bg-overlay)', padding: '2px 8px', borderRadius: 10,
}}>
{messages.length} 条消息 · {roomName}
{mediaTotal > 0 && ` · ${mediaTotal} 条媒体`}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
{phase === 'done' && (
<button className="btn btn-ghost btn-sm" onClick={handleGenerate}>
<Sparkles size={12} /> 重新生成
</button>
)}
{content && (
<button className="btn btn-ghost btn-sm" onClick={handleCopy}>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? '已复制' : '复制'}
</button>
)}
{content && (
<button className="btn btn-ghost btn-sm" onClick={() => {
const blob = new Blob([content], { type: 'text/markdown' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${roomName || 'summary'}_AI总结.md`
a.click()
URL.revokeObjectURL(a.href)
}}>
<Download size={12} /> 导出 MD
</button>
)}
<button
onClick={onClose}
style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}
>
<X size={18} />
</button>
</div>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '18px 22px' }}>
{/* 媒体解析进度 */}
{phase === 'parsing' && (
<div style={{ padding: '40px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--text-muted)', marginBottom: 16 }}>
<div className="loading-spinner" />
正在解析媒体内容图片 / 语音 / 视频...
<span style={{ color: 'var(--accent-light)', fontWeight: 500 }}>
{parseProgress}%
</span>
</div>
<div style={{
height: 4, background: 'var(--bg-overlay)', borderRadius: 4, overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 4,
background: 'var(--accent)',
width: `${parseProgress}%`,
transition: 'width 0.3s',
}} />
</div>
<div style={{ marginTop: 8, fontSize: 11.5, color: 'var(--text-muted)' }}>
{mediaTotal} 条媒体已解析 {parsedMedia}
</div>
</div>
)}
{phase === 'loading' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--text-muted)', padding: '40px 0' }}>
<div className="loading-spinner" />
正在连接 AI 服务...
</div>
)}
{(phase === 'streaming' || phase === 'done' || phase === 'error') && (
<div style={{ lineHeight: 1.8 }} className="ai-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content || ' '}
</ReactMarkdown>
{phase === 'streaming' && (
<span style={{
display: 'inline-block',
width: 8, height: 16,
background: 'var(--accent)', borderRadius: 2,
animation: 'pulse 0.8s infinite',
verticalAlign: 'middle', marginLeft: 2,
}} />
)}
</div>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 18px', borderTop: '1px solid var(--border)', flexShrink: 0,
fontSize: 11.5, color: 'var(--text-muted)',
}}>
{phase === 'parsing' && <><Loader size={12} style={{ animation: 'spin 1s linear infinite' }} /> 解析媒体中{parseProgress}%...</>}
{phase === 'loading' && <><Loader size={12} style={{ animation: 'spin 1s linear infinite' }} /> 连接中...</>}
{phase === 'streaming' && <><Sparkles size={12} color="var(--accent)" /> 生成中...</>}
{phase === 'done' && <><Check size={12} color="var(--success)" /> 生成完成{parsedMedia > 0 && `(含 ${parsedMedia} 条媒体内容)`}</>}
{phase === 'error' && <><X size={12} color="var(--danger)" /> 生成失败</>}
<span style={{ marginLeft: 'auto' }}>chatlog AI · 媒体感知总结</span>
</div>
</div>
{/* Markdown 样式 */}
<style>{`
.ai-markdown h1, .ai-markdown h2, .ai-markdown h3 {
color: var(--text-primary); margin: 14px 0 6px; font-weight: 600;
}
.ai-markdown h1 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.ai-markdown h2 { font-size: 15px; }
.ai-markdown h3 { font-size: 13.5px; }
.ai-markdown p { color: var(--text-primary); margin: 6px 0; font-size: 13.5px; }
.ai-markdown ul, .ai-markdown ol { padding-left: 20px; color: var(--text-primary); font-size: 13.5px; }
.ai-markdown li { margin: 3px 0; }
.ai-markdown table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; }
.ai-markdown th { background: var(--bg-overlay); color: var(--text-secondary); padding: 7px 10px; text-align: left; border: 1px solid var(--border); }
.ai-markdown td { padding: 6px 10px; border: 1px solid var(--border); color: var(--text-primary); }
.ai-markdown tr:nth-child(even) td { background: var(--bg-surface); }
.ai-markdown code { background: var(--bg-overlay); padding: 1px 5px; border-radius: 4px; font-size: 12px; color: var(--accent-light); }
.ai-markdown blockquote { border-left: 3px solid var(--accent); padding: 6px 12px; background: var(--accent-dim); border-radius: 0 6px 6px 0; margin: 8px 0; }
.ai-markdown strong { color: var(--text-primary); font-weight: 600; }
.ai-markdown hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
`}</style>
</div>
)
}
// 流式请求 AI 总结接口
async function streamSummary(context, roomName, controller, setPhase, setContent) {
setPhase('loading')
const resp = await fetch('/api/ai/summarize/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context, room_name: roomName }),
signal: controller.signal,
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.detail || `HTTP ${resp.status}`)
}
setPhase('streaming')
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const json = JSON.parse(line.slice(6))
if (json.done) { setPhase('done'); return }
else if (json.error) throw new Error(json.error)
else if (json.delta) setContent(prev => prev + json.delta)
} catch (e) {
if (e.message !== 'Unexpected end of JSON input') throw e
}
}
}
setPhase('done')
}

View File

@@ -0,0 +1,131 @@
import { useState, useRef, useEffect } from 'react'
import { Users, Check, X } from 'lucide-react'
/**
* 多选成员选择器
* 支持搜索、已选 Tag 展示、点击外部关闭
*/
export default function MemberSelector({ members = [], selected = [], onChange }) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef(null)
// 点击外部关闭
useEffect(() => {
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const filtered = members.filter((m) => {
if (!search) return true
return (
m.accountName?.includes(search) ||
m.groupNickname?.includes(search) ||
m.platformId?.includes(search)
)
})
const toggle = (id) => {
if (selected.includes(id)) {
onChange(selected.filter((s) => s !== id))
} else {
onChange([...selected, id])
}
}
const getMember = (id) => members.find((m) => m.platformId === id)
return (
<div className="member-selector" ref={ref}>
<div
className={`member-selector-trigger ${open ? 'open' : ''}`}
onClick={() => setOpen(!open)}
id="member-selector-trigger"
>
<Users size={14} />
{selected.length === 0 ? (
<span>全部成员</span>
) : (
<span style={{ color: 'var(--accent-light)' }}>已选 {selected.length} </span>
)}
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)', fontSize: 10 }}></span>
</div>
{open && (
<div className="member-selector-dropdown">
{/* 搜索框 */}
<div className="member-search">
<input
className="member-search-input"
placeholder="🔍 搜索成员名称..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
{/* 列表 */}
<div className="member-list-scroll">
{filtered.length === 0 && (
<div style={{ padding: '12px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>
无匹配成员
</div>
)}
{filtered.map((m) => {
const checked = selected.includes(m.platformId)
return (
<div
key={m.platformId}
className={`member-option ${checked ? 'checked' : ''}`}
onClick={() => toggle(m.platformId)}
>
<div className="member-checkbox">
{checked && <Check size={10} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="member-option-name truncate">{m.accountName}</div>
{m.groupNickname && m.groupNickname !== m.accountName && (
<div className="member-option-nick truncate">{m.groupNickname}</div>
)}
</div>
</div>
)
})}
</div>
{/* 已选 + 清空 */}
<div className="member-footer">
<div className="member-selected-tags">
{selected.length === 0 && (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>未选择显示全部</span>
)}
{selected.map((id) => {
const m = getMember(id)
return (
<div key={id} className="member-tag">
{m?.accountName || id}
<span className="member-tag-remove" onClick={(e) => { e.stopPropagation(); toggle(id) }}>
<X size={9} />
</span>
</div>
)
})}
</div>
{selected.length > 0 && (
<button
className="btn btn-ghost btn-sm"
style={{ flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); onChange([]) }}
>
清空
</button>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,696 @@
import { useState, useEffect } from 'react'
import dayjs from 'dayjs'
import { Volume2, Video, FileText, Link, AlertCircle, Type, Play, Download, ExternalLink, Reply } from 'lucide-react'
// key 可能是路径(含 /)或 md5只编码各路径段保留斜杠让 Gin *key 路由正确匹配
const avatarCache = new Map()
function fetchAvatar(wxid) {
if (avatarCache.has(wxid)) return avatarCache.get(wxid)
const p = fetch(`/api/search/avatar?wxid=${encodeURIComponent(wxid)}`)
.then(r => r.json()).then(d => d.url || '').catch(() => '')
avatarCache.set(wxid, p)
return p
}
function mediaUrl(type, key) {
if (!key) return ''
return `/${type}/${key.split('/').map(p => encodeURIComponent(p)).join('/')}`
}
// 调用 chatlog 内置 AI 解析(语音→文字,图片/视频→描述)
async function aiParse(type, key) {
const typeMap = { 1: 'image', 2: 'voice', 3: 'video' }
const aiType = typeMap[type]
if (!aiType || !key) throw new Error('参数不完整')
const res = await fetch('/api/ai/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: aiType, key }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
const detail = err.detail
if (detail && typeof detail === 'object') {
const e = new Error(detail.message || err.message || `HTTP ${res.status}`)
e.diagnostics = detail.diagnostics
throw e
}
throw new Error(err.message || detail || `HTTP ${res.status}`)
}
return res.json() // { text: "..." }
}
function Avatar({ wxid, displayName, color }) {
const [imgUrl, setImgUrl] = useState(null)
const initials = displayName?.slice(0, 2) || wxid?.slice(-2) || '??'
useEffect(() => {
if (!wxid || wxid === 'me') return
fetchAvatar(wxid).then(url => { if (url) setImgUrl(url) })
}, [wxid])
return (
<div className="msg-avatar" style={{ background: color, position: 'relative', overflow: 'hidden' }}>
{initials}
{imgUrl && (
<img
src={imgUrl}
referrerPolicy="no-referrer"
onError={() => setImgUrl(null)}
style={{
position: 'absolute',
top: 0, left: 0, width: '100%', height: '100%',
objectFit: 'cover',
borderRadius: 'inherit',
}}
alt=""
/>
)}
</div>
)
}
export default function MessageBubble({ msg, keyword = '', isNew = false }) {
const isMine = msg.sender === 'me'
const time = dayjs.unix(msg.timestamp).format('HH:mm')
const displayName = msg.groupNickname || msg.accountName
const initials = displayName?.slice(0, 2) || '??'
function highlight(text) {
if (!keyword || !text) return text
const parts = text.split(new RegExp(`(${keyword})`, 'gi'))
return parts.map((part, i) =>
part.toLowerCase() === keyword.toLowerCase()
? <mark key={i}>{part}</mark>
: part
)
}
if (msg.type === 82) {
return (
<div className="msg-row" style={{ justifyContent: 'center' }}>
<span style={{ color: 'var(--text-muted)', fontSize: 12, fontStyle: 'italic' }}>
{msg.content || '[拍了拍]'}
</span>
</div>
)
}
return (
<div className={`msg-row ${isMine ? 'mine' : ''} ${isNew ? 'highlight' : ''}`}>
<Avatar wxid={msg.sender} displayName={displayName} color={getAvatarColor(msg.sender)} />
<div className="msg-body">
<div className="msg-meta">
<span className="msg-sender">{msg.accountName}</span>
{msg.groupNickname && msg.groupNickname !== msg.accountName && (
<span className="msg-nickname">({msg.groupNickname})</span>
)}
<span className="msg-time">{time}</span>
{isNew && <span className="new-badge">新消息</span>}
</div>
<div className="msg-bubble">
<MsgContent msg={msg} highlight={highlight} />
</div>
</div>
</div>
)
}
function MsgContent({ msg, highlight }) {
// 图片/视频用 md5chatlog 按 md5 查库),语音用 voiceKeyServerID
const mediaKey = msg.mediaKey || ''
const voiceKey = msg.voiceKey || ''
const emojiUrl = msg.emojiUrl || ''
const withQuote = (body) => (
<>
<QuoteBlock quote={msg.quote} />
{body}
</>
)
switch (msg.type) {
case 0:
return withQuote(<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{highlight(msg.content)}</span>)
case 1:
return withQuote(<ImageMsg msgKey={mediaKey} msgType={msg.type} />)
case 2:
return withQuote(<VoiceMsg msgKey={voiceKey} content={msg.content} msgType={msg.type} />)
case 3:
return withQuote(<VideoMsg msgKey={mediaKey} mediaPath={msg.mediaPath} msgType={msg.type} />)
case 5:
return withQuote(emojiUrl
? <img src={emojiUrl} alt="表情包" style={{ maxWidth: 120, maxHeight: 120, borderRadius: 4, display: 'block' }} />
: mediaKey
? <ImageMsg msgKey={mediaKey} msgType={msg.type} />
: <MediaTag icon="😄" label="表情包" />)
case 7:
if (msg.isFile || msg.subType === 6) return withQuote(<FileMsg msg={msg} />)
return withQuote(<LinkMsg msg={msg} />)
case 80:
return (
<span style={{ color: 'var(--text-muted)', fontSize: 12, fontStyle: 'italic' }}>
{msg.content || '[系统消息]'}
</span>
)
case 81:
return <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>撤回了一条消息</span>
case 82:
return (
<span style={{ color: 'var(--text-muted)', fontSize: 12, fontStyle: 'italic' }}>
{msg.content || '[拍了拍]'}
</span>
)
default:
return withQuote(<MediaTag icon={<AlertCircle size={13} />} label={getTypeName(msg.type)} />)
}
}
function QuoteBlock({ quote }) {
if (!quote?.content) return null
const sender = quote.sender_name || quote.sender || '引用消息'
return (
<div style={{
marginBottom: 6,
padding: '6px 8px',
borderLeft: '3px solid var(--accent-light)',
background: 'rgba(99,102,241,0.08)',
borderRadius: 4,
maxWidth: 320,
color: 'var(--text-secondary)',
fontSize: 12,
lineHeight: 1.45,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2, color: 'var(--text-muted)' }}>
<Reply size={12} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{sender}</span>
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{quote.content}</div>
</div>
)
}
// ── 文件 ──────────────────────────────────────
function FileMsg({ msg }) {
const [error, setError] = useState('')
const fileName = msg.fileName || msg.linkTitle || msg.content?.replace(/^\[文件\|(.+)\]$/, '$1') || '文件'
const fileMd5 = msg.fileMd5 || msg.mediaMd5 || msg.mediaKey || ''
const fileUrl = msg.fileUrl || (fileMd5 ? `/api/files/${encodeURIComponent(fileMd5)}?filename=${encodeURIComponent(fileName || fileMd5)}` : '')
const ext = getFileExt(fileName)
const openFile = () => {
setError('')
if (!fileUrl) {
setError('缺少文件标识,无法打开原文件')
return
}
const a = document.createElement('a')
a.href = fileUrl
a.download = fileName
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
a.remove()
}
return (
<div style={{
width: 280,
borderRadius: 8,
background: 'var(--surface-2)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', gap: 10, padding: '10px 12px', alignItems: 'center' }}>
<div style={{
width: 38, height: 44, borderRadius: 6,
background: 'rgba(99,102,241,0.12)',
color: 'var(--accent-light)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<FileText size={18} />
<span style={{ fontSize: 9, marginTop: 1, maxWidth: 32, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ext || 'FILE'}
</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
fontWeight: 500,
lineHeight: 1.35,
wordBreak: 'break-word',
}}>
{fileName}
</div>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--text-muted)' }}>
{fileMd5 ? `md5: ${fileMd5.slice(0, 10)}...` : '缺少文件标识'}
</div>
</div>
</div>
<div style={{
borderTop: '1px solid var(--border)',
padding: '7px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}>
<span style={{
fontSize: 11,
color: error ? 'var(--danger)' : 'var(--text-muted)',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}>
{error || (fileUrl ? '原文件' : '不可打开')}
</span>
<button
onClick={openFile}
disabled={!fileUrl}
title="打开或下载原文件"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '4px 8px',
borderRadius: 6,
border: '1px solid var(--border)',
background: '#fff',
color: fileUrl ? 'var(--accent-light)' : 'var(--text-muted)',
cursor: fileUrl ? 'pointer' : 'not-allowed',
fontSize: 11,
flexShrink: 0,
}}
>
<Download size={12} />
下载/打开
</button>
</div>
</div>
)
}
// ── 图片 ──────────────────────────────────────
function ImageMsg({ msgKey, msgType }) {
const [errored, setErrored] = useState(false)
const [enlarged, setEnlarged] = useState(false)
const [aiText, setAiText] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState('')
if (!msgKey) return <MediaTag icon="🖼️" label="图片" />
const url = mediaUrl('image', msgKey)
const thumbUrl = url + '?thumb=1'
const handleAiDesc = async () => {
setAiLoading(true)
setAiError('')
try {
const result = await aiParse(msgType, msgKey)
setAiText(result.text || result.result || JSON.stringify(result))
} catch (e) {
setAiError(e.message)
} finally {
setAiLoading(false)
}
}
return (
<div>
{errored ? (
// 加载失败时仍保留点击 AI 描述的入口
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 12px',
background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 8,
}}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>🖼 图片文件未找到</span>
<AiBtn loading={aiLoading} onClick={handleAiDesc} />
</div>
) : (
<div style={{ display: 'inline-block', position: 'relative' }}>
<img
src={thumbUrl}
alt="图片"
style={{
maxWidth: 220, maxHeight: 220,
borderRadius: 8, cursor: 'pointer',
display: 'block', objectFit: 'cover',
}}
onError={() => setErrored(true)}
onClick={() => setEnlarged(true)}
/>
{/* AI 描述按钮浮在图片右下角 */}
<div style={{ position: 'absolute', bottom: 6, right: 6 }}>
<AiBtn loading={aiLoading} onClick={handleAiDesc} />
</div>
</div>
)}
{/* AI 描述结果 */}
{(aiText || aiError) && (
<div style={{
marginTop: 6, padding: '6px 10px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12, color: aiError ? 'var(--danger)' : 'var(--text-primary)',
maxWidth: 280,
}}>
{aiError ? `AI 识别失败:${aiError}` : aiText}
</div>
)}
{/* 放大预览 */}
{enlarged && (
<div
onClick={() => setEnlarged(false)}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999, cursor: 'zoom-out',
}}
>
<img
src={url}
alt="原图"
style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, cursor: 'default' }}
onClick={e => e.stopPropagation()}
onError={e => { e.currentTarget.onerror = null; e.currentTarget.src = thumbUrl }}
/>
</div>
)}
</div>
)
}
// ── 语音 ──────────────────────────────────────
function VoiceMsg({ msgKey, content, msgType }) {
const [aiText, setAiText] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState('')
const [audioError, setAudioError] = useState(false)
useEffect(() => {
setAudioError(false)
}, [msgKey])
const handleToText = async () => {
setAiLoading(true)
setAiError('')
try {
const result = await aiParse(msgType, msgKey)
setAiText(result.text || result.result || JSON.stringify(result))
} catch (e) {
setAiError(e.message)
} finally {
setAiLoading(false)
}
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Volume2 size={14} color={msgKey ? 'var(--accent-light)' : 'var(--text-muted)'} />
{msgKey ? (
audioError ? (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>语音文件暂不可用</span>
) : (
<audio
controls
preload="none"
style={{ height: 32, maxWidth: 200 }}
src={mediaUrl('voice', msgKey)}
onError={() => setAudioError(true)}
/>
)
) : (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{content || '语音'}无媒体标识无法播放
</span>
)}
{/* 转文字按钮 */}
<button
onClick={handleToText}
disabled={!msgKey || aiLoading}
title={msgKey ? '调用 AI 语音转文字' : '缺少媒体标识,无法转文字'}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '3px 9px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 12, fontSize: 11, cursor: msgKey ? 'pointer' : 'not-allowed',
color: msgKey ? 'var(--accent-light)' : 'var(--text-muted)',
opacity: (!msgKey || aiLoading) ? 0.6 : 1,
whiteSpace: 'nowrap',
}}
>
<Type size={11} />
{aiLoading ? '识别中...' : '转文字'}
</button>
</div>
{/* 转文字结果 */}
{(aiText || aiError) && (
<div style={{
marginTop: 6, padding: '6px 10px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12,
color: aiError ? 'var(--danger)' : 'var(--text-primary)',
maxWidth: 320,
}}>
{aiError ? `转文字失败:${aiError}` : `"${aiText}"`}
</div>
)}
</div>
)
}
// ── 视频 ──────────────────────────────────────
function VideoMsg({ msgKey, mediaPath, msgType }) {
const [showVideo, setShowVideo] = useState(false)
const [thumbErr, setThumbErr] = useState(false)
const [videoErr, setVideoErr] = useState(false)
const [aiText, setAiText] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState('')
if (!msgKey && !mediaPath) return <MediaTag icon={<Video size={13} />} label="视频" />
const thumbUrl = msgKey ? mediaUrl('video', msgKey) + '?thumb=1' : ''
const videoUrl = mediaPath ? mediaUrl('video', mediaPath) : mediaUrl('video', msgKey)
const handleAiDesc = async () => {
setAiLoading(true)
setAiError('')
try {
const result = await aiParse(msgType, msgKey)
setAiText(result.text || result.result || JSON.stringify(result))
} catch (e) {
setAiError(e.message)
} finally {
setAiLoading(false)
}
}
return (
<div>
{showVideo && !videoErr ? (
<div style={{ position: 'relative', display: 'inline-block' }}>
<video
controls
autoPlay
style={{ maxWidth: 320, maxHeight: 240, borderRadius: 8, display: 'block' }}
src={videoUrl}
onError={() => setVideoErr(true)}
/>
</div>
) : (
/* 封面 / 播放按钮 */
<div
style={{
position: 'relative', cursor: 'pointer',
width: 200, height: 140,
background: '#111', borderRadius: 8, overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onClick={() => {
if (videoErr) return
setShowVideo(true)
}}
>
{!thumbErr && (
<img
src={thumbUrl}
alt="视频封面"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.7 }}
onError={() => setThumbErr(true)}
/>
)}
{/* 播放 / 错误图标 */}
<div style={{
position: 'relative', zIndex: 1,
width: 44, height: 44, borderRadius: '50%',
background: videoErr ? 'rgba(255,80,80,0.85)' : 'rgba(255,255,255,0.85)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{videoErr
? <AlertCircle size={20} color="#fff" />
: <Play size={20} color="#333" style={{ marginLeft: 3 }} />
}
</div>
{videoErr && (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
background: 'rgba(0,0,0,0.65)', fontSize: 11,
color: '#fff', textAlign: 'center', padding: '4px 0',
}}>
视频加载失败
</div>
)}
{/* AI 描述按钮 */}
<div
style={{ position: 'absolute', top: 6, right: 6, zIndex: 2 }}
onClick={e => { e.stopPropagation(); handleAiDesc() }}
>
<AiBtn loading={aiLoading} />
</div>
</div>
)}
{/* AI 描述结果 */}
{(aiText || aiError) && (
<div style={{
marginTop: 6, padding: '6px 10px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12,
color: aiError ? 'var(--danger)' : 'var(--text-primary)',
maxWidth: 280,
}}>
{aiError ? `AI 识别失败:${aiError}` : aiText}
</div>
)}
</div>
)
}
// ── 链接 ──────────────────────────────────────
function LinkMsg({ msg }) {
const title = msg.linkTitle || '分享链接'
const desc = msg.linkDesc || ''
const url = msg.linkUrl || ''
const thumb = msg.linkThumb || ''
const source = msg.linkSource || ''
return (
<div
onClick={() => url && window.open(url, '_blank')}
style={{
maxWidth: 280, borderRadius: 8, overflow: 'hidden',
background: 'var(--surface-2)', border: '1px solid var(--border)',
cursor: url ? 'pointer' : 'default',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 12px' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 500, lineHeight: 1.4, marginBottom: desc ? 4 : 0,
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>
{title}
</div>
{desc && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{desc}
</div>
)}
</div>
{thumb && (
<img
src={thumb} alt=""
style={{ width: 56, height: 56, borderRadius: 4, objectFit: 'cover', flexShrink: 0 }}
onError={e => { e.currentTarget.style.display = 'none' }}
/>
)}
</div>
<div style={{
borderTop: '1px solid var(--border)', padding: '5px 12px',
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, color: 'var(--text-muted)',
}}>
<Link size={10} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{source || (url ? (() => { try { return new URL(url).hostname } catch { return url.slice(0, 30) } })() : '链接')}
</span>
</div>
</div>
)
}
// ── AI 按钮(图片/视频浮层用) ──────────────
function AiBtn({ loading, onClick }) {
return (
<button
onClick={onClick}
disabled={loading}
title="AI 识别内容"
style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 7px',
background: 'rgba(99,102,241,0.85)', border: 'none',
borderRadius: 10, fontSize: 10.5, cursor: 'pointer',
color: '#fff', opacity: loading ? 0.7 : 1,
}}
>
{loading ? '...' : 'AI'}
</button>
)
}
// ── 通用占位标签 ──────────────────────────────
function MediaTag({ icon, label }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12, color: 'var(--text-muted)',
}}>
{icon} {label}
</span>
)
}
// ── 工具函数 ──────────────────────────────────
const AVATAR_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f59e0b',
'#10b981', '#3b82f6', '#06b6d4', '#84cc16',
]
function getAvatarColor(id) {
if (id === 'me') return '#6366f1'
let hash = 0
for (let c of (id || '')) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
}
function getFileExt(name) {
const m = String(name || '').match(/\.([a-z0-9]{1,8})(?:\?|#)?$/i)
return m ? m[1].toUpperCase() : ''
}
function getTypeName(type) {
const names = {
1: '图片', 2: '语音', 3: '视频', 4: '文件',
5: '表情包', 7: '链接', 8: '位置',
20: '红包', 21: '转账', 22: '拍一拍',
25: '引用回复', 80: '系统消息', 81: '撤回',
}
return names[type] || `未知(${type})`
}

View File

@@ -0,0 +1,127 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
export const WORD_PAGE_CLASS = 'report-word-page'
export const WORD_PAGE_CSS = `
.${WORD_PAGE_CLASS} {
width: min(100%, 794px);
min-height: 1123px;
margin: 0 auto;
padding: 56px 64px;
box-sizing: border-box;
background: #ffffff;
color: #1f2937;
border: 1px solid #d8dee8;
box-shadow: 0 10px 32px rgba(15, 23, 42, 0.12);
font-family: "Microsoft YaHei", "SimSun", Arial, sans-serif;
font-size: 14px;
line-height: 1.75;
}
.${WORD_PAGE_CLASS} h1 {
margin: 0 0 22px;
padding-bottom: 12px;
border-bottom: 2px solid #111827;
color: #111827;
font-size: 24px;
line-height: 1.35;
text-align: center;
font-weight: 700;
}
.${WORD_PAGE_CLASS} h2 {
margin: 26px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid #d1d5db;
color: #111827;
font-size: 17px;
line-height: 1.45;
font-weight: 700;
}
.${WORD_PAGE_CLASS} h3 {
margin: 18px 0 8px;
color: #1f2937;
font-size: 15px;
line-height: 1.45;
font-weight: 700;
}
.${WORD_PAGE_CLASS} p {
margin: 8px 0;
}
.${WORD_PAGE_CLASS} ul,
.${WORD_PAGE_CLASS} ol {
margin: 8px 0 12px;
padding-left: 24px;
}
.${WORD_PAGE_CLASS} li {
margin: 4px 0;
}
.${WORD_PAGE_CLASS} table {
width: 100%;
margin: 12px 0 16px;
border-collapse: collapse;
table-layout: fixed;
font-size: 13px;
}
.${WORD_PAGE_CLASS} th,
.${WORD_PAGE_CLASS} td {
padding: 8px 10px;
border: 1px solid #9ca3af;
vertical-align: top;
word-break: break-word;
}
.${WORD_PAGE_CLASS} th {
background: #f3f4f6;
color: #111827;
font-weight: 700;
text-align: left;
}
.${WORD_PAGE_CLASS} blockquote {
margin: 10px 0;
padding: 8px 12px;
border-left: 4px solid #9ca3af;
background: #f9fafb;
}
.${WORD_PAGE_CLASS} code {
padding: 1px 4px;
background: #f3f4f6;
border-radius: 3px;
font-family: Consolas, monospace;
font-size: 12px;
}
.${WORD_PAGE_CLASS} hr {
margin: 20px 0;
border: none;
border-top: 1px solid #d1d5db;
}
.${WORD_PAGE_CLASS} img {
display: block;
max-width: 100%;
max-height: 360px;
margin: 10px 0 8px;
padding: 4px;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #f9fafb;
object-fit: contain;
}
@media (max-width: 900px) {
.${WORD_PAGE_CLASS} {
min-height: auto;
padding: 32px 24px;
}
}
`
export default function ReportDocumentView({ content = '' }) {
return (
<>
<style>{WORD_PAGE_CSS}</style>
<article className={WORD_PAGE_CLASS}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content || '(暂无报告内容)'}
</ReactMarkdown>
</article>
</>
)
}

View File

@@ -0,0 +1,817 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ─── Design Tokens ───────────────────────────────────────────────── */
:root {
/* Colors - Light Mode */
--bg-base: #f4f6f9;
--bg-surface: #ffffff;
--bg-elevated: #ffffff;
--bg-overlay: #f0f2f5;
--bg-hover: #eef0f5;
/* 别名:兼容组件中使用的 var(--surface) / var(--surface-2) */
--surface: #ffffff;
--surface-2: #f0f2f5;
--border: rgba(0,0,0,0.08);
--border-strong: rgba(0,0,0,0.14);
--text-primary: #1a1d27;
--text-secondary: #5a6072;
--text-muted: #9ba3b8;
--text-inverse: #ffffff;
/* Brand */
--accent: #6366f1;
--accent-light: #4f46e5;
--accent-dim: rgba(99,102,241,0.10);
--accent-hover: #5254cc;
/* Semantic */
--success: #16a34a;
--success-dim: rgba(22,163,74,0.10);
--warning: #d97706;
--warning-dim: rgba(217,119,6,0.10);
--danger: #dc2626;
--danger-dim: rgba(220,38,38,0.10);
--info: #0284c7;
--info-dim: rgba(2,132,199,0.10);
/* Sidebar */
--sidebar-width: 280px;
--topbar-height: 56px;
/* Spacing */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.10);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.14);
/* Transitions */
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ─── Reset ───────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html { font-size: 14px; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-base);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow: hidden;
height: 100vh;
}
#root { height: 100vh; display: flex; flex-direction: column; }
/* ─── Scrollbar ───────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5e0; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #b0b7c8; }
/* ─── Typography ──────────────────────────────────────────────────── */
h1, h2, h3, h4 { font-weight: 600; letter-spacing: -0.02em; }
code, pre { font-family: 'JetBrains Mono', monospace; }
/* ─── Layout Shell ────────────────────────────────────────────────── */
.app-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ─── Sidebar ─────────────────────────────────────────────────────── */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: #f7f8fb;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-logo {
height: var(--topbar-height);
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-logo-icon {
width: 28px; height: 28px;
background: var(--bg-surface);
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.sidebar-logo-icon img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.sidebar-logo-text {
font-weight: 700;
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
}
.sidebar-logo-version {
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
}
.sidebar-nav {
padding: 12px 10px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent-dim);
color: var(--accent-light);
}
.nav-item svg { opacity: 0.8; flex-shrink: 0; }
.nav-item.active svg { opacity: 1; }
/* ─── Room List (sidebar) ─────────────────────────────────────────── */
.room-list {
flex: 1;
overflow-y: auto;
padding: 8px 10px;
}
.room-list-header {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 6px 4px 4px;
}
.room-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
}
.room-item:hover { background: var(--bg-hover); }
.room-item.active { background: var(--accent-dim); }
.room-avatar {
width: 34px; height: 34px;
border-radius: var(--radius-sm);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
font-size: 14px;
flex-shrink: 0;
color: var(--text-secondary);
font-weight: 600;
}
.room-info { flex: 1; min-width: 0; }
.room-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.room-meta {
font-size: 11px;
color: var(--text-muted);
}
/* ─── Main Content Area ───────────────────────────────────────────── */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-base);
}
/* ─── Topbar ──────────────────────────────────────────────────────── */
.topbar {
height: var(--topbar-height);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
flex-shrink: 0;
}
.topbar-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.topbar-subtitle {
font-size: 12px;
color: var(--text-muted);
}
.topbar-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
/* ─── Filter Bar ──────────────────────────────────────────────────── */
.filter-bar {
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-input, .filter-select {
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 7px 11px;
font-size: 13px;
font-family: inherit;
transition: border-color var(--transition);
outline: none;
min-width: 160px;
}
.filter-input:focus, .filter-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
.filter-input::placeholder { color: var(--text-muted); }
/* Date range */
.date-range {
display: flex;
align-items: center;
gap: 6px;
}
.date-range-sep { color: var(--text-muted); font-size: 12px; }
/* Quick date chips */
.date-chips { display: flex; gap: 4px; }
.chip {
padding: 4px 10px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
}
.chip:hover { border-color: var(--accent); color: var(--accent-light); }
.chip.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent-light); }
/* ─── Message List ────────────────────────────────────────────────── */
.message-area {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.msg-day-divider {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 0 8px;
}
.msg-day-divider-line {
flex: 1;
height: 1px;
background: var(--border);
}
.msg-day-divider-text {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
padding: 2px 10px;
background: var(--bg-overlay);
border-radius: 10px;
}
/* Message bubble */
.msg-row {
display: flex;
gap: 10px;
padding: 4px 6px;
border-radius: var(--radius-md);
transition: background var(--transition);
}
.msg-row:hover { background: #f0f2f7; }
.msg-row.mine { flex-direction: row-reverse; }
.msg-row.highlight { background: rgba(99,102,241,0.06); }
.msg-avatar {
width: 34px; height: 34px;
border-radius: var(--radius-sm);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
color: var(--text-secondary);
align-self: flex-start;
margin-top: 2px;
}
.msg-body { flex: 1; min-width: 0; max-width: 72%; }
.msg-row.mine .msg-body { align-items: flex-end; display: flex; flex-direction: column; }
.msg-meta {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 3px;
}
.msg-row.mine .msg-meta { flex-direction: row-reverse; }
.msg-sender {
font-size: 12px;
font-weight: 600;
color: var(--accent-light);
}
.msg-nickname {
font-size: 11px;
color: var(--text-muted);
}
.msg-time {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.msg-row.mine .msg-time { margin-left: 0; margin-right: auto; }
.msg-bubble {
display: inline-block;
padding: 9px 13px;
border-radius: var(--radius-md);
font-size: 13.5px;
line-height: 1.55;
word-break: break-word;
background: #eef0f5;
color: var(--text-primary);
border: 1px solid var(--border);
max-width: 100%;
}
.msg-row.mine .msg-bubble {
background: var(--accent);
border-color: transparent;
color: #fff;
}
/* New message badge */
.new-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: var(--success-dim);
color: var(--success);
font-weight: 600;
margin-left: 6px;
vertical-align: middle;
}
/* ─── Status Bar ──────────────────────────────────────────────────── */
.status-bar {
height: 34px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
flex-shrink: 0;
}
.status-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--text-muted);
}
.status-dot.connected { background: var(--success); animation: pulse 2s infinite; }
.status-dot.connecting { background: var(--warning); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 11.5px;
color: var(--text-muted);
}
/* ─── Buttons ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all var(--transition);
font-family: inherit;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
/* ─── Empty State ─────────────────────────────────────────────────── */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted);
padding: 40px;
}
.empty-state-icon {
font-size: 40px;
opacity: 0.4;
}
.empty-state-title {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
}
.empty-state-desc {
font-size: 13px;
text-align: center;
line-height: 1.7;
}
/* ─── Loading ─────────────────────────────────────────────────────── */
.loading-spinner {
width: 20px; height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-muted);
font-size: 13px;
}
/* ─── Member Selector Panel ───────────────────────────────────────── */
.member-selector {
position: relative;
}
.member-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 11px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: border-color var(--transition);
min-width: 160px;
}
.member-selector-trigger:hover, .member-selector-trigger.open {
border-color: var(--accent);
}
.member-selector-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 260px;
background: #ffffff;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.member-search {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.member-search-input {
width: 100%;
background: var(--bg-overlay);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 6px 10px;
font-size: 12.5px;
font-family: inherit;
outline: none;
}
.member-search-input:focus { border-color: var(--accent); }
.member-search-input::placeholder { color: var(--text-muted); }
.member-list-scroll {
max-height: 220px;
overflow-y: auto;
padding: 4px;
}
.member-list-section-title {
font-size: 10.5px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 8px 3px;
}
.member-option {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
}
.member-option:hover { background: var(--bg-hover); }
.member-option.checked { background: var(--accent-dim); }
.member-checkbox {
width: 15px; height: 15px;
border: 1.5px solid var(--border-strong);
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
background: transparent;
transition: all var(--transition);
}
.member-option.checked .member-checkbox {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.member-option-name { font-size: 12.5px; color: var(--text-primary); font-weight: 500; }
.member-option-nick { font-size: 11px; color: var(--text-muted); }
.member-footer {
border-top: 1px solid var(--border);
padding: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.member-selected-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
}
.member-tag {
display: flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
background: var(--accent-dim);
border-radius: 10px;
font-size: 11px;
color: var(--accent-light);
}
.member-tag-remove {
cursor: pointer;
opacity: 0.6;
font-size: 11px;
}
.member-tag-remove:hover { opacity: 1; }
/* ─── Webhook test pill ───────────────────────────────────────────── */
.webhook-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: var(--success-dim);
border: 1px solid rgba(34,197,94,0.2);
border-radius: 20px;
font-size: 12px;
color: var(--success);
cursor: pointer;
transition: all var(--transition);
}
.webhook-pill:hover { background: rgba(34,197,94,0.2); }
/* ─── Toast / Notification ────────────────────────────────────────── */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column-reverse;
gap: 8px;
z-index: 9999;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: #ffffff;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 13px;
color: var(--text-primary);
animation: slideIn 0.2s ease-out;
max-width: 320px;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.warning { border-left: 3px solid var(--warning); }
@keyframes slideIn {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ─── Highlight search keyword ────────────────────────────────────── */
mark {
background: rgba(245,158,11,0.3);
color: var(--warning);
border-radius: 2px;
padding: 0 1px;
}
/* ─── Utilities ───────────────────────────────────────────────────── */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.gap-2 { gap: 8px; }
.ml-auto { margin-left: auto; }
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 12px; }
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ─── 消息数据同步进度条 ───────────────────────────────────────────── */
.sync-progress-banner {
margin: 10px 16px 4px;
padding: 10px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
flex-shrink: 0;
}
.sync-progress-info {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.sync-progress-track {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.sync-progress-fill {
height: 100%;
background: var(--accent, #6366f1);
border-radius: 2px;
transition: width 0.5s ease;
min-width: 4px;
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,491 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import dayjs from 'dayjs'
import { Search, RefreshCw, Sparkles, Zap } from 'lucide-react'
import MemberSelector from '../components/MemberSelector'
import MessageBubble from '../components/MessageBubble'
import AISummaryPanel from '../components/AISummaryPanel'
import { getChatlog, getChatroomMembers, subscribeWebhook } from '../api'
const DATE_PRESETS = [
{ label: '今天', getDates: () => [dayjs().startOf('day'), dayjs()] },
{ label: '昨天', getDates: () => [dayjs().subtract(1, 'day').startOf('day'), dayjs().subtract(1, 'day').endOf('day')] },
{ label: '近7天', getDates: () => [dayjs().subtract(7, 'day').startOf('day'), dayjs()] },
{ label: '近30天', getDates: () => [dayjs().subtract(30, 'day').startOf('day'), dayjs()] },
]
function getApiErrorMessage(e, fallback = '未知错误') {
return e?.response?.data?.detail || e?.response?.data?.error || e?.message || fallback
}
const WARMUP_RETRY_LIMIT = 6
const WARMUP_RETRY_DELAY_MS = 1500
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isWarmupError(e) {
const message = getApiErrorMessage(e, '').toLowerCase()
return (
message.includes('自动解密') ||
message.includes('消息索引') ||
message.includes('time range not found') ||
message.includes('message index')
)
}
export default function ChatlogPage({ room, onNewMessage }) {
const [messages, setMessages] = useState([])
const [members, setMembers] = useState([])
const [loading, setLoading] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const [newMsgIds, setNewMsgIds] = useState(new Set())
const [showAI, setShowAI] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const [earliestOffset, setEarliestOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
// 筛选状态
const [startDate, setStartDate] = useState(dayjs().subtract(7, 'day').startOf('day').format('YYYY-MM-DDTHH:mm'))
const [endDate, setEndDate] = useState(dayjs().format('YYYY-MM-DDTHH:mm'))
const [selectedMembers, setSelectedMembers] = useState([])
const [keyword, setKeyword] = useState('')
const [activePreset, setActivePreset] = useState('近7天')
// Webhook / SSE
const [webhookConnected, setWebhookConnected] = useState(false)
const webhookConnectedRef = useRef(false)
const noScrollRef = useRef(false)
const bottomRef = useRef(null)
const scrollAreaRef = useRef(null)
const unsubRef = useRef(null)
// 切换群时重置筛选并自动拉取
useEffect(() => {
if (!room) return
setMessages([])
setHasSearched(false)
setSelectedMembers([])
setErrorMsg('')
setEarliestOffset(0)
setHasMore(false)
// 切群时同时重置日期/关键词/预设为「近7天」防止旧状态污染新群的查询
const newStart = dayjs().subtract(7, 'day').startOf('day').format('YYYY-MM-DDTHH:mm')
const newEnd = dayjs().format('YYYY-MM-DDTHH:mm')
setStartDate(newStart)
setEndDate(newEnd)
setKeyword('')
setActivePreset('近7天')
// 切换群时断开旧 SSE如果之前已连接则自动重连新群
const wasConnected = webhookConnectedRef.current
if (unsubRef.current) {
unsubRef.current()
unsubRef.current = null
webhookConnectedRef.current = false
setWebhookConnected(false)
}
// 只有群聊(@chatroom 结尾)才拉取成员列表
if (room.isGroup !== false) {
getChatroomMembers(room.id)
.then((res) => setMembers(res.data || []))
.catch(() => setMembers([]))
} else {
setMembers([])
}
// 如果之前已连接,自动重连新群的 SSE
if (wasConnected) {
const unsub = subscribeWebhook(room.id, (msg) => {
setMessages((prev) => [...prev, msg])
setNewMsgIds((prev) => new Set([...prev, msg.id]))
onNewMessage?.(room.id, msg)
setTimeout(() => {
setNewMsgIds((prev) => {
const next = new Set(prev)
next.delete(msg.id)
return next
})
}, 3000)
})
unsubRef.current = unsub
webhookConnectedRef.current = true
setWebhookConnected(true)
}
// 同步触发查询,使用显式的 overrides 绕开 setState 时序
// (这样即便新群默认 startDate 与上一个群相同也能正确触发)
fetchMessages(true, {
startDate: newStart,
endDate: newEnd,
keyword: '',
members: [],
activePreset: '近7天',
})
}, [room]) // eslint-disable-line
// 拉取聊天记录
// overrides: 可选,绕开 state 闭包,直接用传入的筛选条件(切群自动加载场景使用)
const fetchMessages = useCallback(async (autoExpandIfEmpty = false, overrides = null) => {
if (!room) return
setLoading(true)
setErrorMsg('')
const startTs = Date.now()
const _startDate = overrides?.startDate ?? startDate
const _endDate = overrides?.endDate ?? endDate
const _selectedMembers = overrides?.members ?? selectedMembers
const _keyword = overrides?.keyword ?? keyword
const _activePreset = overrides?.activePreset ?? activePreset
try {
const LIMIT = 200
const st = dayjs(_startDate).unix()
const et = dayjs(_endDate).unix()
let msgs = []
let usedOffset = 0
for (let attempt = 0; attempt <= WARMUP_RETRY_LIMIT; attempt += 1) {
try {
const first = await getChatlog({ talker: room.id, startTime: st, endTime: et, senders: _selectedMembers, keyword: _keyword, limit: LIMIT, offset: 0 })
const total = first.data?.total || 0
msgs = first.data?.messages || []
usedOffset = 0
if (total > LIMIT) {
usedOffset = total - LIMIT
const last = await getChatlog({ talker: room.id, startTime: st, endTime: et, senders: _selectedMembers, keyword: _keyword, limit: LIMIT, offset: usedOffset })
msgs = last.data?.messages || []
}
// 若近7天为空且 autoExpandIfEmpty自动尝试近30天
if (msgs.length === 0 && autoExpandIfEmpty && _activePreset === '近7天') {
const s30 = dayjs().subtract(30, 'day').startOf('day').unix()
const e30 = dayjs().unix()
const fallback = await getChatlog({ talker: room.id, startTime: s30, endTime: e30, senders: [], keyword: '', limit: LIMIT, offset: 0 })
const fallbackMsgs = fallback.data?.messages || []
if (fallbackMsgs.length > 0) {
setStartDate(dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DDTHH:mm'))
setEndDate(dayjs().format('YYYY-MM-DDTHH:mm'))
setActivePreset('近30天')
msgs = fallbackMsgs
usedOffset = 0
}
}
// 保证 loading 至少显示 300ms防止空状态闪烁
const elapsed = Date.now() - startTs
if (elapsed < 300) await new Promise(r => setTimeout(r, 300 - elapsed))
break
} catch (e) {
if (attempt < WARMUP_RETRY_LIMIT && isWarmupError(e)) {
setErrorMsg(`自动解密仍在处理消息库,正在重试 ${attempt + 1}/${WARMUP_RETRY_LIMIT}...`)
await sleep(WARMUP_RETRY_DELAY_MS)
continue
}
throw e
}
}
setMessages(msgs)
setEarliestOffset(usedOffset)
setHasMore(usedOffset > 0)
setHasSearched(true)
} catch (e) {
setErrorMsg('查询失败:' + getApiErrorMessage(e))
} finally {
setLoading(false)
}
}, [room, startDate, endDate, selectedMembers, keyword, activePreset])
// 滚动到底部(加载更早消息时不滚动)
useEffect(() => {
if (noScrollRef.current) { noScrollRef.current = false; return }
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// 加载更早的消息
const loadEarlier = useCallback(async () => {
if (!room || loadingMore || earliestOffset <= 0) return
setLoadingMore(true)
noScrollRef.current = true
try {
const LIMIT = 200
const st = dayjs(startDate).unix()
const et = dayjs(endDate).unix()
const newOffset = Math.max(0, earliestOffset - LIMIT)
const res = await getChatlog({ talker: room.id, startTime: st, endTime: et, senders: selectedMembers, keyword, limit: LIMIT, offset: newOffset })
const older = res.data?.messages || []
setMessages(prev => [...older, ...prev])
setEarliestOffset(newOffset)
setHasMore(newOffset > 0)
} catch (e) {
setErrorMsg('加载失败:' + getApiErrorMessage(e))
} finally {
setLoadingMore(false)
}
}, [room, earliestOffset, loadingMore, startDate, endDate, selectedMembers, keyword])
// 连接 / 断开 SSE Webhook
// 滚动到顶部自动加载更早消息
useEffect(() => {
const el = scrollAreaRef.current
if (!el) return
const onScroll = () => { if (el.scrollTop === 0 && hasMore && !loadingMore) loadEarlier() }
el.addEventListener('scroll', onScroll)
return () => el.removeEventListener('scroll', onScroll)
}, [hasMore, loadingMore, loadEarlier])
const toggleWebhook = () => {
if (webhookConnected) {
unsubRef.current?.()
unsubRef.current = null
webhookConnectedRef.current = false
setWebhookConnected(false)
} else if (room) {
const unsub = subscribeWebhook(room.id, (msg) => {
setMessages((prev) => [...prev, msg])
setNewMsgIds((prev) => new Set([...prev, msg.id]))
onNewMessage?.(room.id, msg)
setTimeout(() => {
setNewMsgIds((prev) => {
const next = new Set(prev)
next.delete(msg.id)
return next
})
}, 3000)
})
unsubRef.current = unsub
webhookConnectedRef.current = true
setWebhookConnected(true)
}
}
// 应用日期预设
const applyPreset = (preset) => {
const [s, e] = preset.getDates()
setStartDate(s.format('YYYY-MM-DDTHH:mm'))
setEndDate(e.format('YYYY-MM-DDTHH:mm'))
setActivePreset(preset.label)
}
// 按天分组
const grouped = groupByDay(messages)
if (!room) {
return (
<div className="empty-state" style={{ flex: 1 }}>
<div className="empty-state-icon">💬</div>
<div className="empty-state-title">请从左侧选择一个会话</div>
<div className="empty-state-desc">选择会话后即可查看聊天记录</div>
</div>
)
}
return (
<>
{/* ── 筛选栏 ── */}
<div className="filter-bar">
{/* 日期快捷 */}
<div className="filter-group">
<div className="filter-label">快捷日期</div>
<div className="date-chips">
{DATE_PRESETS.map((p) => (
<div
key={p.label}
className={`chip ${activePreset === p.label ? 'active' : ''}`}
onClick={() => applyPreset(p)}
>
{p.label}
</div>
))}
</div>
</div>
{/* 自定义日期范围 */}
<div className="filter-group">
<div className="filter-label">日期范围</div>
<div className="date-range">
<input
type="datetime-local"
className="filter-input"
value={startDate}
onChange={(e) => { setStartDate(e.target.value); setActivePreset('') }}
style={{ minWidth: 155 }}
id="filter-start-date"
/>
<span className="date-range-sep"></span>
<input
type="datetime-local"
className="filter-input"
value={endDate}
onChange={(e) => { setEndDate(e.target.value); setActivePreset('') }}
style={{ minWidth: 155 }}
id="filter-end-date"
/>
</div>
</div>
{/* 人员选择(仅群聊显示) */}
{members.length > 0 && (
<div className="filter-group">
<div className="filter-label">发送人</div>
<MemberSelector
members={members}
selected={selectedMembers}
onChange={setSelectedMembers}
/>
</div>
)}
{/* 关键词 */}
<div className="filter-group">
<div className="filter-label">关键词</div>
<input
className="filter-input"
placeholder="搜索消息内容..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchMessages()}
id="filter-keyword"
/>
</div>
{/* 操作按钮 */}
<div className="filter-group" style={{ justifyContent: 'flex-end', flex: 1 }}>
<div className="filter-label">&nbsp;</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={fetchMessages} id="btn-search" disabled={loading}>
<Search size={14} />
{loading ? '查询中...' : '查询'}
</button>
<button className="btn btn-ghost" onClick={fetchMessages} title="刷新">
<RefreshCw size={14} />
</button>
{messages.length > 0 && (
<button
className="btn btn-ghost"
onClick={() => setShowAI(true)}
id="btn-ai-summary"
title="AI 生成知识总结"
style={{ borderColor: 'rgba(99,102,241,0.4)', color: 'var(--accent-light)' }}
>
<Sparkles size={14} />
AI 总结
</button>
)}
</div>
</div>
</div>
{/* ── 消息区域 ── */}
<div className="message-area" ref={scrollAreaRef}>
{loading && (
<div className="loading-overlay">
<div className="loading-spinner" />
<div>正在加载聊天记录...</div>
</div>
)}
{!loading && loadingMore && (
<div style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--text-muted)' }}>加载更早的消息...</div>
)}
{!loading && errorMsg && (
<div className="empty-state">
<div className="empty-state-icon"></div>
<div className="empty-state-title">查询出错</div>
<div className="empty-state-desc">{errorMsg}</div>
</div>
)}
{!loading && !errorMsg && hasSearched && messages.length === 0 && (
<div className="empty-state">
<div className="empty-state-icon">🔍</div>
<div className="empty-state-title">未找到聊天记录</div>
<div className="empty-state-desc">尝试调整筛选条件或扩大时间范围</div>
{activePreset !== '近30天' && (
<button
className="btn btn-ghost"
style={{ marginTop: 12, fontSize: 12 }}
onClick={() => {
const s = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DDTHH:mm')
const e = dayjs().format('YYYY-MM-DDTHH:mm')
setStartDate(s)
setEndDate(e)
setActivePreset('近30天')
}}
>
查看近 30 天记录
</button>
)}
</div>
)}
{!loading && grouped.map(({ day, msgs }) => (
<div key={day}>
<div className="msg-day-divider">
<div className="msg-day-divider-line" />
<div className="msg-day-divider-text">{day}</div>
<div className="msg-day-divider-line" />
</div>
{msgs.map((msg) => (
<MessageBubble
key={msg.id || `${msg.sender}_${msg.timestamp}`}
msg={msg}
keyword={keyword}
isNew={newMsgIds.has(msg.id)}
/>
))}
</div>
))}
<div ref={bottomRef} />
</div>
{/* ── AI 总结面板 ── */}
{showAI && (
<AISummaryPanel
messages={messages}
roomName={room?.name || '会话'}
onClose={() => setShowAI(false)}
/>
)}
{/* ── 状态栏 ── */}
<div className="status-bar">
<div className={`status-dot ${webhookConnected ? 'connected' : ''}`} />
<span className="status-text">
{webhookConnected ? 'Webhook 实时接收中' : '未连接 Webhook'}
</span>
{messages.length > 0 && (
<span className="status-text"> {messages.length} 条消息</span>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
<div
className="webhook-pill"
onClick={toggleWebhook}
id="webhook-toggle"
>
<Zap size={12} />
{webhookConnected ? '断开 Webhook' : '连接 Webhook'}
</div>
</div>
</div>
</>
)
}
// 按天分组消息
function groupByDay(messages) {
const groups = {}
for (const msg of messages) {
const day = dayjs.unix(msg.timestamp).format('YYYY年MM月DD日 dddd')
if (!groups[day]) groups[day] = []
groups[day].push(msg)
}
return Object.entries(groups).map(([day, msgs]) => ({ day, msgs }))
}

View File

@@ -0,0 +1,236 @@
import { useState, useEffect, useCallback } from 'react'
import { Search, RefreshCw, Edit3, Check, X, Download } from 'lucide-react'
import dayjs from 'dayjs'
import { getKnowledge, getKnowledgeDoc, patchKnowledge } from '../api'
import ReportDocumentView from '../components/ReportDocumentView'
import { exportWordDoc } from '../utils/wordExport'
export default function KnowledgePage({ onToast }) {
const [docs, setDocs] = useState([])
const [selectedDoc, setSelectedDoc] = useState(null)
const [docDetail, setDocDetail] = useState(null)
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState(false)
const [editContent, setEditContent] = useState('')
const [saving, setSaving] = useState(false)
const loadDocs = useCallback(async (kw) => {
setLoading(true)
try {
const data = await getKnowledge(kw)
setDocs(Array.isArray(data) ? data : [])
} catch {
setDocs([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadDocs('') }, [loadDocs])
const handleSearch = () => loadDocs(keyword)
const handleSelect = async (doc) => {
setSelectedDoc(doc)
setDocDetail(null)
setEditing(false)
try {
const detail = await getKnowledgeDoc(doc.id)
setDocDetail(detail)
setEditContent(detail.content || '')
} catch {
setDocDetail(null)
}
}
const handleSave = async () => {
if (!selectedDoc) return
setSaving(true)
try {
await patchKnowledge(selectedDoc.id, editContent)
onToast?.('保存成功')
setEditing(false)
setDocDetail((prev) => prev ? { ...prev, content: editContent } : prev)
} catch {
onToast?.('保存失败', 'error')
} finally {
setSaving(false)
}
}
const handleExport = async () => {
if (!selectedDoc || !docDetail?.content) {
onToast?.('暂无可导出的售后报告', 'error')
return
}
try {
await exportWordDoc(selectedDoc.title || `售后报告_${selectedDoc.id}`, docDetail.content)
onToast?.('Word 文档已导出')
} catch (e) {
onToast?.('Word 导出失败', 'error')
}
}
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', height: '100%' }}>
{/* 左栏:售后报告列表 */}
<div style={{ width: 280, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 8 }}>售后报告库</div>
<div style={{ display: 'flex', gap: 6 }}>
<input
className="filter-input"
style={{ flex: 1, fontSize: 12 }}
placeholder="搜索报告内容..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button className="btn btn-primary" style={{ fontSize: 12 }} onClick={handleSearch}>
<Search size={13} />
</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { setKeyword(''); loadDocs('') }}>
<RefreshCw size={13} />
</button>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{loading && (
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>加载中...</div>
)}
{!loading && docs.length === 0 && (
<div className="empty-state" style={{ flex: 1, padding: 24 }}>
<div className="empty-state-icon">📚</div>
<div className="empty-state-title">暂无售后报告</div>
<div className="empty-state-desc">AI 话题分析选择话题后点击AI 生成售后报告即可生成</div>
</div>
)}
{!loading && docs.length > 0 && (() => {
// 按群聊名称分组
const groupMap = {}
docs.forEach(doc => {
const key = doc.group_name || '未知群聊'
if (!groupMap[key]) groupMap[key] = []
groupMap[key].push(doc)
})
return Object.entries(groupMap).map(([groupName, items]) => (
<div key={groupName}>
<div style={{
padding: '6px 14px',
fontSize: 11,
fontWeight: 600,
color: 'var(--text-muted)',
background: 'var(--bg-overlay)',
borderBottom: '1px solid var(--border)',
letterSpacing: '0.05em',
textTransform: 'uppercase',
position: 'sticky',
top: 0,
zIndex: 1,
}}>
{groupName}
</div>
{items.map((doc) => (
<div
key={doc.id}
onClick={() => handleSelect(doc)}
style={{
padding: '10px 14px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: selectedDoc?.id === doc.id ? 'var(--bg-overlay)' : 'transparent',
}}
>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.title || `文档 #${doc.id}`}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
更新于 {dayjs(doc.updated_at).format('MM-DD HH:mm')}
</div>
</div>
))}
</div>
))
})()}
</div>
</div>
{/* 右栏:文档详情 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{!selectedDoc ? (
<div className="empty-state" style={{ flex: 1 }}>
<div className="empty-state-icon">📄</div>
<div className="empty-state-title">请选择一篇售后报告</div>
</div>
) : (
<>
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>{selectedDoc.title || `文档 #${selectedDoc.id}`}</div>
<div style={{ display: 'flex', gap: 8 }}>
{editing ? (
<>
<button className="btn btn-primary" style={{ fontSize: 12 }} onClick={handleSave} disabled={saving}>
<Check size={13} /> {saving ? '保存中...' : '保存'}
</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { setEditing(false); setEditContent(docDetail?.content || '') }}>
<X size={13} /> 取消
</button>
</>
) : (
<>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={handleExport} disabled={!docDetail?.content}>
<Download size={13} /> 导出 Word
</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => setEditing(true)} disabled={!docDetail}>
<Edit3 size={13} /> 编辑
</button>
</>
)}
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden', padding: '16px 20px' }}>
{!docDetail ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>加载中...</div>
) : editing ? (
<textarea
style={{
width: '100%',
height: '100%',
background: 'var(--bg-overlay)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '14px 16px',
fontSize: 13,
lineHeight: 1.8,
color: 'var(--text)',
resize: 'none',
outline: 'none',
fontFamily: 'inherit',
boxSizing: 'border-box',
}}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
/>
) : (
<div style={{
height: '100%',
overflowY: 'auto',
background: 'var(--bg-overlay)',
borderRadius: 8,
padding: '20px 12px',
border: '1px solid var(--border)',
}}>
<ReportDocumentView content={docDetail.content || '(暂无内容,点击编辑或使用 AI 生成)'} />
</div>
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,271 @@
import { useState, useEffect } from 'react'
import { Copy, Check, Save } from 'lucide-react'
const CONFIG_ITEMS = [
{
group: 'chatlog 底层服务',
items: [
{ label: 'chatlog 地址', value: 'http://127.0.0.1:5030', desc: 'Go 后端,负责读取微信数据库' },
{ label: 'API 前缀', value: '/api/v1', desc: '所有 chatlog 接口均在此前缀下' },
],
},
{
group: 'chatlog_fastAPI 业务层',
items: [
{ label: 'FastAPI 地址', value: 'http://127.0.0.1:8000', desc: 'Python 后端,负责 AI 分析和知识库' },
{ label: '搜索接口', value: '/api/search', desc: '聊天记录搜索' },
{ label: '话题接口', value: '/api/topics', desc: 'AI 话题分析管理' },
{ label: '报告库接口', value: '/api/knowledge', desc: '售后报告管理' },
],
},
{
group: '桌面应用服务',
items: [
{ label: '本地应用入口', value: '/', desc: '桌面应用内置界面,由本地业务服务托管' },
],
},
]
const AI_FIELDS = [
{ key: 'ai_base_url', label: 'AI 接口地址', placeholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1', desc: '兼容 OpenAI 格式的 API 地址' },
{ key: 'ai_api_key', label: 'AI API Key', placeholder: 'sk-...', desc: '留空则 AI 功能不可用', type: 'password' },
{ key: 'ai_model', label: '话题分析模型', placeholder: 'qwen-plus', desc: '用于消息分类的模型' },
{ key: 'summary_model', label: '报告生成模型', placeholder: 'qwen-max', desc: '用于生成售后报告的模型' },
{ key: 'vision_model', label: '视觉模型', placeholder: 'qwen-vl-plus', desc: '用于图片/视频描述' },
{ key: 'voice_model', label: '语音模型', placeholder: 'paraformer-v2', desc: '用于语音转文字' },
]
const TOPIC_PROMPT_PLACEHOLDER = '例如:本群主要是某类设备售后群,请优先按设备部件、故障现象、处理进度来拆分话题;不要按客户名或日期拆分。'
function CopyButton({ text }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<button
onClick={handleCopy}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px' }}
title="复制"
>
{copied ? <Check size={13} color="var(--success, #10b981)" /> : <Copy size={13} />}
</button>
)
}
function AISettingsForm() {
const [form, setForm] = useState({})
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
fetch('/api/settings')
.then(r => r.json())
.then(data => setForm(data))
.catch(() => {})
}, [])
const handleChange = (key, value) => {
setForm(prev => ({ ...prev, [key]: value }))
}
const handleSave = async () => {
setSaving(true)
setMsg('')
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (res.ok) {
setMsg('已保存')
setTimeout(() => setMsg(''), 2000)
const updated = await fetch('/api/settings').then(r => r.json())
setForm(updated)
} else {
setMsg('保存失败')
}
} catch {
setMsg('保存失败')
}
setSaving(false)
}
return (
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
AI 模型配置
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 12 }}>
首次使用请填入你的 API Key 和接口地址保存后立即生效无需重启服务
</div>
{/* 未配置 API Key 时显示橙色警告横条 */}
{!form.ai_api_key && (
<div style={{
marginBottom: 12, padding: '8px 12px',
background: 'rgba(245,158,11,0.1)', border: '1px solid rgba(245,158,11,0.3)',
borderRadius: 8, fontSize: 12, color: '#d97706',
display: 'flex', alignItems: 'center', gap: 6,
}}>
未配置 AI API Key所有 AI 功能不可用请填入您自己的 API Key 并保存
</div>
)}
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{AI_FIELDS.map((field, i) => (
<div
key={field.key}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: i < AI_FIELDS.length - 1 ? '1px solid var(--border)' : 'none',
gap: 12,
}}
>
<div style={{ flex: '0 0 130px' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{field.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{field.desc}</div>
</div>
<input
type={field.type || 'text'}
value={form[field.key] || ''}
placeholder={field.placeholder}
onChange={(e) => handleChange(field.key, e.target.value)}
style={{
flex: 1,
fontSize: 13,
padding: '7px 12px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--surface-2)',
color: 'var(--text)',
outline: 'none',
}}
/>
{/* 配置状态指示点:绿色=已配置,红色=未配置 */}
<div
title={form[field.key] ? '已配置' : '未配置'}
style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: form[field.key] ? 'var(--success, #10b981)' : '#ef4444',
boxShadow: form[field.key] ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)',
}}
/>
</div>
))}
</div>
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>AI 话题分析提示词</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>
作为全局默认分析口径单个群聊可在 AI 话题分析里单独覆盖
</div>
<textarea
value={form.topic_analysis_prompt || ''}
placeholder={TOPIC_PROMPT_PLACEHOLDER}
onChange={(e) => handleChange('topic_analysis_prompt', e.target.value)}
style={{
width: '100%',
minHeight: 120,
boxSizing: 'border-box',
fontSize: 13,
lineHeight: 1.6,
padding: '10px 12px',
border: '1px solid var(--border)',
borderRadius: 8,
background: 'var(--surface-2)',
color: 'var(--text)',
outline: 'none',
resize: 'vertical',
fontFamily: 'inherit',
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 14 }}>
<button
onClick={handleSave}
disabled={saving}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 20px',
background: 'var(--accent, #6366f1)',
color: '#fff',
border: 'none',
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.6 : 1,
}}
>
<Save size={14} />
{saving ? '保存中...' : '保存配置'}
</button>
{msg && <span style={{ fontSize: 12, color: msg === '已保存' ? 'var(--success, #10b981)' : '#ef4444' }}>{msg}</span>}
</div>
</div>
)
}
export default function SettingsPage() {
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '28px 36px' }}>
<div style={{ maxWidth: 720 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 6 }}>设置</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 28 }}>
系统各服务地址及 AI 配置管理
</div>
{/* AI 配置表单 */}
<AISettingsForm />
{CONFIG_ITEMS.map((group) => (
<div key={group.group} style={{ marginBottom: 28 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
{group.group}
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{group.items.map((item, i) => (
<div
key={item.label}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: i < group.items.length - 1 ? '1px solid var(--border)' : 'none',
gap: 12,
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{item.label}</div>
{item.desc && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{item.desc}</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<code style={{
fontSize: 12,
padding: '3px 10px',
background: 'var(--surface-2)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--accent-light, #a5b4fc)',
}}>
{item.value}
</code>
<CopyButton text={item.value} />
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,427 @@
import {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
AlignmentType,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
ShadingType,
ImageRun,
} from 'docx'
// 与 ReportDocumentView 的 WORD_PAGE_CSS 对齐的关键样式常量。
// docx 用半点half-point表示字号1pt=2 half-point用 twips 表示长度1in=1440 twips。
const FONT_FAMILY = 'Microsoft YaHei'
const FONT_BODY_HALF = 28 // 14pt 正文
const FONT_TABLE_HALF = 26 // 13pt 表格
const FONT_H1_HALF = 48 // 24pt
const FONT_H2_HALF = 34 // 17pt
const FONT_H3_HALF = 30 // 15pt
const COLOR_TITLE = '111827'
const COLOR_BODY = '1F2937'
const COLOR_TABLE_BORDER = '9CA3AF'
const COLOR_TH_BG = 'F3F4F6'
const COLOR_HR = 'D1D5DB'
const IMAGE_MAX_WIDTH = 480
const IMAGE_MAX_HEIGHT = 320
const PAGE_MARGIN_VERT = 1134 // ~2 cm
const PAGE_MARGIN_HORI = 1296 // ~2.25 cm
const SOLID_BORDER = (color = COLOR_TABLE_BORDER, size = 6) => ({
style: BorderStyle.SINGLE,
size,
color,
})
function sanitizeFileName(value = '售后报告') {
const cleaned = String(value)
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned.slice(0, 80) || '售后报告'
}
function splitTableRow(line = '') {
return line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function isTableSeparator(line = '') {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)
}
// 行内 markdown**bold**、`code`
function parseInlineRuns(text, baseProps = {}) {
const runs = []
const re = /(\*\*([^*]+)\*\*|`([^`]+)`)/g
let last = 0
let m
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
runs.push(new TextRun({ text: text.slice(last, m.index), font: FONT_FAMILY, ...baseProps }))
}
if (m[2] != null) {
runs.push(new TextRun({ text: m[2], font: FONT_FAMILY, bold: true, ...baseProps }))
} else if (m[3] != null) {
runs.push(new TextRun({ text: m[3], font: 'Consolas', ...baseProps }))
}
last = m.index + m[0].length
}
if (last < text.length) {
runs.push(new TextRun({ text: text.slice(last), font: FONT_FAMILY, ...baseProps }))
}
if (runs.length === 0) {
runs.push(new TextRun({ text: '', font: FONT_FAMILY, ...baseProps }))
}
return runs
}
function makeHeadingParagraph(level, text) {
if (level === 1) {
return new Paragraph({
alignment: AlignmentType.CENTER,
heading: HeadingLevel.HEADING_1,
spacing: { before: 0, after: 240 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 12, color: COLOR_TITLE, space: 4 },
},
children: parseInlineRuns(text, {
bold: true,
size: FONT_H1_HALF,
color: COLOR_TITLE,
}),
})
}
if (level === 2) {
return new Paragraph({
heading: HeadingLevel.HEADING_2,
spacing: { before: 320, after: 120 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: COLOR_HR, space: 2 },
},
children: parseInlineRuns(text, {
bold: true,
size: FONT_H2_HALF,
color: COLOR_TITLE,
}),
})
}
return new Paragraph({
heading: HeadingLevel.HEADING_3,
spacing: { before: 200, after: 80 },
children: parseInlineRuns(text, {
bold: true,
size: FONT_H3_HALF,
color: COLOR_BODY,
}),
})
}
function makeBodyParagraph(text) {
return new Paragraph({
spacing: { before: 0, after: 80, line: 360 },
children: parseInlineRuns(text, { size: FONT_BODY_HALF, color: COLOR_BODY }),
})
}
function makeListParagraph(text, ordered) {
return new Paragraph({
spacing: { before: 0, after: 60, line: 360 },
indent: { left: 480, hanging: 240 },
bullet: ordered ? undefined : { level: 0 },
numbering: ordered ? { reference: 'ordered-default', level: 0 } : undefined,
children: parseInlineRuns(text, { size: FONT_BODY_HALF, color: COLOR_BODY }),
})
}
function makeHrParagraph() {
return new Paragraph({
spacing: { before: 200, after: 200 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: COLOR_HR, space: 2 },
},
children: [new TextRun({ text: '', font: FONT_FAMILY })],
})
}
function normalizeImageUrl(url = '') {
if (/^https?:\/\//i.test(url) || url.startsWith('/')) return url
return `/${url.replace(/^\.?\//, '')}`
}
function inferImageType(contentType = '', url = '') {
const lower = `${contentType} ${url}`.toLowerCase()
if (lower.includes('png')) return 'png'
if (lower.includes('gif')) return 'gif'
if (lower.includes('bmp')) return 'bmp'
if (lower.includes('webp')) return 'jpg'
return 'jpg'
}
function getImageSize(blob) {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(blob)
const img = new Image()
img.onload = () => {
const naturalWidth = img.naturalWidth || IMAGE_MAX_WIDTH
const naturalHeight = img.naturalHeight || IMAGE_MAX_HEIGHT
URL.revokeObjectURL(objectUrl)
const scale = Math.min(IMAGE_MAX_WIDTH / naturalWidth, IMAGE_MAX_HEIGHT / naturalHeight, 1)
resolve({
width: Math.max(1, Math.round(naturalWidth * scale)),
height: Math.max(1, Math.round(naturalHeight * scale)),
})
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
resolve({ width: IMAGE_MAX_WIDTH, height: Math.round(IMAGE_MAX_WIDTH * 0.62) })
}
img.src = objectUrl
})
}
async function makeImageParagraph(alt, url) {
const normalizedUrl = normalizeImageUrl(url)
try {
const resp = await fetch(normalizedUrl)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const blob = await resp.blob()
const data = await blob.arrayBuffer()
const size = await getImageSize(blob)
const type = inferImageType(resp.headers.get('content-type') || '', normalizedUrl)
return new Paragraph({
spacing: { before: 120, after: 80 },
children: [
new ImageRun({
data,
type,
transformation: size,
altText: {
title: alt || '售后图片',
description: alt || '售后图片',
name: alt || '售后图片',
},
}),
],
})
} catch {
return makeBodyParagraph(`[图片无法导出:${normalizedUrl}]`)
}
}
function makeCell(text, isHeader) {
return new TableCell({
margins: { top: 80, bottom: 80, left: 120, right: 120 },
shading: isHeader
? { type: ShadingType.CLEAR, color: 'auto', fill: COLOR_TH_BG }
: undefined,
borders: {
top: SOLID_BORDER(),
bottom: SOLID_BORDER(),
left: SOLID_BORDER(),
right: SOLID_BORDER(),
},
children: [
new Paragraph({
spacing: { before: 0, after: 0, line: 320 },
children: parseInlineRuns(text, {
size: FONT_TABLE_HALF,
color: COLOR_TITLE,
bold: !!isHeader,
}),
}),
],
})
}
function makeTable(headerCells, rows) {
const widthPct = headerCells.length > 0 ? Math.floor(10000 / headerCells.length) : 0
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
columnWidths: headerCells.map(() => widthPct),
rows: [
new TableRow({
tableHeader: true,
children: headerCells.map((cell) => makeCell(cell, true)),
}),
...rows.map(
(row) =>
new TableRow({
children: headerCells.map((_, idx) => makeCell(row[idx] ?? '', false)),
})
),
],
})
}
// 把 markdown 解析为 docx 子元素数组
async function buildChildrenFromMarkdown(markdown) {
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n')
const children = []
let paragraph = []
let listBuf = []
let listOrdered = false
const flushParagraph = () => {
if (!paragraph.length) return
children.push(makeBodyParagraph(paragraph.join(' ')))
paragraph = []
}
const flushList = () => {
if (!listBuf.length) return
for (const item of listBuf) {
children.push(makeListParagraph(item, listOrdered))
}
listBuf = []
}
for (let i = 0; i < lines.length; i += 1) {
const raw = lines[i]
const line = raw.trim()
if (!line) {
flushParagraph()
flushList()
continue
}
if (/^[-*_]{3,}$/.test(line)) {
flushParagraph()
flushList()
children.push(makeHrParagraph())
continue
}
const image = /^!\[([^\]]*)\]\(([^)]+)\)$/.exec(line)
if (image) {
flushParagraph()
flushList()
children.push(await makeImageParagraph(image[1], image[2]))
continue
}
if (line.includes('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
flushParagraph()
flushList()
const header = splitTableRow(lines[i])
let cursor = i + 2
const rows = []
while (
cursor < lines.length &&
lines[cursor].includes('|') &&
lines[cursor].trim() &&
!isTableSeparator(lines[cursor])
) {
rows.push(splitTableRow(lines[cursor]))
cursor += 1
}
children.push(makeTable(header, rows))
i = cursor - 1
continue
}
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
if (heading) {
flushParagraph()
flushList()
children.push(makeHeadingParagraph(heading[1].length, heading[2]))
continue
}
const unordered = /^[-*]\s+(.+)$/.exec(line)
if (unordered) {
flushParagraph()
if (listBuf.length && listOrdered) flushList()
listOrdered = false
listBuf.push(unordered[1])
continue
}
const ordered = /^\d+\.\s+(.+)$/.exec(line)
if (ordered) {
flushParagraph()
if (listBuf.length && !listOrdered) flushList()
listOrdered = true
listBuf.push(ordered[1])
continue
}
flushList()
paragraph.push(line)
}
flushParagraph()
flushList()
return children
}
export async function exportWordDoc(title = '售后报告', content = '') {
const safeTitle = sanitizeFileName(title)
const md = content || `# ${safeTitle}\n\n(暂无报告内容)`
const children = await buildChildrenFromMarkdown(md)
const doc = new Document({
creator: 'ChatLab',
title: safeTitle,
styles: {
default: {
document: {
run: { font: FONT_FAMILY, size: FONT_BODY_HALF, color: COLOR_BODY },
},
},
},
numbering: {
config: [
{
reference: 'ordered-default',
levels: [
{
level: 0,
format: 'decimal',
text: '%1.',
alignment: AlignmentType.START,
style: {
paragraph: { indent: { left: 480, hanging: 240 } },
},
},
],
},
],
},
sections: [
{
properties: {
page: {
margin: {
top: PAGE_MARGIN_VERT,
bottom: PAGE_MARGIN_VERT,
left: PAGE_MARGIN_HORI,
right: PAGE_MARGIN_HORI,
},
},
},
children,
},
],
})
const blob = await Packer.toBlob(doc)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `售后报告_${safeTitle}.docx`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const FASTAPI_PORT = 8000
const CHATLOG_PORT = 5030
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
host: '127.0.0.1', // 强制绑定到 IPv4避免 localhost 解析到 IPv6 导致连接失败
proxy: {
// chatlog_fastAPI Python 后端:所有业务接口
'/api/search': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
'/api/groups': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
'/api/topics': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
'/api/knowledge': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
'/api/tasks': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
'/api/ai': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
'/api/sse': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
'/api/settings': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
'/api/files': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
// chatlog Go 后端:基础通信接口
'/api/v1': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
// chatlog Go 后端:媒体文件直接代理
'/image': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
'/voice': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
'/video': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
'/file': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
'/data': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
},
},
})

BIN
chatlog.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,56 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
datas = []
datas += collect_data_files("jieba")
hiddenimports = []
hiddenimports += collect_submodules("uvicorn")
hiddenimports += collect_submodules("fastapi")
hiddenimports += collect_submodules("pydantic_settings")
hiddenimports += collect_submodules("aiosqlite")
hiddenimports += collect_submodules("apscheduler")
a = Analysis(
["run_backend.py"],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="ChatLabBackend",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name="ChatLabBackend",
)

55
chatlog_fastAPI/config.py Normal file
View File

@@ -0,0 +1,55 @@
import os
import tempfile
from pathlib import Path
from pydantic_settings import BaseSettings
from typing import List
def _default_data_dir() -> str:
configured = os.environ.get("CHATLAB_DATA_DIR")
if configured:
return str(Path(configured).expanduser())
appdata = os.environ.get("APPDATA")
if appdata:
return str(Path(appdata) / "ChatLab")
return str(Path.home() / ".chatlab")
def _default_static_dir() -> str:
configured = os.environ.get("CHATLAB_STATIC_DIR")
if configured:
return str(Path(configured).expanduser())
return str((Path(__file__).resolve().parents[1] / "chatlab-web" / "frontend" / "dist"))
class Settings(BaseSettings):
chatlog_base_url: str = "http://127.0.0.1:5030"
ai_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
ai_api_key: str = ""
ai_model: str = "" # 不设默认值,必须由用户在设置页配置
summary_model: str = "" # 不设默认值,必须由用户在设置页配置
voice_model: str = "" # 不设默认值,必须由用户在设置页配置
vision_model: str = "" # 不设默认值,必须由用户在设置页配置
data_dir: str = _default_data_dir()
static_dir: str = _default_static_dir()
db_path: str = str(Path(_default_data_dir()) / "data" / "knowledge.db")
cors_origins: List[str] = [
"http://127.0.0.1:5173",
"http://localhost:5173",
"http://localhost:3000",
]
class Config:
env_file = ".env"
settings = Settings()
try:
Path(settings.data_dir).mkdir(parents=True, exist_ok=True)
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
except PermissionError:
fallback_dir = Path(tempfile.gettempdir()) / "ChatLab"
fallback_dir.mkdir(parents=True, exist_ok=True)
settings.data_dir = str(fallback_dir)
settings.db_path = str(fallback_dir / "data" / "knowledge.db")
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)

301
chatlog_fastAPI/database.py Normal file
View File

@@ -0,0 +1,301 @@
import aiosqlite
import asyncio
import httpx
import logging
import time
from pathlib import Path
from config import settings
log = logging.getLogger(__name__)
_data_db_dir = Path(settings.db_path).resolve().parent
_data_db_dir.mkdir(parents=True, exist_ok=True)
_current_db_path = str(Path(settings.db_path).resolve())
_initialized_dbs = set()
_resolved_wxid: str | None = None
_wxid_last_resolved: float = 0.0
_WXID_TTL = 60.0 # 60 秒后强制重新检测,确保账号切换能被感知
STALE_SUMMARIZE_ERROR = "AI 报告生成任务超过 15 分钟未完成,已自动标记为失败,可重新生成"
def _db_path_for_wxid(wxid: str) -> str:
if wxid and wxid != "default":
safe = "".join(c for c in wxid if c.isalnum() or c in ("_", "-"))
return str((_data_db_dir / f"knowledge_{safe}.db").resolve())
return str(Path(settings.db_path).resolve())
def reset_wxid_cache():
global _resolved_wxid, _wxid_last_resolved
_resolved_wxid = None
_wxid_last_resolved = 0.0
async def get_current_wxid(force: bool = False):
global _resolved_wxid, _wxid_last_resolved
now = time.time()
# 已有有效缓存且未超时,直接返回
if (
not force
and _resolved_wxid
and _resolved_wxid != "default"
and (now - _wxid_last_resolved) < _WXID_TTL
):
return _resolved_wxid
# 重新解析当前 wxid
base = settings.chatlog_base_url
async with httpx.AsyncClient(trust_env=False, timeout=10.0) as client:
try:
r = await client.get(f"{base}/api/v1/chatlog", params={"talker": "filehelper", "limit": 100, "time": "1970-01-01,2099-12-31", "format": "json"})
if r.status_code == 200:
data = r.json()
for msg in data.get("items", []):
if msg.get("isSelf"):
_resolved_wxid = msg.get("sender")
_wxid_last_resolved = time.time()
return _resolved_wxid
except Exception:
pass
try:
r = await client.get(f"{base}/api/v1/chatroom", params={"limit": 10, "format": "json"})
if r.status_code == 200:
rooms = r.json().get("items", [])
for room in rooms:
room_id = room.get("name")
r2 = await client.get(f"{base}/api/v1/chatlog", params={"talker": room_id, "limit": 50, "time": "1970-01-01,2099-12-31", "format": "json"})
if r2.status_code == 200:
data2 = r2.json()
for msg in data2.get("items", []):
if msg.get("isSelf"):
_resolved_wxid = msg.get("sender")
_wxid_last_resolved = time.time()
return _resolved_wxid
except Exception:
pass
if force:
reset_wxid_cache()
return "default"
async def update_db_path(force: bool = False):
global _current_db_path
wxid = await get_current_wxid(force=force)
new_path = _db_path_for_wxid(wxid)
if new_path != _current_db_path:
log.info(f"Switching database to {new_path}")
_current_db_path = new_path
await init_db(new_path)
return _current_db_path
def get_active_db_path():
return _current_db_path
async def get_db():
path = get_active_db_path()
if path not in _initialized_dbs:
await init_db(path)
async with aiosqlite.connect(path) as db:
db.row_factory = aiosqlite.Row
yield db
async def init_db(path=None):
if path is None:
path = get_active_db_path()
async with aiosqlite.connect(path) as db:
await db.executescript("""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY,
talker TEXT UNIQUE NOT NULL,
name TEXT,
analysis_prompt TEXT DEFAULT '',
cursor_seq INTEGER DEFAULT 0,
initialized INTEGER DEFAULT 0,
poll_interval INTEGER DEFAULT 300,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS topics (
id INTEGER PRIMARY KEY,
group_id INTEGER REFERENCES groups(id),
title TEXT NOT NULL,
source TEXT DEFAULT 'manual',
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS topic_messages (
topic_id INTEGER REFERENCES topics(id),
msg_seq INTEGER,
talker TEXT,
added_by TEXT DEFAULT 'ai',
message_json TEXT,
PRIMARY KEY (topic_id, msg_seq)
);
CREATE TABLE IF NOT EXISTS knowledge_docs (
id INTEGER PRIMARY KEY,
topic_id INTEGER UNIQUE REFERENCES topics(id),
content TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
curated_at DATETIME
);
CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
doc_id UNINDEXED,
title,
content
);
CREATE TABLE IF NOT EXISTS ai_tasks (
id INTEGER PRIMARY KEY,
group_id INTEGER REFERENCES groups(id),
type TEXT,
status TEXT,
progress TEXT,
error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
""")
await db.execute(
"""
UPDATE ai_tasks
SET status='error', error=?, updated_at=CURRENT_TIMESTAMP
WHERE type='summarize'
AND status='running'
AND datetime(updated_at) <= datetime('now', '-15 minutes')
""",
(STALE_SUMMARIZE_ERROR,),
)
await db.execute(
"""
UPDATE topics
SET status='error', updated_at=CURRENT_TIMESTAMP
WHERE status='processing'
AND datetime(updated_at) <= datetime('now', '-15 minutes')
"""
)
await db.commit()
async with db.execute("PRAGMA table_info(topic_messages)") as cur:
topic_message_cols = {row[1] for row in await cur.fetchall()}
if "message_json" not in topic_message_cols:
await db.execute("ALTER TABLE topic_messages ADD COLUMN message_json TEXT")
await db.commit()
log.info(f"[init_db] added topic_messages.message_json in {path}")
async with db.execute("PRAGMA table_info(groups)") as cur:
group_cols = {row[1] for row in await cur.fetchall()}
if "analysis_prompt" not in group_cols:
await db.execute("ALTER TABLE groups ADD COLUMN analysis_prompt TEXT DEFAULT ''")
await db.commit()
log.info(f"[init_db] added groups.analysis_prompt in {path}")
async with db.execute("PRAGMA table_info(topics)") as cur:
topic_cols = {row[1] for row in await cur.fetchall()}
if "source" not in topic_cols:
await db.execute("ALTER TABLE topics ADD COLUMN source TEXT DEFAULT 'manual'")
await db.execute(
"""
UPDATE topics
SET source = CASE
WHEN EXISTS (
SELECT 1 FROM topic_messages tm
WHERE tm.topic_id = topics.id AND tm.added_by = 'user'
) THEN 'manual'
WHEN EXISTS (
SELECT 1 FROM topic_messages tm
WHERE tm.topic_id = topics.id AND COALESCE(tm.added_by, 'ai') = 'ai'
) THEN 'ai'
ELSE 'manual'
END
"""
)
await db.commit()
log.info(f"[init_db] added topics.source in {path}")
async with db.execute("PRAGMA table_info(knowledge_docs)") as cur:
knowledge_cols = {row[1] for row in await cur.fetchall()}
if "curated_at" not in knowledge_cols:
await db.execute("ALTER TABLE knowledge_docs ADD COLUMN curated_at DATETIME")
await db.execute(
"""
UPDATE knowledge_docs
SET curated_at = updated_at
WHERE updated_at IS NOT NULL
AND created_at IS NOT NULL
AND updated_at > created_at
"""
)
await db.commit()
log.info(f"[init_db] added knowledge_docs.curated_at in {path}")
# 迁移 topics 表到 AUTOINCREMENT防止 SQLite rowid 复用导致旧 knowledge_docs
# 被新建话题"接"上(跨群串报告的根因)。每次启动检测一次,已迁移则跳过。
async with db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='sqlite_sequence'"
) as cur:
has_seq_tbl = await cur.fetchone() is not None
needs_migrate = True
if has_seq_tbl:
async with db.execute(
"SELECT 1 FROM sqlite_sequence WHERE name='topics'"
) as cur:
if await cur.fetchone():
needs_migrate = False
if needs_migrate:
await db.executescript("""
BEGIN;
CREATE TABLE topics_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER REFERENCES groups(id),
title TEXT NOT NULL,
source TEXT DEFAULT 'manual',
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO topics_new (id, group_id, title, source, status, created_at, updated_at)
SELECT id, group_id, title, COALESCE(source, 'manual'), status, created_at, updated_at FROM topics;
DROP TABLE topics;
ALTER TABLE topics_new RENAME TO topics;
COMMIT;
""")
await db.execute(
"INSERT OR REPLACE INTO sqlite_sequence(name, seq) "
"SELECT 'topics', COALESCE(MAX(id), 0) FROM topics"
)
await db.commit()
log.info(f"[init_db] migrated topics table to AUTOINCREMENT in {path}")
# 孤儿数据清理:删除 topic_id 不存在于 topics 的 knowledge_docs 及其 FTS。
# 历史上删群时遗漏过这两张表,需要每次启动幂等修复。
await db.execute("""
DELETE FROM knowledge_fts WHERE doc_id IN (
SELECT id FROM knowledge_docs
WHERE topic_id NOT IN (SELECT id FROM topics)
)
""")
await db.execute("""
DELETE FROM knowledge_docs
WHERE topic_id NOT IN (SELECT id FROM topics)
""")
# 错绑数据清理doc 创建时间早于其指向的 topic 创建时间,说明 doc 是历史残留、
# topic 是后建的rowid 复用doc 应清掉。合法 doc 必然在 topic 之后生成。
await db.execute("""
DELETE FROM knowledge_fts WHERE doc_id IN (
SELECT d.id FROM knowledge_docs d
JOIN topics t ON t.id = d.topic_id
WHERE d.created_at < t.created_at
)
""")
await db.execute("""
DELETE FROM knowledge_docs WHERE id IN (
SELECT d.id FROM knowledge_docs d
JOIN topics t ON t.id = d.topic_id
WHERE d.created_at < t.created_at
)
""")
await db.commit()
_initialized_dbs.add(path)

151
chatlog_fastAPI/main.py Normal file
View File

@@ -0,0 +1,151 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from contextlib import asynccontextmanager
import asyncio
import logging
from pathlib import Path
import httpx
from database import get_active_db_path, get_current_wxid, init_db, reset_wxid_cache, update_db_path
from scheduler import start_scheduler
from config import settings
from routers import search, groups, topics, knowledge, ai, sse, files, chatlog_proxy
from routers import settings as settings_router
from services.chatlog_context import get_chatlog_context, update_chatlog_context
from services.media_resolver import diagnose_media
log = logging.getLogger(__name__)
class ChatlogContextRequest(BaseModel):
account: str = ""
workDir: str = ""
dataDir: str = ""
platform: str = "windows"
version: int = 4
chatlogExe: str = ""
chatlogVersion: str = ""
async def _account_watch_loop():
"""每 60 秒检测一次当前微信账号,如账号切换则自动切换数据库。"""
while True:
await asyncio.sleep(60)
try:
await update_db_path()
except Exception as e:
log.warning(f"[account_watch] update_db_path error: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
await start_scheduler()
# 启动后台账号监控任务
task = asyncio.create_task(_account_watch_loop())
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(lifespan=lifespan)
@app.exception_handler(RuntimeError)
async def runtime_error_handler(request: Request, exc: RuntimeError):
return JSONResponse(status_code=500, content={"detail": str(exc)})
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(search.router)
app.include_router(groups.router)
app.include_router(topics.router)
app.include_router(knowledge.router)
app.include_router(ai.router)
app.include_router(sse.router)
app.include_router(files.router)
app.include_router(settings_router.router)
app.include_router(chatlog_proxy.router)
@app.get("/health")
async def health():
chatlog_ok = False
chatlog_error = ""
try:
async with httpx.AsyncClient(timeout=3.0, trust_env=False) as client:
resp = await client.get(f"{settings.chatlog_base_url}/api/v1/session", params={"limit": 1, "format": "json"})
chatlog_ok = resp.status_code == 200
if not chatlog_ok:
chatlog_error = f"HTTP {resp.status_code}"
except Exception as e:
chatlog_error = str(e)
wxid = await get_current_wxid() if chatlog_ok else "default"
return {
"ok": True,
"chatlog_ok": chatlog_ok,
"chatlog_error": chatlog_error,
"wxid": wxid,
"db_path": get_active_db_path(),
"data_dir": settings.data_dir,
}
@app.post("/api/system/refresh-account")
async def refresh_account():
reset_wxid_cache()
db_path = await update_db_path(force=True)
wxid = await get_current_wxid()
return {"ok": True, "wxid": wxid, "db_path": db_path}
@app.post("/api/system/chatlog-context")
async def set_chatlog_context(body: ChatlogContextRequest):
return {"ok": True, "context": update_chatlog_context(body.model_dump())}
@app.get("/api/system/chatlog-context")
async def read_chatlog_context():
return {"ok": True, "context": get_chatlog_context()}
@app.get("/api/system/media-diagnostics")
async def media_diagnostics(kind: str = "voice", key: str = ""):
return await diagnose_media(kind, key)
static_dir = Path(settings.static_dir)
if static_dir.exists():
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
for static_name in ("favicon.svg", "icons.svg"):
static_file = static_dir / static_name
if static_file.exists():
@app.get(f"/{static_name}", include_in_schema=False)
async def _serve_static_file(name=static_name):
return FileResponse(static_dir / name)
@app.get("/", include_in_schema=False)
async def spa_index():
return FileResponse(static_dir / "index.html")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str):
path = static_dir / full_path
if path.exists() and path.is_file():
return FileResponse(path)
return FileResponse(static_dir / "index.html")
if __name__ == "__main__":
import uvicorn
# 为了在使用 PyInstaller 打包时也能正常运行
uvicorn.run(app, host="127.0.0.1", port=8000, reload=False)

View File

@@ -0,0 +1,8 @@
fastapi
uvicorn
httpx>=0.27.0,<0.28.0
openai>=1.56.1,<3.0.0
apscheduler
jieba
aiosqlite
pydantic-settings

View File

View File

@@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional, Literal
import aiosqlite, json, logging
import httpx
from database import get_db
from config import settings
from services.ai_client import get_openai_client
from services.runtime_settings import get_ai_settings
from services.media_parser import parse_media
router = APIRouter(prefix="/api", tags=["ai"])
log = logging.getLogger(__name__)
async def _get_ai_client():
return await get_openai_client()
class SummarizeRequest(BaseModel):
context: str # 已组装好的对话文本(含媒体描述)
room_name: Optional[str] = ""
messages: Optional[list] = None # 兼容旧调用,忽略
class ParseRequest(BaseModel):
type: Literal["voice", "image", "video"]
key: str # voice: ServerID string; image/video: md5
@router.post("/ai/parse")
async def ai_parse(body: ParseRequest):
"""
通过 FastAPI 代理 AI 媒体解析:
- voice: 从 chatlog 下载音频 → DashScope Paraformer ASR 转文字
- image/video: 从 chatlog 下载媒体 → base64 → 视觉模型描述
"""
try:
return await parse_media(body.type, body.key)
except HTTPException:
raise
except Exception as e:
log.error(f"[ai/parse] 媒体解析失败: {e}", exc_info=True)
raise HTTPException(500, f"媒体解析失败: {e}")
@router.post("/ai/summarize/stream")
async def summarize_stream(body: SummarizeRequest):
"""
接收前端已处理好的对话上下文,调用 AI 模型流式输出总结。
前端负责先把媒体(图片/语音/视频)解析成文字再拼进 context。
"""
_ai = await get_ai_settings()
if not _ai.get("ai_api_key"):
async def err_gen():
yield 'data: {"error": "AI 服务未配置,请在「设置」页面填入 AI API Key"}\n\n'
return StreamingResponse(err_gen(), media_type="text/event-stream")
if not _ai.get("ai_model"):
async def err_gen():
yield 'data: {"error": "知识总结模型未配置,请在「设置」页面填入模型名称(如 qwen-max"}\n\n'
return StreamingResponse(err_gen(), media_type="text/event-stream")
context = body.context.strip()
if not context:
async def err_gen():
yield 'data: {"error": "对话内容为空"}\n\n'
return StreamingResponse(err_gen(), media_type="text/event-stream")
room = body.room_name or "会话"
system_prompt = (
"你是一位专业的对话分析助手。"
"请根据提供的聊天记录(可能包含图片描述、语音转文字、视频描述等多媒体内容)"
"生成一份结构清晰的 Markdown 总结。"
"总结应包含:主要话题、关键信息点、媒体内容要点、待办事项(如有)。"
"只输出 Markdown 格式内容,不要有任何额外说明。"
)
user_prompt = (
f"群聊:{room}\n\n"
f"以下是聊天记录(含多媒体内容描述):\n\n"
f"{context[:12000]}\n\n" # 限制 token 数
f"请生成总结:"
)
async def generate():
try:
_client, _ai = await _get_ai_client()
stream = await _client.chat.completions.create(
model=_ai["ai_model"],
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
stream=True,
temperature=0.3,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta:
yield f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n"
yield 'data: {"done": true}\n\n'
except Exception as e:
log.error(f"[summarize/stream] LLM 调用失败: {e}", exc_info=True)
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/tasks/{task_id}")
async def get_task(task_id: int, db: aiosqlite.Connection = Depends(get_db)):
async with db.execute("SELECT * FROM ai_tasks WHERE id=?", (task_id,)) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "not found")
return dict(row)

View File

@@ -0,0 +1,93 @@
from urllib.parse import quote
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import Response, StreamingResponse
from config import settings
router = APIRouter(tags=["chatlog-proxy"])
HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
}
def _copy_headers(headers: httpx.Headers) -> dict[str, str]:
copied: dict[str, str] = {}
for key, value in headers.items():
if key.lower() not in HOP_BY_HOP_HEADERS:
copied[key] = value
return copied
async def _proxy_chatlog(request: Request, upstream_path: str) -> Response:
query = request.url.query
target = f"{settings.chatlog_base_url}{upstream_path}"
if query:
target = f"{target}?{query}"
body = await request.body()
headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
}
async with httpx.AsyncClient(timeout=None, trust_env=False, follow_redirects=True) as client:
upstream = await client.request(
request.method,
target,
content=body if body else None,
headers=headers,
)
response_headers = _copy_headers(upstream.headers)
return StreamingResponse(
iter([upstream.content]),
status_code=upstream.status_code,
media_type=upstream.headers.get("content-type"),
headers=response_headers,
)
@router.api_route("/api/v1/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
async def proxy_api_v1(path: str, request: Request):
return await _proxy_chatlog(request, f"/api/v1/{path}")
async def _proxy_media(kind: str, path: str, request: Request):
safe_path = "/".join(quote(part, safe="") for part in path.split("/"))
return await _proxy_chatlog(request, f"/{kind}/{safe_path}")
@router.api_route("/image/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_image(path: str, request: Request):
return await _proxy_media("image", path, request)
@router.api_route("/voice/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_voice(path: str, request: Request):
return await _proxy_media("voice", path, request)
@router.api_route("/video/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_video(path: str, request: Request):
return await _proxy_media("video", path, request)
@router.api_route("/file/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_file(path: str, request: Request):
return await _proxy_media("file", path, request)
@router.api_route("/data/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_data(path: str, request: Request):
return await _proxy_media("data", path, request)

View File

@@ -0,0 +1,190 @@
import mimetypes
import os
import re
import shutil
import sqlite3
import tempfile
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, StreamingResponse
from config import settings
from services.chatlog_client import chatlog_client
router = APIRouter(prefix="/api/files", tags=["files"])
OFFICE_MEDIA_TYPES = {
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pdf": "application/pdf",
".dwg": "application/acad",
}
def _connect_hardlink_db(hardlink_db: Path) -> sqlite3.Connection:
"""
chatlog may keep hardlink.db open. Copying a tiny snapshot avoids transient
"unable to open database file" errors on Windows while keeping reads safe.
"""
tmp = Path(tempfile.gettempdir()) / f"chatlab_hardlink_{os.getpid()}_{hardlink_db.stat().st_mtime_ns}.db"
if not tmp.exists() or tmp.stat().st_size != hardlink_db.stat().st_size:
shutil.copy2(hardlink_db, tmp)
con = sqlite3.connect(tmp)
con.row_factory = sqlite3.Row
return con
def _safe_download_name(name: str, fallback: str) -> str:
name = (name or fallback).replace("\r", "").replace("\n", "").strip()
return name or fallback
def _content_disposition(filename: str) -> str:
quoted = quote(filename)
ascii_fallback = re.sub(r"[^A-Za-z0-9._-]+", "_", filename) or "download"
return f"attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quoted}"
def _guess_media_type(filename: str, fallback: str = "") -> str:
ext = Path(filename or "").suffix.lower()
return OFFICE_MEDIA_TYPES.get(ext) or mimetypes.guess_type(filename)[0] or fallback or "application/octet-stream"
async def _proxy_chatlog_file(md5: str, filename: str = ""):
url = f"{settings.chatlog_base_url}/file/{quote(md5, safe='')}"
try:
async with httpx.AsyncClient(timeout=30, trust_env=False, follow_redirects=True) as client:
resp = await client.get(url)
except Exception:
return None
if resp.status_code != 200 or resp.content == b'"media not found"':
return None
headers = {
"Content-Length": str(len(resp.content)),
"X-ChatLab-File-Source": "chatlog",
}
if filename:
headers["Content-Disposition"] = _content_disposition(filename)
media_type = _guess_media_type(filename, resp.headers.get("content-type") or "")
return StreamingResponse(iter([resp.content]), media_type=media_type, headers=headers)
def _xwechat_roots_from_hardlink_db(hardlink_db: Path) -> list[Path]:
roots: list[Path] = []
try:
con = _connect_hardlink_db(hardlink_db)
row = con.execute("SELECT ValueStdStr FROM db_info WHERE Key='uuid'").fetchone()
raw = row["ValueStdStr"] if row else ""
except Exception:
raw = ""
if raw:
m = re.search(r"([A-Za-z]:\\[^|]+?xwechat_files)", raw)
if m:
roots.append(Path(m.group(1)))
roots.extend([
Path.home() / "xwechat_files",
Path.home() / "Documents" / "WeChat Files",
])
uniq: list[Path] = []
seen = set()
for root in roots:
s = str(root).lower()
if s not in seen:
uniq.append(root)
seen.add(s)
return uniq
def _find_local_file(hardlink_db: Path, md5: str, requested_name: str = "") -> Path | None:
try:
con = _connect_hardlink_db(hardlink_db)
row = con.execute(
"""
SELECT md5, file_name, file_size, dir1, dir2
FROM file_hardlink_info_v4
WHERE md5=?
ORDER BY _rowid_ DESC
LIMIT 1
""",
(md5,),
).fetchone()
except Exception:
row = None
if not row:
return None
names = [requested_name, row["file_name"]]
names = [n for n in names if n]
size = int(row["file_size"] or 0)
roots = _xwechat_roots_from_hardlink_db(hardlink_db)
for root in roots:
if not root.exists():
continue
for name in names:
for candidate in root.rglob(name):
try:
if candidate.is_file() and (not size or candidate.stat().st_size == size):
return candidate
except Exception:
continue
if size:
# Fallback by size in the common file store. This is intentionally limited
# to msg/file to avoid scanning unrelated huge trees for every request.
for file_root in root.glob("*/msg/file"):
if not file_root.exists():
continue
for candidate in file_root.rglob("*"):
try:
if candidate.is_file() and candidate.stat().st_size == size:
if not names or candidate.name in names:
return candidate
except Exception:
continue
return None
@router.get("/{md5}")
async def get_file(md5: str, filename: str = Query("")):
md5 = md5.strip()
if not re.fullmatch(r"[0-9a-fA-F]{8,64}", md5):
raise HTTPException(400, "文件 md5 不合法")
filename = _safe_download_name(filename, md5)
proxied = await _proxy_chatlog_file(md5, filename)
if proxied:
return proxied
db_paths = await chatlog_client.get_db_paths()
hardlink_paths = db_paths.get("media") or []
for raw_path in hardlink_paths:
hardlink_db = Path(raw_path)
if not hardlink_db.exists():
continue
local_file = _find_local_file(hardlink_db, md5, filename)
if local_file:
media_type = _guess_media_type(filename or local_file.name)
return FileResponse(
path=str(local_file),
filename=filename or local_file.name,
media_type=media_type,
headers={
"Content-Disposition": _content_disposition(filename or local_file.name),
"Content-Length": str(local_file.stat().st_size),
"X-ChatLab-File-Source": "local-hardlink",
},
)
raise HTTPException(404, "原文件未找到,可能未解密或已清理")

View File

@@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
import aiosqlite, json
from database import get_db
router = APIRouter(prefix="/api/groups", tags=["groups"])
class GroupCreate(BaseModel):
talker: str
name: Optional[str] = ""
poll_interval: int = 300
class GroupPatch(BaseModel):
analysis_prompt: Optional[str] = None
class InitParams(BaseModel):
start_time: int # unix 秒
end_time: int # unix 秒
@router.post("")
async def create_group(body: GroupCreate, db: aiosqlite.Connection = Depends(get_db)):
try:
await db.execute(
"INSERT INTO groups (talker, name, poll_interval) VALUES (?, ?, ?)",
(body.talker, body.name, body.poll_interval)
)
await db.commit()
async with db.execute("SELECT * FROM groups WHERE talker=?", (body.talker,)) as cur:
row = await cur.fetchone()
return dict(row)
except aiosqlite.IntegrityError:
raise HTTPException(409, "talker already exists")
@router.get("")
async def list_groups(db: aiosqlite.Connection = Depends(get_db)):
async with db.execute("SELECT * FROM groups") as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
@router.patch("/{group_id}")
async def patch_group(group_id: int, body: GroupPatch, db: aiosqlite.Connection = Depends(get_db)):
async with db.execute("SELECT id FROM groups WHERE id=?", (group_id,)) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "group not found")
updates = body.model_dump(exclude_none=True)
if "analysis_prompt" in updates:
await db.execute(
"UPDATE groups SET analysis_prompt=? WHERE id=?",
(updates["analysis_prompt"], group_id),
)
await db.commit()
async with db.execute("SELECT * FROM groups WHERE id=?", (group_id,)) as cur:
return dict(await cur.fetchone())
@router.post("/{group_id}/init")
async def trigger_init(
group_id: int,
body: InitParams,
db: aiosqlite.Connection = Depends(get_db),
):
"""对指定时间区间内全部消息做一次性 AI 分类。串行执行。"""
async with db.execute("SELECT * FROM groups WHERE id=?", (group_id,)) as cur:
group = await cur.fetchone()
if not group:
raise HTTPException(404, "group not found")
from services.topic_engine import get_classifying_group
busy = get_classifying_group()
if busy is not None:
raise HTTPException(409, f"已有群正在分析group_id={busy}),请等待完成后再试")
if body.end_time <= body.start_time:
raise HTTPException(400, "end_time 必须大于 start_time")
await db.execute(
"INSERT INTO ai_tasks (group_id, type, status, progress) VALUES (?, 'classify_window', 'running', ?)",
(group_id, json.dumps({"processed": 0, "total": 0})),
)
await db.commit()
async with db.execute("SELECT last_insert_rowid()") as cur:
task = await cur.fetchone()
task_id = task[0]
from services.topic_engine import run_classify_window
import asyncio
asyncio.create_task(
run_classify_window(group_id, task_id, dict(group), body.start_time, body.end_time)
)
return {"task_id": task_id}
@router.get("/{group_id}/task")
async def get_task(group_id: int, db: aiosqlite.Connection = Depends(get_db)):
async with db.execute(
"SELECT * FROM ai_tasks WHERE group_id=? ORDER BY id DESC LIMIT 1", (group_id,)
) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "no task found")
return dict(row)
@router.delete("/{group_id}")
async def delete_group(group_id: int, db: aiosqlite.Connection = Depends(get_db)):
async with db.execute("SELECT id FROM groups WHERE id=?", (group_id,)) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "group not found")
# 级联删除关联数据FTS → 知识库报告 → 话题消息 → 话题 → 任务 → 群组)
# 注意:必须先清 knowledge_fts/knowledge_docs否则 SQLite 复用 topic_id 时
# 残留报告会"接"到新建话题上,造成跨群串报告。
await db.execute(
"""
DELETE FROM knowledge_fts WHERE doc_id IN (
SELECT id FROM knowledge_docs WHERE topic_id IN (
SELECT id FROM topics WHERE group_id=?
)
)
""",
(group_id,)
)
await db.execute(
"""
DELETE FROM knowledge_docs WHERE topic_id IN (
SELECT id FROM topics WHERE group_id=?
)
""",
(group_id,)
)
await db.execute(
"DELETE FROM topic_messages WHERE topic_id IN (SELECT id FROM topics WHERE group_id=?)",
(group_id,)
)
await db.execute("DELETE FROM topics WHERE group_id=?", (group_id,))
await db.execute("DELETE FROM ai_tasks WHERE group_id=?", (group_id,))
await db.execute("DELETE FROM groups WHERE id=?", (group_id,))
await db.commit()
return {"ok": True}

View File

@@ -0,0 +1,67 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
import aiosqlite
from database import get_db
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
class KnowledgePatch(BaseModel):
content: str
@router.get("")
async def list_knowledge(
keyword: Optional[str] = None,
db: aiosqlite.Connection = Depends(get_db)
):
if keyword:
# FTS5 查询前先用 jieba 分词,提高中文召回率
from services.fts import build_match_query
fts_query = build_match_query(keyword)
if not fts_query:
return []
async with db.execute(
"SELECT k.id, k.topic_id, k.created_at, k.updated_at, t.title, t.group_id, g.name as group_name "
"FROM knowledge_docs k JOIN topics t ON k.topic_id=t.id "
"LEFT JOIN groups g ON t.group_id=g.id "
"WHERE k.id IN (SELECT doc_id FROM knowledge_fts WHERE knowledge_fts MATCH ?)",
(fts_query,)
) as cur:
return [dict(r) for r in await cur.fetchall()]
async with db.execute(
"SELECT k.id, k.topic_id, k.created_at, k.updated_at, t.title, t.group_id, g.name as group_name "
"FROM knowledge_docs k JOIN topics t ON k.topic_id=t.id "
"LEFT JOIN groups g ON t.group_id=g.id "
"ORDER BY g.name, k.updated_at DESC"
) as cur:
return [dict(r) for r in await cur.fetchall()]
@router.get("/{doc_id}")
async def get_knowledge(doc_id: int, db: aiosqlite.Connection = Depends(get_db)):
async with db.execute("SELECT * FROM knowledge_docs WHERE id=?", (doc_id,)) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "not found")
return dict(row)
@router.patch("/{doc_id}")
async def patch_knowledge(doc_id: int, body: KnowledgePatch, db: aiosqlite.Connection = Depends(get_db)):
await db.execute(
"UPDATE knowledge_docs SET content=?, updated_at=CURRENT_TIMESTAMP, curated_at=CURRENT_TIMESTAMP WHERE id=?",
(body.content, doc_id)
)
await db.commit()
# update FTS
async with db.execute("SELECT topic_id FROM knowledge_docs WHERE id=?", (doc_id,)) as cur:
row = await cur.fetchone()
if row:
async with db.execute("SELECT title FROM topics WHERE id=?", (row["topic_id"],)) as cur:
topic = await cur.fetchone()
await db.execute("DELETE FROM knowledge_fts WHERE doc_id=?", (doc_id,))
from services.fts import tokenize
await db.execute(
"INSERT INTO knowledge_fts (doc_id, title, content) VALUES (?, ?, ?)",
(doc_id, tokenize(topic["title"]), tokenize(body.content))
)
await db.commit()
return {"ok": True}

View File

@@ -0,0 +1,144 @@
from fastapi import APIRouter, HTTPException, Query
from urllib.parse import quote
from services.chatlog_client import MessageIndexNotReady, chatlog_client
from services.message_formatter import extract_quote
router = APIRouter(prefix="/api/search", tags=["search"])
@router.get("")
async def search(
talker: str = Query(..., description="群/联系人 ID"),
time: str = Query("", description="时间范围,如 2024-01-01,2024-01-31"),
sender: str = Query("", description="发送者 ID可选"),
keyword: str = Query("", description="关键词,可选"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
):
"""透传 chatlog /api/v1/chatlog返回 {"total": N, "items": [...]}"""
offset = (page - 1) * page_size
try:
data = await chatlog_client.get_messages(
talker,
time=time,
sender=sender,
keyword=keyword,
limit=page_size,
offset=offset,
)
except MessageIndexNotReady as e:
raise HTTPException(status_code=503, detail=str(e)) from e
for item in data.get("items", []) or []:
contents = item.get("contents") or item.get("Contents") or {}
if not isinstance(contents, dict):
contents = {}
try:
is_file = int(item.get("type") or item.get("Type") or 0) == 49 and int(
item.get("subType") or item.get("sub_type") or item.get("SubType") or 0
) == 6
except Exception:
is_file = False
file_md5 = str(contents.get("md5") or "") if is_file else ""
item["is_file"] = is_file
item["file_name"] = (
contents.get("title") or contents.get("fileName") or contents.get("filename") or ""
) if is_file else ""
item["file_md5"] = file_md5
item["quote"] = item.get("quote") or extract_quote(item)
file_name = item["file_name"]
item["file_url"] = f"/api/files/{quote(file_md5, safe='')}?filename={quote(file_name or file_md5, safe='')}" if file_md5 else ""
return data
@router.get("/chatrooms")
async def chatrooms(
keyword: str = Query("", description="关键词搜索"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""获取所有可用的微信群聊列表"""
fetch_limit = min(2000, offset + limit)
rooms_data = await chatlog_client.get_chatrooms(keyword=keyword, limit=fetch_limit, offset=0)
if isinstance(rooms_data, list):
room_items = rooms_data
total = len(room_items)
else:
room_items = rooms_data.get("items") or rooms_data.get("data") or []
total = rooms_data.get("total", len(room_items))
merged = []
seen = set()
def get_room_id(item: dict) -> str:
return str(item.get("name") or item.get("Name") or item.get("userName") or item.get("UserName") or "")
def add_room(item: dict):
room_id = get_room_id(item)
if not room_id or not room_id.endswith("@chatroom") or room_id in seen:
return
seen.add(room_id)
merged.append(item)
for item in room_items:
if isinstance(item, dict):
add_room(item)
# Freshly imported phone records may exist in sessions/messages before
# chatroom metadata is populated. Merge @chatroom sessions as fallback.
try:
session_items = await chatlog_client.get_sessions(keyword="", limit=2000)
except Exception:
session_items = []
lowered_keyword = (keyword or "").lower()
for session in session_items:
if not isinstance(session, dict):
continue
user_name = str(session.get("userName") or session.get("UserName") or "")
if not user_name.endswith("@chatroom"):
continue
nick_name = session.get("nickName") or session.get("NickName") or ""
remark = session.get("remark") or session.get("Remark") or ""
if lowered_keyword:
haystack = f"{user_name} {nick_name} {remark}".lower()
if lowered_keyword not in haystack:
continue
add_room({
"name": user_name,
"nickName": nick_name,
"remark": remark,
"source": "session",
})
return {"total": max(total, len(merged)), "items": merged[offset:offset + limit]}
@router.get("/avatar")
async def avatar(wxid: str = Query(...)):
url = await chatlog_client.get_avatar_url(wxid)
return {"url": url}
@router.get("/members")
async def members(
talker: str = Query(..., description="群 ID"),
time: str = Query("", description="统计时间范围,可选"),
):
"""
获取群成员列表(按发言量降序)
返回 {"members": [...], "total": N}
每个成员userName, displayName, msgCount, lastSpeakTime
"""
return await chatlog_client.get_chatroom_members(talker, time=time)
@router.get("/sessions")
async def sessions(
keyword: str = Query("", description="关键词搜索"),
limit: int = Query(500, ge=1, le=2000),
):
"""
获取所有会话列表,含最新一条消息预览和时间(来自微信原生 Session 表)。
返回:[{ userName, nickName, remark, content, nTime, nOrder }]
"""
items = await chatlog_client.get_sessions(keyword=keyword, limit=limit)
return items

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
import aiosqlite
from database import get_db
router = APIRouter(prefix="/api/settings", tags=["settings"])
EDITABLE_KEYS = [
"ai_base_url", "ai_api_key", "ai_model", "summary_model",
"vision_model", "voice_model", "topic_analysis_prompt",
]
def _mask_key(value: str) -> str:
if not value or len(value) <= 8:
return "*" * len(value) if value else ""
return value[:3] + "*" * (len(value) - 7) + value[-4:]
@router.get("")
async def get_settings(db: aiosqlite.Connection = Depends(get_db)):
result = {}
placeholders = ",".join("?" for _ in EDITABLE_KEYS)
async with db.execute(
f"SELECT key, value FROM app_settings WHERE key IN ({placeholders})",
EDITABLE_KEYS,
) as cur:
rows = await cur.fetchall()
for row in rows:
k, v = row["key"], row["value"]
result[k] = _mask_key(v) if k == "ai_api_key" else v
for k in EDITABLE_KEYS:
if k not in result:
result[k] = ""
return result
class SettingsUpdate(BaseModel):
ai_base_url: Optional[str] = None
ai_api_key: Optional[str] = None
ai_model: Optional[str] = None
summary_model: Optional[str] = None
vision_model: Optional[str] = None
voice_model: Optional[str] = None
topic_analysis_prompt: Optional[str] = None
@router.put("")
async def update_settings(body: SettingsUpdate, db: aiosqlite.Connection = Depends(get_db)):
updates = body.model_dump(exclude_none=True)
for k, v in updates.items():
if k not in EDITABLE_KEYS:
continue
if k == "ai_api_key" and "*" in v:
continue
await db.execute(
"INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
(k, v, v),
)
await db.commit()
from services.runtime_settings import invalidate_cache
invalidate_cache()
return {"status": "ok"}

View File

@@ -0,0 +1,40 @@
import asyncio, json, logging
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from services.chatlog_client import chatlog_client
from services.message_formatter import attach_quote
router = APIRouter(prefix="/api/sse", tags=["sse"])
log = logging.getLogger(__name__)
@router.get("/chatlog")
async def sse_chatlog(talker: str = Query(...)):
async def generate():
try:
data = await chatlog_client.get_messages(talker, limit=1, offset=0)
last_total = data.get("total", 0)
except Exception:
last_total = 0
while True:
await asyncio.sleep(2)
try:
data = await chatlog_client.get_messages(talker, limit=50, offset=last_total)
msgs = data.get("messages") or data.get("items") or []
new_total = data.get("total", last_total)
for msg in msgs:
attach_quote(msg)
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
if new_total > last_total:
last_total = new_total
except asyncio.CancelledError:
return
except Exception as e:
log.warning(f"[sse] poll error: {e}")
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)

View File

@@ -0,0 +1,435 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
import aiosqlite, json
from datetime import datetime
from urllib.parse import quote
from database import get_db
from services.message_formatter import extract_quote
router = APIRouter(prefix="/api/topics", tags=["topics"])
CHATLOG_BATCH_SIZE = 80
NAME_LOOKUP_PAGE_SIZE = 500
NAME_LOOKUP_MAX_ITEMS = 5000
STALE_SUMMARIZE_MINUTES = 15
class TopicCreate(BaseModel):
group_id: int
title: str
class TopicPatch(BaseModel):
title: Optional[str] = None
status: Optional[str] = None
class MessageAdd(BaseModel):
msg_seq: int
talker: str
async def _mark_stale_summarize_tasks(db: aiosqlite.Connection, group_id: int, topic_id: int) -> None:
error = "AI 报告生成任务超过 15 分钟未完成,已自动标记为失败,可重新生成"
stale_window = f"-{STALE_SUMMARIZE_MINUTES} minutes"
await db.execute(
"""
UPDATE ai_tasks
SET status='error', error=?, updated_at=CURRENT_TIMESTAMP
WHERE group_id=?
AND type='summarize'
AND status='running'
AND datetime(updated_at) <= datetime('now', ?)
""",
(error, group_id, stale_window),
)
await db.execute(
"""
UPDATE topics
SET status='error', updated_at=CURRENT_TIMESTAMP
WHERE id=?
AND status='processing'
AND datetime(updated_at) <= datetime('now', ?)
""",
(topic_id, stale_window),
)
def _normalize_chatlog_message(item: dict, fallback_seq: int = 0) -> dict:
contents = item.get("contents") or item.get("Contents") or {}
if not isinstance(contents, dict):
contents = {}
media_key = (
contents.get("rawmd5")
or contents.get("md5")
or contents.get("path")
or item.get("media_key")
or item.get("mediaKey")
or ""
)
voice_key = (
str(contents.get("voice"))
if contents.get("voice")
else item.get("voice_key") or item.get("voiceKey") or ""
)
raw_type = item.get("type") or item.get("Type") or 1
raw_sub_type = item.get("sub_type") or item.get("subType") or item.get("SubType") or 0
try:
is_file = int(raw_type) == 49 and int(raw_sub_type) == 6
except Exception:
is_file = False
file_md5 = str(contents.get("md5") or item.get("file_md5") or item.get("fileMd5") or "") if is_file else ""
file_name = (
contents.get("title")
or contents.get("fileName")
or contents.get("filename")
or item.get("file_name")
or item.get("fileName")
or ""
) if is_file else ""
file_url = f"/api/files/{quote(file_md5, safe='')}?filename={quote(file_name or file_md5, safe='')}" if file_md5 else ""
return {
"seq": item.get("seq") or item.get("Seq") or item.get("sort_seq") or fallback_seq or 0,
"sender": item.get("sender") or item.get("Sender") or "",
"sender_name": (
item.get("sender_name")
or item.get("senderName")
or item.get("SenderName")
or item.get("sender")
or item.get("Sender")
or ""
),
"create_time": item.get("create_time") or item.get("time") or item.get("CreateTime") or "",
"content": item.get("content") or item.get("Content") or "",
"type": raw_type,
"sub_type": raw_sub_type,
"contents": contents,
"media_key": media_key,
"voice_key": voice_key,
"image_path": media_key,
"voice_path": voice_key,
"video_path": media_key,
"file_path": media_key,
"link_url": contents.get("url") or item.get("link_url") or "",
"link_title": contents.get("title") or item.get("link_title") or "",
"link_desc": contents.get("desc") or item.get("link_desc") or "",
"link_thumb": contents.get("thumbUrl") or contents.get("thumb_url") or item.get("link_thumb") or "",
"link_source": contents.get("sourceName") or contents.get("source_name") or item.get("link_source") or "",
"quote": item.get("quote") or extract_quote(item),
"is_file": is_file,
"file_name": file_name,
"file_md5": file_md5,
"file_url": file_url,
}
def _message_from_snapshot(raw: str | None, fallback_seq: int) -> dict | None:
if not raw:
return None
try:
item = json.loads(raw)
except Exception:
return None
if not isinstance(item, dict):
return None
return _normalize_chatlog_message(item, fallback_seq)
def _looks_like_raw_sender_id(value: str | None) -> bool:
value = (value or "").strip()
return (
not value
or value.startswith("wxid_")
or value.startswith("gh_")
or value.endswith("@chatroom")
or value.startswith("chatroom_")
)
def _sender_display_name(item: dict, sender: str = "") -> str:
for key in (
"sender_name",
"senderName",
"SenderName",
"accountName",
"groupNickname",
"displayName",
"nickName",
"remark",
):
value = str(item.get(key) or "").strip()
if value and value != sender and not _looks_like_raw_sender_id(value):
return value
return ""
def _message_date(value: str | None) -> str | None:
value = (value or "").strip()
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d")
except Exception:
return value[:10] if len(value) >= 10 and value[4:5] == "-" else None
async def _build_sender_name_map(talker: str, messages: list[dict]) -> dict[str, str]:
names: dict[str, str] = {}
for msg in messages:
sender = str(msg.get("sender") or "").strip()
name = _sender_display_name(msg, sender)
if sender and name:
names[sender] = name
missing = {
str(msg.get("sender") or "").strip()
for msg in messages
if str(msg.get("sender") or "").strip()
and _looks_like_raw_sender_id(str(msg.get("sender_name") or "").strip())
}
missing = {sender for sender in missing if sender not in names}
if not missing:
return names
from services.chatlog_client import chatlog_client
dates = sorted({
date
for msg in messages
for date in [_message_date(str(msg.get("create_time") or ""))]
if date
})
if dates:
time_range = f"{dates[0]},{dates[-1]}"
offset = 0
seen = 0
while missing and seen < NAME_LOOKUP_MAX_ITEMS:
try:
data = await chatlog_client.get_messages(
talker,
time=time_range,
limit=NAME_LOOKUP_PAGE_SIZE,
offset=offset,
)
except Exception as e:
print(f"Failed to fetch sender names talker={talker}: {e}")
break
items = data.get("items", []) or []
if not items:
break
for item in items:
sender = str(item.get("sender") or item.get("Sender") or "").strip()
if sender not in missing:
continue
name = _sender_display_name(item, sender)
if name:
names[sender] = name
missing.discard(sender)
seen += len(items)
offset += len(items)
total = int(data.get("total") or 0)
if total and offset >= total:
break
if len(items) < NAME_LOOKUP_PAGE_SIZE:
break
if missing:
try:
members = await chatlog_client.get_chatroom_members(talker)
raw_members = members.get("members", []) if isinstance(members, dict) else []
for member in raw_members:
sender = str(member.get("userName") or member.get("UserName") or "").strip()
if sender not in missing:
continue
name = _sender_display_name(
{
"displayName": member.get("displayName") or member.get("DisplayName"),
"nickName": member.get("nickName") or member.get("NickName"),
"remark": member.get("remark") or member.get("Remark"),
},
sender,
)
if name:
names[sender] = name
missing.discard(sender)
except Exception as e:
print(f"Failed to fetch chatroom members talker={talker}: {e}")
return names
async def _fill_sender_names(talker: str, messages: list[dict]) -> None:
if not talker or not messages:
return
names = await _build_sender_name_map(talker, messages)
if not names:
return
for msg in messages:
sender = str(msg.get("sender") or "").strip()
if not sender or sender not in names:
continue
current = str(msg.get("sender_name") or "").strip()
if not current or current == sender or _looks_like_raw_sender_id(current):
msg["sender_name"] = names[sender]
msg["senderName"] = names[sender]
@router.get("")
async def list_topics(
group_id: Optional[int] = None,
status: Optional[str] = None,
keyword: Optional[str] = None,
db: aiosqlite.Connection = Depends(get_db)
):
sql = "SELECT * FROM topics WHERE 1=1"
params = []
if group_id:
sql += " AND group_id=?"; params.append(group_id)
if status:
sql += " AND status=?"; params.append(status)
if keyword:
sql += " AND title LIKE ?"; params.append(f"%{keyword}%")
async with db.execute(sql, params) as cur:
return [dict(r) for r in await cur.fetchall()]
@router.post("")
async def create_topic(body: TopicCreate, db: aiosqlite.Connection = Depends(get_db)):
await db.execute(
"INSERT INTO topics (group_id, title, source) VALUES (?, ?, 'manual')",
(body.group_id, body.title),
)
await db.commit()
async with db.execute("SELECT * FROM topics ORDER BY id DESC LIMIT 1") as cur:
return dict(await cur.fetchone())
@router.get("/{topic_id}")
async def get_topic(topic_id: int, db: aiosqlite.Connection = Depends(get_db)):
async with db.execute("SELECT * FROM topics WHERE id=?", (topic_id,)) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "not found")
# 拿到该话题下关联的所有 seq
async with db.execute("SELECT * FROM topic_messages WHERE topic_id=? ORDER BY msg_seq ASC", (topic_id,)) as cur:
msg_rows = await cur.fetchall()
msgs = []
if msg_rows:
# 获取群聊 ID
talker = msg_rows[0]["talker"]
seq_list = [r["msg_seq"] for r in msg_rows]
# 分批调用 5030 接口获取真实消息内容,避免大批量 batch 触发 500。
from services.chatlog_client import chatlog_client
fetched_by_seq: dict[int, dict] = {}
for i in range(0, len(seq_list), CHATLOG_BATCH_SIZE):
chunk = seq_list[i: i + CHATLOG_BATCH_SIZE]
try:
msgs_data = await chatlog_client.get_messages_batch(talker, chunk)
raw_items = msgs_data.get("items", []) or msgs_data.get("Items", [])
for item in raw_items:
normalized = _normalize_chatlog_message(item)
seq = normalized.get("seq")
if seq:
fetched_by_seq[int(seq)] = normalized
except Exception as e:
print(f"Failed to fetch real messages chunk topic={topic_id}: {e}")
for r in msg_rows:
seq = int(r["msg_seq"])
if seq in fetched_by_seq:
msgs.append(fetched_by_seq[seq])
continue
snap = _message_from_snapshot(r["message_json"] if "message_json" in r.keys() else None, seq)
if snap:
msgs.append(snap)
continue
msgs.append({
"seq": seq,
"sender": "",
"sender_name": "系统提示",
"create_time": "",
"content": f"原始消息无法从 chatlog 找回 (seq: {seq})",
"type": 1,
"sub_type": 0,
})
# 获取知识文档
if msg_rows:
await _fill_sender_names(talker, msgs)
async with db.execute("SELECT id, topic_id, content, created_at, updated_at FROM knowledge_docs WHERE topic_id=?", (topic_id,)) as cur:
doc = await cur.fetchone()
return {**dict(row), "messages": msgs, "knowledge_doc": dict(doc) if doc else None}
@router.patch("/{topic_id}")
async def patch_topic(topic_id: int, body: TopicPatch, db: aiosqlite.Connection = Depends(get_db)):
if body.title:
await db.execute(
"UPDATE topics SET title=?, source='manual', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(body.title, topic_id),
)
if body.status:
await db.execute(
"UPDATE topics SET status=?, source='manual', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(body.status, topic_id),
)
await db.commit()
async with db.execute("SELECT * FROM topics WHERE id=?", (topic_id,)) as cur:
return dict(await cur.fetchone())
@router.delete("/{topic_id}")
async def delete_topic(topic_id: int, db: aiosqlite.Connection = Depends(get_db)):
# 先拿到 doc_id用于清 FTS
async with db.execute("SELECT id FROM knowledge_docs WHERE topic_id=?", (topic_id,)) as cur:
doc_row = await cur.fetchone()
if doc_row:
await db.execute("DELETE FROM knowledge_fts WHERE doc_id=?", (doc_row["id"],))
await db.execute("DELETE FROM topic_messages WHERE topic_id=?", (topic_id,))
await db.execute("DELETE FROM knowledge_docs WHERE topic_id=?", (topic_id,))
await db.execute("DELETE FROM topics WHERE id=?", (topic_id,))
await db.commit()
return {"ok": True}
@router.post("/{topic_id}/messages")
async def add_message(topic_id: int, body: MessageAdd, db: aiosqlite.Connection = Depends(get_db)):
await db.execute(
"INSERT OR IGNORE INTO topic_messages (topic_id, msg_seq, talker, added_by) VALUES (?, ?, ?, 'user')",
(topic_id, body.msg_seq, body.talker)
)
await db.execute(
"UPDATE topics SET source='manual', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(topic_id,),
)
await db.commit()
return {"ok": True}
@router.delete("/{topic_id}/messages/{seq}")
async def remove_message(topic_id: int, seq: int, db: aiosqlite.Connection = Depends(get_db)):
await db.execute("DELETE FROM topic_messages WHERE topic_id=? AND msg_seq=?", (topic_id, seq))
await db.execute(
"UPDATE topics SET source='manual', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(topic_id,),
)
await db.commit()
return {"ok": True}
@router.post("/{topic_id}/summarize")
async def summarize(topic_id: int, db: aiosqlite.Connection = Depends(get_db)):
async with db.execute("SELECT * FROM topics WHERE id=?", (topic_id,)) as cur:
topic = await cur.fetchone()
if not topic:
raise HTTPException(404, "not found")
topic_data = dict(topic)
await _mark_stale_summarize_tasks(db, topic_data["group_id"], topic_id)
# 创建 ai_tasks 记录以追踪进度
await db.execute(
"INSERT INTO ai_tasks (group_id, type, status, progress) VALUES (?, 'summarize', 'running', ?)",
(topic_data["group_id"], json.dumps({"processed": 0, "total": 1}))
)
await db.commit()
async with db.execute("SELECT last_insert_rowid() AS id") as cur:
task_row = await cur.fetchone()
task_id = task_row["id"]
from services.summary_engine import run_summarize
import asyncio
asyncio.create_task(run_summarize(topic_id, topic_data, task_id))
return {"ok": True, "task_id": task_id}

View File

@@ -0,0 +1,13 @@
import os
import uvicorn
from main import app
def main():
port = int(os.environ.get("CHATLAB_BACKEND_PORT", "8000"))
uvicorn.run(app, host="127.0.0.1", port=port, reload=False, log_level="info")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,49 @@
"""
APScheduler — 仅保留 wxid/数据库切换检测。
(不再运行任何 AI 分类轮询AI 分析改为用户手动按时间窗口触发)
"""
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from database import update_db_path
log = logging.getLogger(__name__)
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
_sync_failures = 0
def register_poll_job(group_id: int, poll_interval: int):
"""已废弃。保留空函数避免其他模块旧引用炸。"""
log.debug(f"[scheduler] register_poll_job called (no-op now): group={group_id}")
def _reschedule_sync(seconds: int):
if scheduler.get_job("sync_jobs"):
scheduler.remove_job("sync_jobs")
scheduler.add_job(_sync_jobs, "interval", seconds=seconds, id="sync_jobs")
async def _sync_jobs():
"""定期触发 wxid 重新检测,让账号切换能自动切换数据库。"""
global _sync_failures
try:
await update_db_path()
if _sync_failures > 0:
_sync_failures = 0
_reschedule_sync(10)
except Exception as e:
_sync_failures += 1
log.error(f"[scheduler] sync error (consecutive={_sync_failures}): {e}")
if _sync_failures == 3:
_reschedule_sync(60)
log.warning("[scheduler] sync backoff to 60s after 3 failures")
async def start_scheduler():
scheduler.add_job(_sync_jobs, "interval", seconds=10, id="sync_jobs")
scheduler.start()
# Do not block FastAPI startup on chatlog. Electron starts the backend
# before chatlog, so the first account sync must happen in the background.
scheduler.add_job(_sync_jobs, "date", id="sync_jobs_initial")
log.info("[scheduler] started (db-path watcher only, no poll jobs)")

View File

View File

@@ -0,0 +1,31 @@
import httpx
from openai import AsyncOpenAI
from services.runtime_settings import get_ai_settings
_client_cache: dict[tuple[str, str], AsyncOpenAI] = {}
_http_client_cache: dict[tuple[str, str], httpx.AsyncClient] = {}
async def get_openai_client() -> tuple[AsyncOpenAI, dict]:
settings = await get_ai_settings()
cache_key = (
settings.get("ai_base_url") or "",
settings.get("ai_api_key") or "",
)
if cache_key not in _client_cache:
for http_client in _http_client_cache.values():
await http_client.aclose()
_client_cache.clear()
_http_client_cache.clear()
http_client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0))
_http_client_cache[cache_key] = http_client
_client_cache[cache_key] = AsyncOpenAI(
api_key=settings.get("ai_api_key") or "missing",
base_url=settings.get("ai_base_url"),
http_client=http_client,
)
return _client_cache[cache_key], settings

View File

@@ -0,0 +1,203 @@
import httpx
import asyncio
from typing import List
from config import settings
class ChatlogHTTPError(RuntimeError):
def __init__(self, status_code: int, method: str, path: str, detail: str):
self.status_code = status_code
self.method = method
self.path = path
self.detail = detail
super().__init__(f"chatlog HTTP {status_code}: {method} {path} body={detail!r}")
class MessageIndexNotReady(RuntimeError):
"""Raised when chatlog has sessions but its message time index is not usable yet."""
class ChatlogClient:
def __init__(self):
self.base = settings.chatlog_base_url
self._contact_db_file = None
async def _get(self, path: str, params: dict, timeout: float = 30.0) -> dict:
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
r = await client.get(f"{self.base}{path}", params=params)
r.raise_for_status()
return r.json()
except httpx.TimeoutException:
raise RuntimeError(f"chatlog timeout: GET {path}")
except httpx.HTTPStatusError as e:
detail = self._response_detail(e.response)
raise ChatlogHTTPError(e.response.status_code, "GET", path, detail)
except Exception as e:
raise RuntimeError(f"chatlog request failed: {e}")
async def _post(self, path: str, body: dict, timeout: float = 30.0) -> dict:
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
r = await client.post(f"{self.base}{path}", json=body)
r.raise_for_status()
return r.json()
except httpx.TimeoutException:
raise RuntimeError(f"chatlog timeout: POST {path}")
except httpx.HTTPStatusError as e:
detail = self._response_detail(e.response)
raise ChatlogHTTPError(e.response.status_code, "POST", path, detail)
except Exception as e:
raise RuntimeError(f"chatlog request failed: {e}")
def _response_detail(self, response: httpx.Response) -> str:
try:
body = response.json()
if isinstance(body, dict):
return str(body.get("error") or body.get("detail") or body)
return str(body)
except Exception:
return response.text
async def get_messages(
self,
talker: str,
time: str = "",
sender: str = "",
keyword: str = "",
min_seq: int = 0,
limit: int = 100,
offset: int = 0,
) -> dict:
params: dict = {
"talker": talker,
"limit": limit,
"offset": offset,
"format": "json",
}
if time:
params["time"] = time
else:
params["time"] = "1970-01-01,2099-12-31"
if sender:
params["sender"] = sender
if keyword:
params["keyword"] = keyword
if min_seq > 0:
params["min_seq"] = min_seq
try:
data = await self._get("/api/v1/chatlog", params)
except ChatlogHTTPError as e:
detail = e.detail.lower()
if e.status_code == 404 and "time range not found" in detail:
await asyncio.sleep(0.2)
try:
data = await self._get("/api/v1/chatlog", params)
except ChatlogHTTPError as retry_error:
if (
retry_error.status_code == 404
and "time range not found" in retry_error.detail.lower()
):
raise MessageIndexNotReady(
"自动解密仍在处理消息库,请稍后刷新聊天记录;如果长时间为空,请在微信里打开该聊天并翻看历史消息。"
) from retry_error
raise
elif e.status_code == 404 and "not found" in detail:
# chatlog sometimes reports a valid date window as missing while it is warming/querying.
await asyncio.sleep(0.2)
try:
data = await self._get("/api/v1/chatlog", params)
except ChatlogHTTPError as retry_error:
retry_detail = retry_error.detail.lower()
if (
retry_error.status_code == 404
and "time range not found" in retry_detail
):
raise MessageIndexNotReady(
"自动解密仍在处理消息库,请稍后刷新聊天记录;如果长时间为空,请在微信里打开该聊天并翻看历史消息。"
) from retry_error
if retry_error.status_code == 404 and "not found" in retry_detail:
return {"total": 0, "items": []}
raise
else:
raise
if isinstance(data, dict):
return data
return {"total": len(data), "items": data}
async def get_message(self, talker: str, seq: int) -> dict | None:
try:
async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client:
r = await client.get(
f"{self.base}/api/v1/chatlog/message",
params={"talker": talker, "seq": seq},
)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
except httpx.TimeoutException:
raise RuntimeError("chatlog timeout: get_message")
except Exception as e:
raise RuntimeError(f"chatlog request failed: {e}")
async def get_messages_batch(self, talker: str, seqs: List[int]) -> dict:
return await self._post("/api/v1/chatlog/batch", {"talker": talker, "seqs": seqs})
async def get_chatrooms(self, keyword: str = "", limit: int = 100, offset: int = 0) -> dict:
params: dict = {"limit": limit, "offset": offset, "format": "json"}
if keyword:
params["keyword"] = keyword
return await self._get("/api/v1/chatroom", params, timeout=10.0)
async def get_contacts(self, keyword: str = "", limit: int = 100, offset: int = 0) -> dict:
params: dict = {"limit": limit, "offset": offset, "format": "json"}
if keyword:
params["keyword"] = keyword
return await self._get("/api/v1/contact", params, timeout=10.0)
async def get_chatroom_members(self, talker: str, time: str = "") -> dict:
params: dict = {"talker": talker}
if time:
params["time"] = time
return await self._get("/api/v1/chatroom/members", params)
async def get_sessions(self, keyword: str = "", limit: int = 500) -> list:
params: dict = {"limit": limit, "format": "json"}
if keyword:
params["keyword"] = keyword
data = await self._get("/api/v1/session", params, timeout=15.0)
if isinstance(data, list):
return data
return data.get("items", data.get("data", []))
async def get_avatar_url(self, wxid: str) -> str:
if self._contact_db_file is None:
try:
db_list = await self._get("/api/v1/db", {})
self._contact_db_file = (db_list.get("contact") or [""])[0]
except Exception:
self._contact_db_file = ""
if not self._contact_db_file:
return ""
safe_wxid = wxid.replace("'", "''")
sql = f"SELECT small_head_url, big_head_url FROM contact WHERE username='{safe_wxid}' LIMIT 1"
params = {"group": "contact", "file": self._contact_db_file, "sql": sql}
try:
rows = await self._get("/api/v1/db/query", params, timeout=5.0)
if rows:
url = rows[0].get("small_head_url") or rows[0].get("big_head_url") or ""
if url:
return url
except Exception:
pass
return ""
async def get_db_paths(self) -> dict:
data = await self._get("/api/v1/db", {}, timeout=10.0)
return data if isinstance(data, dict) else {}
chatlog_client = ChatlogClient()

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
@dataclass
class ChatlogContext:
account: str = ""
work_dir: str = ""
data_dir: str = ""
platform: str = "windows"
version: int = 4
chatlog_exe: str = ""
chatlog_version: str = ""
_context = ChatlogContext()
def update_chatlog_context(payload: dict) -> dict:
global _context
_context = ChatlogContext(
account=str(payload.get("account") or ""),
work_dir=str(payload.get("workDir") or payload.get("work_dir") or ""),
data_dir=str(payload.get("dataDir") or payload.get("data_dir") or ""),
platform=str(payload.get("platform") or "windows"),
version=int(payload.get("version") or 4),
chatlog_exe=str(payload.get("chatlogExe") or payload.get("chatlog_exe") or ""),
chatlog_version=str(payload.get("chatlogVersion") or payload.get("chatlog_version") or ""),
)
return get_chatlog_context()
def get_chatlog_context() -> dict:
return asdict(_context)

View File

@@ -0,0 +1,25 @@
import jieba
import re
def tokenize(text: str) -> str:
return " ".join(jieba.cut(text))
def build_match_query(text: str, limit: int = 12) -> str:
"""Build a safe FTS5 MATCH query from user/model text."""
terms: list[str] = []
seen: set[str] = set()
for token in tokenize(text or "").split():
token = token.strip()
if not token or not re.search(r"\w", token, flags=re.UNICODE):
continue
upper = token.upper()
if upper in {"AND", "OR", "NOT", "NEAR"}:
continue
if token in seen:
continue
seen.add(token)
terms.append('"' + token.replace('"', '""') + '"')
if len(terms) >= limit:
break
return " OR ".join(terms)

View File

@@ -0,0 +1,142 @@
import base64
import logging
import httpx
from fastapi import HTTPException
from services.ai_client import get_openai_client
from services.media_resolver import resolve_media
from services.runtime_settings import get_ai_settings
log = logging.getLogger(__name__)
async def _get_ai_client():
return await get_openai_client()
async def parse_media(kind: str, key: str) -> dict:
"""
Parse one chatlog media object into text.
kind: voice, image, or video.
key: chatlog media key.
"""
if kind not in {"voice", "image", "video"}:
raise HTTPException(400, "不支持的媒体类型")
if not key:
raise HTTPException(400, "媒体 key 不能为空")
ai = await get_ai_settings()
if not ai.get("ai_api_key"):
raise HTTPException(503, "AI 服务未配置,请在设置页填写 AI API Key")
if kind == "voice" and not ai.get("voice_model"):
raise HTTPException(503, "语音模型未配置,请在设置页填写语音模型名称,例如 paraformer-v2")
if kind in ("image", "video") and not ai.get("vision_model"):
raise HTTPException(503, "视觉模型未配置,请在设置页填写视觉模型名称,例如 qwen-vl-plus")
media = await resolve_media(kind, key)
if kind == "voice":
return {"text": await _parse_voice(media.bytes, media.content_type)}
return {"text": await _parse_visual(kind, media.bytes, media.content_type)}
async def _parse_voice(media_bytes: bytes, content_type: str) -> str:
b64_audio = base64.b64encode(media_bytes).decode()
audio_ct = content_type.lower()
if "silk" in audio_ct or "x-silk" in audio_ct:
audio_mime = "audio/silk"
elif "amr" in audio_ct:
audio_mime = "audio/amr"
elif "ogg" in audio_ct or "opus" in audio_ct:
audio_mime = "audio/ogg"
elif "wav" in audio_ct:
audio_mime = "audio/wav"
else:
audio_mime = "audio/mpeg"
data_uri = f"data:{audio_mime};base64,{b64_audio}"
_, ai = await _get_ai_client()
asr_headers = {
"Authorization": f"Bearer {ai['ai_api_key']}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=60) as http:
submit = await http.post(
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription",
headers={**asr_headers, "X-DashScope-Async": "enable"},
json={
"model": ai["voice_model"],
"input": {"file_urls": [data_uri]},
"parameters": {"language_hints": ["zh", "en"]},
},
timeout=30,
)
submit_data = submit.json()
if submit.status_code not in (200, 201):
raise HTTPException(500, f"提交识别任务失败: {submit_data.get('message', submit_data)}")
task_id = submit_data.get("output", {}).get("task_id")
if not task_id:
raise HTTPException(500, f"未获取到 task_id: {submit_data}")
for _ in range(30):
import asyncio
await asyncio.sleep(1)
poll = await http.get(
f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}",
headers=asr_headers,
timeout=10,
)
poll_data = poll.json()
status = poll_data.get("output", {}).get("task_status", "")
if status == "SUCCEEDED":
results = poll_data.get("output", {}).get("results", [])
log.info("[media_parser] ASR SUCCEEDED results: %s", results)
if not results:
return "(识别结果为空)"
trans_url = results[0].get("transcription_url", "")
if trans_url:
trans_resp = await http.get(trans_url, timeout=10)
trans_data = trans_resp.json()
log.info("[media_parser] transcription_url content: %s", str(trans_data)[:500])
transcripts = trans_data.get("transcripts", [])
text = transcripts[0].get("text", "") if transcripts else ""
else:
text = results[0].get("transcription", "")
return text or "(识别结果为空)"
if status in ("FAILED", "CANCELLED"):
raise HTTPException(500, f"识别任务失败: {poll_data.get('output', {}).get('message', status)}")
raise HTTPException(500, "语音识别超时30秒")
async def _parse_visual(kind: str, media_bytes: bytes, content_type: str) -> str:
b64 = base64.b64encode(media_bytes).decode()
ct = content_type.lower()
if "png" in ct:
mime = "image/png"
elif "webp" in ct:
mime = "image/webp"
else:
mime = "image/jpeg"
data_url = f"data:{mime};base64,{b64}"
prompt = "请用中文简洁描述这张图片的内容。" if kind == "image" else "请用中文简洁描述这个视频截图的内容。"
client, ai = await _get_ai_client()
resp_ai = await client.chat.completions.create(
model=ai["vision_model"],
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
],
}
],
max_tokens=300,
)
return resp_ai.choices[0].message.content or ""

View File

@@ -0,0 +1,174 @@
from __future__ import annotations
import logging
import sqlite3
from dataclasses import dataclass
from pathlib import Path
import httpx
from fastapi import HTTPException
from config import settings
from services.chatlog_context import get_chatlog_context
log = logging.getLogger(__name__)
@dataclass
class ResolvedMedia:
bytes: bytes
content_type: str
url: str
def _media_url(kind: str, key: str, thumb: bool = False) -> str:
url = f"{settings.chatlog_base_url}/{kind}/{key}"
if thumb:
url += "?thumb=1"
return url
def _read_voice_resource_status(key: str) -> dict:
ctx = get_chatlog_context()
work_dir = ctx.get("work_dir") or ""
if not work_dir:
return {"checked": False, "reason": "missing_work_dir"}
db_path = Path(work_dir) / "db_storage" / "message" / "message_resource.db"
if not db_path.exists():
return {"checked": False, "reason": "message_resource_db_missing", "path": str(db_path)}
try:
conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
info = conn.execute(
"SELECT * FROM MessageResourceInfo WHERE message_svr_id=?",
(int(key),),
).fetchone()
if not info:
return {
"checked": True,
"found": False,
"path": str(db_path),
"message": "当前已解密资源库里没有这条语音的媒体资源记录",
}
details = conn.execute(
"SELECT type,size,status,data_index FROM MessageResourceDetail WHERE message_id=?",
(info["message_id"],),
).fetchall()
return {
"checked": True,
"found": True,
"path": str(db_path),
"message_id": info["message_id"],
"resources": [dict(row) for row in details],
}
finally:
conn.close()
except Exception as exc:
return {"checked": False, "reason": "resource_db_read_failed", "error": str(exc), "path": str(db_path)}
def _download_failure_message(kind: str, key: str, status_code: int | None, body: str = "") -> str:
if kind == "voice":
base = "底层语音文件未读取成功"
if status_code:
base += f"chatlog /voice 返回 HTTP {status_code}"
return (
f"{base}。请先确认已安装新版程序并重新识别当前微信账号;"
"如果仍失败,说明当前 chatlog 版本还不能解析该 WeChat 4.x 语音资源。"
)
if status_code:
return f"从 chatlog 下载媒体失败: HTTP {status_code}"
return f"从 chatlog 下载媒体失败: {body or 'unknown error'}"
async def diagnose_media(kind: str, key: str) -> dict:
if kind not in {"voice", "image", "video"}:
raise HTTPException(400, "不支持的媒体类型")
if not key:
raise HTTPException(400, "媒体 key 不能为空")
url = _media_url(kind, key, thumb=kind in {"image", "video"})
result = {
"ok": False,
"kind": kind,
"key": key,
"url": url,
"chatlog_base_url": settings.chatlog_base_url,
"chatlog_context": get_chatlog_context(),
}
async with httpx.AsyncClient(timeout=20, trust_env=False, follow_redirects=True) as client:
try:
resp = await client.get(url)
content_type = resp.headers.get("content-type", "")
result.update(
{
"status_code": resp.status_code,
"content_type": content_type,
"content_length": len(resp.content or b""),
"ok": resp.status_code < 400 and bool(resp.content),
}
)
if resp.status_code >= 400:
result["error"] = _download_failure_message(kind, key, resp.status_code, resp.text[:500])
result["response_preview"] = resp.text[:500]
elif not resp.content:
result["error"] = "chatlog 返回了空媒体文件"
except Exception as exc:
result.update({"error": f"无法连接 chatlog 媒体接口: {exc}", "exception": str(exc)})
if kind == "voice":
result["resource_db"] = _read_voice_resource_status(key)
return result
async def resolve_media(kind: str, key: str) -> ResolvedMedia:
if kind not in {"voice", "image", "video"}:
raise HTTPException(400, "不支持的媒体类型")
if not key:
raise HTTPException(400, "媒体 key 不能为空")
url = _media_url(kind, key, thumb=kind in {"image", "video"})
async with httpx.AsyncClient(timeout=60, trust_env=False, follow_redirects=True) as client:
try:
resp = await client.get(url)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
diagnostics = await diagnose_media(kind, key)
log.warning("[media_resolver] media download failed: %s", diagnostics)
raise HTTPException(
502,
{
"message": _download_failure_message(kind, key, exc.response.status_code, exc.response.text[:500]),
"diagnostics": diagnostics,
},
)
except Exception as exc:
diagnostics = await diagnose_media(kind, key)
log.warning("[media_resolver] media download exception: %s", diagnostics)
raise HTTPException(
502,
{
"message": _download_failure_message(kind, key, None, str(exc)),
"diagnostics": diagnostics,
},
)
if not resp.content:
diagnostics = await diagnose_media(kind, key)
raise HTTPException(
502,
{
"message": "chatlog 返回了空媒体文件",
"diagnostics": diagnostics,
},
)
return ResolvedMedia(
bytes=resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
url=url,
)

View File

@@ -0,0 +1,253 @@
import html
import json
import re
import xml.etree.ElementTree as ET
from typing import Any
QUOTE_CONTENT_LIMIT = 600
def extract_contents(item: dict) -> dict:
contents = item.get("contents") or item.get("Contents") or {}
return contents if isinstance(contents, dict) else {}
def clean_message_text(value: Any) -> str:
text = html.unescape(str(value or "")).strip()
text = re.sub(r"\s+", " ", text)
if len(text) > QUOTE_CONTENT_LIMIT:
text = text[:QUOTE_CONTENT_LIMIT] + "..."
return text
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _safe_int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(str(value).strip())
except Exception:
return None
def _first(data: dict, *keys: str) -> Any:
for key in keys:
value = data.get(key)
if value not in (None, ""):
return value
return None
def _has_quote_indicator(data: dict) -> bool:
keys = {str(key) for key in data.keys()}
indicators = {
"quote",
"refermsg",
"referMsg",
"refer",
"recordInfo",
"recordinfo",
"fromusr",
"fromUser",
"chatusr",
"chatUser",
"displayname",
"displayName",
"referContent",
"svrid",
"newmsgid",
"newMsgId",
}
return bool(keys & indicators)
def _decode_json(value: str) -> Any:
try:
return json.loads(value)
except Exception:
return None
def _xml_node_text(node: ET.Element, names: set[str]) -> str:
for child in node.iter():
if _local_name(child.tag) in names:
text = "".join(child.itertext()).strip()
if text:
return text
return ""
def _quote_from_xml(value: str) -> dict | None:
text = html.unescape(value or "").strip()
if "<" not in text or ">" not in text:
return None
try:
root = ET.fromstring(text)
except Exception:
try:
root = ET.fromstring(f"<root>{text}</root>")
except Exception:
return None
refer_node = None
for node in root.iter():
if _local_name(node.tag).lower() == "refermsg":
refer_node = node
break
if refer_node is None:
return None
content = _xml_node_text(refer_node, {"content", "title", "desc"})
sender_name = _xml_node_text(refer_node, {"displayname", "nickname", "fromnickname"})
sender = _xml_node_text(refer_node, {"fromusr", "chatusr", "sender"})
msg_type = _safe_int(_xml_node_text(refer_node, {"type"}))
seq = _safe_int(_xml_node_text(refer_node, {"seq", "msgid", "newmsgid", "svrid"}))
return _normalize_quote(
{
"sender": sender,
"sender_name": sender_name,
"content": content,
"type": msg_type,
"seq": seq,
}
)
def _find_quote_payload(value: Any, allow_plain_text: bool = False) -> dict | None:
if value in (None, ""):
return None
if isinstance(value, str):
text = value.strip()
if not text:
return None
decoded = _decode_json(text) if text[:1] in ("{", "[") else None
if decoded is not None:
return _find_quote_payload(decoded, allow_plain_text=allow_plain_text)
xml_quote = _quote_from_xml(text)
if xml_quote:
return xml_quote
if allow_plain_text:
return _normalize_quote({"content": text})
return None
if isinstance(value, list):
for item in value:
quote = _find_quote_payload(item, allow_plain_text=allow_plain_text)
if quote:
return quote
return None
if not isinstance(value, dict):
return None
for key in ("quote", "refermsg", "referMsg", "refer", "recordInfo", "recordinfo"):
if key in value:
quote = _find_quote_payload(value.get(key), allow_plain_text=True)
if quote:
return quote
quote = _normalize_quote(value) if allow_plain_text or _has_quote_indicator(value) else None
if quote:
return quote
for nested in value.values():
quote = _find_quote_payload(nested, allow_plain_text=False)
if quote:
return quote
return None
def _normalize_quote(data: dict) -> dict | None:
content = clean_message_text(
_first(
data,
"content",
"Content",
"text",
"title",
"desc",
"digest",
"displayContent",
"referContent",
)
)
if not content:
return None
sender = clean_message_text(
_first(data, "sender", "Sender", "fromusr", "fromUser", "chatusr", "chatUser", "from")
)
sender_name = clean_message_text(
_first(data, "sender_name", "senderName", "SenderName", "displayname", "displayName", "nickname", "nickName")
)
msg_type = _safe_int(_first(data, "type", "Type", "msgType", "subType"))
seq = _safe_int(_first(data, "seq", "Seq", "sort_seq", "msgid", "msgId", "newmsgid", "newMsgId", "svrid"))
return {
"sender": sender,
"sender_name": sender_name,
"content": content,
"type": msg_type,
"seq": seq,
}
def extract_quote(item: dict | None) -> dict | None:
if not isinstance(item, dict):
return None
contents = extract_contents(item)
explicit_sources = (
item.get("quote"),
item.get("Quote"),
item.get("refer"),
item.get("recordInfo"),
contents.get("quote"),
contents.get("refer"),
contents.get("refermsg"),
contents.get("referMsg"),
contents.get("recordInfo"),
contents.get("recordinfo"),
)
for source in explicit_sources:
quote = _find_quote_payload(source, allow_plain_text=True)
if quote:
return quote
for source in (
contents.get("appmsg"),
item.get("content"),
item.get("Content"),
):
quote = _find_quote_payload(source, allow_plain_text=False)
if quote:
return quote
return None
def attach_quote(item: dict) -> dict:
item["quote"] = extract_quote(item)
return item
def quote_to_text(quote: dict | None) -> str:
if not quote:
return ""
sender = quote.get("sender_name") or quote.get("sender") or "未知"
seq = quote.get("seq")
seq_text = f" seq={seq}" if seq else ""
return f"[引用消息{seq_text}] {sender}: {quote.get('content') or ''}".strip()
def append_quote_text(base_text: str, item: dict) -> str:
parts = [base_text.strip()] if base_text and base_text.strip() else []
quote_text = quote_to_text(extract_quote(item))
if quote_text:
parts.append(quote_text)
return "".join(parts)

View File

@@ -0,0 +1,139 @@
import re
import aiosqlite
from services.fts import build_match_query
MAX_EXAMPLES = 3
MAX_EXAMPLE_CHARS = 1800
MAX_CONTEXT_CHARS = 5200
def _compact(text: str, limit: int = MAX_EXAMPLE_CHARS) -> str:
text = re.sub(r"\n{3,}", "\n\n", (text or "").strip())
if len(text) <= limit:
return text
return text[:limit].rstrip() + "\n..."
def _format_examples(rows: list[aiosqlite.Row], purpose: str) -> str:
if not rows:
return ""
heading = {
"topic": "历史人工修订报告参考(用于学习话题命名和分类口径)",
"summary": "历史人工修订报告参考(只学习结构、措辞和关注点,不得照抄历史事实)",
}.get(purpose, "历史人工修订报告参考")
parts = [heading]
total = len(parts[0])
for idx, row in enumerate(rows, 1):
block = (
f"\n\n--- 示例 {idx} ---\n"
f"群聊:{row['group_name'] or row['talker'] or row['group_id']}\n"
f"话题标题:{row['title']}\n"
f"报告内容:\n{_compact(row['content'])}"
)
if total + len(block) > MAX_CONTEXT_CHARS:
break
parts.append(block)
total += len(block)
return "".join(parts).strip()
async def build_report_learning_context(
db: aiosqlite.Connection,
*,
group_id: int | None,
query: str = "",
exclude_topic_id: int | None = None,
purpose: str = "summary",
limit: int = MAX_EXAMPLES,
) -> str:
params: list[object] = []
exclude_sql = ""
if exclude_topic_id is not None:
exclude_sql = " AND t.id<>?"
params.append(exclude_topic_id)
selected: list[aiosqlite.Row] = []
seen_doc_ids: set[int] = set()
if group_id is not None:
async with db.execute(
f"""
SELECT k.id, k.content, k.updated_at, t.id AS topic_id, t.title, t.group_id,
g.name AS group_name, g.talker
FROM knowledge_docs k
JOIN topics t ON t.id = k.topic_id
LEFT JOIN groups g ON g.id = t.group_id
WHERE k.curated_at IS NOT NULL
AND t.group_id=?
{exclude_sql}
ORDER BY k.curated_at DESC, k.updated_at DESC
LIMIT ?
""",
[group_id, *params, limit],
) as cur:
rows = await cur.fetchall()
for row in rows:
selected.append(row)
seen_doc_ids.add(int(row["id"]))
if len(selected) < limit:
remaining = limit - len(selected)
fts_query = build_match_query(query or "")
if fts_query:
async with db.execute(
f"""
SELECT k.id, k.content, k.updated_at, t.id AS topic_id, t.title, t.group_id,
g.name AS group_name, g.talker
FROM knowledge_docs k
JOIN topics t ON t.id = k.topic_id
LEFT JOIN groups g ON g.id = t.group_id
WHERE k.curated_at IS NOT NULL
AND k.id IN (SELECT doc_id FROM knowledge_fts WHERE knowledge_fts MATCH ?)
{exclude_sql}
ORDER BY CASE WHEN t.group_id=? THEN 0 ELSE 1 END,
k.curated_at DESC,
k.updated_at DESC
LIMIT ?
""",
[fts_query, *params, group_id or -1, remaining * 3],
) as cur:
rows = await cur.fetchall()
for row in rows:
doc_id = int(row["id"])
if doc_id in seen_doc_ids:
continue
selected.append(row)
seen_doc_ids.add(doc_id)
if len(selected) >= limit:
break
if len(selected) < limit:
remaining = limit - len(selected)
async with db.execute(
f"""
SELECT k.id, k.content, k.updated_at, t.id AS topic_id, t.title, t.group_id,
g.name AS group_name, g.talker
FROM knowledge_docs k
JOIN topics t ON t.id = k.topic_id
LEFT JOIN groups g ON g.id = t.group_id
WHERE k.curated_at IS NOT NULL
{exclude_sql}
ORDER BY CASE WHEN t.group_id=? THEN 0 ELSE 1 END,
k.curated_at DESC,
k.updated_at DESC
LIMIT ?
""",
[*params, group_id or -1, remaining * 3],
) as cur:
rows = await cur.fetchall()
for row in rows:
doc_id = int(row["id"])
if doc_id in seen_doc_ids:
continue
selected.append(row)
seen_doc_ids.add(doc_id)
if len(selected) >= limit:
break
return _format_examples(selected[:limit], purpose)

View File

@@ -0,0 +1,45 @@
import logging
import aiosqlite
from config import settings as default_settings
from database import get_active_db_path
log = logging.getLogger(__name__)
_cache: dict | None = None
def invalidate_cache():
global _cache
_cache = None
async def get_ai_settings() -> dict:
global _cache
if _cache is not None:
return _cache
# ai_base_url 保留默认值(阿里云兼容 OpenAI 格式地址),其余字段必须由用户在设置页配置
result = {
"ai_base_url": default_settings.ai_base_url,
"ai_api_key": "",
"ai_model": "",
"summary_model": "",
"vision_model": "",
"voice_model": "",
"topic_analysis_prompt": "",
}
try:
path = get_active_db_path()
async with aiosqlite.connect(path) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT key, value FROM app_settings") as cur:
rows = await cur.fetchall()
for row in rows:
if row["key"] in result and row["value"]:
result[row["key"]] = row["value"]
except Exception as e:
log.warning(f"Failed to read runtime settings: {e}")
_cache = result
return result

View File

@@ -0,0 +1,476 @@
"""
售后报告生成引擎
- 从 topic_messages 拿到所有 msg_seq
- 通过 chatlog batch 接口批量拉回消息原文
- 用配置的总结模型生成 Markdown 售后事件报告
- 写入 knowledge_docs + knowledge_ftsjieba 分词)
"""
import asyncio
import logging
import json
import aiosqlite
from urllib.parse import quote
from database import get_active_db_path
from services.ai_client import get_openai_client
from services.fts import tokenize
from services.message_formatter import append_quote_text, extract_contents, extract_quote
from services.report_learning import build_report_learning_context
log = logging.getLogger(__name__)
CHATLOG_BATCH_SIZE = 80
SUMMARY_LLM_TIMEOUT_SECONDS = 300
async def _get_client():
return await get_openai_client()
def _message_line(item: dict, fallback_seq: int = 0) -> tuple[int, str] | None:
if not item:
return None
seq = item.get("seq") or item.get("Seq") or item.get("sort_seq") or fallback_seq or 0
time_str = item.get("create_time") or item.get("time") or item.get("CreateTime") or ""
sender = (
item.get("sender_name")
or item.get("senderName")
or item.get("SenderName")
or item.get("sender")
or item.get("Sender")
or ""
)
content = _message_text(item)
if not content:
return None
return int(seq), f"[{time_str}] {sender}: {content}"
def _message_meta(item: dict, fallback_seq: int = 0) -> dict:
return {
"seq": int(item.get("seq") or item.get("Seq") or item.get("sort_seq") or fallback_seq or 0),
"time": item.get("create_time") or item.get("time") or item.get("CreateTime") or "",
"sender": (
item.get("sender_name")
or item.get("senderName")
or item.get("SenderName")
or item.get("sender")
or item.get("Sender")
or ""
),
"type": item.get("type") or item.get("Type") or 1,
}
def _extract_contents(item: dict) -> dict:
return extract_contents(item)
def _message_text(item: dict) -> str:
content = item.get("content") or item.get("Content") or ""
contents = _extract_contents(item)
if isinstance(content, str) and content.lstrip().startswith("<") and extract_quote(item):
content = ""
link_title = contents.get("title") or item.get("link_title") or ""
link_desc = contents.get("desc") or item.get("link_desc") or ""
link_source = contents.get("sourceName") or contents.get("source_name") or item.get("link_source") or ""
link_url = contents.get("url") or item.get("link_url") or ""
if link_title:
parts = [f"[链接卡片] {link_title}"]
if link_desc:
parts.append(link_desc)
if link_source:
parts.append(f"来源:{link_source}")
if link_url:
parts.append(f"URL{link_url}")
if content and content not in parts:
parts.append(content)
return append_quote_text("".join(parts), item)
return append_quote_text(content, item)
def _extract_image_key(item: dict) -> str:
contents = _extract_contents(item)
key = (
contents.get("rawmd5")
or contents.get("md5")
or contents.get("path")
or item.get("media_key")
or item.get("mediaKey")
or item.get("image_path")
or ""
)
return str(key).replace("\\", "/")
def _is_image_message(item: dict) -> bool:
try:
return int(item.get("type") or item.get("Type") or 0) == 3
except Exception:
return False
def _media_path(kind: str, key: str) -> str:
return f"/{kind}/" + "/".join(quote(part) for part in key.split("/"))
def _image_url(key: str) -> str:
return f"{_media_path('image', key)}?thumb=1"
def _collect_image_evidence(messages: list[dict]) -> tuple[list[dict], list[dict]]:
images: list[dict] = []
failures: list[dict] = []
for item in messages:
if not _is_image_message(item):
continue
meta = _message_meta(item)
key = _extract_image_key(item)
if not key:
failures.append({**meta, "url": "", "reason": "图片无法展示,缺少图片文件标识"})
continue
url = _image_url(key)
images.append({**meta, "key": key, "url": url})
return images, failures
def _image_evidence_context(images: list[dict], failures: list[dict]) -> str:
lines: list[str] = []
if images:
lines.append("系统将作为原始材料插入报告的现场图片:")
for img in images:
lines.append(f"- [{img['time']}] {img['sender']} seq={img['seq']} url={img['url']}")
if failures:
lines.append("无法展示的图片清单:")
for img in failures:
link = f",查看图片:{img['url']}" if img.get("url") else ""
lines.append(f"- [{img['time']}] {img['sender']} seq={img['seq']}{img['reason']}{link}")
return "\n".join(lines)
def _image_success_markdown(images: list[dict]) -> str:
if not images:
return ""
blocks = ["### 现场图片"]
for img in images:
alt = f"现场图片 - {img['time']} {img['sender']}".strip()
blocks.extend(
[
f"![{alt}]({img['url']})",
f"来源:{img['time']} {img['sender']} seq={img['seq']}",
"",
]
)
return "\n".join(blocks).strip()
def _image_failure_markdown(failures: list[dict]) -> str:
if not failures:
return ""
lines = ["## 图片展示提示"]
for img in failures:
link = f",查看图片:{img['url']}" if img.get("url") else ""
lines.append(f"- [{img['time']}] {img['sender']} seq={img['seq']}{img['reason']}{link}")
return "\n".join(lines)
def _insert_after_heading(content: str, heading: str, addition: str) -> str:
if not addition:
return content
lines = content.splitlines()
for i, line in enumerate(lines):
if line.strip() == heading:
return "\n".join(lines[: i + 1] + ["", addition, ""] + lines[i + 1 :]).strip()
for i, line in enumerate(lines):
if line.startswith("# "):
return "\n".join(lines[: i + 1] + ["", heading, "", addition, ""] + lines[i + 1 :]).strip()
return f"{heading}\n\n{addition}\n\n{content}".strip()
def _merge_image_sections(content: str, successes: list[dict], failures: list[dict]) -> str:
result = _insert_after_heading(content, "## 关键聊天依据", _image_success_markdown(successes))
failure_md = _image_failure_markdown(failures)
if failure_md:
result = f"{result.rstrip()}\n\n{failure_md}"
return result.strip()
def _line_from_snapshot(raw: str | None, fallback_seq: int) -> str | None:
if not raw:
return None
try:
item = json.loads(raw)
except Exception:
return None
line = _message_line(item, fallback_seq)
return line[1] if line else None
MARKDOWN_TEMPLATE = """\
# {title}
请按聊天记录中的实际内容生成一份【具体售后问题点】报告,不要照抄固定字段,也不要输出占位文案。
必须围绕以下结构组织,按内容决定是否保留章节,不要输出空章节:
## 问题摘要
## 关键聊天依据
## 当前处理状态
## 是否解决
## AI 建议/解决方法
输出规则:
- 只写聊天记录中能直接识别或合理归纳的信息。
- 没有识别到的客户、门店、联系人、合同、订单、物流、日期、价格、原因等信息直接省略。
- 不要写“未从聊天记录中识别”“待补充”“未知”“无”等占位内容。
- “是否解决”只能从聊天记录判断,取值限定为:已解决、未解决、处理中、待确认。
- 如果聊天内容不足以形成明确售后问题点,仍然按当前话题内容整理,但用更保守的“待确认”结论。
- “AI 建议/解决方法”必须放在文档下方,并附注:注:此方法由 AI 生成,仅供参考,请以人工复核和现场实际情况为准。
- 只输出 Markdown 报告,不要输出这些规则本身。
"""
async def _mark_summarize_failed(topic_id: int, task_id: int | None, error: str):
path = get_active_db_path()
message = error or "AI 报告生成失败"
try:
async with aiosqlite.connect(path) as db:
await db.execute(
"UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(topic_id,),
)
if task_id is not None:
await db.execute(
"""
UPDATE ai_tasks
SET status='error', progress=?, error=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
""",
(json.dumps({"processed": 0, "total": 1}), message, task_id),
)
await db.commit()
except Exception as exc:
log.warning(f"[summarize] 标记失败状态失败 topic={topic_id} task={task_id}: {exc}")
async def _run_summarize_impl(topic_id: int, topic: dict, task_id: int | None = None):
"""
为指定话题生成/更新 Markdown 售后事件报告。
由 POST /api/topics/{id}/summarize手动触发调用。
task_id: 若提供,则更新 ai_tasks 表的状态和进度。
"""
path = get_active_db_path()
async def _update_task(status: str, processed: int = 0, total: int = 1, error: str = ""):
"""辅助函数:更新 ai_tasks 状态和进度"""
if task_id is None:
return
try:
async with aiosqlite.connect(path) as _db:
_db.row_factory = aiosqlite.Row
await _db.execute(
"""
UPDATE ai_tasks
SET status=?, progress=?, error=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
""",
(status, json.dumps({"processed": processed, "total": total}), error or None, task_id)
)
await _db.commit()
except Exception as e:
log.warning(f"[summarize] 更新 task {task_id} 失败: {e}")
path = get_active_db_path()
async with aiosqlite.connect(path) as db:
db.row_factory = aiosqlite.Row
# 将话题状态置为 processing
await db.execute("UPDATE topics SET status = 'processing', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
await db.commit()
await _update_task("running", 0, 1)
# 1. 拿到该话题的所有消息 seq 和群 talker
async with db.execute(
"""
SELECT tm.msg_seq, tm.talker, tm.message_json
FROM topic_messages tm
WHERE tm.topic_id = ?
ORDER BY tm.msg_seq
""",
(topic_id,),
) as cur:
msg_rows = await cur.fetchall()
if not msg_rows:
log.warning(f"[summarize] topic={topic_id} 没有消息,跳过")
error = "该话题没有关联消息,无法生成 AI 报告"
await db.execute("UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
await db.commit()
await _update_task("error", 0, 1, error)
return
seqs = [r["msg_seq"] for r in msg_rows]
# talker 在 topic_messages 里存的是群 IDchatlog 叫 talker
group_talker = msg_rows[0]["talker"]
# 2. 批量从 chatlog 拉取消息原文(最多 100 条/批)
from services.chatlog_client import chatlog_client
messages_text: list[str] = []
message_items: dict[int, dict] = {}
fetched_lines: dict[int, str] = {}
for i in range(0, len(seqs), CHATLOG_BATCH_SIZE):
chunk_seqs = seqs[i: i + CHATLOG_BATCH_SIZE]
try:
result = await chatlog_client.get_messages_batch(group_talker, chunk_seqs)
for m in result.get("items", []):
meta = _message_meta(m)
if meta["seq"]:
message_items[meta["seq"]] = m
line = _message_line(m)
if line:
fetched_lines[line[0]] = line[1]
except Exception as e:
log.error(f"[summarize] batch 拉取失败 topic={topic_id}: {e}")
for r in msg_rows:
seq = int(r["msg_seq"])
if seq in fetched_lines:
messages_text.append(fetched_lines[seq])
continue
snap_raw = r["message_json"] if "message_json" in r.keys() else None
if seq not in message_items and snap_raw:
try:
snap_item = json.loads(snap_raw)
if isinstance(snap_item, dict):
message_items[seq] = snap_item
except Exception:
pass
snap_line = _line_from_snapshot(snap_raw, seq)
if snap_line:
messages_text.append(snap_line)
image_successes, image_failures = _collect_image_evidence(
[message_items[seq] for seq in seqs if seq in message_items]
)
if not messages_text and not image_successes and not image_failures:
log.warning(f"[summarize] topic={topic_id} 从 chatlog 获取到 0 条有效消息")
error = "未能从 chatlog 获取到有效消息,无法生成 AI 报告"
await db.execute("UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
await db.commit()
await _update_task("error", 0, 1, error)
return
chat_text = "\n".join(messages_text) if messages_text else "无文字消息,仅有图片或媒体证据。"
image_context = _image_evidence_context(image_successes, image_failures)
learning_context = await build_report_learning_context(
db,
group_id=topic.get("group_id"),
query=f"{topic.get('title', '')}\n{chat_text[:2000]}",
exclude_topic_id=topic_id,
purpose="summary",
)
# 3. 构建 Prompt
template_filled = MARKDOWN_TEMPLATE.format(title=topic["title"])
prompt = (
f"售后问题点话题:{topic['title']}\n\n"
f"以下是该售后问题点关联的完整微信群聊天记录(按时间顺序):\n\n"
f"{chat_text}\n\n"
f"以下是系统将插入报告的现场图片信息(如有):\n\n{image_context or '无现场图片。'}\n\n"
"请根据上述聊天记录输出一份 Markdown 报告。\n"
"报告要求:\n"
"1. 保持售后问题点口径,优先提炼问题现象、涉及产品/部件、现场材料、处理过程和处理结果。\n"
"2. 只能使用聊天记录中能直接识别或合理归纳的信息,不要编造客户、合同、订单、物流、日期、价格、原因或处理结果。\n"
"3. 不要输出空字段、空项目、空章节、空表格;某个章节没有有效内容时整段省略。\n"
"4. 「是否解决」必须写在文档中,并使用:已解决 / 未解决 / 处理中 / 待确认。\n"
"5. 「AI 建议/解决方法」必须写在文档中,且在段末附上固定注释:注:此方法由 AI 生成,仅供参考,请以人工复核和现场实际情况为准。\n"
"6. 如果聊天内容不足以形成明确售后问题点,也不要编造结论;只按聊天中已有事实给出保守的待确认判断。\n"
"7. 图片会由系统作为「现场图片」原始材料插入「关键聊天依据」;你不要猜测图片内容,也不要自行输出图片 Markdown 或图片说明。\n"
"8. 如果聊天文字中有人描述图片内容,可以引用这些文字;但不要根据图片本身编造故障细节。\n"
"9. 聊天记录中的「[引用消息]」属于当前回复的上下文证据,可以用于理解被回复的问题和处理过程。\n"
"10. 只输出 Markdown 报告,不要输出模板说明或额外解释。\n\n"
f"以下是本企业报告库中人工修订过的历史报告示例(如有)。请只学习它们的栏目结构、措辞风格、问题关注点和结论表达方式;不得复制历史事实、客户名、设备状态或处理结果到当前报告:\n\n{learning_context or '暂无可学习的人工修订报告。'}\n\n"
f"{template_filled}"
)
# 4. 调用 LLM
try:
_client, _ai = await _get_client()
async with asyncio.timeout(SUMMARY_LLM_TIMEOUT_SECONDS):
resp = await _client.chat.completions.create(
model=_ai["summary_model"],
messages=[
{
"role": "system",
"content": (
"你是资深售后运营与设备服务工程师,负责根据微信群聊天记录整理具体售后问题点报告。"
"你必须忠实依据聊天记录,只输出已识别到的有效信息,缺失信息直接省略,不得编造。"
"你要在文档中明确给出是否解决结论,并给出 AI 建议/解决方法和免责声明。只输出 Markdown 报告,不要有任何额外说明。"
),
},
{"role": "user", "content": prompt},
],
temperature=0.2,
)
content = resp.choices[0].message.content.strip()
content = _merge_image_sections(content, image_successes, image_failures)
except TimeoutError:
error = "AI 报告生成超时,请检查模型/API或稍后重试"
log.error(f"[summarize] LLM 调用超时 topic={topic_id}")
await db.execute("UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
await db.commit()
await _update_task("error", 0, 1, error)
return
except Exception as e:
log.error(f"[summarize] LLM 调用失败 topic={topic_id}: {e}", exc_info=True)
await db.execute("UPDATE topics SET status = 'error', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
await db.commit()
await _update_task("error", 0, 1, str(e) or "LLM 调用失败")
return
# 5. 写入 knowledge_docs
async with db.execute(
"SELECT id FROM knowledge_docs WHERE topic_id = ?", (topic_id,)
) as cur:
existing = await cur.fetchone()
if existing:
doc_id = existing["id"]
await db.execute(
"UPDATE knowledge_docs SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(content, doc_id),
)
else:
await db.execute(
"INSERT INTO knowledge_docs (topic_id, content) VALUES (?, ?)",
(topic_id, content),
)
async with db.execute("SELECT last_insert_rowid() AS id") as cur:
doc_id = (await cur.fetchone())["id"]
# 6. 更新 FTS先删后插
await db.execute("DELETE FROM knowledge_fts WHERE doc_id = ?", (doc_id,))
await db.execute(
"INSERT INTO knowledge_fts (doc_id, title, content) VALUES (?, ?, ?)",
(doc_id, tokenize(topic["title"]), tokenize(content)),
)
await db.execute("UPDATE topics SET status = 'completed', updated_at = CURRENT_TIMESTAMP WHERE id = ?", (topic_id,))
await db.commit()
await _update_task("done", 1, 1)
log.info(f"[summarize] topic={topic_id} doc={doc_id} 生成完成({len(content)} 字符)")
async def run_summarize(topic_id: int, topic: dict, task_id: int | None = None):
try:
await _run_summarize_impl(topic_id, topic, task_id)
except Exception as e:
error = str(e) or e.__class__.__name__
log.error(f"[summarize] 未捕获异常 topic={topic_id}: {error}", exc_info=True)
await _mark_summarize_failed(topic_id, task_id, error)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
const path = require('path');
function envText(name) {
const value = process.env[name];
if (value == null) return '';
return String(value).trim();
}
function envFlag(name) {
return /^(1|true|yes|y|on)$/i.test(envText(name));
}
const pfxFile = envText('CHATLAB_PFX_FILE');
const pfxPassword = envText('CHATLAB_PFX_PASSWORD');
const publisherName = envText('CHATLAB_CERT_PUBLISHER_NAME');
const forceSigning = envFlag('CHATLAB_FORCE_SIGN');
const timestampServer = envText('CHATLAB_TIMESTAMP_SERVER') || 'http://timestamp.digicert.com';
const shouldSign = Boolean(pfxFile);
const buildLabel = envText('CHATLAB_BUILD_LABEL')
|| new Date().toISOString().replace(/[-:T]/g, '').slice(0, 12);
if (forceSigning && !shouldSign) {
throw new Error('CHATLAB_FORCE_SIGN=1 requires CHATLAB_PFX_FILE to point to a .pfx/.p12 code signing certificate.');
}
if (pfxPassword) {
process.env.WIN_CSC_KEY_PASSWORD = pfxPassword;
process.env.CSC_KEY_PASSWORD = pfxPassword;
}
const win = {
target: 'nsis',
icon: 'build/icon.ico',
artifactName: `ChatLab-Setup-\${version}-${buildLabel}.\${ext}`,
signAndEditExecutable: shouldSign,
forceCodeSigning: forceSigning,
};
if (publisherName) {
win.publisherName = publisherName;
}
if (shouldSign) {
win.signtoolOptions = {
certificateFile: pfxFile,
signingHashAlgorithms: ['sha256'],
rfc3161TimeStampServer: timestampServer,
};
if (publisherName) {
win.signtoolOptions.publisherName = publisherName;
}
}
module.exports = {
appId: 'com.chatlab.desktop',
productName: 'ChatLab售后智能助手',
icon: 'build/icon.ico',
directories: {
output: 'dist',
},
files: [
'main.js',
'preload.js',
'index.html',
'build/company-logo.jpg',
'build/icon.ico',
'build/icon.png',
'package.json',
],
extraResources: [
{
from: path.join(__dirname, 'build-resources'),
to: '.',
filter: [
'**/*',
'!**/.env',
'!**/knowledge*.db',
'!**/__pycache__/**',
'!**/*.pfx',
'!**/*.p12',
'!**/*.pvk',
'!**/*.cer',
'!**/*.crt',
'!**/*.key',
'!**/certs/**',
],
},
],
win,
nsis: {
oneClick: false,
installerIcon: 'build/icon.ico',
uninstallerIcon: 'build/icon.ico',
allowToChangeInstallationDirectory: true,
perMachine: false,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'ChatLab售后智能助手',
},
};

View File

@@ -0,0 +1,967 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微信知识库 - 服务控制台</title>
<style>
:root {
--bg: #030405;
--panel: rgba(20, 22, 26, 0.78);
--panel-strong: rgba(28, 30, 36, 0.92);
--line: rgba(255, 255, 255, 0.1);
--line-strong: rgba(255, 255, 255, 0.18);
--text: #f3f7fb;
--muted: #9aa5b1;
--subtle: #5f6875;
--cyan: #20d3d2;
--blue: #7ca5ff;
--green: #2cd889;
--red: #ff6c91;
--warning: #ffd166;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
--robot-x: 0deg;
--robot-y: 0deg;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
}
body {
min-width: 1180px;
min-height: 760px;
}
button {
border: 0;
border-radius: 10px;
color: #06100f;
cursor: pointer;
font: inherit;
font-weight: 700;
transition: transform 0.18s ease, opacity 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.42;
transform: none;
}
.launcher {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at 52% 48%, rgba(255, 255, 255, 0.08), transparent 22%),
radial-gradient(circle at 75% 35%, rgba(32, 211, 210, 0.08), transparent 28%),
linear-gradient(180deg, #050607 0%, #020303 100%);
}
.launcher::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: linear-gradient(to bottom, rgba(0,0,0,0.45), transparent 68%);
pointer-events: none;
}
.launcher::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(0,0,0,0.58), transparent 38%, rgba(0,0,0,0.62)),
linear-gradient(180deg, rgba(0,0,0,0.1), rgba(0,0,0,0.78));
pointer-events: none;
}
.brand {
position: absolute;
top: 32px;
left: 36px;
z-index: 5;
display: flex;
align-items: center;
gap: 12px;
color: rgba(255,255,255,0.86);
font-size: 20px;
font-weight: 800;
}
.brand img {
width: 58px;
height: 36px;
object-fit: contain;
border-radius: 4px;
}
.brand span {
text-shadow: 0 0 24px rgba(32, 211, 210, 0.24);
}
.copy {
position: absolute;
z-index: 4;
left: 36px;
top: 320px;
width: 820px;
}
.copy h1 {
margin: 0;
font-size: 46px;
line-height: 1.18;
font-weight: 900;
white-space: nowrap;
color: transparent;
background: linear-gradient(90deg, #8d8cff 0%, #42b9e8 38%, #22d0b0 78%);
-webkit-background-clip: text;
background-clip: text;
text-shadow: 0 22px 60px rgba(20, 150, 165, 0.26);
}
.copy p {
margin: 24px 0 0;
color: rgba(255,255,255,0.66);
font-size: 19px;
font-style: italic;
font-weight: 700;
}
.robot-stage {
position: absolute;
z-index: 3;
left: 50%;
top: 53%;
width: 560px;
height: 660px;
transform: translate(-50%, -50%);
pointer-events: none;
filter: drop-shadow(0 48px 55px rgba(0,0,0,0.78));
}
.robot {
width: 100%;
height: 100%;
overflow: visible;
}
.robot-head {
transform-box: fill-box;
transform-origin: center;
transition: transform 0.12s ease-out;
}
.robot-left-arm {
transform-origin: 206px 277px;
animation: armFloatLeft 3.4s ease-in-out infinite;
}
.robot-right-arm {
transform-origin: 354px 277px;
animation: armFloatRight 3.2s ease-in-out infinite;
}
@keyframes armFloatLeft {
0%, 100% { transform: translateY(0) rotate(-3deg); }
50% { transform: translateY(-18px) rotate(4deg); }
}
@keyframes armFloatRight {
0%, 100% { transform: translateY(-4px) rotate(3deg); }
50% { transform: translateY(16px) rotate(-4deg); }
}
.control-panel {
position: absolute;
z-index: 4;
right: 72px;
top: 105px;
width: 420px;
padding: 26px;
border: 1px solid var(--line-strong);
border-radius: 24px;
background: linear-gradient(180deg, rgba(27,29,34,0.94), rgba(15,16,19,0.9));
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.panel-title {
margin-bottom: 18px;
text-align: center;
}
.panel-title strong {
display: block;
color: #f6f8fb;
font-size: 28px;
line-height: 1.2;
}
.panel-title span {
display: block;
margin-top: 8px;
color: var(--muted);
font-size: 14px;
}
.service-list {
display: grid;
gap: 10px;
}
.service-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(0,0,0,0.24);
}
.service-name {
color: var(--text);
font-size: 15px;
font-weight: 800;
}
.status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 16px rgba(255,108,145,0.45);
}
.dot.active {
background: var(--green);
box-shadow: 0 0 18px rgba(44,216,137,0.65);
}
.service-actions {
display: flex;
gap: 8px;
}
.btn-start,
.btn-stop {
width: 48px;
height: 34px;
font-size: 13px;
}
.btn-start {
background: var(--green);
}
.btn-stop {
background: var(--red);
}
.action-row {
display: grid;
gap: 10px;
margin-top: 18px;
}
.btn-open,
.btn-refresh-account {
width: 100%;
min-height: 50px;
padding: 0 18px;
font-size: 17px;
}
.btn-open {
background: linear-gradient(90deg, #7d94f3, #20d3d2, #2ba35f);
color: #fff;
box-shadow: 0 16px 30px rgba(32, 211, 210, 0.18);
}
.btn-open.ready {
animation: readyPulse 1.8s ease-in-out infinite;
}
.btn-refresh-account {
background: rgba(255,255,255,0.1);
color: #dbe3ee;
border: 1px solid var(--line);
}
@keyframes readyPulse {
0%, 100% { box-shadow: 0 0 0 rgba(44,216,137,0.0), 0 16px 30px rgba(32,211,210,0.18); }
50% { box-shadow: 0 0 28px rgba(44,216,137,0.34), 0 16px 30px rgba(32,211,210,0.18); }
}
.account-refresh-status {
min-height: 20px;
margin-top: 12px;
color: var(--subtle);
font-size: 12px;
line-height: 1.5;
}
.account-refresh-status.success {
color: var(--green);
}
.account-refresh-status.error {
color: var(--red);
}
.decrypt-bar-wrap {
display: none;
margin-top: 18px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(0,0,0,0.25);
}
.decrypt-bar-wrap.show {
display: block;
}
.decrypt-label {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 9px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.decrypt-track {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: rgba(255,255,255,0.1);
}
.decrypt-fill {
width: 35%;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--blue), var(--cyan));
animation: slide 1.2s ease-in-out infinite;
}
.decrypt-fill.done {
width: 100%;
background: var(--green);
animation: none;
}
@keyframes slide {
0% { transform: translateX(-105%); }
100% { transform: translateX(310%); }
}
.log-panel {
position: absolute;
z-index: 4;
left: 36px;
right: 36px;
bottom: 28px;
height: 170px;
}
.log-title {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
color: #e7ecff;
font-size: 18px;
font-weight: 900;
}
.log-title::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--cyan);
box-shadow: 0 0 16px rgba(32,211,210,0.8);
}
.logs {
height: calc(100% - 32px);
overflow-y: auto;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(6, 7, 11, 0.84);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.02);
color: #b9c4d4;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.log-entry {
margin-bottom: 4px;
}
.log-source {
color: var(--cyan);
font-weight: 800;
}
#close-warning {
display: none;
position: absolute;
z-index: 30;
top: 24px;
left: 50%;
transform: translateX(-50%);
padding: 12px 18px;
border: 1px solid rgba(255,108,145,0.4);
border-radius: 12px;
background: rgba(255,108,145,0.92);
color: #120407;
font-weight: 900;
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
}
.modal-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 100;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(8px);
}
.modal-overlay.show {
display: flex;
}
.modal-box {
width: min(720px, 88vw);
padding: 34px 38px;
border: 1px solid var(--line-strong);
border-radius: 22px;
background: linear-gradient(180deg, rgba(35,37,44,0.96), rgba(18,19,24,0.96));
box-shadow: var(--shadow);
}
.modal-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
color: #9fc0ff;
font-size: 22px;
font-weight: 900;
}
.modal-body {
color: #d7ddeb;
font-size: 15px;
line-height: 1.8;
}
.modal-steps {
display: grid;
gap: 12px;
margin: 18px 0;
padding: 0;
list-style: none;
counter-reset: step;
}
.modal-steps li {
display: flex;
gap: 12px;
align-items: center;
counter-increment: step;
}
.modal-steps li::before {
content: counter(step);
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: #9fc0ff;
color: #07101f;
font-size: 13px;
font-weight: 900;
flex-shrink: 0;
}
.modal-body .tip {
margin-top: 14px;
padding: 12px 14px;
border-left: 3px solid var(--cyan);
border-radius: 8px;
background: rgba(0,0,0,0.28);
color: #d0b8ff;
font-size: 13px;
}
.modal-footer {
display: flex;
align-items: center;
gap: 10px;
margin-top: 22px;
color: var(--muted);
font-size: 13px;
}
.modal-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.18);
border-top-color: var(--cyan);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="launcher" id="launcher">
<div id="close-warning">请先停止所有运行中的服务再退出。</div>
<div class="brand">
<img src="build/company-logo.jpg" alt="">
<span>灵泽万川 ChatLab</span>
</div>
<section class="copy" aria-label="启动页介绍">
<h1>智能售后知识库服务控制台</h1>
<p>Next-Generation Intelligent Service Knowledge Platform</p>
</section>
<section class="robot-stage" aria-hidden="true">
<svg class="robot" viewBox="0 0 560 660" role="img">
<defs>
<radialGradient id="metal" cx="38%" cy="22%" r="75%">
<stop offset="0%" stop-color="#f4f7fb"/>
<stop offset="18%" stop-color="#8e949c"/>
<stop offset="46%" stop-color="#20242a"/>
<stop offset="100%" stop-color="#050607"/>
</radialGradient>
<linearGradient id="body" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#5c626c"/>
<stop offset="35%" stop-color="#111419"/>
<stop offset="100%" stop-color="#020304"/>
</linearGradient>
<linearGradient id="edge" x1="0" x2="1">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="45%" stop-color="#252a31"/>
<stop offset="100%" stop-color="#000000"/>
</linearGradient>
<filter id="softGlow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="9" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<ellipse cx="280" cy="612" rx="145" ry="26" fill="rgba(0,0,0,0.75)"/>
<g class="robot-left-arm">
<path d="M210 282 C158 272 126 310 120 370" stroke="url(#edge)" stroke-width="46" stroke-linecap="round" fill="none"/>
<ellipse cx="116" cy="384" rx="48" ry="40" fill="url(#metal)" transform="rotate(-15 116 384)"/>
<path d="M85 386 C103 403 132 404 150 384" stroke="#070809" stroke-width="10" stroke-linecap="round" fill="none"/>
</g>
<g class="robot-right-arm">
<path d="M350 282 C402 272 434 310 440 370" stroke="url(#edge)" stroke-width="46" stroke-linecap="round" fill="none"/>
<ellipse cx="444" cy="384" rx="48" ry="40" fill="url(#metal)" transform="rotate(15 444 384)"/>
<path d="M410 384 C428 404 457 402 475 386" stroke="#070809" stroke-width="10" stroke-linecap="round" fill="none"/>
</g>
<path d="M232 248 L328 248 C355 294 362 394 338 482 C322 515 238 515 222 482 C198 394 205 294 232 248Z" fill="url(#body)"/>
<path d="M248 508 L236 610" stroke="url(#edge)" stroke-width="24" stroke-linecap="round"/>
<path d="M312 508 L324 610" stroke="url(#edge)" stroke-width="24" stroke-linecap="round"/>
<ellipse cx="230" cy="622" rx="44" ry="18" fill="url(#metal)"/>
<ellipse cx="330" cy="622" rx="44" ry="18" fill="url(#metal)"/>
<path d="M248 230 L246 198 L314 198 L312 230" stroke="url(#edge)" stroke-width="22" stroke-linecap="round" fill="none"/>
<g class="robot-head">
<ellipse cx="280" cy="132" rx="78" ry="88" fill="url(#metal)"/>
<ellipse cx="280" cy="139" rx="57" ry="65" fill="#030405"/>
<path d="M222 106 C245 58 315 58 338 106 C325 88 236 88 222 106Z" fill="rgba(255,255,255,0.78)"/>
<g filter="url(#softGlow)" fill="#d9f7ff">
<circle cx="254" cy="136" r="2.2"/>
<circle cx="260" cy="136" r="2.2"/>
<circle cx="266" cy="136" r="2.2"/>
<circle cx="254" cy="143" r="2.2"/>
<circle cx="260" cy="143" r="2.2"/>
<circle cx="266" cy="143" r="2.2"/>
<circle cx="294" cy="136" r="2.2"/>
<circle cx="300" cy="136" r="2.2"/>
<circle cx="306" cy="136" r="2.2"/>
<circle cx="294" cy="143" r="2.2"/>
<circle cx="300" cy="143" r="2.2"/>
<circle cx="306" cy="143" r="2.2"/>
</g>
</g>
</svg>
</section>
<aside class="control-panel" aria-label="服务控制面板">
<div class="panel-title">
<strong>服务控制台</strong>
<span>启动本地服务并进入售后知识库</span>
</div>
<div class="service-list">
<div class="service-row" id="card-chatlog">
<div>
<div class="service-name">底层服务 (Go)</div>
<div class="status"><div class="dot" id="dot-chatlog"></div><span id="text-chatlog">未运行</span></div>
</div>
<div class="service-actions">
<button class="btn-start" onclick="window.electronAPI.startChatlog()">启动</button>
<button class="btn-stop" onclick="window.electronAPI.stopChatlog()">停止</button>
</div>
</div>
<div class="service-row" id="card-fastapi">
<div>
<div class="service-name">业务层 (Python)</div>
<div class="status"><div class="dot" id="dot-fastapi"></div><span id="text-fastapi">未运行</span></div>
</div>
<div class="service-actions">
<button class="btn-start" onclick="window.electronAPI.startFastapi()">启动</button>
<button class="btn-stop" onclick="window.electronAPI.stopFastapi()">停止</button>
</div>
</div>
<div class="service-row" id="card-frontend">
<div>
<div class="service-name">UI 界面 (React)</div>
<div class="status"><div class="dot" id="dot-frontend"></div><span id="text-frontend">未运行</span></div>
</div>
<div class="service-actions">
<button class="btn-start" onclick="window.electronAPI.startFrontend()">启动</button>
<button class="btn-stop" onclick="window.electronAPI.stopFrontend()">停止</button>
</div>
</div>
</div>
<div class="decrypt-bar-wrap" id="decrypt-wrap">
<div class="decrypt-label">
<span id="decrypt-text">数据库解密中...</span>
<span id="decrypt-elapsed"></span>
</div>
<div class="decrypt-track"><div class="decrypt-fill" id="decrypt-fill"></div></div>
</div>
<div class="action-row">
<button class="btn-open" id="btn-enter">启动并进入系统</button>
<button class="btn-refresh-account" id="btn-refresh-account">重新识别当前微信账号</button>
</div>
<div class="account-refresh-status" id="account-refresh-status"></div>
</aside>
<section class="log-panel" aria-label="终端运行日志">
<h2 class="log-title">终端运行日志</h2>
<div class="logs" id="logs-container"></div>
</section>
<div class="modal-overlay" id="decrypt-dialog">
<div class="modal-box">
<div class="modal-title">检测到新账号,正在解密数据</div>
<div class="modal-body">
系统正在为此微信账号首次解密数据库和图片,同时已开启自动解密。
<ol class="modal-steps">
<li>请切换到微信窗口</li>
<li>随意点击任意一个聊天对话</li>
<li>翻看一下历史消息记录</li>
<li>等待本窗口自动关闭即可</li>
</ol>
<div class="tip">这与您在 chatlog.exe 界面中手动解密的操作完全相同,只需配合操作微信一次,后续将全程自动化。</div>
</div>
<div class="modal-footer">
<div class="modal-spinner"></div>
<span id="decrypt-dialog-status">解密进行中,请在微信里随意点击一个聊天窗口...</span>
<span id="decrypt-dialog-elapsed" style="margin-left:auto;"></span>
</div>
</div>
</div>
</div>
<script>
const logsContainer = document.getElementById('logs-container');
const btnEnter = document.getElementById('btn-enter');
const btnRefreshAccount = document.getElementById('btn-refresh-account');
const accountRefreshStatus = document.getElementById('account-refresh-status');
const decryptWrap = document.getElementById('decrypt-wrap');
const decryptText = document.getElementById('decrypt-text');
const decryptElapsed = document.getElementById('decrypt-elapsed');
const decryptFill = document.getElementById('decrypt-fill');
const decryptDialog = document.getElementById('decrypt-dialog');
const decryptDialogStatus = document.getElementById('decrypt-dialog-status');
const decryptDialogElapsed = document.getElementById('decrypt-dialog-elapsed');
const launcher = document.getElementById('launcher');
const robotHead = document.querySelector('.robot-head');
let decryptReady = false;
let frontendRunning = false;
let startingAll = false;
let refreshingAccount = false;
launcher.addEventListener('mousemove', (event) => {
const rect = launcher.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
const y = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
const clampedX = Math.max(-1, Math.min(1, x));
const clampedY = Math.max(-1, Math.min(1, y));
robotHead.style.transform = `translate(${clampedX * 9}px, ${clampedY * 6}px) rotate(${clampedX * 7}deg)`;
});
launcher.addEventListener('mouseleave', () => {
robotHead.style.transform = 'translate(0, 0) rotate(0deg)';
});
function setRefreshStatus(text, type = '') {
accountRefreshStatus.textContent = text || '';
accountRefreshStatus.className = `account-refresh-status${type ? ` ${type}` : ''}`;
}
function setRefreshButtonBusy(busy) {
refreshingAccount = busy;
btnRefreshAccount.disabled = busy;
btnRefreshAccount.textContent = busy ? '识别中...' : '重新识别当前微信账号';
}
function updateEnterButton() {
if (decryptReady) {
btnEnter.disabled = false;
btnEnter.className = 'btn-open ready';
btnEnter.textContent = '进入系统界面';
btnEnter.onclick = () => window.electronAPI.openInApp();
}
}
function setServiceUI(key, running) {
const dot = document.getElementById(`dot-${key}`);
const text = document.getElementById(`text-${key}`);
if (!dot || !text) return;
if (running) {
dot.classList.add('active');
text.innerText = '运行中';
text.style.color = 'var(--green)';
} else {
dot.classList.remove('active');
text.innerText = '未运行';
text.style.color = 'var(--muted)';
}
if (key === 'frontend') frontendRunning = running;
}
function appendLog(source, text) {
const div = document.createElement('div');
div.className = 'log-entry';
div.innerHTML = `<span class="log-source">[${source}]</span> ${String(text || '').trim()}`;
logsContainer.appendChild(div);
logsContainer.scrollTop = logsContainer.scrollHeight;
}
window.electronAPI.getProcessStatus().then((status) => {
['chatlog', 'fastapi', 'frontend'].forEach((key) => setServiceUI(key, Boolean(status[key])));
if (status.chatlog) {
fetch('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1')
.then((r) => r.json())
.then((data) => {
if (!data.error || !data.error.includes('decrypting')) {
decryptReady = true;
decryptWrap.classList.remove('show');
updateEnterButton();
} else {
decryptWrap.classList.add('show');
decryptText.textContent = '数据库解密中...';
}
})
.catch(() => {
decryptWrap.classList.add('show');
decryptText.textContent = '等待底层服务就绪...';
});
}
});
btnEnter.onclick = async () => {
if (startingAll || refreshingAccount) return;
startingAll = true;
btnEnter.disabled = true;
btnEnter.className = 'btn-open';
btnEnter.textContent = '正在启动本地服务...';
try {
await window.electronAPI.startAll();
} catch (e) {
btnEnter.disabled = false;
btnEnter.textContent = '启动并进入系统';
appendLog('App(Error)', e?.message || '启动失败');
} finally {
startingAll = false;
}
};
btnRefreshAccount.onclick = async () => {
if (refreshingAccount || startingAll) return;
setRefreshButtonBusy(true);
decryptReady = false;
btnEnter.disabled = true;
btnEnter.className = 'btn-open';
btnEnter.textContent = '正在重新识别账号...';
setRefreshStatus('正在重新识别当前微信账号,并重建消息索引...');
try {
const result = await window.electronAPI.refreshCurrentAccount();
decryptReady = Boolean(result?.messageIndexReady);
if (decryptReady) {
const removed = Number(result?.removedMessageCache || 0);
setRefreshStatus(removed > 0 ? `已重新识别并重建消息索引,清理了 ${removed} 个消息缓存。` : '已重新识别当前微信账号,消息索引已就绪。', 'success');
updateEnterButton();
} else {
btnEnter.disabled = true;
btnEnter.className = 'btn-open';
btnEnter.textContent = '消息索引未就绪,请重试重新识别';
setRefreshStatus('重新识别已完成,但消息索引仍未就绪。请在微信打开聊天窗口翻看历史消息后重试。', 'error');
}
} catch (e) {
decryptReady = false;
btnEnter.disabled = false;
btnEnter.className = 'btn-open';
btnEnter.textContent = '启动并进入系统';
setRefreshStatus(e?.message || '重新识别失败,请确认 PC 微信已登录。', 'error');
appendLog('App(Error)', e?.message || '重新识别失败');
} finally {
setRefreshButtonBusy(false);
}
};
window.electronAPI.onLog((data) => {
appendLog(data.source, data.text);
});
window.electronAPI.onProcessError?.((data) => {
decryptDialog.classList.remove('show');
decryptDialogElapsed.textContent = '';
decryptDialogStatus.textContent = data?.message || '服务启动失败';
btnEnter.disabled = false;
btnEnter.className = 'btn-open';
btnEnter.textContent = '启动并进入系统';
setRefreshButtonBusy(false);
setRefreshStatus(data?.message || '服务启动失败', 'error');
appendLog('Error', data?.message || '服务启动失败');
});
window.electronAPI.onProcessStarted((key) => {
setServiceUI(key, true);
if (key === 'chatlog') {
decryptWrap.classList.add('show');
decryptText.textContent = '数据库解密中...';
decryptFill.className = 'decrypt-fill';
btnEnter.disabled = true;
btnEnter.className = 'btn-open';
btnEnter.textContent = '等待数据库解密完成...';
btnEnter.onclick = null;
}
});
window.electronAPI.onProcessStopped((key) => {
setServiceUI(key, false);
if (key === 'chatlog') {
decryptReady = false;
decryptWrap.classList.remove('show');
btnEnter.disabled = true;
btnEnter.className = 'btn-open';
btnEnter.textContent = '请先启动底层服务并等待解密完成';
btnEnter.onclick = null;
}
});
window.electronAPI.onDecryptStatus(({ elapsed }) => {
const min = Math.floor(elapsed / 60);
const sec = elapsed % 60;
const timeStr = min > 0 ? `${min}${sec}` : `${sec}`;
decryptElapsed.textContent = timeStr;
if (decryptDialog.classList.contains('show')) {
decryptDialogElapsed.textContent = timeStr;
}
});
window.electronAPI.onDecryptReady(() => {
decryptReady = true;
decryptDialog.classList.remove('show');
decryptText.textContent = '解密完成';
decryptElapsed.textContent = '';
decryptFill.className = 'decrypt-fill done';
updateEnterButton();
});
window.electronAPI.onShowDecryptDialog(() => {
decryptDialog.classList.add('show');
decryptDialogStatus.textContent = '解密进行中,请在微信里随意点击一个聊天窗口...';
decryptDialogElapsed.textContent = '';
});
window.electronAPI.onDecryptReset(() => {
decryptReady = false;
decryptWrap.classList.remove('show');
btnEnter.disabled = true;
btnEnter.className = 'btn-open';
btnEnter.textContent = '请先启动底层服务并等待解密完成';
btnEnter.onclick = null;
});
let warningTimer = null;
window.electronAPI.onShowCloseWarning(() => {
const el = document.getElementById('close-warning');
el.style.display = 'block';
if (warningTimer) clearTimeout(warningTimer);
warningTimer = setTimeout(() => { el.style.display = 'none'; }, 3000);
});
</script>
</body>
</html>

1336
electron-launcher/main.js Normal file

File diff suppressed because it is too large Load Diff

3643
electron-launcher/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "chatlab-desktop",
"version": "1.0.1",
"main": "main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder --win --config electron-builder.config.cjs",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"electron": "^42.0.0",
"electron-builder": "^26.8.1"
}
}

View File

@@ -0,0 +1,24 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
startAll: () => ipcRenderer.invoke('start-all'),
startChatlog: () => ipcRenderer.send('start-chatlog'),
stopChatlog: () => ipcRenderer.send('stop-chatlog'),
startFastapi: () => ipcRenderer.send('start-fastapi'),
stopFastapi: () => ipcRenderer.send('stop-fastapi'),
startFrontend: () => ipcRenderer.send('start-frontend'),
stopFrontend: () => ipcRenderer.send('stop-frontend'),
openInApp: () => ipcRenderer.send('open-in-app'),
getProcessStatus: () => ipcRenderer.invoke('get-process-status'),
refreshCurrentAccount: () => ipcRenderer.invoke('refresh-current-account'),
onLog: (callback) => ipcRenderer.on('log', (_event, value) => callback(value)),
onProcessStarted: (callback) => ipcRenderer.on('process-started', (_event, value) => callback(value)),
onProcessStopped: (callback) => ipcRenderer.on('process-stopped', (_event, value) => callback(value)),
onProcessError: (callback) => ipcRenderer.on('process-error', (_event, value) => callback(value)),
onShowCloseWarning: (callback) => ipcRenderer.on('show-close-warning', () => callback()),
onDecryptStatus: (callback) => ipcRenderer.on('decrypt-status', (_event, value) => callback(value)),
onDecryptReady: (callback) => ipcRenderer.on('decrypt-ready', () => callback()),
onDecryptReset: (callback) => ipcRenderer.on('decrypt-reset', () => callback()),
onShowDecryptDialog: (callback) => ipcRenderer.on('show-decrypt-dialog', () => callback())
});

BIN
lib/windows_x64/wx_key.dll Normal file

Binary file not shown.

View File

@@ -0,0 +1,777 @@
# ChatLab 正式上线、打包、联网授权、离线授权操作手册
这份文档按实际销售流程写,不讲太多专业词。你可以把它理解成:
```text
先把授权服务放到你的服务器上
再把桌面软件里的授权地址改成你的服务器地址
然后重新打包安装包
客户安装后输入授权码,就会连你的服务器激活
```
---
## 1. 正式上线前你要准备什么
你需要准备 4 样东西:
```text
1. 一台云服务器
2. 一个域名,比如 https://license.xxx.com
3. 授权后台 license_server
4. 打包好的客户安装包
```
建议你正式卖客户时使用:
```text
默认:联网授权
备用:离线授权
```
联网授权适合大多数客户。
离线授权只给网络限制很严的客户用。
---
## 2. 修改客户软件里的授权服务器地址
正式上线后,先打开这个文件:
```text
electron-launcher/license-config.json
```
本地测试时通常是:
```json
{
"licenseServerUrl": "http://127.0.0.1:8787"
}
```
正式上线后改成你的公网授权地址:
```json
{
"licenseServerUrl": "https://license.你的域名.com"
}
```
例子:
```json
{
"licenseServerUrl": "https://license.chatlab.cn"
}
```
注意:
```text
不要给客户正式包继续使用 http://127.0.0.1:8787
```
因为 `127.0.0.1` 代表客户自己的电脑。
客户电脑上没有你的授权服务,就会激活失败。
---
## 3. 修改授权地址后怎么打包
在你自己的开发电脑上打开 PowerShell。
进入项目根目录:
```powershell
cd C:\Users\12138\Desktop\get_wechat_new\用户使用版本\第二版\get_wechat_me
```
推荐使用完整打包命令:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
```
这个命令会重新打包前端、后端和 Electron 安装包。
打包完成后,正式发给客户的是:
```text
release\ChatLab-Setup-1.0.0.exe
```
如果你只是临时测试 Electron 打包,也可以执行:
```powershell
cd .\electron-launcher
npm.cmd run build
```
临时测试输出一般在:
```text
electron-launcher\dist\ChatLab-Setup-1.0.0.exe
```
但正式发客户,建议用:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
```
---
## 4. 如果你有代码签名证书
正式商业销售建议做代码签名,否则客户电脑可能提示未知发布者。
有证书时使用:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1 `
-Sign `
-CertificateFile "D:\certs\ChatLab-CodeSigning.pfx" `
-CertificatePassword "证书密码" `
-PublisherName "你的公司名称" `
-ForceSign
```
如果暂时没有证书,也可以先用普通打包命令:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build-desktop.ps1
```
只是客户安装时可能会看到系统提醒。
---
## 5. 部署联网授权服务
下面以 Ubuntu 云服务器为例。
### 5.1 上传授权后台
把本地这个目录上传到服务器:
```text
license_server
```
建议服务器上放到:
```text
/opt/chatlab-license-server
```
服务器上的目录大概是:
```text
/opt/chatlab-license-server
admin_cli.py
main.py
license_core.py
requirements.txt
dev_private_key.pem
```
注意:
```text
不要把整个桌面软件项目都发到客户电脑。
license_server 是你自己服务器用的,不发给客户。
```
---
### 5.2 安装运行环境
登录服务器后执行:
```bash
cd /opt/chatlab-license-server
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
```
先手动测试运行:
```bash
python main.py
```
如果看到类似:
```text
Uvicorn running on http://127.0.0.1:8787
```
说明授权服务已经启动。
再测试:
```bash
curl http://127.0.0.1:8787/health
```
正常会返回:
```json
{"ok":true}
```
---
## 6. 绑定域名和 HTTPS
假设你的域名是:
```text
license.你的域名.com
```
先在域名解析后台添加:
```text
类型A
主机记录license
记录值:你的服务器公网 IP
```
然后服务器安装 Nginx 和证书工具:
```bash
sudo apt update
sudo apt install nginx certbot python3-certbot-nginx -y
```
开放网页端口:
```bash
sudo ufw allow 80
sudo ufw allow 443
```
申请 HTTPS 证书:
```bash
sudo certbot --nginx -d license.你的域名.com
```
成功后,在浏览器打开:
```text
https://license.你的域名.com/health
```
如果看到:
```json
{"ok":true}
```
说明公网授权服务已经通了。
---
## 7. 让授权服务一直运行
手动执行 `python main.py` 只适合测试。
正式上线要让它一直在服务器后台运行。
创建服务文件:
```bash
sudo nano /etc/systemd/system/chatlab-license.service
```
写入:
```ini
[Unit]
Description=ChatLab License Server
After=network.target
[Service]
WorkingDirectory=/opt/chatlab-license-server
ExecStart=/opt/chatlab-license-server/.venv/bin/python /opt/chatlab-license-server/main.py
Restart=always
RestartSec=3
Environment=CHATLAB_LICENSE_DB=/opt/chatlab-license-server/license_server.db
Environment=CHATLAB_LICENSE_PRIVATE_KEY_FILE=/opt/chatlab-license-server/dev_private_key.pem
Environment=CHATLAB_LICENSE_ADMIN_TOKEN=请改成一串很长的后台管理密码
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
sudo systemctl daemon-reload
sudo systemctl enable chatlab-license
sudo systemctl start chatlab-license
```
查看状态:
```bash
sudo systemctl status chatlab-license
```
如果看到 `active``running`,说明授权服务正在运行。
查看日志:
```bash
sudo journalctl -u chatlab-license -f
```
---
## 8. 正式环境私钥怎么处理
私钥可以理解成:
```text
你的授权盖章工具
```
谁拿到私钥,谁就可以自己生成授权。
所以正式上线要记住:
```text
私钥只放你的服务器
不要发给客户
不要放进安装包
不要上传到公开仓库
不要放到网盘分享
```
如果你换了正式私钥,就必须保证:
```text
服务器用新私钥签授权
客户软件里内置的新公钥能验证这个授权
```
如果私钥和公钥不是一对,客户会看到:
```text
授权签名校验失败
```
简单理解:
```text
服务器负责盖章
客户软件负责验章
两边必须是一套章
```
---
## 9. 联网授权怎么做
联网授权是正式销售最推荐的方式。
### 9.1 你先给客户创建授权码
登录服务器:
```bash
cd /opt/chatlab-license-server
. .venv/bin/activate
```
创建一年授权:
```bash
python admin_cli.py create-license --customer-name "某某公司" --days 365
```
如果是 7 天试用:
```bash
python admin_cli.py create-license --customer-name "某某公司-试用" --days 7
```
它会输出类似:
```json
{
"customer_id": "cus_xxx",
"license_id": "lic_xxx",
"license_key": "CL-2026-XXXX-XXXXXX-XXXXXX",
"expires_at": "2027-05-18T00:00:00Z"
}
```
你要发给客户的是:
```text
license_key
```
也就是类似:
```text
CL-2026-XXXX-XXXXXX-XXXXXX
```
---
### 9.2 你发给客户两个东西
发给客户:
```text
1. 安装包ChatLab-Setup-1.0.0.exe
2. 授权码CL-2026-XXXX-XXXXXX-XXXXXX
```
客户操作:
```text
1. 安装软件
2. 打开软件
3. 输入授权码
4. 点“激活并进入”
5. 软件自动连接你的授权服务器
6. 激活成功后绑定当前电脑
```
客户第一次激活成功后,这个授权默认绑定这台电脑。
---
### 9.3 你给客户的话术
可以直接复制:
```text
您好,这是 ChatLab 安装包和授权码。
安装后打开软件,输入授权码即可在线激活。
首次激活会绑定当前电脑。
如果后续更换电脑,请联系我处理换机。
授权期内包含软件维护、问题修复和基础使用支持。
```
---
## 10. 离线授权怎么做
离线授权只建议作为备用方案。
适合这些情况:
```text
客户公司网络拦截你的授权服务器
客户电脑不能访问外网
客户临时无法联网激活
```
不建议默认给客户一年离线授权。
建议:
```text
普通离线授权30 天或 90 天
非常稳定客户180 天
一年离线授权谨慎使用
```
---
### 10.1 客户先复制离线请求码
客户打开软件后:
```text
1. 点“离线授权”
2. 复制“离线请求码”
3. 发给你
```
离线请求码通常是一长串字符,例如:
```text
eyJsaWNlbnNlX2tleSI6IiIsImRldmljZV9maW5nZXJwcmludF9zaGEyNTYiOi...
```
---
### 10.2 你生成离线授权文件
在服务器或你的授权管理电脑上执行:
```bash
cd /opt/chatlab-license-server
. .venv/bin/activate
```
生成 90 天离线授权:
```bash
python admin_cli.py offline-grant \
--license-key "CL-2026-XXXX-XXXXXX-XXXXXX" \
--request-code "客户发来的离线请求码" \
--days 90 \
--output customer.chatlab-license
```
如果你在 Windows PowerShell 里执行,可以写成一行:
```powershell
python .\admin_cli.py offline-grant --license-key "CL-2026-XXXX-XXXXXX-XXXXXX" --request-code "客户发来的离线请求码" --days 90 --output customer.chatlab-license
```
注意:
```text
--license-key 后面必须是真实授权码
不能写 CL-...
```
错误写法:
```powershell
--license-key "CL-..."
```
正确写法:
```powershell
--license-key "CL-2026-C6D0-4A9B22-F1B5B7"
```
---
### 10.3 把离线授权内容发给客户
命令成功后,会生成:
```text
customer.chatlab-license
```
打开这个文件,把里面全部内容发给客户。
客户操作:
```text
1. 打开软件
2. 点“离线授权”
3. 把你发的授权内容粘贴进去
4. 点“导入离线授权”
5. 成功后进入系统
```
---
## 11. 联网授权和离线授权怎么选
建议你这样定规则:
```text
默认使用联网授权
客户网络有限制时,再使用离线授权
```
推荐策略:
| 场景 | 用哪种 |
|---|---|
| 普通客户电脑能联网 | 联网授权 |
| 客户需要年费续费 | 联网授权 |
| 客户可能换电脑 | 联网授权 |
| 客户公司限制外网 | 离线授权 |
| 客户临时无法访问授权服务器 | 离线授权 |
| 试用客户 | 联网授权,或短期离线授权 |
---
## 12. 常见错误和处理方法
### 12.1 connect ECONNREFUSED
错误类似:
```text
connect ECONNREFUSED 127.0.0.1:8787
```
意思是:
```text
软件找不到授权服务
```
常见原因:
```text
1. 授权服务没启动
2. 软件里的授权地址还是 127.0.0.1
3. 客户电脑访问不到你的服务器
4. 域名或 HTTPS 没配置好
```
处理:
```text
1. 浏览器打开 https://license.你的域名.com/health
2. 确认返回 {"ok":true}
3. 检查 electron-launcher/license-config.json
4. 修改后重新打包安装包
```
---
### 12.2 授权密钥不存在
错误类似:
```text
KeyError: '授权密钥不存在。'
```
常见原因:
```text
1. 你用了 CL-... 这种占位符
2. 授权码输错
3. 你连的是另一份授权数据库
4. 这个客户授权还没有创建
```
处理:
```text
先创建真实授权码,再生成离线授权。
```
创建授权码:
```bash
python admin_cli.py create-license --customer-name "客户名称" --days 365
```
---
### 12.3 授权签名校验失败
意思是:
```text
客户软件验不过授权文件
```
常见原因:
```text
1. 客户改过授权文件
2. 服务器私钥和客户端公钥不是一对
3. 你换了服务器私钥,但没有重新打包客户端
```
处理:
```text
1. 不要让客户手动改授权文件
2. 确保服务器私钥和客户端公钥匹配
3. 换密钥后重新打包客户安装包
```
---
### 12.4 授权文件不属于当前电脑
意思是:
```text
这个离线授权是给另一台电脑生成的
```
处理:
```text
让客户在当前电脑重新复制离线请求码
你重新生成离线授权文件
```
---
## 13. 你每天真实销售时怎么操作
### 联网授权客户
```text
1. 客户付款或试用
2. 你在服务器创建授权码
3. 你发安装包和授权码
4. 客户安装并输入授权码
5. 软件自动激活并绑定电脑
6. 你记录客户信息和到期时间
```
### 离线授权客户
```text
1. 客户付款或试用
2. 你创建授权码
3. 客户复制离线请求码发给你
4. 你生成 customer.chatlab-license
5. 客户导入离线授权
6. 你记录离线授权到期时间
```
---
## 14. 建议你的客户记录表
你可以建一个 Excel字段如下
```text
客户名称
联系人
微信
电话
授权码
授权类型:联网 / 离线
购买日期
到期日期
离线授权到期日期
绑定电脑备注
是否已安装
是否已培训
备注
```
这样续费、换机、售后会清楚很多。
---
## 15. 最推荐的正式流程
最终建议你采用这个流程:
```text
1. 授权服务部署到公网服务器
2. 域名使用 HTTPS
3. electron-launcher/license-config.json 改成公网授权地址
4. 重新打包安装包
5. 普通客户使用联网授权
6. 特殊客户使用离线授权
7. 离线授权尽量给 30 天或 90 天
8. 私钥只放服务器,不发给客户
```
一句话总结:
```text
正式销售用联网授权,离线授权只做备用。
改完授权域名后,用 build-desktop.ps1 重新打包,再把 release 里的安装包发给客户。
```

527
plan.md Normal file
View File

@@ -0,0 +1,527 @@
# ChatLab 接入公司 AgentBox 与内网平台方案
## 1. 目标
当前 ChatLab 项目是独立运行的 Windows 本地应用,主要能力包括:
- 读取 PC 微信聊天记录
- 群聊检索
- 话题分类
- 售后问题归档
- AI 总结与报告生成
- 本地知识库管理
公司已有 AgentBox 主机,主机内置:
- 7B Qwen 智能体
- 类 Dify 的平台系统
- 业务系统
- 数据库
- 工作流
- MCP 工具体系
- 内网部署能力
本方案目标是把 ChatLab 接回公司平台体系,并部署到 AgentBox 内网环境中,让员工只能在公司内网访问,同时让平台工作流和智能体能够调用 ChatLab 的售后分析能力。
## 2. 推荐方案概览
推荐采用:
**Windows 采集端 + AgentBox 业务端 + MCP 工具接入 + 平台统一鉴权**
整体形态如下:
```text
员工浏览器
-> 公司平台 / 内网网关
-> AgentBox 上的 ChatLab Web
-> AgentBox 上的 ChatLab FastAPI
-> AgentBox 上的 MCP Server
-> AgentBox 上的 Qwen 7B 模型服务
-> 公司平台业务 API / 平台数据库
-> Windows 微信采集端
-> PC 微信
-> chatlog.exe
```
不建议第一版把微信采集能力完全搬到 AgentBox 上,因为当前项目依赖 PC 微信、Windows 进程、chatlog.exe 和本地微信数据解密。AgentBox 更适合承载平台、模型、业务系统、MCP、数据库和内网 Web 服务。
## 3. 模块分工
### 3.1 Windows 采集端
Windows 采集端继续负责和微信强相关的能力:
- 登录 PC 微信
- 启动并维护 `chatlog.exe`
- 读取群聊、会话、消息、图片、语音、文件等微信数据
- 识别当前微信账号
- 将消息增量同步给 AgentBox
- 响应 AgentBox 的按需查询任务
采集端只负责取数据,不再作为主要业务入口。
### 3.2 AgentBox 业务端
AgentBox 负责承载 ChatLab 的核心业务能力:
- ChatLab 后端 FastAPI
- ChatLab Web 页面
- MCP 工具服务
- AI 总结、分类、报告生成
- 与公司平台业务 API 对接
- 与 AgentBox 上的 Qwen 7B 模型对接
- 内网访问控制
- 平台数据库写入
AgentBox 是正式的业务入口和平台集成点。
### 3.3 公司平台
公司平台负责:
- 用户登录
- 权限控制
- 菜单入口
- 工作流编排
- 智能体调用
- 业务对象管理
- 数据库存储
- 审计日志
- 报告流转与人工确认
ChatLab 不单独做完整账号体系,而是接入公司平台统一身份体系。
## 4. 部署方式
### 4.1 AgentBox 上部署的服务
AgentBox 上建议部署以下服务:
```text
chatlab-api
chatlab-web
chatlab-mcp
qwen-openai-compatible-api
```
其中:
- `chatlab-api` 是当前 `chatlog_fastAPI` 改造后的服务端版本
- `chatlab-web` 是当前 React 前端构建后的静态页面
- `chatlab-mcp` 是给公司平台或智能体调用的 MCP 工具服务
- `qwen-openai-compatible-api` 是 AgentBox 本地 Qwen 7B 的 OpenAI 兼容接口
### 4.2 内网访问方式
推荐访问地址:
```text
http://agentbox内网IP/chatlab
```
或者由公司平台菜单进入:
```text
公司平台 -> 售后智能助手 -> ChatLab
```
对外只开放内网访问,不开放公网访问。
推荐网关路径:
```text
/chatlab/ -> ChatLab Web
/chatlab/api/ -> ChatLab FastAPI
/chatlab/mcp/ -> ChatLab MCP Server
```
### 4.3 端口建议
```text
80 / 443 公司平台网关或 Nginx
8000 ChatLab FastAPI仅内网或网关访问
8001 ChatLab MCP仅平台服务访问
模型端口 只允许 AgentBox 本机访问
5030 chatlog.exe仅 Windows 采集端本机访问
```
## 5. 数据流设计
### 5.1 聊天数据采集流程
```text
PC 微信
-> chatlog.exe
-> Windows 采集端
-> AgentBox ChatLab API
-> 平台 API
-> 平台数据库
```
采集端不直接暴露给用户访问,只负责把微信数据同步给 AgentBox。
### 5.2 AI 报告生成流程
```text
用户选择群聊 / 平台工作流触发
-> ChatLab 检索聊天记录
-> 创建话题
-> 调用 AgentBox Qwen 7B
-> 生成 Markdown 报告
-> 写入公司平台业务对象
-> 人工确认 / 工作流流转
```
### 5.3 MCP 调用流程
```text
平台智能体 / 工作流
-> MCP Tool
-> ChatLab API
-> 消息检索 / 话题生成 / 报告生成
-> 平台业务数据
```
## 6. 平台数据库对接方式
推荐不让 ChatLab 直接连接平台数据库,而是通过公司平台 API 写入业务对象。
原因:
- 保留平台权限控制
- 保留审计日志
- 保留工作流触发能力
- 避免绕过平台业务规则
- 后续平台升级时耦合更低
建议平台侧提供以下业务对象。
### 6.1 售后群对象
```json
{
"external_id": "微信群ID",
"name": "群名称",
"source": "wechat",
"collector_id": "采集端ID",
"status": "active"
}
```
### 6.2 话题对象
```json
{
"external_id": "ChatLab话题ID",
"group_external_id": "微信群ID",
"title": "话题标题",
"source": "manual|ai|workflow",
"status": "pending|processing|done|failed",
"message_refs": [123, 124, 125]
}
```
### 6.3 售后报告对象
```json
{
"external_id": "报告ID",
"topic_external_id": "话题ID",
"title": "报告标题",
"content_markdown": "Markdown报告正文",
"evidence": [],
"ai_model": "qwen-7b",
"review_status": "pending|approved|rejected"
}
```
### 6.4 任务对象
```json
{
"external_id": "任务ID",
"type": "sync|summarize|topic_detect",
"status": "queued|running|success|failed",
"progress": {
"processed": 10,
"total": 100
},
"error": ""
}
```
## 7. MCP 工具设计
第一版建议提供以下 MCP 工具:
```text
chatlab_list_groups
chatlab_search_messages
chatlab_get_message_context
chatlab_create_topic
chatlab_add_topic_messages
chatlab_summarize_topic
chatlab_get_report
chatlab_search_reports
```
### 7.1 群列表工具
```text
chatlab_list_groups
```
用途:
- 获取当前可分析的微信群
- 给平台智能体或工作流选择分析对象
### 7.2 消息检索工具
```text
chatlab_search_messages
```
参数示例:
```json
{
"group_id": "微信群ID",
"keyword": "退款",
"start_date": "2026-05-01",
"end_date": "2026-05-18",
"limit": 50
}
```
用途:
- 按关键词查找聊天记录
- 支持售后问题溯源
- 支持工作流自动筛选问题片段
### 7.3 上下文获取工具
```text
chatlab_get_message_context
```
用途:
- 根据某条消息获取前后文
- 避免 AI 只看单条消息误判
### 7.4 创建话题工具
```text
chatlab_create_topic
```
用途:
- 把一组消息整理成售后话题
- 可由人工触发,也可由工作流触发
### 7.5 生成报告工具
```text
chatlab_summarize_topic
```
参数示例:
```json
{
"topic_id": "话题ID",
"write_to_platform": true
}
```
用途:
- 调用 AgentBox 上的 Qwen 7B
- 生成售后问题报告
- 写入平台业务对象
- 进入人工确认流程
## 8. AI 模型对接方案
当前项目已经使用 OpenAI-compatible 调用方式,因此 AgentBox 的 Qwen 7B 只需要提供兼容接口:
```text
POST /v1/chat/completions
```
ChatLab 后端配置为:
```text
AI_BASE_URL=http://127.0.0.1:模型服务端口/v1
AI_API_KEY=agentbox-local-key
AI_MODEL=qwen-7b
```
第一版优先支持:
- 文本聊天总结
- 售后问题提取
- 话题分类
- Markdown 报告生成
- AI 建议生成
图片、语音、视频解析可以作为第二阶段增强能力。如果 AgentBox 暂时没有视觉模型或语音模型,系统应允许跳过这些内容,并在报告中提示媒体内容需人工查看。
## 9. 权限与安全
### 9.1 访问边界
- 只允许内网访问
- 不暴露公网
- AgentBox 服务只接受公司平台网关或内网指定 IP 访问
- MCP 服务只允许平台服务账号调用
- 采集端只允许带签名的请求写入数据
### 9.2 用户鉴权
推荐使用公司平台统一登录。
平台网关向 ChatLab 注入用户身份:
```text
X-Platform-User-Id
X-Platform-User-Name
X-Platform-Roles
```
ChatLab 根据这些身份头判断用户权限。
### 9.3 角色建议
```text
普通用户:
- 查看授权群
- 查看报告
售后人员:
- 创建话题
- 生成报告
- 提交人工确认
管理员:
- 管理采集端
- 管理模型配置
- 管理平台 API 配置
- 查看同步状态
工作流服务账号:
- 调用 MCP 工具
- 写入报告和任务状态
```
### 9.4 采集端安全
采集端和 AgentBox 通信使用 HMAC 签名。
请求头建议:
```text
X-Collector-Id
X-Timestamp
X-Signature
```
签名方式:
```text
HMAC_SHA256(secret, method + path + timestamp + body_sha256)
```
AgentBox 拒绝:
- 无签名请求
- 签名错误请求
- 时间偏差超过 5 分钟的请求
- 未注册采集端请求
## 10. 数据存储策略
第一版建议:
- 平台数据库存正式业务数据
- ChatLab 本地 SQLite 只作为缓存或临时任务状态
- 微信原始数据库不上传 AgentBox
- 消息正文按业务需要同步
- 图片、语音、文件按需同步或按需代理访问
正式进入平台数据库的数据包括:
- 群信息
- 话题信息
- 关键消息引用
- AI 报告
- 任务状态
- 人工确认状态
- 审计记录
不建议直接保存所有微信原始数据,除非公司合规要求允许并且平台已有对应权限模型。
## 11. 推荐落地阶段
### 第一阶段:平台接入最小闭环
目标:
- AgentBox 能访问 ChatLab Web
- 平台能通过 MCP 调用 ChatLab
- ChatLab 能调用 Qwen 7B 生成报告
- 报告能写入平台业务对象
- 员工只能内网访问
第一阶段完成后,就可以在公司内部演示完整售后分析闭环。
### 第二阶段:采集端服务化
目标:
- Windows 采集端自动心跳
- 自动增量同步消息
- AgentBox 显示采集端在线状态
- 支持多采集端绑定
- 支持多个微信账号隔离
### 第三阶段:工作流深度融合
目标:
- 平台工作流自动发现售后问题
- 自动生成待确认报告
- 人工确认后进入业务系统
- 报告和任务进入平台审计体系
- 支持通知、派单、复盘等业务流转
### 第四阶段:多模态增强
目标:
- 图片内容识别
- 语音转文字
- 视频摘要
- 文件内容解析
- 多模态证据进入售后报告
## 12. 推荐结论
第一版不要追求把整个项目完全搬进 AgentBox。
最稳妥的路线是:
```text
微信采集继续留在 Windows
业务系统迁到 AgentBox
AI 调用切到 AgentBox Qwen
数据写入公司平台
能力通过 MCP 暴露给工作流和智能体
Web 入口通过内网平台访问
```
这样可以最大程度复用当前项目,同时快速接回公司平台体系,并且避免微信采集在 AgentBox 上不可控的问题。

Binary file not shown.

Binary file not shown.

766
release/manifest.txt Normal file
View File

@@ -0,0 +1,766 @@
FullName
--------
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\chatlog.exe
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\DISCLAIMER.md
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\LICENSE
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\ChatLabBacke...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ba...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\li...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\li...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\li...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\sq...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\uc...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\un...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\VC...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\VC...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_a...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_b...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_c...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_d...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_e...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_h...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_l...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_m...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_o...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_q...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_s...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_s...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_s...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_u...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_w...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\_z...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ap...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ce...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ce...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\cl...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ji...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\py...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\se...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\tz...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\backend\_internal\ya...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\company-log...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\favicon.svg
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\icons.svg
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\index.html
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\assets\inde...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\frontend\assets\inde...
C:\Users\12138\Desktop\get_wechat_new\用户使用版本\大铁\get_wechat_me\electron-launcher\build-resources\lib\windows_x64\wx_k...

234
scripts/build-desktop.ps1 Normal file
View File

@@ -0,0 +1,234 @@
param(
[string]$PythonLauncher = "py",
[string]$PythonVersion = "-3.12",
[switch]$SkipIcon,
[switch]$SkipBackend,
[switch]$SkipFrontend,
[switch]$SkipInstaller,
[switch]$Sign,
[string]$CertificateFile,
[string]$CertificatePassword,
[string]$PublisherName,
[string]$TimestampServer = "http://timestamp.digicert.com",
[string[]]$VoiceSmokeKeys = @(),
[switch]$ForceSign
)
$ErrorActionPreference = "Stop"
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
$Frontend = Join-Path $Root "chatlab-web\frontend"
$Backend = Join-Path $Root "chatlog_fastAPI"
$Electron = Join-Path $Root "electron-launcher"
$Resources = Join-Path $Electron "build-resources"
$Release = Join-Path $Root "release"
function Invoke-Python312($Arguments) {
$pyCommand = Get-Command $PythonLauncher -ErrorAction SilentlyContinue
if ($pyCommand) {
try {
& $PythonLauncher $PythonVersion -V | Out-Null
if ($LASTEXITCODE -eq 0) {
& $PythonLauncher $PythonVersion @Arguments
return
}
} catch {
Write-Host "Python launcher $PythonLauncher $PythonVersion is not available, falling back to user Python312."
}
}
$fallback = Join-Path $env:LOCALAPPDATA "Programs\Python\Python312\python.exe"
if (-not (Test-Path $fallback)) {
throw "Python 3.12 was not found. Install it first or pass -PythonLauncher/-PythonVersion."
}
& $fallback @Arguments
}
function Reset-Dir($Path) {
if (Test-Path $Path) {
$resolved = Resolve-Path $Path
if (-not $resolved.Path.StartsWith($Root.Path)) {
throw "Refusing to remove path outside project: $($resolved.Path)"
}
Remove-Item -LiteralPath $resolved.Path -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $Path | Out-Null
}
function Copy-Dir($Source, $Dest) {
if (-not (Test-Path $Source)) {
throw "Missing required source: $Source"
}
New-Item -ItemType Directory -Force -Path (Split-Path $Dest) | Out-Null
Copy-Item -LiteralPath $Source -Destination $Dest -Recurse -Force
}
function Set-OptionalEnv($Name, $Value) {
if ([string]::IsNullOrWhiteSpace($Value)) {
Remove-Item -Path "Env:$Name" -ErrorAction SilentlyContinue
} else {
Set-Item -Path "Env:$Name" -Value $Value
}
}
function Test-ForbiddenReleaseFile($File) {
return $File.Name -eq ".env" -or
$File.Name -like "knowledge*.db" -or
$File.FullName -match "\\__pycache__\\" -or
$File.Name -like "*.pfx" -or
$File.Name -like "*.p12" -or
$File.Name -like "*.pvk" -or
$File.Name -like "*.cer" -or
$File.Name -like "*.crt" -or
$File.Name -like "*.key" -or
$File.FullName -match "\\certs\\"
}
Set-Location $Root
$resolvedCertificateFile = $null
if ($CertificateFile) {
if (-not (Test-Path -LiteralPath $CertificateFile)) {
throw "Certificate file was not found: $CertificateFile"
}
$resolvedCertificateFile = (Resolve-Path -LiteralPath $CertificateFile).Path
if ($resolvedCertificateFile.StartsWith($Root.Path)) {
throw "For safety, keep the code signing certificate outside the project folder: $resolvedCertificateFile"
}
}
$envCertificateFile = if ($resolvedCertificateFile) { $resolvedCertificateFile } else { $env:CHATLAB_PFX_FILE }
$envCertificateFile = if ($envCertificateFile) { $envCertificateFile.Trim() } else { "" }
$shouldSign = $Sign -or -not [string]::IsNullOrWhiteSpace($envCertificateFile)
if ($ForceSign -and -not $shouldSign) {
throw "-ForceSign requires -CertificateFile or CHATLAB_PFX_FILE."
}
if ($shouldSign) {
if (-not $envCertificateFile) {
throw "Signing was requested, but no certificate was provided. Use -CertificateFile or CHATLAB_PFX_FILE."
}
if (-not (Test-Path -LiteralPath $envCertificateFile)) {
throw "Certificate file was not found: $envCertificateFile"
}
$envCertificateFile = (Resolve-Path -LiteralPath $envCertificateFile).Path
if ($envCertificateFile.StartsWith($Root.Path)) {
throw "For safety, keep the code signing certificate outside the project folder: $envCertificateFile"
}
Set-OptionalEnv "CHATLAB_PFX_FILE" $envCertificateFile
if ($CertificatePassword) {
Set-OptionalEnv "CHATLAB_PFX_PASSWORD" $CertificatePassword
Set-OptionalEnv "WIN_CSC_KEY_PASSWORD" $CertificatePassword
Set-OptionalEnv "CSC_KEY_PASSWORD" $CertificatePassword
}
if ($PublisherName) { Set-OptionalEnv "CHATLAB_CERT_PUBLISHER_NAME" $PublisherName }
Set-OptionalEnv "CHATLAB_TIMESTAMP_SERVER" $TimestampServer
if ($ForceSign) { Set-OptionalEnv "CHATLAB_FORCE_SIGN" "1" }
Write-Host "Code signing enabled. Certificate: $envCertificateFile"
} else {
Remove-Item -Path "Env:CHATLAB_PFX_FILE" -ErrorAction SilentlyContinue
Remove-Item -Path "Env:CHATLAB_PFX_PASSWORD" -ErrorAction SilentlyContinue
Remove-Item -Path "Env:CHATLAB_CERT_PUBLISHER_NAME" -ErrorAction SilentlyContinue
Remove-Item -Path "Env:CHATLAB_FORCE_SIGN" -ErrorAction SilentlyContinue
Remove-Item -Path "Env:WIN_CSC_KEY_PASSWORD" -ErrorAction SilentlyContinue
Remove-Item -Path "Env:CSC_KEY_PASSWORD" -ErrorAction SilentlyContinue
Write-Host "Code signing disabled. Unsigned installer build is allowed."
}
if (-not $SkipIcon) {
& node (Join-Path $Root "scripts\make-icon.cjs")
}
if (-not $SkipFrontend) {
$dist = Join-Path $Frontend "dist"
if (Test-Path $dist) {
Remove-Item -LiteralPath (Resolve-Path $dist).Path -Recurse -Force
}
Push-Location $Frontend
& npm.cmd run build
Pop-Location
}
if (-not $SkipBackend) {
Push-Location $Backend
Invoke-Python312 @("-m", "PyInstaller", "ChatLabBackend.spec", "--noconfirm", "--clean")
Pop-Location
}
Reset-Dir $Resources
New-Item -ItemType Directory -Force -Path $Release | Out-Null
Copy-Item -LiteralPath (Join-Path $Root "chatlog.exe") -Destination (Join-Path $Resources "chatlog.exe") -Force
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$chatlogVersionOutput = & (Join-Path $Resources "chatlog.exe") version 2>&1
$chatlogVersionExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
if ($chatlogVersionExitCode -ne 0) {
throw "chatlog.exe version check failed:`n$chatlogVersionOutput"
}
Write-Host "chatlog.exe version: $chatlogVersionOutput"
foreach ($voiceKey in $VoiceSmokeKeys) {
if ([string]::IsNullOrWhiteSpace($voiceKey)) { continue }
try {
$response = Invoke-WebRequest -Uri "http://127.0.0.1:5030/voice/$voiceKey" -UseBasicParsing -TimeoutSec 15
if ($response.StatusCode -ge 400 -or $response.RawContentLength -le 0) {
throw "HTTP $($response.StatusCode), length=$($response.RawContentLength)"
}
Write-Host "voice smoke passed: $voiceKey ($($response.RawContentLength) bytes)"
} catch {
throw "voice smoke failed for $voiceKey. Do not ship this installer until chatlog can read WeChat voice media. $($_.Exception.Message)"
}
}
Copy-Dir (Join-Path $Root "lib") (Join-Path $Resources "lib")
Copy-Dir (Join-Path $Frontend "dist") (Join-Path $Resources "frontend")
Copy-Dir (Join-Path $Backend "dist\ChatLabBackend") (Join-Path $Resources "backend")
Copy-Item -LiteralPath (Join-Path $Root "DISCLAIMER.md") -Destination (Join-Path $Resources "DISCLAIMER.md") -Force
Copy-Item -LiteralPath (Join-Path $Root "LICENSE") -Destination (Join-Path $Resources "LICENSE") -Force
$forbidden = Get-ChildItem -LiteralPath $Resources -Recurse -Force |
Where-Object { Test-ForbiddenReleaseFile $_ }
if ($forbidden) {
$names = ($forbidden | Select-Object -ExpandProperty FullName) -join "`n"
throw "Sensitive or cache files found in release resources:`n$names"
}
Get-ChildItem -LiteralPath $Resources -Recurse -File |
Select-Object FullName, Length |
Out-File -Encoding UTF8 (Join-Path $Release "manifest.txt")
if (-not $SkipInstaller) {
Push-Location $Electron
& npm.cmd run build
Pop-Location
Copy-Item -Path (Join-Path $Electron "dist\*.exe") -Destination $Release -Force
Copy-Item -Path (Join-Path $Electron "dist\*.blockmap") -Destination $Release -Force -ErrorAction SilentlyContinue
$releaseForbidden = Get-ChildItem -LiteralPath $Release -Recurse -Force |
Where-Object { Test-ForbiddenReleaseFile $_ }
if ($releaseForbidden) {
$names = ($releaseForbidden | Select-Object -ExpandProperty FullName) -join "`n"
throw "Sensitive certificate, private data, or cache files found in release output:`n$names"
}
$installer = Get-ChildItem -LiteralPath $Release -Filter "ChatLab-Setup-*.exe" -File |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($installer) {
$signature = Get-AuthenticodeSignature -FilePath $installer.FullName
if ($shouldSign -or $ForceSign) {
if ($signature.Status -ne "Valid") {
throw "Installer signing verification failed: $($signature.Status) $($signature.StatusMessage)"
}
Write-Host "Installer signature verified: $($signature.SignerCertificate.Subject)"
} else {
Write-Host "Installer is unsigned by design for this build."
}
}
}
Write-Host "Desktop build completed. Output: $Release"

226
scripts/make-icon.cjs Normal file
View File

@@ -0,0 +1,226 @@
#!/usr/bin/env node
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const root = path.resolve(__dirname, "..");
const sourceImagePath = path.join(root, "chatlab-web", "frontend", "public", "company-logo.jpg");
const outputDir = path.join(root, "electron-launcher", "build");
const outputIco = path.join(outputDir, "icon.ico");
const outputPng = path.join(outputDir, "icon.png");
const sizes = [16, 24, 32, 48, 64, 128, 256];
const electronNodeModules = path.join(root, "electron-launcher", "node_modules");
const electronUserData =
process.env.CHATLAB_ICON_USER_DATA || path.join(os.tmpdir(), "chatlab-icon-renderer-user-data");
function electronApi() {
if (process.versions.electron) {
return require("electron");
}
return require(path.join(electronNodeModules, "electron"));
}
function resolveElectronBinary() {
return electronApi();
}
function writeIco(entries, destination) {
const headerSize = 6;
const entrySize = 16;
let offset = headerSize + entries.length * entrySize;
const header = Buffer.alloc(offset);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(entries.length, 4);
for (const [index, entry] of entries.entries()) {
const pos = headerSize + index * entrySize;
header.writeUInt8(entry.size === 256 ? 0 : entry.size, pos);
header.writeUInt8(entry.size === 256 ? 0 : entry.size, pos + 1);
header.writeUInt8(0, pos + 2);
header.writeUInt8(0, pos + 3);
header.writeUInt16LE(1, pos + 4);
header.writeUInt16LE(32, pos + 6);
header.writeUInt32LE(entry.png.length, pos + 8);
header.writeUInt32LE(offset, pos + 12);
offset += entry.png.length;
}
fs.writeFileSync(destination, Buffer.concat([header, ...entries.map((entry) => entry.png)]));
}
async function renderSourcePng() {
const { app, BrowserWindow, nativeImage } = electronApi();
await app.whenReady();
const imageBytes = fs.readFileSync(sourceImagePath);
const dataUri = `data:image/jpeg;base64,${imageBytes.toString("base64")}`;
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
width: 256px;
height: 256px;
margin: 0;
overflow: hidden;
background: transparent;
}
body {
display: grid;
place-items: center;
}
img {
display: block;
width: 256px;
height: 256px;
object-fit: contain;
}
</style>
</head>
<body>
<img src="${dataUri}" alt="">
</body>
</html>`;
const win = new BrowserWindow({
show: false,
width: 256,
height: 256,
transparent: true,
backgroundColor: "#00000000",
resizable: false,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
offscreen: true,
sandbox: true,
},
});
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
await win.webContents.executeJavaScript(`
new Promise((resolve, reject) => {
const image = document.querySelector("img");
if (!image) {
reject(new Error("Icon image element was not created"));
return;
}
if (image.complete && image.naturalWidth > 0) {
setTimeout(resolve, 80);
return;
}
image.onload = () => setTimeout(resolve, 80);
image.onerror = () => reject(new Error("Icon SVG failed to render"));
})
`);
const captured = await win.webContents.capturePage({ x: 0, y: 0, width: 256, height: 256 });
win.destroy();
const normalized = nativeImage
.createFromBuffer(captured.toPNG())
.resize({ width: 256, height: 256, quality: "best" });
return normalized.toPNG();
}
async function mainElectron() {
const { app } = electronApi();
app.disableHardwareAcceleration();
app.setPath("userData", electronUserData);
app.commandLine.appendSwitch("disable-gpu");
app.commandLine.appendSwitch("disable-gpu-compositing");
app.commandLine.appendSwitch("disable-software-rasterizer");
app.commandLine.appendSwitch("disk-cache-dir", path.join(electronUserData, "cache"));
if (!fs.existsSync(sourceImagePath)) {
throw new Error(`Missing icon source: ${sourceImagePath}`);
}
fs.mkdirSync(outputDir, { recursive: true });
const { nativeImage } = electronApi();
const sourcePng = await renderSourcePng();
const sourceImage = nativeImage.createFromBuffer(sourcePng);
const pngEntries = sizes.map((size) => ({
size,
png: sourceImage.resize({ width: size, height: size, quality: "best" }).toPNG(),
}));
fs.writeFileSync(outputPng, sourcePng);
writeIco(pngEntries, outputIco);
console.log(`Generated ${path.relative(root, outputIco)} (${sizes.join(", ")} px)`);
console.log(`Generated ${path.relative(root, outputPng)} (256 px)`);
app.quit();
}
function mainNode() {
const rendererDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatlab-icon-renderer-"));
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatlab-icon-user-data-"));
fs.mkdirSync(rendererDir, { recursive: true });
fs.writeFileSync(
path.join(rendererDir, "package.json"),
JSON.stringify({ name: "chatlab-icon-renderer", main: "main.js" }, null, 2),
);
fs.writeFileSync(
path.join(rendererDir, "main.js"),
`process.env.CHATLAB_ICON_RENDER = "1";\nrequire(${JSON.stringify(__filename)});\n`,
);
const electronBinary = resolveElectronBinary();
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
env.NODE_PATH = [electronNodeModules, process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
env.ELECTRON_DISABLE_CRASHPAD = "1";
env.ELECTRON_ENABLE_LOGGING = "0";
env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
env.CHATLAB_ICON_USER_DATA = userDataDir;
const result = spawnSync(
electronBinary,
[
rendererDir,
"--disable-crash-reporter",
"--disable-gpu",
"--disable-gpu-compositing",
`--user-data-dir=${userDataDir}`,
`--disk-cache-dir=${path.join(userDataDir, "cache")}`,
],
{
cwd: root,
stdio: "inherit",
windowsHide: true,
env,
},
);
fs.rmSync(rendererDir, { recursive: true, force: true });
fs.rmSync(userDataDir, { recursive: true, force: true });
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
}
if (process.versions.electron && process.env.CHATLAB_ICON_RENDER === "1") {
mainElectron().catch((error) => {
console.error(error);
process.exitCode = 1;
try {
electronApi().app.quit();
} catch {
process.exit(1);
}
});
} else {
mainNode();
}

File diff suppressed because it is too large Load Diff

264
售后文档.md Normal file
View File

@@ -0,0 +1,264 @@
# ChatLab 售后智能助手 — 项目说明文档
> 内部培训文档 · 销售团队专用 · 请勿对外传阅
---
## 一、这个产品是做什么的
简单说:**把微信群里的售后聊天记录,自动整理成可以搜索的知识库。**
售后工程师每天在微信群里处理客户问题这些经验都散落在聊天记录里时间一长就找不到了。这套系统会自动监控指定的售后群AI 自动识别每条消息在讨论什么问题,把整个排查过程整理成一份标准文档存起来。下次遇到同样问题,搜一下就能找到历史案例。
**对用户来说,日常使用微信完全不受影响,系统在后台静默运行。**
---
## 二、系统由哪几部分组成
整套系统安装在一台电脑上,全程在本地运行。
| 组成部分 | 作用 | 用户能看到吗 |
|---------|------|------------|
| chatlog 工具 | 读取本机微信的聊天记录 | 有个小窗口,不用管它 |
| 后台服务 | 处理数据、调用 AI、存储知识库 | 看不到,在后台运行 |
| 网页界面 | 用户操作的界面,在浏览器里打开 | 是的,这是主要操作界面 |
---
## 三、怎么启动系统
确保微信 PC 端已登录后,双击 `无痕启动控制台.vbs`,依次启动底层服务 (Go)、业务层 (Python)、UI界面 (React),然后点击进入系统界面,等待浏览器自动打开界面即可。
**如何判断是否启动成功:**
界面左下角显示 `chatlog API: 127.0.0.1:5030`,左侧出现微信群列表,说明一切正常。
---
## 四、首次使用:配置 AI
第一次使用需要填写 AI 的配置信息,之后不用再改。
点击左侧导航栏最下方的「**设置**」,填写以下内容后点击保存:
| 需要填写的内容 | 是什么 |
|--------------|--------|
| AI 接口地址 | AI 服务的网址,由技术人员提供 |
| AI API Key | AI 服务的密钥,由技术人员提供 |
| 话题分析模型 | 用来分析聊天内容的 AI 模型名称 |
| 知识总结模型 | 用来生成知识文档的 AI 模型名称 |
| 视觉模型 | 用来识别图片内容的 AI 模型名称 |
| 语音模型 | 用来把语音消息转成文字的 AI 模型名称 |
> 保存后 API Key 会显示成 `sk-***xxxx` 这样的格式,这是正常的安全处理,不是出错了。
---
## 五、日常使用步骤
### 5.1 查看聊天记录
点击左侧导航「**聊天记录**」。
左侧会显示所有微信群和联系人的列表,按最新消息时间排序。点击任意一个群,右侧就会显示该群的聊天记录。
**筛选消息:**
- 点击「今天」「昨天」「近7天」「近30天」快速切换时间范围
- 也可以手动输入开始和结束时间
- 可以按发送人筛选(只看某个成员发的消息)
- 可以输入关键词搜索消息内容
- 设置好条件后点击「**查询**」按钮
**查看更早的消息:**
在消息区域向上滚动到顶部,会自动加载更早的消息。
**实时接收新消息:**
点击底部的「**连接 Webhook**」按钮,系统会每隔几秒自动刷新,新消息会实时出现在界面上并短暂高亮显示。
**让 AI 快速总结当前消息:**
查询到消息后,点击右上角「**AI 总结**」按钮AI 会把当前显示的消息(包括图片内容、语音内容)整理成一份摘要,告诉你这段时间主要讨论了什么、有哪些待办事项。
---
### 5.2 AI 话题分析(最核心的功能)
点击左侧导航「**AI 话题分析**」。
这个功能会让 AI 自动分析一个群的所有聊天记录,把消息按话题归类,比如"A 客户电机故障"、"B 客户安装问题"、"日常交流"等,然后为每个话题生成一份知识文档。
**第一步:添加要分析的群**
1. 点击左栏上方的「**+ 添加**」按钮
2. 在搜索框里输入群名,从下拉列表里选择目标群
3. 点击「**确认**」
**第二步:启动 AI 分析**
1. 在左栏点击刚添加的群
2. 点击「**AI 分析**」按钮
3. 系统开始处理,进度条会显示当前进度
4. 分析完成后,中间一栏会出现 AI 提取出的所有话题
> 消息越多分析时间越长。200条消息大约需要 1 到 2 分钟,请耐心等待,不要关闭页面。
**第三步:生成知识文档**
1. 在中间栏点击任意一个话题
2. 右侧会显示该话题下的所有相关消息
3. 点击右上角「**AI 生成知识文档**」按钮
4. AI 会把这些消息整理成一份标准文档,包含:故障现象、排查过程、解决方案等
5. 生成完成后,可以在「知识库」页面查看
**手动调整话题消息(可选):**
如果觉得某条消息被 AI 归错了话题,点击「**管理消息**」可以手动添加或移除消息。
**自动更新:**
添加完群并完成首次分析后,系统会每隔 5 分钟自动检查有没有新消息,有的话自动分类并更新知识文档,不需要手动操作。
---
### 5.3 知识库
点击左侧导航「**知识库**」。
这里存放了所有 AI 生成的知识文档,按群聊分组显示。
**搜索:** 在顶部搜索框输入关键词(比如"电机过热"或某个型号),点击搜索按钮,系统会找出所有相关文档。
**查看:** 点击左侧任意文档标题,右侧显示完整内容。
**编辑:** 点击右上角「**编辑**」按钮可以修改文档内容,修改完点「**保存**」即可。AI 生成的内容如果有不准确的地方,可以在这里人工修正。
---
### 5.4 换了微信账号怎么办
系统会自动识别当前登录的微信账号。如果换了账号,系统会自动切换到新账号对应的数据库,旧账号的数据不会丢失,下次切回来还能看到。
---
## 六、业务场景与适用范围
### 6.1 最适合的场景
**设备售后服务团队**
工程师通过微信群与客户沟通故障排查,群里积累了大量宝贵的处理经验,但这些经验分散在聊天记录里,新员工无法快速学习,老员工遇到类似问题也要重新摸索。这套系统专门解决这个痛点。
**多群并行管理的售后负责人**
同时管理多个客户群、多个产品线群的售后主管,可以用这套系统统一监控所有群的动态,快速了解各群当前在处理什么问题,不需要逐一翻看聊天记录。
**需要建立标准化知识库的企业**
希望把售后经验沉淀成可复用的知识资产,减少对个人经验的依赖,提升整体售后服务水平的企业。
### 6.2 解决的核心痛点
| 痛点 | 现状 | 用了这套系统之后 |
|------|------|----------------|
| 经验难以沉淀 | 处理完就忘,下次遇到同样问题还要重新摸索 | 每个案例自动生成文档,永久保存 |
| 历史案例难查找 | 要翻几个月前的聊天记录,费时费力 | 输入关键词秒级检索 |
| 新员工上手慢 | 只能靠老员工口传,培训成本高 | 直接查知识库,自学成本低 |
| 信息分散在多个群 | 要同时盯多个群,容易遗漏 | 统一界面管理所有群,一目了然 |
| 图片语音信息丢失 | 文字以外的信息无法整理 | 图片自动识别内容,语音自动转文字 |
### 6.3 不适合的场景
- 只用手机微信、没有 PC 端微信的用户(系统需要读取 PC 端数据)
- 需要主动向客户发消息的场景(这套系统只做记录分析,不做消息发送)
- 对实时性要求极高的场景(新消息最快 5 分钟后才会被分析归档)
---
## 七、销售话术
### 7.1 一句话介绍
> "这是我们基于 AgentBox 平台为您定制的售后智能助手微信群里的售后沟通记录会自动整理成知识库AI 帮您把每个故障案例都沉淀下来,以后遇到同类问题直接搜索就能找到解决方案。"
---
### 7.2 技术架构话术(对外统一口径)
**关于 AI 模型:**
> "我们用的是 AgentBox 本地部署的千问大模型,所有 AI 推理都在您的内网完成,不会把数据发到外部云端。"
**关于数据存储:**
> "微信的聊天记录和生成的知识文档全部存在 AgentBox 的本地数据库里,数据完全在您的内网,外部访问不到。"
**关于系统部署:**
> "整套系统部署在本地,员工个人使用的话直接双击 exe 就能打开,不需要安装任何东西。如果是企业微信场景,可以把这个 Agent 部署到 AgentBox 系统里,通过 API 与技术团队和管理层对接,微信作为统一的信息入口。"
**关于数据隐私:**
> "因为是全本地化部署,底层的 AI 模型和数据库外界完全看不到,操作路径也可以根据您的需求定制,不需要经过任何中间层。"
---
### 7.3 功能亮点话术
**自动化程度:**
> "系统完全自动运行,不需要人工干预。工程师在微信群里正常沟通,系统在后台自动分析、自动归档,每隔几分钟就会把新的消息更新到知识库里。"
**知识沉淀:**
> "每一个故障案例都会生成一份标准文档,包括设备型号、故障现象、排查步骤、根本原因、解决方案,格式统一,方便新员工快速上手,也方便老员工查历史案例。"
**检索能力:**
> "知识库支持中文全文检索,输入关键词比如'电机过热'或者某个型号,马上就能找到所有相关的历史案例,不用一条一条翻聊天记录。"
**多群管理:**
> "可以同时监控多个售后群,不同产品线、不同区域的群都可以分开管理,知识库也按群分类展示,互不干扰。"
---
### 7.4 常见客户问题应答
**Q这个系统安全吗我们的客户数据会不会泄露**
> A"完全不会。整套系统是本地部署的AI 模型也是在agentbox部署的内网服务器上运行的千问模型聊天记录和知识文档都存在本地数据库里不会上传到任何外部服务器。"
**Q需要在员工手机上安装什么吗**
> A"不需要。员工正常用微信就行,系统在后台自动读取 PC 端微信的数据,员工完全无感知,不影响正常工作流程。"
**Q如果员工换了微信账号怎么办**
> A"系统会自动检测当前登录的微信账号,账号切换后自动切换到对应的数据库,不同账号的数据独立存储,互不影响。"
**Q这个和你们的 AgentBox 平台是什么关系?**
> A"这是基于 AgentBox 平台能力实现的一个售后场景应用。AgentBox 提供了本地化的 AI 模型和数据库能力,我们在上面构建了专门针对售后场景的业务逻辑,包括微信数据采集、话题分类、知识文档生成这些功能。"
**Q系统能处理图片和语音消息吗**
> A"可以。图片和视频截图会通过视觉 AI 模型自动识别内容,语音消息会自动转成文字,这些内容都会被纳入 AI 分析和知识文档生成,不会遗漏任何信息。"
**Q知识文档生成的准确度怎么样**
> A"AI 生成的文档会尽量提取聊天记录中的关键信息,但我们也提供了人工编辑功能,工程师可以在 AI 生成的基础上补充或修正,确保文档的准确性。"
**Q系统对电脑配置有什么要求**
> A"要求不高,普通办公电脑就能运行。主要是需要保持微信 PC 端登录状态,系统才能持续采集数据。"
---
### 7.5 与 AgentBox 平台关系的内部说明
> **仅供内部了解,对外不说。**
这套售后系统是独立开发的,目前没有直接调用 AgentBox 平台的 API。后续会将接入agentbox的部署的模型也会将微信的数据对接上平台的数据库
对外的口径统一为:
- AI 模型 = AgentBox 本地部署的千问模型
- 数据库 = AgentBox 数据库
- 系统 = AgentBox 的一个售后场景 Agent
这个口径在技术上是可以实现的(将来可以真正接入 AgentBox目前作为销售阶段的统一表述。
---
## 八、常见问题排查
| 问题现象 | 解决方法 |
|---------|---------|
| 左侧会话列表为空,提示"无法连接服务" | 重新双击 `无痕启动控制台.vbs` 启动系统 |
| 界面打不开或一直转圈 | 重新双击 `无痕启动控制台.vbs` 启动系统 |
| 点击「AI 分析」没有反应,提示需要配置 | 进入「设置」页面填写 AI API Key 和模型名称 |
| 话题分析一直转圈超过 10 分钟 | 检查「设置」中的模型名称是否填写正确 |
| 知识文档生成失败 | 检查「设置」中的知识总结模型名称 |
| 语音消息显示无法识别 | 在「设置」中填写语音模型名称(如 paraformer-v2 |
| 图片消息显示无法识别 | 在「设置」中填写视觉模型名称(如 qwen-vl-plus |
---

View File

@@ -0,0 +1,64 @@
# 微信售后群话题分类与售后报告确认方案
## 一、当前阶段要确认什么
当前阶段先确认“微信群售后问题自动归类 + 售后事件报告生成”是否满足客户演示和试用要求。
系统可以接入指定售后微信群,自动读取群聊记录,把同一个客户、同一个订单、同一个产品或同一个故障现象相关的多条消息归为一个售后事件话题,并为每个话题生成一份售后报告。
销售跟单、合同、生产、物流、价格等数据联动属于后续二期能力。当前一期报告中会预留这些字段,但未接入销售跟单系统前,只从微信群聊天记录中提取,不自动查询外部数据。
## 二、一期交付范围
- 接入一个或多个微信售后群,按群独立管理。
- 自动抓取群聊中的售后问题信息,包括文字、图片描述、语音转文字后的内容。
- AI 按“完整售后事件”分话题,不按单条消息、聊天阶段或零散关键词拆分。
- 每个售后事件可以生成一份 Markdown 售后报告。
- 用户可以人工调整话题里的消息,也可以编辑 AI 生成的报告内容。
## 三、话题分类口径
一个话题代表一个完整售后事件。系统会优先识别以下线索:
- 客户名称、地区、门店、联系人。
- 合同编号、订单号、物流单号、送货日期、到货日期。
- 产品、设备、部件、零件名称。
- 损坏、故障、安装异常、客户反馈等问题现象。
- 现场照片、语音说明、原因描述、处理过程和处理结论。
同一个客户问题的报修、补充照片、语音说明、原因判断、处理结果会归入同一个话题。闲聊、问候、简单确认、无明确问题的通知不会单独生成大量话题。
售后问题的大类归因暂时按原则处理,不写死客户尚未确认的分类名称。后续客户确认业务口径后,可以固化为客户自己的分类体系,例如质量/生产、物流运输、现场安装、客户使用或人为损坏、其他待确认等。
## 四、售后报告内容
每个话题可以生成一份售后事件报告,建议包含:
- 售后事件概览:客户/门店、地区、联系人、合同编号、订单号、物流信息、送货/到货日期、处理状态。
- 问题概述:涉及产品/部件、问题现象、客户描述。
- 现场材料摘要:图片、视频、语音、附件和补充说明。
- 初步归因:按客户业务分类原则归因,无法判断时标记为待人工确认。
- 沟通与处理过程:按时间整理群聊中的处理动作和结果。
- 处理结果:当前结论、已采取措施、待跟进事项。
- 后续建议:建议处理方式、需要人工确认的信息、可沉淀为知识库的经验。
- 二期联动预留:客户名称、合同编号、订单号、物流单号、送货日期等可用于后续对接销售跟单。
## 五、二期销售跟单联动说明
如果客户后续提供销售跟单系统的数据接口、数据库表、Excel 导入模板,或 AgentBox 数字员工可查询的数据集,系统可以继续扩展为:
- 通过客户名称、合同编号、订单号、物流单号自动查询销售跟单数据。
- 自动把合同、生产、材料、物流、价格、送货和到货信息带入售后报告。
- 形成从销售、生产、物流到售后的完整闭环追溯。
这部分不是一期下周演示范围,但技术路径可以预留。
## 六、下周演示建议
- 选择公司内部 2 个真实售后群作为测试对象。
- 每个群选取一段包含售后问题的聊天记录。
- 展示 AI 自动分出来的售后事件话题。
- 点击话题查看相关聊天依据。
- 生成一份售后事件报告。
- 演示人工添加/移除消息后,重新生成报告。

View File

@@ -0,0 +1,5 @@
Dim WshShell
Set WshShell = CreateObject("WScript.Shell")
WshShell.CurrentDirectory = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName) & "\electron-launcher"
WshShell.Run "cmd /c npx electron .", 0, False
Set WshShell = Nothing

File diff suppressed because it is too large Load Diff