345 lines
15 KiB
Markdown
345 lines
15 KiB
Markdown
# 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 几 MB,base64 后 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 IP(100.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 角标(未读计数) | 当前是直接切视图打断用户,简单粗暴;如需不打断可加角标 |
|