# 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 计划文件),用如下结构: ``` /sessions/ ├── _index.json # 轻量索引: [{id, title, msg_count, title_generated, ...}] ├── 2026-06-14_153500/ # session id = 创建时间戳 │ ├── chat.jsonl # 一行一条 JSON 消息 │ └── images/.{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` 渲染截图 + `` 搜索高亮 + `` 折叠 | **关键设计**: - **消息 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//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` 改成 `image_path: Option`,后端读文件、转 base64、构造 `ImageAttachment` 2. **加 `load_chat_image` 路径校验**:拒绝绝对路径 / `..` 路径遍历 / 非 png|jpg 后缀;限定路径必须在 `sessions//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 { ... } pub fn assert_not_reminder(id, op) -> Result<()> { ... } // 拒绝 CRUD pub fn append_reminder(app, action_type, message) -> Result { ... } ``` `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` 顶部加 ` ``` --- ### 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//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` 顶部的 `