nanobot/desktop/src/notifications.ts
Xubin Ren ab9f49970d
feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
2026-06-06 19:49:33 +08:00

160 lines
4.5 KiB
TypeScript

import {
app,
BrowserWindow,
Notification,
} from "electron";
type NotificationSource = {
kind?: unknown;
label?: unknown;
};
type WsMessageFrame = {
chat_id?: unknown;
event?: unknown;
kind?: unknown;
source?: NotificationSource;
stream_id?: unknown;
text?: unknown;
};
interface DesktopNotifierOptions {
getWindow: () => BrowserWindow | null;
}
const MAX_NOTIFICATION_BODY_LENGTH = 180;
const MAX_NOTIFICATION_TITLE_LENGTH = 80;
let unreadNotificationCount = 0;
const streamTextBuffers = new Map<string, string>();
export function handleDesktopNotificationFrame(
data: string,
options: DesktopNotifierOptions,
): void {
const frame = parseWsMessageFrame(data);
const notificationFrame = frame ? notificationFrameFromWsFrame(frame) : null;
if (!notificationFrame) return;
if (!shouldNotify(options.getWindow())) return;
showDesktopNotification(notificationFrame, options);
}
export function clearDesktopNotificationBadge(): void {
unreadNotificationCount = 0;
app.setBadgeCount(0);
}
function parseWsMessageFrame(data: string): WsMessageFrame | null {
try {
const parsed = JSON.parse(data) as unknown;
return parsed && typeof parsed === "object"
? parsed as WsMessageFrame
: null;
} catch {
return null;
}
}
function isAssistantNotificationFrame(frame: WsMessageFrame): frame is WsMessageFrame & {
chat_id: string;
text: string;
} {
return (
frame.event === "message" &&
typeof frame.chat_id === "string" &&
typeof frame.text === "string" &&
frame.text.trim().length > 0 &&
frame.kind !== "tool_hint" &&
frame.kind !== "progress" &&
frame.kind !== "reasoning"
);
}
function notificationFrameFromWsFrame(frame: WsMessageFrame): WsMessageFrame & {
chat_id: string;
text: string;
} | null {
if (isAssistantNotificationFrame(frame)) return frame;
if (frame.event === "delta") {
if (typeof frame.chat_id === "string" && typeof frame.text === "string") {
const key = streamNotificationKey(frame);
streamTextBuffers.set(key, `${streamTextBuffers.get(key) ?? ""}${frame.text}`);
}
return null;
}
if (frame.event === "stream_end" && typeof frame.chat_id === "string") {
const key = streamNotificationKey(frame);
const text = typeof frame.text === "string"
? frame.text
: streamTextBuffers.get(key) ?? "";
streamTextBuffers.delete(key);
return text.trim().length > 0
? { ...frame, chat_id: frame.chat_id, text }
: null;
}
return null;
}
function streamNotificationKey(frame: WsMessageFrame): string {
const streamId = typeof frame.stream_id === "string" ? frame.stream_id : "";
return `${frame.chat_id ?? ""}\u0000${streamId}`;
}
function shouldNotify(win: BrowserWindow | null): boolean {
if (!Notification.isSupported()) return false;
if (!win || win.isDestroyed()) return false;
return !win.isFocused();
}
function showDesktopNotification(
frame: WsMessageFrame & { chat_id: string; text: string },
options: DesktopNotifierOptions,
): void {
const notification = new Notification({
title: notificationTitle(frame.source),
body: notificationBody(frame.text),
subtitle: "nanobot",
});
notification.on("failed", (_event, error) => {
console.warn(`[nanobot] Desktop notification failed: ${error}`);
});
notification.on("click", () => openChatFromNotification(frame.chat_id, options));
notification.show();
unreadNotificationCount += 1;
app.setBadgeCount(unreadNotificationCount);
}
function notificationTitle(source: NotificationSource | undefined): string {
if (source?.kind === "cron" && typeof source.label === "string") {
const label = source.label.trim();
if (label) return truncateText(label, MAX_NOTIFICATION_TITLE_LENGTH);
}
return "nanobot";
}
function notificationBody(text: string): string {
const compact = text.replace(/\s+/g, " ").trim();
return truncateText(compact, MAX_NOTIFICATION_BODY_LENGTH);
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 3)}...`;
}
function openChatFromNotification(chatId: string, options: DesktopNotifierOptions): void {
const win = options.getWindow();
if (!win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
win.focus();
clearDesktopNotificationBadge();
const sessionKey = `websocket:${chatId}`;
const hash = `#/chat/${encodeURIComponent(sessionKey)}`;
void win.webContents.executeJavaScript(
`window.location.hash = ${JSON.stringify(hash)}`,
true,
).catch(() => {});
}