Files
EzVibeR/docs/impl/2026-06-14-session-summary.md
Claude Agent 5f830b3a1c Update: ReadMe
2026-06-14 16:40:02 +08:00

345 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EzVibeR+ 2026-06-14 会话工作汇总
> 日期: 2026-06-14
> 分支: main
> 范围: 多会话持久化、提醒中心、截图存档、UI 调整、LLM 兼容性修复
---
## 一、整体工作概览
本次会话横跨 7 个独立但有交叉的功能模块,最终全部完成、编译通过、`npm run tauri:dev` 验证可运行。
| # | 模块 | 状态 | 用户原话要点 |
|---|------|------|------------|
| 1 | 多会话聊天持久化JSONL + 图片) | ✅ | "每次聊天做记录 / 多会话 / 自动标题 / 搜索 / 导出" |
| 2 | 提醒中心伪 session | ✅ | "reminder 单独占一个对话,叫提醒中心" |
| 3 | 截图显示 + 发送给 AI 修复 | ✅ | "截图之后无法正常显示图片 / 发给AI不支持" |
| 4 | 模型位置 + 浮动工具栏 | ✅ | "更靠近对话区域 / 调出右侧设置按钮" |
| 5 | LLM 兼容性detail / 超时) | ✅ | "invalid image detail / 60s 超时" |
| 6 | 右侧滚动条修复 | ✅ | "滑一下整个界面全在动" |
| 7 | 移除 help banner | ✅ | "把这段话删掉,每次都出现" |
---
## 二、模块详解
### 2.1 多会话聊天持久化(最复杂的一坨)
**用户原始诉求链**
1. "我需要你把图片持久地放在聊天区域内"(截图持久化)
2. "对每次聊天做记录在日志文件中"JSONL 日志)
3. "我列出来 4 个局限,改进所有"(自动标题 / 搜索 / 导出 / 内存不存 base64
**架构决策**:参考 `/home/e2hang/.claude/plans/binary-puzzling-storm.md`plan mode 计划文件),用如下结构:
```
<app_data_dir>/sessions/
├── _index.json # 轻量索引: [{id, title, msg_count, title_generated, ...}]
├── 2026-06-14_153500/ # session id = 创建时间戳
│ ├── chat.jsonl # 一行一条 JSON 消息
│ └── images/<uuid>.{png,jpg} # 截图二进制
```
**实现要点**
| 文件 | 作用 |
|------|------|
| `src-tauri/src/modules/session.rs` (新建 ~400 行) | 索引读写 / jsonl 追加 / 截图存盘 / 搜索 / 导出 |
| `src-tauri/src/app/commands.rs` | 11 个 Tauri command: `list_sessions`, `create_session`, `delete_session`, `rename_session`, `load_session_messages`, `append_message`, `save_chat_image`, `search_all_sessions`, `export_session`, `check_title_trigger`, `generate_session_title`, `ensure_reminder_session` |
| `src-tauri/src/main.rs` | `invoke_handler!` 注册所有新 command |
| `src/live2d/hooks/useChat.ts` (重写 ~600 行) | 多会话状态、持久化防抖、搜索、导出、截图 listener |
| `src/live2d/components/ChatPanel.vue` (重写 ~600 行) | 顶部 session 下拉 + 工具栏 + 全局搜索弹窗 |
| `src/live2d/components/MessageBubble.vue` | `convertFileSrc` 渲染截图 + `<mark>` 搜索高亮 + `<think>` 折叠 |
**关键设计**
- **消息 ID 用 `${Date.now()}-${rand}`**,避免重复
- **持久化防抖 250ms** (`PERSIST_DEBOUNCE_MS`):消息 push 后不立即写盘,连续 push 只写一次
- **自动标题触发条件**`msg_count >= 5 && !title_generated``TITLE_TRIGGER_AT = 5`),异步调 LLM 生成 ≤20 字短标题
- **截图消息**`role: "screenshot"`,消息里只存 `imagePath` (JPEG) 和 `imagePngPath` (PNG)**base64 不进内存**
---
### 2.2 截图显示 + 发送给 AI 修复
**Bug 现象**
- 截图后气泡里图片不显示(空白)
- 点"发送给 AI"后报 60s 超时
**问题诊断**
**Bug A — 图片不显示**
- `MessageBubble.vue` 用了 `convertFileSrc(p)``p` 是相对路径 `sessions/<id>/images/abc.jpg`
- **Tauri 2 的 `convertFileSrc` 只接受绝对路径**,相对路径会得到无效的 `asset://` URL
- 同时 `tauri.conf.json` 没开 `assetProtocol.scope`webview 没权限访问 sessions/ 目录
**Bug B — 发送给 AI 失败**
- `useChat.sendImageInternal` 把**文件路径字符串**当 base64 塞给 `invoke("chat", { imageData })`
- Rust 把它当 base64 解码,喂给 LLM 全是乱码
**解决思路**
1. **改 Rust chat 命令**:从 `image_data: Option<String>` 改成 `image_path: Option<String>`,后端读文件、转 base64、构造 `ImageAttachment`
2. **加 `load_chat_image` 路径校验**:拒绝绝对路径 / `..` 路径遍历 / 非 png|jpg 后缀;限定路径必须在 `sessions/<id>/images/` 下;文件大小上限 20MB
3. **加 `tauri.conf.json` 资产协议白名单**`scope: ["$APPDATA/sessions/**"]`
4. **改前端 MessageBubble**:用 `appDataDir() + join` 拼绝对路径再 `convertFileSrc`,加 loading 占位
**结果**
- `src-tauri/src/app/commands.rs:220-274``chat` 命令签名
- `src-tauri/src/app/commands.rs:644-740` 新增 `load_chat_image` 辅助函数
- `src-tauri/tauri.conf.json:33-40``assetProtocol`
- `src/live2d/components/MessageBubble.vue:4-65``appDataDir` + `watch` 异步刷新 src
---
### 2.3 提醒中心伪 session
**用户原始决策**"我希望 reminder 单独占一个对话,叫提醒中心"
**架构设计**
- 保留 ID `__reminders__`,标题固定 `🔔 提醒中心`
- 后端定时 tick 触发 reminder 时**直接写**到该 session 的 jsonl不依赖前端 listener
- emit `ezvibe:reminder` 事件时附带 `session_id` 字段供前端切换
- 前端 listener 收到后**自动切到提醒中心视图**
- reminders session **不能删除 / 不能重命名 / 不参与 LLM 自动标题生成**
**实现要点**
`session.rs` 新增:
```rust
pub const REMINDER_SESSION_ID: &str = "__reminders__";
pub const REMINDER_SESSION_TITLE: &str = "🔔 提醒中心";
pub fn is_reminder_session(id: &str) -> bool { ... }
pub fn ensure_reminder_session(app) -> Result<SessionMeta> { ... }
pub fn assert_not_reminder(id, op) -> Result<()> { ... } // 拒绝 CRUD
pub fn append_reminder(app, action_type, message) -> Result<u32> { ... }
```
`main.rs:78-127` 改定时 tick 流程:
```
brain.think() → action
→ session::append_reminder(...) // 直接落盘
→ emit("ezvibe:reminder", {..., session_id: REMINDER_SESSION_ID})
→ notify::send(...) // OS 通知
```
`useChat.ts` listener 改动:
- 收到 reminder → 切到 `REMINDER_SESSION_ID``load_session_messages` reload
- 不再 `pushMessage`(后端是权威源)
`ChatPanel.vue` UI 改动:
- ✎ 重命名 / 🗑 删除按钮在 reminders session 变灰禁用
- 新增 🔔 快速跳转按钮(高亮激活态)
- `isOnReminderCenter` computed
---
### 2.4 模型位置 + 浮动工具栏
**用户两轮反馈**
1. "让她更靠近对话区域"(模型偏右)
2. "不不,我需要模型在那个模型界面的中间,但是增大模型的大小到身体宽度不超出模型界面范围,高度接近对话框"
**最终公式**`loadModel` + `recenterModel` 共享):
```js
const leftPaneWidth = chatCollapsed ? w : (w - 340);
let modelHeight = h; // 目标高度 = 窗口高(接近 chat 高度)
let modelWidth = modelHeight * 0.5; // 宽高比 0.5
if (modelWidth > leftPaneWidth) { // 宽度超界 → 反推
modelWidth = leftPaneWidth;
modelHeight = modelWidth / 0.5;
}
model.x = (leftPaneWidth - modelWidth) / 2; // 水平居中
model.y = (h - modelHeight) / 2; // 垂直居中
```
**默认 600×480 窗口chat 开)**:左窗格 260×480 → 模型 240×480左右各 10px 余量,垂直贴满。
**浮动工具栏修复**
- **问题**`.waifu-tool``position: absolute`,但 `.waifu` 没设 `position: relative`,导致 absolute 落到 body被 chat 面板z-index 999盖住
- **修复**
- `.waifu``position: relative`(变成定位上下文)
- `.waifu-tool` 重写:竖排、半透明白底+模糊背景、紧贴左窗格右侧
- `z-index: 50`(高于 hitbox=2低于 chat-pane=999
- 默认可见,不依赖 hover
- hover 高亮蓝色背景
**9 个按钮都在**(自上而下):💬 chat 切换 / ⚙ 配置 / ☐ 背景 / 👁 换模型 / 📍 位置 / ⬚ 大小 / ⓘ 复制URL / 🔒 穿透 / ❌ 关闭
---
### 2.5 LLM 兼容性detail / 超时)
**两个错**
**错 A — `invalid image detail: auto (2013)`**
- 用户自部署的 OpenAI 兼容服务严格校验 `detail` 字段
- OpenAI 官方 spec`detail` 是可选,默认 `auto`,但某些网关会拒绝
- **解决**:不传 `detail` 字段(`openai.rs:148-156`
- 顺便Anthropic 用 `type: "image"` + `source.base64`,没这个问题
**错 B — `请求超时60s: operation timed out`**
- `curl` 测了 1.5s 就通401 是没带 key→ 网络/代理都正常
- 慢在 LLM 视觉模型处理多模态(图片 base64 上传 + 视觉编码 + 文本生成)
- **解决 1**:超时 60s → 180s`openai.rs:45` / `anthropic.rs:36`
- **解决 2**:发送图片改用 **JPEG 预览**而不是 PNG 原图(`MessageBubble.vue:17-26`
- PNG 几 MBbase64 后 7-20MB
- JPEG 几十到几百 KB体积 ~1/10
- LLM 不需要无损quality 80 的 JPEG 足够
---
### 2.6 右侧滚动条修复
**现象**"最右侧的那个滚动条,滑一下整个界面全在动"
**根因**
- `index.vue` 里的 scoped CSS 写了 `body, html { overflow: hidden }`
- Vue scoped CSS 会给选择器加 `[data-v-xxxx]` 属性选择器
- **但 `body` / `html` 标签上没有 `data-v-xxxx` 属性** → 这条规则被静默丢弃
- 结果body 默认 overflow: visible → 透明窗口边缘出现滚动条,滑动带整体 layout 偏移
**解决**
-`live2d.html` 顶部加 `<style>` 块直接锁定根容器
- 顺便设了 `background: transparent`(桌面宠物透明窗口需要)
- 删了 `index.vue` 里那段无效的 scoped 规则
```html
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; background: transparent; }
#live2d-app { width: 100vw; height: 100vh; overflow: hidden; }
</style>
```
---
### 2.7 移除 help banner
**用户反馈**"现在把这段话删掉...每次都出现在窗口中"
**根因**`onMounted``if (!llmKeyConfigured) showHelp = true`,每次启动 API key 未配置时强制展开帮助横幅。
**解决**:彻底移除 help banner 相关代码(不留任何痕迹):
- `const showHelp = ref(false)` state
- onMounted 里的强制展开
- 顶栏 `?` 切换按钮
- `.help-banner` 整块 HTML包含 LLM 设置说明 + API key 警告 + Provider/Key/Model/URL 列表)
- 对应的 scoped CSS
---
## 三、关键经验与通用原则
### 3.1 Tauri 2 的 `convertFileSrc` 必须是绝对路径
- 相对路径(如 `sessions/<id>/images/x.png`)会得到无效的 `asset://` URL图片无法加载
- 必须先用 `appDataDir() + join(...)` 拼绝对路径
- 同步的 `appDataDir()` 是 async前端要用 `watch` 监听 props 变化异步刷新 src
### 3.2 `tauri.conf.json` 的 `assetProtocol.scope` 必须白名单放行
- 默认 Tauri 2 不允许 webview 访问 `file://` / `asset://` 任意路径
- 截图存在 `app_data_dir` 下,必须显式加 `"$APPDATA/sessions/**"` 到 scope
- 不开 → convertFileSrc 拿到 URL 也读不出文件
### 3.3 前端 → 后端的 base64 数据要走命令参数,不走事件
- 事件是单向 fire-and-forget传递大 base64 不合适
- 让后端用路径作为 handle**后端自己读文件转 base64**
- 避免 IPC 大 payload
- 路径校验在后端做,安全性高
- 前端只关心"哪个文件",不关心"内容"
### 3.4 Vue scoped CSS 对 `body` / `html` 无效
- scoped CSS 的 `[data-v-xxxx]` 属性只加在组件根元素
- 顶层标签(`html` / `body` / `:root`)永远不会有这个属性
- 涉及全局的样式必须放 `index.html` / `xxx.html` 顶部的 `<style>` 块,或独立的全局 CSS 文件
### 3.5 Tailscale IP + clash-verge 代理冲突
- `.no_proxy()` 是必须的clash-verge 会劫持 Tailscale IP100.64.0.0/10
- 不加 → 内部服务走 clash 出去再绕回来,超时
- 加 → 直连,正常
- 之前我们修过这个 bug这次主要是 LLM 视觉模型本身慢
### 3.6 定时任务写入数据库要后端权威
- 之前 reminder 走前端 listener前端 push → 触发持久化)→ 竞态currentSessionId 可能是 null
- 改成后端直接 `append_reminder` 写 jsonl前端只负责 UI 切换
- 单一数据源disk前端不再有"消息丢了 / 重复了"的问题
---
## 四、验证状态
| 检查项 | 结果 |
|--------|------|
| `npx vue-tsc --noEmit` | EXIT 0 |
| `cargo check` | Finished仅 dead-code warnings |
| `npm run tauri:dev` 启动 | 成功,进程稳定运行 |
| LLM 文本对话 | ✅ |
| 截图保存到磁盘 | ✅ `~/.local/share/com.ezviber.plus/sessions/<id>/images/` |
| 截图在气泡内显示 | ✅convertFileSrc + assetProtocol |
| 截图发送给 AI | ✅load_chat_image + JPEG 优化) |
| 多会话切换 | ✅ 下拉选 |
| 自动标题生成 | ✅ 5 条消息后异步调 LLM |
| 当前会话搜索 | ✅ `<mark>` 黄色高亮 |
| 全局搜索 | ✅ 弹窗 + 跨会话命中 |
| 导出 MD / JSON | ✅ Blob 下载 |
| reminder 写入提醒中心 | ✅ 60s tick / 手动 trigger |
| 提醒中心视图自动切换 | ✅ |
| reminders session 删除/重命名保护 | ✅ Rust 端拒绝 |
| 模型尺寸居中且接近 chat 高度 | ✅ 240×480 默认 |
| 浮动工具栏可见 | ✅ 右侧 9 个按钮 |
| 滚动条不出现 | ✅ html/body locked |
| help banner 不再出现 | ✅ 彻底删除 |
| LLM 60s 超时修复 | ✅ 180s + JPEG 优化 |
---
## 五、文件变更清单
### 后端 Rust新增 / 修改)
| 文件 | 类型 | 说明 |
|------|------|------|
| `src-tauri/src/modules/session.rs` | 新增 | 多会话、JSONL、截图存盘、搜索、导出、提醒中心 |
| `src-tauri/src/modules/screenshot.rs` | 沿用 | xdg-desktop-portal 截屏(之前会话完成) |
| `src-tauri/src/modules/notify.rs` | 沿用 | 系统通知封装(之前会话完成) |
| `src-tauri/src/app/commands.rs` | 修改 | +12 个 session/截图/搜索/导出 command, `chat` 改用 image_path |
| `src-tauri/src/main.rs` | 修改 | 注册新 command, reminder 写入提醒中心 |
| `src-tauri/src/modules/mod.rs` | 修改 | `pub mod session;` |
| `src-tauri/tauri.conf.json` | 修改 | `assetProtocol.scope: ["$APPDATA/sessions/**"]` |
| `src-tauri/src/modules/openai.rs` | 修改 | 移除 `detail:"auto"`, 超时 60→180s |
| `src-tauri/src/modules/anthropic.rs` | 修改 | 超时 60→180s |
### 前端(新增 / 修改)
| 文件 | 类型 | 说明 |
|------|------|------|
| `src/live2d/hooks/useChat.ts` | 重写 | 多会话状态、持久化、搜索、导出、提醒中心 |
| `src/live2d/components/ChatPanel.vue` | 重写 | session 下拉 / 工具栏 / 全局搜索 / 提醒中心按钮 |
| `src/live2d/components/MessageBubble.vue` | 重写 | 截图气泡 / 搜索高亮 / 思考折叠 / 取消按钮 |
| `src/live2d/index.vue` | 修改 | 模型尺寸/位置公式, 浮动工具栏样式, 删冗余 body 规则 |
| `live2d.html` | 修改 | 顶部加 `<style>` 锁 body/html overflow |
### 文档
| 文件 | 说明 |
|------|------|
| `docs/impl/2026-06-14-session-summary.md` | 本文档 |
---
## 六、未做 / 留给后续
| 项 | 备注 |
|----|------|
| 图片缩放(> 1024px 等比缩到 1024 长边) | 用户没要,体积已经够小 |
| reminders session 显示/隐藏开关 | 已在下拉第一行自然浮现,无需单独控制 |
| 截图历史清理(> 30 天自动删) | 暂未做,磁盘不紧张 |
| 多模态 LLM 端点连通性测试 | 已知通;具体 LLM 处理速度是端点问题 |
| reminders UI 角标(未读计数) | 当前是直接切视图打断用户,简单粗暴;如需不打断可加角标 |