Initial upload for secondary development
This commit is contained in:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
397
CHANGELOG.md
Normal 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 SDK,chatlog.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 GB(2.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 MB,electron 二进制)
|
||||
├── 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 MB,Vite + 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.js,keyProcess.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` | 功能新增 | Bug3B(summarize 接口返回 task_id) |
|
||||
| `chatlog_fastAPI/services/summary_engine.py` | 功能新增 | Bug3B(运行时更新 ai_tasks 进度状态) |
|
||||
121
DISCLAIMER.md
Normal file
121
DISCLAIMER.md
Normal 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
202
LICENSE
Normal 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
114
README.md
Normal 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
87
chatlab-web/README.md
Normal 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
24
chatlab-web/frontend/.gitignore
vendored
Normal 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?
|
||||
16
chatlab-web/frontend/README.md
Normal file
16
chatlab-web/frontend/README.md
Normal 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.
|
||||
21
chatlab-web/frontend/eslint.config.js
Normal file
21
chatlab-web/frontend/eslint.config.js
Normal 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 } },
|
||||
},
|
||||
},
|
||||
])
|
||||
14
chatlab-web/frontend/index.html
Normal file
14
chatlab-web/frontend/index.html
Normal 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
4470
chatlab-web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
chatlab-web/frontend/package.json
Normal file
35
chatlab-web/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
chatlab-web/frontend/public/company-logo.jpg
Normal file
BIN
chatlab-web/frontend/public/company-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
1
chatlab-web/frontend/public/favicon.svg
Normal file
1
chatlab-web/frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
chatlab-web/frontend/public/icons.svg
Normal file
24
chatlab-web/frontend/public/icons.svg
Normal 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 |
336
chatlab-web/frontend/src/App.jsx
Normal file
336
chatlab-web/frontend/src/App.jsx
Normal 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
|
||||
}
|
||||
279
chatlab-web/frontend/src/api/index.js
Normal file
279
chatlab-web/frontend/src/api/index.js
Normal 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_size(page 从 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 查库
|
||||
// 视频: path(Windows 反斜杠需转成正斜杠让 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}`)
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Topics(AI 话题)
|
||||
// ─────────────────────────────────────────────
|
||||
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
|
||||
}
|
||||
119
chatlab-web/frontend/src/api/mock.js
Normal file
119
chatlab-web/frontend/src/api/mock.js
Normal 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: '好的,我来处理' },
|
||||
],
|
||||
}
|
||||
BIN
chatlab-web/frontend/src/assets/hero.png
Normal file
BIN
chatlab-web/frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
chatlab-web/frontend/src/assets/vite.svg
Normal file
1
chatlab-web/frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
337
chatlab-web/frontend/src/components/AISummaryPanel.jsx
Normal file
337
chatlab-web/frontend/src/components/AISummaryPanel.jsx
Normal 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]
|
||||
// 语音用 voiceKey(ServerID),图片/视频用 mediaKey(md5)
|
||||
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')
|
||||
}
|
||||
131
chatlab-web/frontend/src/components/MemberSelector.jsx
Normal file
131
chatlab-web/frontend/src/components/MemberSelector.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
696
chatlab-web/frontend/src/components/MessageBubble.jsx
Normal file
696
chatlab-web/frontend/src/components/MessageBubble.jsx
Normal 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 }) {
|
||||
// 图片/视频用 md5(chatlog 按 md5 查库),语音用 voiceKey(ServerID)
|
||||
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})`
|
||||
}
|
||||
127
chatlab-web/frontend/src/components/ReportDocumentView.jsx
Normal file
127
chatlab-web/frontend/src/components/ReportDocumentView.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
817
chatlab-web/frontend/src/index.css
Normal file
817
chatlab-web/frontend/src/index.css
Normal 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;
|
||||
}
|
||||
10
chatlab-web/frontend/src/main.jsx
Normal file
10
chatlab-web/frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
491
chatlab-web/frontend/src/pages/ChatlogPage.jsx
Normal file
491
chatlab-web/frontend/src/pages/ChatlogPage.jsx
Normal 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"> </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 }))
|
||||
}
|
||||
236
chatlab-web/frontend/src/pages/KnowledgePage.jsx
Normal file
236
chatlab-web/frontend/src/pages/KnowledgePage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
271
chatlab-web/frontend/src/pages/SettingsPage.jsx
Normal file
271
chatlab-web/frontend/src/pages/SettingsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1154
chatlab-web/frontend/src/pages/TopicsPage.jsx
Normal file
1154
chatlab-web/frontend/src/pages/TopicsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
427
chatlab-web/frontend/src/utils/wordExport.js
Normal file
427
chatlab-web/frontend/src/utils/wordExport.js
Normal 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)
|
||||
}
|
||||
34
chatlab-web/frontend/vite.config.js
Normal file
34
chatlab-web/frontend/vite.config.js
Normal 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
BIN
chatlog.exe
Normal file
Binary file not shown.
56
chatlog_fastAPI/ChatLabBackend.spec
Normal file
56
chatlog_fastAPI/ChatLabBackend.spec
Normal 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
55
chatlog_fastAPI/config.py
Normal 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
301
chatlog_fastAPI/database.py
Normal 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
151
chatlog_fastAPI/main.py
Normal 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)
|
||||
8
chatlog_fastAPI/requirements.txt
Normal file
8
chatlog_fastAPI/requirements.txt
Normal 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
|
||||
0
chatlog_fastAPI/routers/__init__.py
Normal file
0
chatlog_fastAPI/routers/__init__.py
Normal file
116
chatlog_fastAPI/routers/ai.py
Normal file
116
chatlog_fastAPI/routers/ai.py
Normal 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)
|
||||
93
chatlog_fastAPI/routers/chatlog_proxy.py
Normal file
93
chatlog_fastAPI/routers/chatlog_proxy.py
Normal 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)
|
||||
190
chatlog_fastAPI/routers/files.py
Normal file
190
chatlog_fastAPI/routers/files.py
Normal 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, "原文件未找到,可能未解密或已清理")
|
||||
138
chatlog_fastAPI/routers/groups.py
Normal file
138
chatlog_fastAPI/routers/groups.py
Normal 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}
|
||||
67
chatlog_fastAPI/routers/knowledge.py
Normal file
67
chatlog_fastAPI/routers/knowledge.py
Normal 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}
|
||||
144
chatlog_fastAPI/routers/search.py
Normal file
144
chatlog_fastAPI/routers/search.py
Normal 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
|
||||
64
chatlog_fastAPI/routers/settings.py
Normal file
64
chatlog_fastAPI/routers/settings.py
Normal 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"}
|
||||
40
chatlog_fastAPI/routers/sse.py
Normal file
40
chatlog_fastAPI/routers/sse.py
Normal 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"},
|
||||
)
|
||||
435
chatlog_fastAPI/routers/topics.py
Normal file
435
chatlog_fastAPI/routers/topics.py
Normal 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}
|
||||
13
chatlog_fastAPI/run_backend.py
Normal file
13
chatlog_fastAPI/run_backend.py
Normal 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()
|
||||
49
chatlog_fastAPI/scheduler.py
Normal file
49
chatlog_fastAPI/scheduler.py
Normal 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)")
|
||||
0
chatlog_fastAPI/services/__init__.py
Normal file
0
chatlog_fastAPI/services/__init__.py
Normal file
31
chatlog_fastAPI/services/ai_client.py
Normal file
31
chatlog_fastAPI/services/ai_client.py
Normal 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
|
||||
203
chatlog_fastAPI/services/chatlog_client.py
Normal file
203
chatlog_fastAPI/services/chatlog_client.py
Normal 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()
|
||||
35
chatlog_fastAPI/services/chatlog_context.py
Normal file
35
chatlog_fastAPI/services/chatlog_context.py
Normal 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)
|
||||
25
chatlog_fastAPI/services/fts.py
Normal file
25
chatlog_fastAPI/services/fts.py
Normal 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)
|
||||
142
chatlog_fastAPI/services/media_parser.py
Normal file
142
chatlog_fastAPI/services/media_parser.py
Normal 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 ""
|
||||
174
chatlog_fastAPI/services/media_resolver.py
Normal file
174
chatlog_fastAPI/services/media_resolver.py
Normal 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,
|
||||
)
|
||||
253
chatlog_fastAPI/services/message_formatter.py
Normal file
253
chatlog_fastAPI/services/message_formatter.py
Normal 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)
|
||||
139
chatlog_fastAPI/services/report_learning.py
Normal file
139
chatlog_fastAPI/services/report_learning.py
Normal 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)
|
||||
45
chatlog_fastAPI/services/runtime_settings.py
Normal file
45
chatlog_fastAPI/services/runtime_settings.py
Normal 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
|
||||
476
chatlog_fastAPI/services/summary_engine.py
Normal file
476
chatlog_fastAPI/services/summary_engine.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
售后报告生成引擎
|
||||
- 从 topic_messages 拿到所有 msg_seq
|
||||
- 通过 chatlog batch 接口批量拉回消息原文
|
||||
- 用配置的总结模型生成 Markdown 售后事件报告
|
||||
- 写入 knowledge_docs + knowledge_fts(jieba 分词)
|
||||
"""
|
||||
|
||||
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"",
|
||||
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 里存的是群 ID(chatlog 叫 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)
|
||||
1094
chatlog_fastAPI/services/topic_engine.py
Normal file
1094
chatlog_fastAPI/services/topic_engine.py
Normal file
File diff suppressed because it is too large
Load Diff
101
electron-launcher/electron-builder.config.cjs
Normal file
101
electron-launcher/electron-builder.config.cjs
Normal 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售后智能助手',
|
||||
},
|
||||
};
|
||||
967
electron-launcher/index.html
Normal file
967
electron-launcher/index.html
Normal 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
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
3643
electron-launcher/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
electron-launcher/package.json
Normal file
18
electron-launcher/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
24
electron-launcher/preload.js
Normal file
24
electron-launcher/preload.js
Normal 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
BIN
lib/windows_x64/wx_key.dll
Normal file
Binary file not shown.
777
license_server/正式上线打包与授权流程.md
Normal file
777
license_server/正式上线打包与授权流程.md
Normal 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
527
plan.md
Normal 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 上不可控的问题。
|
||||
BIN
release/ChatLab-Setup-1.0.1-202605310641.exe
Normal file
BIN
release/ChatLab-Setup-1.0.1-202605310641.exe
Normal file
Binary file not shown.
BIN
release/ChatLab-Setup-1.0.1-202605310641.exe.blockmap
Normal file
BIN
release/ChatLab-Setup-1.0.1-202605310641.exe.blockmap
Normal file
Binary file not shown.
766
release/manifest.txt
Normal file
766
release/manifest.txt
Normal 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
234
scripts/build-desktop.ps1
Normal 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
226
scripts/make-icon.cjs
Normal 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();
|
||||
}
|
||||
1144
售后开发底座二次开发交付手册.md
Normal file
1144
售后开发底座二次开发交付手册.md
Normal file
File diff suppressed because it is too large
Load Diff
264
售后文档.md
Normal file
264
售后文档.md
Normal 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) |
|
||||
|
||||
---
|
||||
|
||||
64
微信售后群话题分类与报告确认方案.md
Normal file
64
微信售后群话题分类与报告确认方案.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 微信售后群话题分类与售后报告确认方案
|
||||
|
||||
## 一、当前阶段要确认什么
|
||||
|
||||
当前阶段先确认“微信群售后问题自动归类 + 售后事件报告生成”是否满足客户演示和试用要求。
|
||||
|
||||
系统可以接入指定售后微信群,自动读取群聊记录,把同一个客户、同一个订单、同一个产品或同一个故障现象相关的多条消息归为一个售后事件话题,并为每个话题生成一份售后报告。
|
||||
|
||||
销售跟单、合同、生产、物流、价格等数据联动属于后续二期能力。当前一期报告中会预留这些字段,但未接入销售跟单系统前,只从微信群聊天记录中提取,不自动查询外部数据。
|
||||
|
||||
## 二、一期交付范围
|
||||
|
||||
- 接入一个或多个微信售后群,按群独立管理。
|
||||
- 自动抓取群聊中的售后问题信息,包括文字、图片描述、语音转文字后的内容。
|
||||
- AI 按“完整售后事件”分话题,不按单条消息、聊天阶段或零散关键词拆分。
|
||||
- 每个售后事件可以生成一份 Markdown 售后报告。
|
||||
- 用户可以人工调整话题里的消息,也可以编辑 AI 生成的报告内容。
|
||||
|
||||
## 三、话题分类口径
|
||||
|
||||
一个话题代表一个完整售后事件。系统会优先识别以下线索:
|
||||
|
||||
- 客户名称、地区、门店、联系人。
|
||||
- 合同编号、订单号、物流单号、送货日期、到货日期。
|
||||
- 产品、设备、部件、零件名称。
|
||||
- 损坏、故障、安装异常、客户反馈等问题现象。
|
||||
- 现场照片、语音说明、原因描述、处理过程和处理结论。
|
||||
|
||||
同一个客户问题的报修、补充照片、语音说明、原因判断、处理结果会归入同一个话题。闲聊、问候、简单确认、无明确问题的通知不会单独生成大量话题。
|
||||
|
||||
售后问题的大类归因暂时按原则处理,不写死客户尚未确认的分类名称。后续客户确认业务口径后,可以固化为客户自己的分类体系,例如质量/生产、物流运输、现场安装、客户使用或人为损坏、其他待确认等。
|
||||
|
||||
## 四、售后报告内容
|
||||
|
||||
每个话题可以生成一份售后事件报告,建议包含:
|
||||
|
||||
- 售后事件概览:客户/门店、地区、联系人、合同编号、订单号、物流信息、送货/到货日期、处理状态。
|
||||
- 问题概述:涉及产品/部件、问题现象、客户描述。
|
||||
- 现场材料摘要:图片、视频、语音、附件和补充说明。
|
||||
- 初步归因:按客户业务分类原则归因,无法判断时标记为待人工确认。
|
||||
- 沟通与处理过程:按时间整理群聊中的处理动作和结果。
|
||||
- 处理结果:当前结论、已采取措施、待跟进事项。
|
||||
- 后续建议:建议处理方式、需要人工确认的信息、可沉淀为知识库的经验。
|
||||
- 二期联动预留:客户名称、合同编号、订单号、物流单号、送货日期等可用于后续对接销售跟单。
|
||||
|
||||
## 五、二期销售跟单联动说明
|
||||
|
||||
如果客户后续提供销售跟单系统的数据接口、数据库表、Excel 导入模板,或 AgentBox 数字员工可查询的数据集,系统可以继续扩展为:
|
||||
|
||||
- 通过客户名称、合同编号、订单号、物流单号自动查询销售跟单数据。
|
||||
- 自动把合同、生产、材料、物流、价格、送货和到货信息带入售后报告。
|
||||
- 形成从销售、生产、物流到售后的完整闭环追溯。
|
||||
|
||||
这部分不是一期下周演示范围,但技术路径可以预留。
|
||||
|
||||
## 六、下周演示建议
|
||||
|
||||
- 选择公司内部 2 个真实售后群作为测试对象。
|
||||
- 每个群选取一段包含售后问题的聊天记录。
|
||||
- 展示 AI 自动分出来的售后事件话题。
|
||||
- 点击话题查看相关聊天依据。
|
||||
- 生成一份售后事件报告。
|
||||
- 演示人工添加/移除消息后,重新生成报告。
|
||||
|
||||
5
无痕启动控制台.vbs
Normal file
5
无痕启动控制台.vbs
Normal 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
|
||||
1481
桌面应用打包技术方案.md
Normal file
1481
桌面应用打包技术方案.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user