mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 05:48:38 +03:00
feat(desktop): add native reply notifications
This commit is contained in:
+9
-1
@@ -19,6 +19,10 @@ import {
|
|||||||
import type { IpcMainInvokeEvent, WebContents } from "electron";
|
import type { IpcMainInvokeEvent, WebContents } from "electron";
|
||||||
|
|
||||||
import { UnixWebSocketClient } from "./unixWebSocket.js";
|
import { UnixWebSocketClient } from "./unixWebSocket.js";
|
||||||
|
import {
|
||||||
|
clearDesktopNotificationBadge,
|
||||||
|
handleDesktopNotificationFrame,
|
||||||
|
} from "./notifications.js";
|
||||||
|
|
||||||
type EngineStatus = "starting" | "ready" | "restarting" | "stopped" | "crashed";
|
type EngineStatus = "starting" | "ready" | "restarting" | "stopped" | "crashed";
|
||||||
|
|
||||||
@@ -547,6 +551,7 @@ function createWindow(): BrowserWindow {
|
|||||||
});
|
});
|
||||||
|
|
||||||
win.once("ready-to-show", () => win.show());
|
win.once("ready-to-show", () => win.show());
|
||||||
|
win.on("focus", clearDesktopNotificationBadge);
|
||||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
openExternalIfSafe(url);
|
openExternalIfSafe(url);
|
||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
@@ -622,7 +627,10 @@ function registerIpcHandlers(): void {
|
|||||||
const id = randomBytes(12).toString("hex");
|
const id = randomBytes(12).toString("hex");
|
||||||
const client = new UnixWebSocketClient(runtime.socketPath, url, {
|
const client = new UnixWebSocketClient(runtime.socketPath, url, {
|
||||||
onOpen: () => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "open" }),
|
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 }),
|
onError: (message) => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "error", message }),
|
||||||
onClose: (code, reason) => {
|
onClose: (code, reason) => {
|
||||||
hostSockets.delete(id);
|
hostSockets.delete(id);
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user