Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Paimon,让你能在浏览器里跟所有 [Pi](https://pi.dev/) 实例对话。
- 🔒 **访问认证** — Access Token 保护所有 API 和 WebSocket 连接
- 🎨 **毛玻璃风格界面** — 清爽的 macOS 风格设计
- 🌐 **多语言支持** — 中文/英文界面,设置页一键切换
- 🔔 **任务完成通知** — 后台运行时,实例完成任务自动弹系统通知
- 📱 **响应式设计** — 桌面/移动端自适应,iOS Safe Area 适配

## 📋 前置要求
Expand Down
4 changes: 4 additions & 0 deletions src/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
40 changes: 39 additions & 1 deletion src/web/src/components/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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";

// ========================================
// 通用组件
Expand Down Expand Up @@ -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") },
Expand Down Expand Up @@ -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 (
<div className="flex-1 flex items-start justify-center p-4 md:p-6 overflow-y-auto scrollbar-auto">
<div className="w-full max-w-[480px]">
Expand Down Expand Up @@ -195,6 +204,35 @@ export function Settings() {
/>
</SettingRow>
</section>
{/* 通知(仅在浏览器支持 Notification API 时显示) */}
{supportsNotification && (
<>
<div className="text-[14px] leading-[20px] font-semibold text-[var(--label-primary)] mb-2 mt-6 px-4 select-none">
{t("notification.title")}
</div>
<section className="glass-panel overflow-hidden">
<SettingRow label={t("notification.label")} showSeparator={false}>
<SegmentedControl
options={notificationOptions}
value={notification ? "on" : "off"}
onChange={async (v) => {
if (v === "on") {
// 首次开启时请求权限
const permission = await Notification.requestPermission();
if (permission === "granted") {
setNotification(true);
} else {
showToast(t("notification.denied"), "warning");
}
} else {
setNotification(false);
}
}}
/>
</SettingRow>
</section>
</>
)}
</div>
</div>
);
Expand Down
116 changes: 116 additions & 0 deletions src/web/src/hooks/useTaskNotifier.ts
Original file line number Diff line number Diff line change
@@ -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<Map<InstanceId, InstanceStatus>>(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();
};
}
10 changes: 10 additions & 0 deletions src/web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/web/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ const zhCN = {
langEn: "English",
},

// ─── 通知 ───
notification: {
title: "通知",
label: "任务完成通知",
on: "开启",
off: "关闭",
denied: "通知权限已被浏览器拒绝,请在浏览器设置中允许",
taskComplete: "✅ {{name}} 任务完成",
},

// ─── 新建实例弹窗 ───
newInstance: {
title: "新建实例",
Expand Down
20 changes: 20 additions & 0 deletions src/web/src/stores/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ interface SettingsState {
background: Background;
/** 解析后的实际主题(考虑 system 偏好) */
resolvedTheme: "light" | "dark";
/** 是否启用任务完成系统通知 */
notification: boolean;
setAppearance: (value: Appearance) => void;
setBackground: (value: Background) => void;
setNotification: (value: boolean) => void;
}

// ── 常量 ──

const KEYS = {
appearance: "paimon:appearance",
background: "paimon:background",
notification: "paimon:notification",
} as const;

// ── localStorage 读取 ──
Expand All @@ -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" {
Expand All @@ -61,6 +69,7 @@ export const useSettings = create<SettingsState>((set) => ({
appearance: readAppearance(),
background: readBackground(),
resolvedTheme: resolveTheme(readAppearance()),
notification: readNotification(),

setAppearance: (value) => {
localStorage.setItem(KEYS.appearance, value);
Expand All @@ -73,6 +82,11 @@ export const useSettings = create<SettingsState>((set) => ({
syncDOM(useSettings.getState().appearance, value);
set({ background: value });
},

setNotification: (value) => {
localStorage.setItem(KEYS.notification, String(value));
set({ notification: value });
},
}));

// ── 系统主题变化监听 ──
Expand Down Expand Up @@ -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());