15 KiB
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 多会话聊天持久化(最复杂的一坨)
用户原始诉求链:
- "我需要你把图片持久地放在聊天区域内"(截图持久化)
- "对每次聊天做记录在日志文件中"(JSONL 日志)
- "我列出来 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 全是乱码
解决思路:
- 改 Rust chat 命令:从
image_data: Option<String>改成image_path: Option<String>,后端读文件、转 base64、构造ImageAttachment - 加
load_chat_image路径校验:拒绝绝对路径 /..路径遍历 / 非 png|jpg 后缀;限定路径必须在sessions/<id>/images/下;文件大小上限 20MB - 加
tauri.conf.json资产协议白名单:scope: ["$APPDATA/sessions/**"] - 改前端 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加assetProtocolsrc/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 新增:
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_messagesreload - 不再
pushMessage(后端是权威源)
ChatPanel.vue UI 改动:
- ✎ 重命名 / 🗑 删除按钮在 reminders session 变灰禁用
- 新增 🔔 快速跳转按钮(高亮激活态)
isOnReminderCentercomputed
2.4 模型位置 + 浮动工具栏
用户两轮反馈:
- "让她更靠近对话区域"(模型偏右)
- "不不,我需要模型在那个模型界面的中间,但是增大模型的大小到身体宽度不超出模型界面范围,高度接近对话框"
最终公式(loadModel + recenterModel 共享):
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 规则
<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 角标(未读计数) | 当前是直接切视图打断用户,简单粗暴;如需不打断可加角标 |