445 lines
17 KiB
Markdown
445 lines
17 KiB
Markdown
# 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 | 当前只在启动跑一次,够用 |
|