feat(desktop): add native reply notifications

This commit is contained in:
Xubin Ren
2026-06-06 00:19:31 +08:00
parent f02b4e15ad
commit aee793ee19
2 changed files with 132 additions and 1 deletions
+9 -1
View File
@@ -19,6 +19,10 @@ import {
import type { IpcMainInvokeEvent, WebContents } from "electron";
import { UnixWebSocketClient } from "./unixWebSocket.js";
import {
clearDesktopNotificationBadge,
handleDesktopNotificationFrame,
} from "./notifications.js";
type EngineStatus = "starting" | "ready" | "restarting" | "stopped" | "crashed";
@@ -547,6 +551,7 @@ function createWindow(): BrowserWindow {
});
win.once("ready-to-show", () => win.show());
win.on("focus", clearDesktopNotificationBadge);
win.webContents.setWindowOpenHandler(({ url }) => {
openExternalIfSafe(url);
return { action: "deny" };
@@ -622,7 +627,10 @@ function registerIpcHandlers(): void {
const id = randomBytes(12).toString("hex");
const client = new UnixWebSocketClient(runtime.socketPath, url, {
onOpen: () => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "open" }),
onMessage: (data) => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "message", data }),
onMessage: (data) => {
handleDesktopNotificationFrame(data, { getWindow: () => mainWindow });
sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "message", data });
},
onError: (message) => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "error", message }),
onClose: (code, reason) => {
hostSockets.delete(id);
+123
View File
@@ -0,0 +1,123 @@
import {
app,
BrowserWindow,
Notification,
} from "electron";
type NotificationSource = {
kind?: unknown;
label?: unknown;
};
type WsMessageFrame = {
chat_id?: unknown;
event?: unknown;
kind?: unknown;
source?: NotificationSource;
text?: unknown;
};
interface DesktopNotifierOptions {
getWindow: () => BrowserWindow | null;
}
const MAX_NOTIFICATION_BODY_LENGTH = 180;
const MAX_NOTIFICATION_TITLE_LENGTH = 80;
let unreadNotificationCount = 0;
export function handleDesktopNotificationFrame(
data: string,
options: DesktopNotifierOptions,
): void {
const frame = parseWsMessageFrame(data);
if (!frame || !isAssistantNotificationFrame(frame)) return;
if (!shouldNotify(options.getWindow())) return;
showDesktopNotification(frame, 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 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("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(() => {});
}