17 KiB
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 应用后图片变成"截图加载中…"占位。
根因排查:
-
前端
MessageBubble.vue用convertFileSrc(relativePath),Tauri 2 的convertFileSrc要求绝对路径,相对路径会得到无效asset://URL → 改成appDataDir() + join(...)异步刷新 src -
改完后图片依然不显示 → 怀疑
tauri.conf.jsonassetProtocol scope 不够 → 检查tauri.conf.json,scope["$APPDATA/sessions/**"]已经在了,不是这个问题 -
真正根因:
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 注解:
#[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():
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 后仍未消除,原因:
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,气泡永远删不掉。
最终解法:
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" 分支:
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 混合数组:
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;
});
模板:
<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" 行,清理旧数据残留 |
后端拒绝写入的实现:
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 异步函数:
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 四步算法:
- 遍历
<APP_DATA>/sessions/<id>/images/下所有.png / .jpg / .jpeg→ 候选集合 A(同时维护basename → 绝对路径反向表) - 遍历所有 session 的 chat.jsonl,收集
image_path/image_png_path字段 → 引用集合 R(只取 basename,防御路径污染) A - R= 孤儿集合 O- 删除 O,记录
deleted_files/failed_files/bytes_freed
main.rs setup 钩子挂载:
.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 块不是兜底网
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 | 当前只在启动跑一次,够用 |