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