Files
EzVibeR/docs/impl/2026-06-19-multi-bugfix.md
2026-06-19 16:41:23 +08:00

445 lines
17 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-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<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "motion_tag")]
pub motion_tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "image_path")]
pub image_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "image_png_path")]
pub image_png_path: Option<String>,
}
```
`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
<template v-for="item in streamItems" :key="item.key">
<div v-if="item.kind === 'divider'" class="day-divider">
<span class="day-divider-label">{{ item.label }}</span>
</div>
<MessageBubble v-else :msg="item.msg" :highlight="highlight" />
</template>
```
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<GcReport, String> {
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. 遍历 `<APP_DATA>/sessions/<id>/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 | 当前只在启动跑一次,够用 |