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

17 KiB
Raw Permalink Blame History

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_topvisible_on_all_workspaces,需重设 Bug
9 仿微信做日期分割线(含年月日) 新功能
10 screenshot 角色不要写入聊天记录 Bug(回归修复)
11 添加 GC,在启动时跑 新功能

二、时间线与迭代(避坑记录)

轮次 关键事件 结论
1 MessageBubbleappDataDir + join 拼绝对路径 加载仍未恢复
2 #[serde(rename_all = "camelCase")] + aliasPersistedMessage 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.vueconvertFileSrc(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 注解:

#[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-424w.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_topvisible_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 加三个工具函数 + 改造 filteredMessagesstreamItems 混合数组:

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: 11pxpadding: 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 四步算法

  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 钩子挂载

.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_screenshotset_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 当前只在启动跑一次,够用