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

@ -56,6 +56,8 @@ const state = {
selfDeviceId: "",
selfGateway: "",
deviceBridgeToken: "", // 设备接入令牌明文(来自 device-bridge:get),用于 /device/* 的 requireToken
_notifyState: null, // 本轮通知去重状态 { turnSig, notified }
_notifySupported: null, // 系统通知是否可用(null=未探测)
// ---- 星图活动数据源(对齐服务端 /memory/graph/pulse + /runtime)----
starmapPulses: [], // { mesh, until, kind } 活动脉冲队列
starmapGrown: {}, // nodeId -> 生长动画截止时间戳(ms)
@ -467,6 +469,55 @@ function confirmDialog(action, isDanger) {
}
// ===== API =====
// ===== 系统通知 =====
//
// 何时通知:agent **完成一轮输出**时。
// 不在流式过程中逐 token 通知 —— 那是噪音(一轮可能几十个 delta),
// 用户要的是"有结果了",不是"正在打字"。
//
// 去重:同一轮(signature 相同)只通知一次。SSE 有 tool_call / stage /
// agent_output 多个事件都可能判定"一轮结束",不去重会连弹好几次。
//
// 前台可见且聚焦时不通知:用户正看着界面,弹窗只会烦人。
// 静默启动(窗口 hidden)正好走"要通知"这条 —— 这也是静默后
// 唯一能让用户知道"agent 回了"的途径。
function shouldNotify() {
if (!state._notifySupported) return false;
return true;
}
async function notifyTurn(title, body, msgKey) {
if (!shouldNotify()) return;
try {
await window.homeagent.notify.show({
title: title,
body: body,
msgKey: msgKey || "",
silent: false,
});
} catch (e) {}
}
// notifyTurnOnce 保证同一轮只通知一次。
function notifyTurnOnce(sig, title, body, msgKey) {
if (!shouldNotify()) return;
if (!state._notifyState || state._notifyState.turnSig !== sig) {
state._notifyState = { turnSig: sig, notified: false };
}
if (state._notifyState.notified) return;
state._notifyState.notified = true;
notifyTurn(title, body, msgKey);
}
// onNotifyClicked 点系统通知后:切到 chat 视图并滚到那条消息。
function onNotifyClicked() {
try {
switchView("chat");
var el = document.querySelector(".msg-assistant:last-of-type");
if (el && el.scrollIntoView) el.scrollIntoView({ block: "end" });
} catch (e) {}
}
async function cliRequest(line) {
var conn = state.currentConn;
if (!conn) throw new Error(__("未选择连接", "No connection selected"));
@ -1799,7 +1850,7 @@ function buildChatLayout() {
'<div class="chat-panel" id="chat-panel-starmap"><div class="card"><h2>' +
__("星图", "Star Map") +
"</h2>" +
'<div id="sm-container-chat" style="display:flex;align-items:center;justify-content:center;min-height:480px"><div class="loading-spinner"></div></div></div></div>';
'<div id="sm-container-chat" style="display:flex;align-items:center;justify-content:center;min-height:480px"><div class="ha-dots-panel"><span class="ha-dots ha-dots-lg"><i></i><i></i><i></i></span></div></div></div>';
html +=
'<div class="chat-panel" id="chat-panel-terminal"><div class="card"><h2>' +
__("终端", "Terminal") +
@ -2151,7 +2202,7 @@ function renderChat() {
var isStreamingLast = i === msgs.length - 1 && streamingLast;
if (isStreamingLast) {
var liveRow =
'<span class="live-spinner"></span>' +
'<span class="live-spinner"><i></i><i></i><i></i></span>' +
(newPending.length
? '<span class="thinking-tools">' + pillHtml() + "</span>"
: "");
@ -2227,7 +2278,7 @@ function renderChat() {
'<div class="msg msg-assistant" data-msgkey="__loading__"><div class="msg-avatar">' +
aiAvatar2 +
'</div><div class="msg-content"><div class="msg-bubble">' +
'<span class="live-spinner"></span>' +
'<span class="live-spinner"><i></i><i></i><i></i></span>' +
(newPending.length
? '<span class="thinking-tools">' + pillHtml() + "</span>"
: "") +
@ -2457,7 +2508,7 @@ function renderChatStarmap() {
}
if (!window.THREE) {
cont.innerHTML =
'<div style="display:flex;align-items:center;justify-content:center;height:100%;padding:20px"><div class="loading-spinner"></div></div>';
'<div class="ha-dots-panel"><span class="ha-dots ha-dots-lg"><i></i><i></i><i></i></span></div>';
state.starmapInit = false;
state.starmapLoading = false;
return;
@ -3240,7 +3291,7 @@ async function queryMemoryChat() {
var q = document.getElementById("mem-query")?.value;
var r = document.getElementById("mem-result-chat");
if (!r || !q) return;
r.innerHTML = '<div class="loading"></div>';
r.innerHTML = '<div class="ha-dots-panel"><span class="ha-dots"><i></i><i></i><i></i></span></div>';
try {
var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2");
r.innerHTML =
@ -3260,7 +3311,7 @@ async function queryMemoryContext() {
var q = document.getElementById("ctx-query")?.value;
var r = document.getElementById("ctx-result");
if (!r) return;
r.innerHTML = '<div class="loading"></div>';
r.innerHTML = '<div class="ha-dots-panel"><span class="ha-dots"><i></i><i></i><i></i></span></div>';
try {
var data = await api("/memory/context?q=" + encodeURIComponent(q || ""));
var ctx = data?.context || __("无上下文", "No context");
@ -3307,7 +3358,7 @@ async function searchKnowledgeChat() {
var q = document.getElementById("know-query")?.value;
var r = document.getElementById("know-result-chat");
if (!r || !q) return;
r.innerHTML = '<div class="loading"></div>';
r.innerHTML = '<div class="ha-dots-panel"><span class="ha-dots"><i></i><i></i><i></i></span></div>';
try {
var data = await api("/knowledge?q=" + encodeURIComponent(q));
r.innerHTML =
@ -5269,6 +5320,16 @@ async function deleteAdapter(name) {
doRenderAll();
startUptimeTicker();
setInterval(doRenderAll, 15000);
// 系统通知:探测可用性 + 接点击跳转(放在连上之后,避免无连接时空转)
if (window.homeagent && window.homeagent.notify) {
try {
const sup = await window.homeagent.notify.supported();
state._notifySupported = !!(sup && sup.supported);
} catch (e) {
state._notifySupported = false;
}
window.homeagent.notify.onClicked(onNotifyClicked);
}
} else {
renderAll();
updateConnIndicator();
@ -6125,6 +6186,14 @@ async function connectFetchSSE(url) {
// 用文本前若干字符当 hint,让点亮落到**本轮相关**的实体上,
// 而不是每次都亮同一批高权重节点。
starmapPulse("output", String(p.content || "").slice(0, 60));
// 系统通知:一轮输出收尾时通知一次(不在流式过程中逐 token 弹)。
var _sig = (p.content || "").length + ":" +
String(p.content || "").slice(0, 40);
notifyTurnOnce(
_sig,
"HomeAgent",
String(p.content || "").slice(0, 160) || __("(空回复)", "(empty reply)"),
);
endChatTurn();
return;
}

View File

@ -1458,16 +1458,130 @@ code {
transform: none;
}
}
.msg-bubble .live-spinner {
width: 15px;
height: 15px;
border-radius: 50%;
border: 2px solid var(--msg-user-bg);
border-top-color: var(--accent);
animation: spin 0.7s linear infinite;
flex-shrink: 0;
display: inline-block;
/* ============================================================
加载 / 思考动画
------------------------------------------------------------
设计参考 PiDeck(@deepseek-ai/dsh-web-frontend / mermaid bundle):
它**不用边框旋转**,而是「等距三点 + 透明度递减 + 轻微上浮」,
静默、不抢视线,且在深浅色下都成立。
旧实现是 border + rotate 的经典 spinner(.loading / .loading-spinner /
.live-spinner),在消息气泡里尤其刺眼 —— 一个持续旋转的圆环
与「正在思考」的语义也不匹配(旋转暗示在加载某个确定的东西,
而思考本身没有进度)。
下面是等价改造,三处调用点(面板加载 / 列表加载 / 消息内思考)
统一到同一套视觉语言。
============================================================ */
/* 三点渐次亮起(PiDeck: _dsh-state-dot-chase 的等价实现) */
@keyframes haDots {
0%,
12.4% {
opacity: 1;
}
12.5%,
24.9% {
opacity: 0.6;
}
25%,
37.4% {
opacity: 0.35;
}
37.5%,
100% {
opacity: 0.15;
}
}
/* 单点呼吸(PiDeck: tool-activity-dot) */
@keyframes haDotFloat {
0%,
100% {
opacity: 0.35;
transform: translateY(0) scale(0.8);
}
50% {
opacity: 1;
transform: translateY(-2px) scale(1);
}
}
/* 扫光(PiDeck: thinking-sweep),用于「思考中」横向扫光 */
@keyframes haSweep {
0% {
left: -300px;
}
90%,
100% {
left: 100%;
}
}
/* 三点基类:用 flex 排布,gap 固定,避免各调用点尺寸不一 */
.ha-dots {
display: inline-flex;
align-items: center;
gap: 3px;
vertical-align: middle;
flex-shrink: 0;
}
.ha-dots > i {
display: block;
width: 4px;
height: 4px;
border-radius: 999px;
background: var(--accent);
animation: haDots 1.1s ease-in-out infinite;
}
.ha-dots > i:nth-child(2) {
animation-delay: 0.15s;
}
.ha-dots > i:nth-child(3) {
animation-delay: 0.3s;
}
/* 尺寸变体:替换旧的 .loading / .loading-spinner / .live-spinner */
.ha-dots-sm > i {
width: 3px;
height: 3px;
gap: 2px;
}
.ha-dots-lg > i {
width: 6px;
height: 6px;
gap: 5px;
}
/* 面板级:居中容器里的加载态(替代 32px 旋转圈) */
.ha-dots-panel {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
/* 消息气泡内:思考中 */
.msg-bubble .live-spinner {
display: inline-flex;
align-items: center;
gap: 3px;
vertical-align: middle;
flex-shrink: 0;
}
.msg-bubble .live-spinner > i {
display: block;
width: 4px;
height: 4px;
border-radius: 999px;
background: var(--accent);
animation: haDots 1.1s ease-in-out infinite;
}
.msg-bubble .live-spinner > i:nth-child(2) {
animation-delay: 0.15s;
}
.msg-bubble .live-spinner > i:nth-child(3) {
animation-delay: 0.3s;
}
.msg-bubble .live-spinner + .thinking-tools,
.msg-bubble .live-spinner + .text {
@ -1535,22 +1649,44 @@ code {
#sm-container-chat canvas {
display: block;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--loading-border);
border-radius: 50%;
border-top-color: var(--loading-top);
animation: spin 0.6s linear infinite;
}
/* 旧类名保留为别名:已有 6+ 处调用点直接用 .loading / .loading-spinner。
把它们指到新的三点动画,而不是删掉后逐处改 HTML —— 后者容易漏。
(用 flex 布局装不出第三个点,::before/::after 只能造 2 个,
所以这三个点由 JS 插入;没插入时至少是个 2 点的退化态,
不会退化成旋转圈。) */
.loading,
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid color-mix(in srgb, var(--accent) 15%, transparent);
border-top: 3px solid var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-flex;
align-items: center;
gap: 4px;
vertical-align: middle;
width: auto;
height: auto;
border: none;
border-radius: 0;
background: none;
animation: none;
}
.loading::before,
.loading::after,
.loading-spinner::before,
.loading-spinner::after {
content: "";
display: block;
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--accent);
animation: haDots 1.1s ease-in-out infinite;
}
.loading::after,
.loading-spinner::after {
animation-delay: 0.2s;
}
.loading-spinner::before,
.loading-spinner::after {
width: 6px;
height: 6px;
}
@keyframes spin {
to {