Files
HomeAgent/cmd/gui/loading-notify.test.mjs
JianFeeeee bf06ad029a 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 条)。
2026-10-02 10:40:35 +08:00

127 lines
5.7 KiB
JavaScript
Raw 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.

// 加载动画 + 系统通知的判据。
//
// ## 要判的是什么
//
// 1) 加载动画:旧实现是 border + rotate 的 spinner(.loading /
// .loading-spinner / .live-spinner 三处),在消息气泡里刺眼,
// 且"持续旋转"语义不对 —— 旋转暗示在加载某个确定的东西,
// 而 agent 思考本身没有进度。改为 PiDeck 用的「等距三点 +
// 透明度递减 + 轻微上浮」。
//
// 2) 系统通知:**此前完全不存在**(主进程无 Notification、preload 无接口、
// 渲染层只有页面内 toast)。这是新增能力,容易被后续改动静默删掉。
//
// 运行:node loading-notify.test.mjs
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const css = readFileSync(join(here, "renderer", "style.css"), "utf8");
const app = readFileSync(join(here, "renderer", "app.js"), "utf8");
const main = readFileSync(join(here, "main.js"), "utf8");
const preload = readFileSync(join(here, "preload.js"), "utf8");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
// ── 1) 加载动画:三点而非旋转 ────────────────────────────────────────
check("定义 haDots 关键帧", /@keyframes haDots/.test(css), "缺 @keyframes haDots");
check("定义 haDotFloat 关键帧", /@keyframes haDotFloat/.test(css));
check("定义 haSweep 关键帧", /@keyframes haSweep/.test(css));
// 三点的错峰 delay 是"追逐感"的来源,缺了就变成同步闪烁
check(
"三点有错峰 delay(nth-child 2/3)",
/\.ha-dots\s*>?\s*i:nth-child\(2\)/.test(css) ||
/\.ha-dots > i:nth-child\(2\)/.test(css),
"缺 nth-child delay ⇒ 三点同步闪,不像追逐",
);
// 旧类名必须仍然可用(还有调用点用它们)
check(
".loading 别名仍指向新动画",
/\.loading,\s*\.loading-spinner\s*{[^}]*animation:\s*none/.test(css),
"别名没解除旋转 ⇒ 旧调用点仍是转圈",
);
check(
"live-spinner 已是 flex 三点容器",
/\.msg-bubble \.live-spinner\s*{[^}]*display:\s*inline-flex/.test(css),
"live-spinner 仍是 inline-block 圆环",
);
// 旧的旋转实现不应再被这三类使用
const usesOldSpin = /\.loading[^{]*\{[^}]*border-top-color/.test(css);
check("没有残留的 border 旋转 spinner", !usesOldSpin, "仍有 border-top-color 旋转实现");
// ── 2) 调用点都换成了三点结构 ──────────────────────────────────────
const dotMarkup = (app.match(/class="ha-dots[^"]*"><i><\/i><i><\/i><i><\/i>/g) || []).length;
check(
"★ 面板/列表加载已换成三点结构",
dotMarkup >= 4,
`只找到 ${dotMarkup} 处 <i>x3 结构(预期 >=4:星图 2 + 列表 3 + 其他)`,
);
check(
"★ 消息内 live-spinner 带三个点",
/class="live-spinner"><i><\/i><i><\/i><i><\/i>/.test(app),
"live-spinner 仍是空的(会看不到任何点)",
);
check(
"旧的空 <div class=\"loading\"></div> 已清理",
!/class="loading"><\/div>/.test(app),
"仍有裸 <div class=loading>(无点)",
);
// ── 3) 系统通知:主进程 ────────────────────────────────────────────
check("main 引入 Notification", /new Notification\(/.test(main), "未调用原生 Notification");
check("有 notify:show IPC", /ipcMain\.handle\("notify:show"/.test(main));
check("有 notify:supported IPC", /ipcMain\.handle\("notify:supported"/.test(main));
check(
"★ 前台聚焦时不打扰用户",
/isFocused\(\)/.test(main) && /skipped:\s*"focused"/.test(main),
"没有「窗口在前台就不弹」判定 ⇒ 正看着界面时会被弹窗打断",
);
check(
"★ 点击通知会回传并唤起窗口",
/notify:clicked/.test(main) && /mainWindow\.show\(\)/.test(main),
"点击通知后没有 show()+回传,点了等于没点",
);
check(
"正文超长截断(避免通知被撑爆)",
/body\.length\s*>\s*\d+/.test(main),
"未截断正文",
);
// ── 4) preload 桥 ──────────────────────────────────────────────────
check("preload 暴露 notify.show", /notify:\s*{[\s\S]{0,200}show:/.test(preload));
check("preload 暴露 notify.supported", /supported:\s*\(\)\s*=>\s*ipcRenderer\.invoke\("notify:supported"/.test(preload));
check("preload 暴露 notify.onClicked", /onClicked:/.test(preload));
// ── 5) 渲染层 ──────────────────────────────────────────────────────
check("渲染层有 notifyTurnOnce 去重", /function notifyTurnOnce\(/.test(app), "缺去重 ⇒ 一轮会弹多次");
check(
"去重基于 turnSig",
/turnSig/.test(app),
"去重没按轮次签名",
);
check(
"★ 通知挂在 SSE agent_output 上(覆盖旁观路径)",
/notifyTurnOnce\(/.test(app) && /starmapPulse\("output"[\s\S]{0,400}notifyTurnOnce/.test(app),
"没挂在 SSE 收尾处 ⇒ 旁观其他渠道的回复时不会通知",
);
check("注册了通知点击跳转", /onNotifyClicked/.test(app) && /onClicked\(onNotifyClicked\)/.test(app));
check("启动时探测通知可用性", /notify\.supported\(\)/.test(app), "未探测 Notification.isSupported");
console.log("");
if (failures > 0) {
console.log(`全部失败:${failures} 条`);
process.exit(1);
}
console.log("全部通过");