mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 23:34:00 +00:00
* 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>
160 lines
4.5 KiB
TypeScript
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(() => {});
|
|
}
|