From b27c81983e63b7c0243efbc0f1c36f2a3b581055 Mon Sep 17 00:00:00 2001 From: tyanxie <494966054@qq.com> Date: Fri, 7 Aug 2026 20:33:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=AE=8C=E6=88=90=E6=97=B6=E5=8F=91=E9=80=81?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useTaskNotifier hook,检测 streaming/compacting → idle 转换 - 页面失焦时通过 Notification API 弹系统通知 - 设置页新增通知开关,首次开启请求浏览器权限 - 点击通知聚焦窗口并 SPA 导航到对应实例 - 不支持 Notification API 的环境自动隐藏通知设置项 --- README.en.md | 1 + README.md | 1 + src/web/src/App.tsx | 4 + src/web/src/components/Settings.tsx | 40 ++++++++- src/web/src/hooks/useTaskNotifier.ts | 116 +++++++++++++++++++++++++++ src/web/src/i18n/en.ts | 10 +++ src/web/src/i18n/zh-CN.ts | 10 +++ src/web/src/stores/useSettings.ts | 20 +++++ 8 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/web/src/hooks/useTaskNotifier.ts diff --git a/README.en.md b/README.en.md index 5ac3a5b..ee10e83 100644 --- a/README.en.md +++ b/README.en.md @@ -23,6 +23,7 @@ Paimon lets you talk to all your [Pi](https://pi.dev/) instances from the browse - 🔒 **Access Control** — Access Token protects all API and WebSocket connections - 🎨 **Frosted Glass UI** — Clean macOS-style design - 🌐 **Multi-language** — Chinese/English interface, switchable in settings +- 🔔 **Task Notifications** — Auto system notification when an instance completes its task in background - 📱 **Responsive Design** — Desktop/mobile adaptive, iOS Safe Area support ## 📋 Prerequisites diff --git a/README.md b/README.md index 3eef56c..637bc70 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Paimon,让你能在浏览器里跟所有 [Pi](https://pi.dev/) 实例对话。 - 🔒 **访问认证** — Access Token 保护所有 API 和 WebSocket 连接 - 🎨 **毛玻璃风格界面** — 清爽的 macOS 风格设计 - 🌐 **多语言支持** — 中文/英文界面,设置页一键切换 +- 🔔 **任务完成通知** — 后台运行时,实例完成任务自动弹系统通知 - 📱 **响应式设计** — 桌面/移动端自适应,iOS Safe Area 适配 ## 📋 前置要求 diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx index 0f48f67..c2406e7 100644 --- a/src/web/src/App.tsx +++ b/src/web/src/App.tsx @@ -6,6 +6,7 @@ import { useViewportHeight } from "./hooks/useViewportHeight"; import { useAuth } from "./hooks/useAuth"; import { useWebSocket } from "./stores/useWebSocket"; import { useInstances } from "./stores/useInstances"; +import { useTaskNotifier } from "./hooks/useTaskNotifier"; import { Sidebar } from "./components/Sidebar"; import { InstanceView } from "./components/InstanceView"; import { Home } from "./components/Home"; @@ -56,6 +57,9 @@ export default function App() { return () => disconnect(); }, [authToken, connect, disconnect, handleAuthError]); + // ── 任务完成通知 ── + useTaskNotifier(); + // ── 常驻 WS 订阅:instance_list / instance_update → useInstances ── useEffect(() => { return subscribe((msg) => { diff --git a/src/web/src/components/Settings.tsx b/src/web/src/components/Settings.tsx index 811f407..c719fd6 100644 --- a/src/web/src/components/Settings.tsx +++ b/src/web/src/components/Settings.tsx @@ -1,14 +1,17 @@ -// 设置页面:外观(主题 + 背景)+ 语言 +// 设置页面:外观(主题 + 背景)+ 语言 + 通知 import { useTranslation } from "react-i18next"; import { useAppearance, useBackground, + useNotification, type Appearance, type Background, } from "../stores/useSettings"; +import { supportsNotification } from "../hooks/useTaskNotifier"; import { type Language, setStoredLanguage } from "../i18n"; import { MobileNavBar } from "./ui/MobileNavBar"; +import { showToast } from "./ui/Toast"; // ======================================== // 通用组件 @@ -119,6 +122,7 @@ export function Settings() { const { t, i18n } = useTranslation(); const [appearance, setAppearance] = useAppearance(); const [background, setBackground] = useBackground(); + const [notification, setNotification] = useNotification(); const appearanceOptions: { value: Appearance; label: string }[] = [ { value: "light", label: t("settings.themeLight") }, @@ -153,6 +157,11 @@ export function Settings() { { value: "en", label: t("settings.langEn") }, ]; + const notificationOptions: { value: "on" | "off"; label: string }[] = [ + { value: "on", label: t("notification.on") }, + { value: "off", label: t("notification.off") }, + ]; + return (
@@ -195,6 +204,35 @@ export function Settings() { /> + {/* 通知(仅在浏览器支持 Notification API 时显示) */} + {supportsNotification && ( + <> +
+ {t("notification.title")} +
+
+ + { + if (v === "on") { + // 首次开启时请求权限 + const permission = await Notification.requestPermission(); + if (permission === "granted") { + setNotification(true); + } else { + showToast(t("notification.denied"), "warning"); + } + } else { + setNotification(false); + } + }} + /> + +
+ + )}
); diff --git a/src/web/src/hooks/useTaskNotifier.ts b/src/web/src/hooks/useTaskNotifier.ts new file mode 100644 index 0000000..9cb3c81 --- /dev/null +++ b/src/web/src/hooks/useTaskNotifier.ts @@ -0,0 +1,116 @@ +// 任务完成系统通知:检测实例 streaming/compacting → idle 转换,弹系统通知 +// +// 内部维护 prevStatusMap 记录各实例上一次状态,不依赖 subscribe 注册顺序。 +// 仅在页面失焦(document.hasFocus() === false)时弹系统通知。 + +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router"; +import i18next from "i18next"; +import { useWebSocket } from "../stores/useWebSocket"; +import { useInstances } from "../stores/useInstances"; +import { useSettings } from "../stores/useSettings"; +import type { + InstanceId, + InstanceStatus, + HubToBrowserMessage, +} from "../../../protocol/types"; + +/** 是否支持 Notification API */ +export const supportsNotification = "Notification" in window; + +/** 是否属于"忙碌 → 空闲"的完成转换 */ +function isCompletionTransition( + prev: InstanceStatus | undefined, + next: InstanceStatus, +): boolean { + return (prev === "streaming" || prev === "compacting") && next === "idle"; +} + +/** + * 挂载任务完成通知。内部维护 prevStatusMap 跟踪状态变化, + * 不依赖外部 subscribe 顺序。 + */ +export function useTaskNotifier() { + const subscribe = useWebSocket((s) => s.subscribe); + const navigate = useNavigate(); + const navigateRef = useRef(navigate); + useEffect(() => { + navigateRef.current = navigate; + }, [navigate]); + + // 用 ref 读取最新设置,避免 subscribe handler 闭包过时 + const notificationRef = useRef(useSettings.getState().notification); + useEffect(() => { + return useSettings.subscribe((state) => { + notificationRef.current = state.notification; + }); + }, []); + + // 维护各实例的上一次状态 + const prevStatusMap = useRef>(new Map()); + + // 初始化 prevStatusMap(从当前 store 快照) + useEffect(() => { + const instances = useInstances.getState().instances; + for (const inst of instances) { + prevStatusMap.current.set(inst.id, inst.status); + } + }, []); + + useEffect(() => { + return subscribe((msg: HubToBrowserMessage) => { + // 同步 instance_list 到 prevStatusMap + if (msg.type === "instance_list") { + prevStatusMap.current.clear(); + for (const inst of msg.payload.instances) { + prevStatusMap.current.set(inst.id, inst.status); + } + return; + } + + if (msg.type === "instance_update") { + const { action, instance } = msg.payload; + + if (action === "disconnected") { + prevStatusMap.current.delete(instance.id); + return; + } + + const prevStatus = prevStatusMap.current.get(instance.id); + // 更新 map(无论是否需要通知) + prevStatusMap.current.set(instance.id, instance.status); + + if (action === "updated" && notificationRef.current) { + if (isCompletionTransition(prevStatus, instance.status)) { + showCompletionNotification(instance.id, instance.cwd, navigateRef); + } + } + } + }); + }, [subscribe]); +} + +/** 弹出系统通知 */ +function showCompletionNotification( + instanceId: InstanceId, + cwd: string, + navigateRef: React.RefObject<(path: string) => void>, +) { + // 用户正在看页面时不弹系统通知 + if (document.hasFocus()) return; + if (!supportsNotification || Notification.permission !== "granted") return; + + const dirName = cwd.split("/").pop() || cwd; + // 不设 tag:同 tag 的通知会静默替换旧通知(无声音/无弹窗动画), + // 导致同一实例连续完成任务时后续通知不可见。 + const notification = new Notification("Paimon", { + body: i18next.t("notification.taskComplete", { name: dirName }), + }); + + // 点击通知:聚焦窗口并导航到对应实例 + notification.onclick = () => { + window.focus(); + navigateRef.current?.(`/instance/${instanceId}`); + notification.close(); + }; +} diff --git a/src/web/src/i18n/en.ts b/src/web/src/i18n/en.ts index 4a87317..6dab819 100644 --- a/src/web/src/i18n/en.ts +++ b/src/web/src/i18n/en.ts @@ -80,6 +80,16 @@ const en: LocaleResource = { langEn: "English", }, + // ─── Notification ─── + notification: { + title: "Notification", + label: "Task Completion Notification", + on: "On", + off: "Off", + denied: "Notification permission denied. Please allow in browser settings", + taskComplete: "✅ {{name}} task completed", + }, + // ─── New Instance Modal ─── newInstance: { title: "New Instance", diff --git a/src/web/src/i18n/zh-CN.ts b/src/web/src/i18n/zh-CN.ts index b909d95..a1ef989 100644 --- a/src/web/src/i18n/zh-CN.ts +++ b/src/web/src/i18n/zh-CN.ts @@ -78,6 +78,16 @@ const zhCN = { langEn: "English", }, + // ─── 通知 ─── + notification: { + title: "通知", + label: "任务完成通知", + on: "开启", + off: "关闭", + denied: "通知权限已被浏览器拒绝,请在浏览器设置中允许", + taskComplete: "✅ {{name}} 任务完成", + }, + // ─── 新建实例弹窗 ─── newInstance: { title: "新建实例", diff --git a/src/web/src/stores/useSettings.ts b/src/web/src/stores/useSettings.ts index f7ad0d0..89d0320 100644 --- a/src/web/src/stores/useSettings.ts +++ b/src/web/src/stores/useSettings.ts @@ -12,8 +12,11 @@ interface SettingsState { background: Background; /** 解析后的实际主题(考虑 system 偏好) */ resolvedTheme: "light" | "dark"; + /** 是否启用任务完成系统通知 */ + notification: boolean; setAppearance: (value: Appearance) => void; setBackground: (value: Background) => void; + setNotification: (value: boolean) => void; } // ── 常量 ── @@ -21,6 +24,7 @@ interface SettingsState { const KEYS = { appearance: "paimon:appearance", background: "paimon:background", + notification: "paimon:notification", } as const; // ── localStorage 读取 ── @@ -37,6 +41,10 @@ function readBackground(): Background { return "mist"; } +function readNotification(): boolean { + return localStorage.getItem(KEYS.notification) === "true"; +} + // ── DOM 同步 ── function resolveTheme(appearance: Appearance): "light" | "dark" { @@ -61,6 +69,7 @@ export const useSettings = create((set) => ({ appearance: readAppearance(), background: readBackground(), resolvedTheme: resolveTheme(readAppearance()), + notification: readNotification(), setAppearance: (value) => { localStorage.setItem(KEYS.appearance, value); @@ -73,6 +82,11 @@ export const useSettings = create((set) => ({ syncDOM(useSettings.getState().appearance, value); set({ background: value }); }, + + setNotification: (value) => { + localStorage.setItem(KEYS.notification, String(value)); + set({ notification: value }); + }, })); // ── 系统主题变化监听 ── @@ -104,6 +118,12 @@ export function useResolvedTheme(): "light" | "dark" { return useSettings((s) => s.resolvedTheme); } +export function useNotification(): [boolean, (v: boolean) => void] { + const notification = useSettings((s) => s.notification); + const setNotification = useSettings((s) => s.setNotification); + return [notification, setNotification]; +} + // ── 初始化(模块加载时同步 DOM)── syncDOM(readAppearance(), readBackground());