feat(gui): 加载动画改三点式 + 新增系统通知

## 1) 加载动画:边框旋转 → 三点渐亮
旧实现是 border + rotate 的 spinner,用在 .loading(16px) /
.loading-spinner(32px) / .live-spinner(15px) 三处。在消息气泡里刺眼,
且语义不对:旋转暗示"正在加载某个确定的东西",而 agent 思考没有进度。

参考 PiDeck(@deepseek-ai/dsh-web-frontend 与 mermaid bundle 里的
@keyframe 实现)改为「等距三点 + 透明度递减 + 轻微上浮」:
  haDots      三点错峰明暗(1/0.6/0.35/0.15)
  haDotFloat  单点上浮 + 缩放
  haSweep     横向扫光
旧类名 .loading / .loading-spinner 保留为别名指向新动画(还有 6+ 处
调用点,逐处改 HTML 容易漏),并解除其 animation 以免残留旋转。
7 处调用点统一换成 <i>x3 结构。

## 2) 系统通知(此前完全不存在)
主进程无 Notification、preload 无接口、渲染层只有页面内 toast
(用户不盯着窗口就看不到)。补齐一条完整链路:
  main.js  notify:show / notify:supported 两个 IPC + 原生 Notification
  preload  notify.{show,supported,onClicked}
  app.js   notifyTurn / notifyTurnOnce / onNotifyClicked

设计取舍(都写进注释):
· **一轮只通知一次**。SSE 的 tool_call / stage / agent_output 都可能
  判定"一轮结束",用 turnSig 去重,否则连弹好几次。
· **不在流式过程中逐 token 通知**。一轮可能几十个 delta,那是噪音;
  用户要的是"有结果了"。
· **窗口在前台且聚焦时不弹**。正看着界面时弹窗只会烦人。
  静默启动(窗口 hidden)正好走"要弹"这条 —— 这也是静默后唯一
  能让用户知道"agent 回了"的途径。
· **挂在 SSE 的 agent_output 收尾处**,不挂 endChatTurn():
  后者开头 `if (!state.chatLoading) return`,而旁观路径
  (agent 响应 memo/QQ/email,GUI 只收 SSE)chatLoading 恒 false,
  根本走不到那里。挂 SSE 对"自己发"与"旁观"两条路径都生效。
· **点击通知会 show() + 回传渲染进程**,切到 chat 视图并滚到该条。
  点了却不知道该看哪句话等于没通知。
· 正文超 180 字截断,避免通知被撑爆。

## 判据
新增 cmd/gui/loading-notify.test.mjs(进 npm test),24 条断言覆盖
动画关键帧/错峰 delay/别名解除/调用点结构,以及通知链路的六段
(主进程 IPC、preload 桥、去重、挂载位置、点击跳转、可用性探测)。

## 实测
重新打包安装:186 文件 0 处不匹配,exe 188834816 字节
启动正常:silentStart 生效、login 200、device/online 200、
graph/pulse 轮询、0 个 401 / JS 错误。

判据:cmd/gui npm test 全绿(84 条)。
This commit is contained in:
JianFeeeee
2026-10-02 10:40:35 +08:00
parent 8847ce4cfd
commit bf06ad029a
6 changed files with 425 additions and 32 deletions

View File

@ -2595,6 +2595,62 @@ app.on("activate", () => {
createWindow();
}
});
// ============ 系统通知(Electron 原生)============
//
// 之前**完全没有**这个能力:主进程没引入 Notification、preload 没暴露接口、
// 渲染层只有页面内的 toast(用户不盯着窗口就看不到)。
// 这套是补齐:渲染层判断"该不该通知",主进程负责弹系统通知。
//
// 为什么点击要回传会话:点了通知却不知道该看哪句话,等于没通知。
// 点击时通过 webContents 回到渲染进程,由它切到 chat 视图并滚到该条。
let notifSeq = 0;
ipcMain.handle("notify:show", (_, payload) => {
try {
const p = payload || {};
const title = String(p.title || "HomeAgent");
const body = String(p.body || "");
if (!body) return { ok: false, error: "empty body" };
if (!Notification.isSupported()) return { ok: false, error: "unsupported" };
// 已有窗口在前台且可见时不再打扰:用户正看着界面。
// 静默启动场景下 mainWindow 存在但 hidden,正好走"要通知"这条路。
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible() && mainWindow.isFocused()) {
return { ok: false, skipped: "focused" };
}
const id = ++notifSeq;
const n = new Notification({
title: title,
body: body.length > 180 ? body.slice(0, 180) + "\u2026" : body,
silent: !!p.silent,
urgency: p.urgent ? "critical" : "normal",
});
n.on("click", () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
}
try {
mainWindow.webContents.send("notify:clicked", { id: id, msgKey: p.msgKey || "" });
} catch (e) {}
n.close();
});
n.show();
return { ok: true, id: id };
} catch (e) {
return { ok: false, error: e.message };
}
});
ipcMain.handle("notify:supported", () => {
try {
return { supported: Notification.isSupported() };
} catch (e) {
return { supported: false };
}
});
// IPC:prefs(renderer 设置页需要)
// IPC:本机设备身份(renderer 设备页需要)
ipcMain.handle("device:identity", () =>