# EzVibeR+ 2026-06-19 多 Bug 修复 + 日期分割线 + 启动 GC 工作汇总 > 日期: 2026-06-19 > 分支: main > 范围: reload 后图片不显示 / 截图后整面板不可点 / 截图预览气泡不消失 / 截图后窗口属性丢失 / 日期分割线 / 启动时图片孤儿 GC --- ## 一、用户原始诉求链 本次会话是**多 Bug 修复 + 两项新功能**,按用户提出顺序: | # | 诉求 | 类型 | |---|------|------| | 1 | reload 之后,之前发过的图片无法显示 | Bug | | 2 | 把搜索/导出工具栏做成弹出按钮(顶部 🔍) | UI 调整 | | 3 | reload 之后依然截图加载不出来(只显示"截图加载中") | Bug(1 的延续) | | 4 | 点击人物截图之后,整个 panel 无法正常点击 | Bug | | 5 | 截图后点"发送给AI"或"取消"要删除该气泡(包含那两个按钮) | Bug | | 6 | "发送给AI"应该是先发 API,再让气泡消失(和取消效果一样) | Bug(5 的延续) | | 7 | "发送给AI"之后气泡依然没消失,重新检查 | Bug(6 的延续) | | 8 | 截图后窗口丢失 `always_on_top` 和 `visible_on_all_workspaces`,需重设 | Bug | | 9 | 仿微信做日期分割线(含年月日) | 新功能 | | 10 | screenshot 角色不要写入聊天记录 | Bug(回归修复) | | 11 | 添加 GC,在启动时跑 | 新功能 | --- ## 二、时间线与迭代(避坑记录) | 轮次 | 关键事件 | 结论 | |------|---------|------| | 1 | 改 `MessageBubble` 用 `appDataDir + join` 拼绝对路径 | 加载仍未恢复 | | 2 | 加 `#[serde(rename_all = "camelCase")]` + `alias` 到 `PersistedMessage` | reload 后图片显示 ✅ | | 3 | 截图后加 `w.set_focus()` 恢复焦点 | panel 可点 ✅ | | 4 | 在 `sendImageInternal` `finally` 块删气泡 | **失败**:三个 early return 跳过 finally | | 5 | 把 `removeMessage(screenshotMsgId)` 提到函数开头 | 气泡立刻消失 ✅ | | 6 | 把 screenshot 拒绝写入 jsonl + reload 时跳过 screenshot 行 | 磁盘彻底干净 ✅ | | 7 | 加启动 GC,扫 `images/` 目录删孤儿 | 清理历史遗留 ✅ | --- ## 三、模块详解 ### 3.1 reload 后图片不显示(诉求 1 + 3) **现象**:截图后 chat 里图片能正常显示,但 reload 应用后图片变成"截图加载中…"占位。 **根因排查**: 1. 前端 `MessageBubble.vue` 用 `convertFileSrc(relativePath)`,Tauri 2 的 `convertFileSrc` 要求**绝对路径**,相对路径会得到无效 `asset://` URL → 改成 `appDataDir() + join(...)` 异步刷新 src 2. 改完后图片**依然不显示** → 怀疑 `tauri.conf.json` assetProtocol scope 不够 → 检查 `tauri.conf.json`,scope `["$APPDATA/sessions/**"]` 已经在了,不是这个问题 3. 真正根因:`PersistedMessage` 用 snake_case(`image_path`),前端 `ChatMessage` 用 camelCase(`imagePath`),Tauri 2 IPC **不做 snake↔camel 自动转换** → JSONL 写出的字段前端读不到,reload 时 `msg.imagePath` 全是 undefined → `MessageBubble` 走到 `thumbSrc = undefined` 分支,显示"截图加载中" **解决**: `src-tauri/src/modules/session.rs` `PersistedMessage` 加 serde 注解: ```rust #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PersistedMessage { pub id: String, pub role: String, pub text: String, pub ts: i64, #[serde(default, skip_serializing_if = "Option::is_none", alias = "action_type")] pub action_type: Option, #[serde(default, skip_serializing_if = "Option::is_none", alias = "motion_tag")] pub motion_tag: Option, #[serde(default, skip_serializing_if = "Option::is_none", alias = "image_path")] pub image_path: Option, #[serde(default, skip_serializing_if = "Option::is_none", alias = "image_png_path")] pub image_png_path: Option, } ``` `alias` 同时保留 snake_case 别名,使得之前 jsonl 已经写入的数据仍能被读出(向后兼容)。 --- ### 3.2 搜索/导出工具栏改为弹出(诉求 2) **用户原话**: > "把'搜索当前会话'以及导出会话内容的这个横向的框,做成一个弹出,就是在顶部有一个按钮,点击按钮后再展示这个界面,而不是直接固定在对话框内" **实现**:`ChatPanel.vue` 新增 `showToolbar` ref + 🔍 切换按钮,工具栏 `v-if="showToolbar"` 折叠。 同时清理了已经失效的 `showGlobalSearchInput` 死代码。 --- ### 3.3 截图后整面板不可点击(诉求 4) **现象**:点桌宠截图,截图完成后整个 chat panel 无法点击(看起来"卡死")。 **根因**: - `capture_screenshot` 流程:hide 主窗口 → 睡 250ms → 调外部截图工具 → `w.show()` 恢复显示 - 在 X11 + `decorations: false` 的组合下,hide→show 之后窗口可见但**没有焦点** - 用户没办法通过点击标题栏拿回焦点(根本没标题栏) - webview 收不到 click,chat panel 看起来"卡死" **解决**:`src-tauri/src/app/commands.rs:417-424` 在 `w.show()` 之后加 `w.set_focus()`: ```rust if let Err(e) = w.show() { log::warn!(...); } if let Err(e) = w.set_focus() { log::warn!(...); } ``` --- ### 3.4 截图预览气泡不消失(诉求 5 + 6 + 7) **现象演进**: - v1:用户报告"点发送给AI或取消,气泡都不消失" - v2(我修完后):用户报告"点取消会消失,但发送给AI不会消失" - v3:用户要求"先发 API,再让气泡消失,效果和取消一样" - v4:**我改成 finally 块删除,用户反馈气泡还是没消失** - v5:**最终解法:把删除提到函数开头** **根因(迭代记录)**: v1 错误做法是根本没在合适的位置调 `removeMessage`,改成 finally 后仍未消除,原因: ```ts async function sendImageInternal(imagePath, userText, screenshotMsgId) { if (loading.value) return; // ← early return if (!imagePath) return; // ← early return if (!currentSessionId.value) return; // ← early return try { ... } finally { if (screenshotMsgId) removeMessage(screenshotMsgId); // ← 命中 early return 时根本不执行 } } ``` 最典型触发:用户先发了文字消息,LLM 还在思考(`loading.value === true`),这时截图并点"发送给AI"——`sendImageInternal` 第一行直接 return,气泡永远删不掉。 **最终解法**: ```ts async function sendImageInternal(imagePath, userText, screenshotMsgId) { // 和 cancel 一致 —— 进入函数先把"图片预览"气泡删掉,UI 立刻干净 if (screenshotMsgId) removeMessage(screenshotMsgId); if (loading.value) return; // ← 即使 return,气泡也已经删了 if (!imagePath) return; if (!currentSessionId.value) return; // ... push user msg, invoke chat, push assistant ... } ``` 副作用:现在无论走哪个分支(包括 early return / catch),气泡都已经被移除。这与 `cancel` 完全等价。 --- ### 3.5 截图后窗口属性丢失(诉求 8) **现象**:截图完成后,桌宠丢失 `always_on_top` 和 `visible_on_all_workspaces`,需要点 tray 菜单的"显示桌宠"才能恢复。 **根因**:某些 WM(GNOME mutter / KDE)在 `hide → show` 之后会丢掉 `_NET_WM_STATE_ABOVE` 和 `_NET_WM_STATE_STICKY` 等 hint。 **方案对比**: | 方案 | 评价 | |------|------| | A. 前端 emit `show_pet` 命令,后端再走一遍 tray show 分支 | 引入额外 IPC 往返,跨层调用,职责不清 | | B. 后端直接复用 tray show 分支的恢复逻辑 | **采用**,内聚、零前端改动、行为等价 | **实现**:`commands.rs` capture_screenshot 恢复阶段照搬 tray "show" 分支: ```rust if let Some(w) = &main_window { if let Err(e) = w.show() { ... } if let Err(e) = w.set_focus() { ... } if let Err(e) = w.set_always_on_top(true) { ... } if let Err(e) = w.set_visible_on_all_workspaces(true) { ... } let _ = w.set_maximizable(false); } ``` --- ### 3.6 仿微信日期分割线(诉求 9) **用户原话**: > "做一个类似微信IM的那种,就是在有日期分割线的那种,做一个类似的,需要记录具体的日期(包括年月日)" **确认三件事**(用 AskUserQuestion): | 选项 | 用户选择 | |------|---------| | 显示格式 | 今天 / 昨天 / YYYY-MM-DD | | 时区 | 用户本地时区 | | 范围 | 主聊天面板(覆盖全部消息类型) | **实现**:`ChatPanel.vue` 加三个工具函数 + 改造 `filteredMessages` 为 `streamItems` 混合数组: ```ts function dayKey(ts: number): string { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function dayLabel(ts: number): string { const todayKey = dayKey(Date.now()); const msgKey = dayKey(ts); if (msgKey === todayKey) return "今天"; const yest = new Date(Date.now() - 86400000); if (msgKey === dayKey(yest.getTime())) return "昨天"; return msgKey; // YYYY-MM-DD } const streamItems = computed(() => { const items = []; let lastDay = ""; for (const m of filteredMessages.value) { const dk = dayKey(m.ts); if (dk !== lastDay) { items.push({ kind: "divider", key: `d-${dk}-${m.id}`, label: dayLabel(m.ts) }); lastDay = dk; } items.push({ kind: "msg", key: `m-${m.id}`, msg: m }); } return items; }); ``` 模板: ```vue ``` CSS:仿微信居中、浅灰圆角背景、`font-size: 11px`、`padding: 2px 10px`。 **关键设计**: - **不存盘**——磁盘 jsonl 完全不动,reload 时按每条消息的 `ts` 现场算 - 分割线本身**不是消息**,不进 useChat,不污染 ChatMessage 类型 - 第一条消息前必定有分割线;同一天内的多条消息共用同一条 --- ### 3.7 screenshot 角色不写入聊天记录(诉求 10) **现象**:reload 后"图片预览"气泡又回来了。 **根因**: - 前端 push screenshot 消息时立刻调用 `schedulePersist`(250ms debounce) - 用户在 debounce 窗口内(250ms)点"取消"或"发送给AI" - `removeMessage` 只 splice 内存数组,**debounce timer 还挂着** - 250ms 后仍然把那条 screenshot 消息写进 `chat.jsonl` - reload 时后端 `load_session_messages` 不加区分地全部读回,气泡复活 **三层修复**: | 层 | 文件 | 作用 | |----|------|------| | 前端 | `useChat.ts removeMessage` | 顺手清掉 `persistTimers` 里该 id 的 debounce timer,保证未来不再写入 | | 后端 | `session.rs append_message` | 拒绝 `role: "screenshot"` 写入,即使前端漏掉也兜底 | | 后端 | `session.rs load_session_messages` | 读 jsonl 时跳过 `role: "screenshot"` 行,清理旧数据残留 | 后端拒绝写入的实现: ```rust if msg.role == "screenshot" { log::debug!("拒绝 append role=screenshot 的消息到 jsonl(临时 UI 态)"); // 不增加 msg_count(因为根本没写入),返回索引里的当前计数 let sessions = read_index(app)?; return Ok(sessions.iter().find(|s| s.id == session_id).map(|s| s.msg_count).unwrap_or(0)); } ``` --- ### 3.8 启动 GC(诉求 11) **用户原话**: > "添加 GC,在启动的时候启动 GC。按照你说的8进行" (8 指上一轮我提议的方案:"扫一遍 jsonl 里出现过的所有 imagePath 集合,差集就是孤儿,定期删") **实现**: **`session.rs` 新增 `gc_orphan_images` 异步函数**: ```rust pub async fn gc_orphan_images(app: &tauri::AppHandle) -> Result { let app = app.clone(); tokio::task::spawn_blocking(move || gc_orphan_images_blocking(&app)) .await .map_err(|e| format!("GC join 失败: {}", e))? } ``` **`gc_orphan_images_blocking` 四步算法**: 1. 遍历 `/sessions//images/` 下所有 `.png / .jpg / .jpeg` → 候选集合 A(同时维护 `basename → 绝对路径` 反向表) 2. 遍历所有 session 的 chat.jsonl,收集 `image_path` / `image_png_path` 字段 → 引用集合 R(只取 basename,防御路径污染) 3. `A - R` = 孤儿集合 O 4. 删除 O,记录 `deleted_files` / `failed_files` / `bytes_freed` **`main.rs` setup 钩子挂载**: ```rust .setup(move |app| { start_background_loop(...); // 启动时 GC —— 异步跑,不阻塞 setup 后续(tray、commands 注册等) { use crate::modules::session; let app_handle_for_gc = app.handle().clone(); tauri::async_runtime::spawn(async move { match session::gc_orphan_images(&app_handle_for_gc).await { Ok(report) => log::info!("启动 GC: 扫描 {} 张,引用 {} 张,删除 {} 张孤儿,释放 {} 字节", ...), Err(e) => log::warn!("启动 GC 失败(非致命): {}", e), } }); } // ... tray 等后续步骤 }) ``` **关键设计**: - **spawn_blocking** 而非直接 await——文件 IO 慢,放到 blocking 线程池 - **保守策略**:单 session jsonl 读失败 → 该 session 的图片视为"被引用"保留,宁可漏删不误删 - **扩展名过滤**:只动 `.png / .jpg / .jpeg`,`images/` 里手放别的不碰 - **失败非致命**:GC 失败只 `log::warn`,不阻断启动 - **每次启动一次**:不写定时器、不写后台循环 - **可观测**:汇总日志 `扫描/引用/删除/失败/释放字节` 全在 `log::info` --- ## 四、关键经验 ### 4.1 finally 块不是兜底网 ```ts if (loading.value) return; // ← 命中,直接走人 // ... try { ... } finally { /* 永远不执行 */ } ``` `finally` 在 early return 时**不会**被执行。如果某个清理动作必须发生,不能依赖 finally——把它放到函数开头,或者用 `try { 清理 } finally { ... }` 把整个函数体包起来。 ### 4.2 Tauri 2 IPC 没有 snake↔camel 自动转换 - 后端 `#[derive(Serialize)]` 默认 snake_case - 前端 TypeScript interface 默认 camelCase - Tauri 2 IPC 是**纯 JSON 序列化**,不做名字转换 - 必须显式对齐:`#[serde(rename_all = "camelCase")]` + `alias` 兼容旧数据 ### 4.3 hide→show 不是简单的反向操作 `w.show()` 之后窗口可见,但在某些 WM(GNOME mutter / KDE)上: - **焦点丢失** → 需要 `set_focus()` 主动恢复 - **`always_on_top` / `visible_on_all_workspaces` 等 WM hint 丢失** → 需要重新 `set_always_on_top(true)` / `set_visible_on_all_workspaces(true)` 凡是 hide 之后再 show 的代码,都要把这三件事补齐。 ### 4.4 debounce + remove 的死亡组合 任何带 debounce 写入的"先 push 再 remove"逻辑,`remove` 都要**清掉 debounce timer**,否则 timer fire 后仍然会写入磁盘。这是隐藏很深的 bug——只在用户操作够快(< debounce 窗口)时触发。 ### 4.5 临时 UI 态要从数据层彻底排除 "图片预览"气泡本质是**临时 UI 态**,不该出现在聊天记录里。 - 前端:不持久化(清除 debounce) - 后端:拒绝写入(双保险) - 后端:拒绝读出(兜底清理) 三层防御确保即使有遗漏也不会复活。 ### 4.6 数据派生层不进数据模型 日期分割线是"展示派生",不是数据。 - 不进 `ChatMessage` 类型 - 不进 jsonl 持久化 - reload 时按 `ts` 现场算 这样数据模型干净,展示层独立演进。 --- ## 五、验证状态 | 检查项 | 结果 | |--------|------| | `npm run build`(vue-tsc + vite) | ✅ 通过 | | `cargo check` | ✅ Finished(仅 pre-existing dead-code warnings) | | reload 后图片正常显示 | ✅ serde camelCase + alias 修复 | | 工具栏弹出按钮(🔍) | ✅ | | 截图后 panel 可点 | ✅ set_focus | | 取消按钮删除气泡 | ✅ | | 发送给AI后删除气泡(和取消一致) | ✅ removeMessage 提到函数开头 | | 截图后窗口属性保留 | ✅ always_on_top + visible_on_all_workspaces | | 日期分割线(今天/昨天/YYYY-MM-DD) | ✅ | | screenshot 不写入 jsonl | ✅ 三层防御 | | 启动 GC 跑通 | ✅ 日志可见 | --- ## 六、文件变更清单 ### 后端 Rust | 文件 | 变更 | |------|------| | `src-tauri/src/modules/session.rs` | `PersistedMessage` camelCase + alias;`append_message` 拒绝 screenshot;`load_session_messages` 跳过 screenshot;新增 `gc_orphan_images` / `GcReport` | | `src-tauri/src/app/commands.rs` | `capture_screenshot` 加 `set_focus` + 恢复 always_on_top / visible_on_all_workspaces | | `src-tauri/src/main.rs` | setup 钩子里挂 `gc_orphan_images` tokio::spawn | ### 前端 | 文件 | 变更 | |------|------| | `src/live2d/hooks/useChat.ts` | `sendImageInternal` 重构,`removeMessage` 清理 debounce timer | | `src/live2d/components/ChatPanel.vue` | 工具栏弹出按钮;日期分割线(`streamItems` computed + 模板 + CSS) | | `src/live2d/components/MessageBubble.vue` | `appDataDir + join` 拼绝对路径;user 消息渲染 imagePath 图 | --- ## 七、未做 / 留给后续 | 项 | 备注 | |----|------| | 后台周期 GC(比如每天一次) | 用户只要"启动时",未加定时器 | | GC 进度前端可视化(弹个 toast 报告删了多少张) | 当前只有 log,前端看不到 | | screenshot 旧 jsonl 行手动清理工具 | 已通过 `load_session_messages` 跳过,等于隐式清理,不需要单独工具 | | 图片孤儿阈值(比如保留最近 N 天) | 现在是无差别全删,可能误删用户想保留的;不过 UUID 文件名 + 无引用 = 真孤儿,风险低 | | 模型切换时是否也走 GC | 当前只在启动跑一次,够用 |