mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
127
cmd/gui/loading-notify.test.mjs
Normal file
127
cmd/gui/loading-notify.test.mjs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
// 加载动画 + 系统通知的判据。
|
||||||
|
//
|
||||||
|
// ## 要判的是什么
|
||||||
|
//
|
||||||
|
// 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("全部通过");
|
||||||
@ -2595,6 +2595,62 @@ app.on("activate", () => {
|
|||||||
createWindow();
|
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:prefs(renderer 设置页需要)
|
||||||
// IPC:本机设备身份(renderer 设备页需要)
|
// IPC:本机设备身份(renderer 设备页需要)
|
||||||
ipcMain.handle("device:identity", () =>
|
ipcMain.handle("device:identity", () =>
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron . --no-sandbox",
|
"start": "electron . --no-sandbox",
|
||||||
"dev": "electron . --no-sandbox --dev",
|
"dev": "electron . --no-sandbox --dev",
|
||||||
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs && node silent-start.test.mjs && node package-config.test.mjs",
|
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs && node silent-start.test.mjs && node package-config.test.mjs && node loading-notify.test.mjs",
|
||||||
"test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs"
|
"test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -34,6 +34,11 @@ contextBridge.exposeInMainWorld("homeagent", {
|
|||||||
},
|
},
|
||||||
cacheBg: (src) => ipcRenderer.invoke("bg:cache", { src }),
|
cacheBg: (src) => ipcRenderer.invoke("bg:cache", { src }),
|
||||||
log: (m) => ipcRenderer.invoke("log:r", m),
|
log: (m) => ipcRenderer.invoke("log:r", m),
|
||||||
|
notify: {
|
||||||
|
show: (payload) => ipcRenderer.invoke("notify:show", payload),
|
||||||
|
supported: () => ipcRenderer.invoke("notify:supported"),
|
||||||
|
onClicked: (cb) => ipcRenderer.on("notify:clicked", (_e, d) => cb(d)),
|
||||||
|
},
|
||||||
prefs: {
|
prefs: {
|
||||||
get: () => ipcRenderer.invoke("prefs:get"),
|
get: () => ipcRenderer.invoke("prefs:get"),
|
||||||
set: (p) => ipcRenderer.invoke("prefs:set", p),
|
set: (p) => ipcRenderer.invoke("prefs:set", p),
|
||||||
|
|||||||
@ -56,6 +56,8 @@ const state = {
|
|||||||
selfDeviceId: "",
|
selfDeviceId: "",
|
||||||
selfGateway: "",
|
selfGateway: "",
|
||||||
deviceBridgeToken: "", // 设备接入令牌明文(来自 device-bridge:get),用于 /device/* 的 requireToken
|
deviceBridgeToken: "", // 设备接入令牌明文(来自 device-bridge:get),用于 /device/* 的 requireToken
|
||||||
|
_notifyState: null, // 本轮通知去重状态 { turnSig, notified }
|
||||||
|
_notifySupported: null, // 系统通知是否可用(null=未探测)
|
||||||
// ---- 星图活动数据源(对齐服务端 /memory/graph/pulse + /runtime)----
|
// ---- 星图活动数据源(对齐服务端 /memory/graph/pulse + /runtime)----
|
||||||
starmapPulses: [], // { mesh, until, kind } 活动脉冲队列
|
starmapPulses: [], // { mesh, until, kind } 活动脉冲队列
|
||||||
starmapGrown: {}, // nodeId -> 生长动画截止时间戳(ms)
|
starmapGrown: {}, // nodeId -> 生长动画截止时间戳(ms)
|
||||||
@ -467,6 +469,55 @@ function confirmDialog(action, isDanger) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== API =====
|
// ===== 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) {
|
async function cliRequest(line) {
|
||||||
var conn = state.currentConn;
|
var conn = state.currentConn;
|
||||||
if (!conn) throw new Error(__("未选择连接", "No connection selected"));
|
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>' +
|
'<div class="chat-panel" id="chat-panel-starmap"><div class="card"><h2>' +
|
||||||
__("星图", "Star Map") +
|
__("星图", "Star Map") +
|
||||||
"</h2>" +
|
"</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 +=
|
html +=
|
||||||
'<div class="chat-panel" id="chat-panel-terminal"><div class="card"><h2>' +
|
'<div class="chat-panel" id="chat-panel-terminal"><div class="card"><h2>' +
|
||||||
__("终端", "Terminal") +
|
__("终端", "Terminal") +
|
||||||
@ -2151,7 +2202,7 @@ function renderChat() {
|
|||||||
var isStreamingLast = i === msgs.length - 1 && streamingLast;
|
var isStreamingLast = i === msgs.length - 1 && streamingLast;
|
||||||
if (isStreamingLast) {
|
if (isStreamingLast) {
|
||||||
var liveRow =
|
var liveRow =
|
||||||
'<span class="live-spinner"></span>' +
|
'<span class="live-spinner"><i></i><i></i><i></i></span>' +
|
||||||
(newPending.length
|
(newPending.length
|
||||||
? '<span class="thinking-tools">' + pillHtml() + "</span>"
|
? '<span class="thinking-tools">' + pillHtml() + "</span>"
|
||||||
: "");
|
: "");
|
||||||
@ -2227,7 +2278,7 @@ function renderChat() {
|
|||||||
'<div class="msg msg-assistant" data-msgkey="__loading__"><div class="msg-avatar">' +
|
'<div class="msg msg-assistant" data-msgkey="__loading__"><div class="msg-avatar">' +
|
||||||
aiAvatar2 +
|
aiAvatar2 +
|
||||||
'</div><div class="msg-content"><div class="msg-bubble">' +
|
'</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
|
(newPending.length
|
||||||
? '<span class="thinking-tools">' + pillHtml() + "</span>"
|
? '<span class="thinking-tools">' + pillHtml() + "</span>"
|
||||||
: "") +
|
: "") +
|
||||||
@ -2457,7 +2508,7 @@ function renderChatStarmap() {
|
|||||||
}
|
}
|
||||||
if (!window.THREE) {
|
if (!window.THREE) {
|
||||||
cont.innerHTML =
|
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.starmapInit = false;
|
||||||
state.starmapLoading = false;
|
state.starmapLoading = false;
|
||||||
return;
|
return;
|
||||||
@ -3240,7 +3291,7 @@ async function queryMemoryChat() {
|
|||||||
var q = document.getElementById("mem-query")?.value;
|
var q = document.getElementById("mem-query")?.value;
|
||||||
var r = document.getElementById("mem-result-chat");
|
var r = document.getElementById("mem-result-chat");
|
||||||
if (!r || !q) return;
|
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 {
|
try {
|
||||||
var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2");
|
var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2");
|
||||||
r.innerHTML =
|
r.innerHTML =
|
||||||
@ -3260,7 +3311,7 @@ async function queryMemoryContext() {
|
|||||||
var q = document.getElementById("ctx-query")?.value;
|
var q = document.getElementById("ctx-query")?.value;
|
||||||
var r = document.getElementById("ctx-result");
|
var r = document.getElementById("ctx-result");
|
||||||
if (!r) return;
|
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 {
|
try {
|
||||||
var data = await api("/memory/context?q=" + encodeURIComponent(q || ""));
|
var data = await api("/memory/context?q=" + encodeURIComponent(q || ""));
|
||||||
var ctx = data?.context || __("无上下文", "No context");
|
var ctx = data?.context || __("无上下文", "No context");
|
||||||
@ -3307,7 +3358,7 @@ async function searchKnowledgeChat() {
|
|||||||
var q = document.getElementById("know-query")?.value;
|
var q = document.getElementById("know-query")?.value;
|
||||||
var r = document.getElementById("know-result-chat");
|
var r = document.getElementById("know-result-chat");
|
||||||
if (!r || !q) return;
|
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 {
|
try {
|
||||||
var data = await api("/knowledge?q=" + encodeURIComponent(q));
|
var data = await api("/knowledge?q=" + encodeURIComponent(q));
|
||||||
r.innerHTML =
|
r.innerHTML =
|
||||||
@ -5269,6 +5320,16 @@ async function deleteAdapter(name) {
|
|||||||
doRenderAll();
|
doRenderAll();
|
||||||
startUptimeTicker();
|
startUptimeTicker();
|
||||||
setInterval(doRenderAll, 15000);
|
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 {
|
} else {
|
||||||
renderAll();
|
renderAll();
|
||||||
updateConnIndicator();
|
updateConnIndicator();
|
||||||
@ -6125,6 +6186,14 @@ async function connectFetchSSE(url) {
|
|||||||
// 用文本前若干字符当 hint,让点亮落到**本轮相关**的实体上,
|
// 用文本前若干字符当 hint,让点亮落到**本轮相关**的实体上,
|
||||||
// 而不是每次都亮同一批高权重节点。
|
// 而不是每次都亮同一批高权重节点。
|
||||||
starmapPulse("output", String(p.content || "").slice(0, 60));
|
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();
|
endChatTurn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1458,16 +1458,130 @@ code {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.msg-bubble .live-spinner {
|
/* ============================================================
|
||||||
width: 15px;
|
加载 / 思考动画
|
||||||
height: 15px;
|
------------------------------------------------------------
|
||||||
border-radius: 50%;
|
设计参考 PiDeck(@deepseek-ai/dsh-web-frontend / mermaid bundle):
|
||||||
border: 2px solid var(--msg-user-bg);
|
它**不用边框旋转**,而是「等距三点 + 透明度递减 + 轻微上浮」,
|
||||||
border-top-color: var(--accent);
|
静默、不抢视线,且在深浅色下都成立。
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
flex-shrink: 0;
|
旧实现是 border + rotate 的经典 spinner(.loading / .loading-spinner /
|
||||||
display: inline-block;
|
.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;
|
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 + .thinking-tools,
|
||||||
.msg-bubble .live-spinner + .text {
|
.msg-bubble .live-spinner + .text {
|
||||||
@ -1535,22 +1649,44 @@ code {
|
|||||||
#sm-container-chat canvas {
|
#sm-container-chat canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.loading {
|
/* 旧类名保留为别名:已有 6+ 处调用点直接用 .loading / .loading-spinner。
|
||||||
display: inline-block;
|
把它们指到新的三点动画,而不是删掉后逐处改 HTML —— 后者容易漏。
|
||||||
width: 16px;
|
(用 flex 布局装不出第三个点,::before/::after 只能造 2 个,
|
||||||
height: 16px;
|
所以这三个点由 JS 插入;没插入时至少是个 2 点的退化态,
|
||||||
border: 2px solid var(--loading-border);
|
不会退化成旋转圈。) */
|
||||||
border-radius: 50%;
|
.loading,
|
||||||
border-top-color: var(--loading-top);
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
}
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 32px;
|
display: inline-flex;
|
||||||
height: 32px;
|
align-items: center;
|
||||||
border: 3px solid color-mix(in srgb, var(--accent) 15%, transparent);
|
gap: 4px;
|
||||||
border-top: 3px solid var(--accent);
|
vertical-align: middle;
|
||||||
border-radius: 50%;
|
width: auto;
|
||||||
animation: spin 0.8s linear infinite;
|
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 {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
|
|||||||
Reference in New Issue
Block a user