吸收数字员工通知权限引导
This commit is contained in:
@@ -677,6 +677,36 @@
|
||||
}
|
||||
.de-notification-switch input,
|
||||
.de-notification-toggle-grid input { accent-color: #1684d8; }
|
||||
.de-notification-desktop-status {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(61,132,184,0.14);
|
||||
background: #fff;
|
||||
}
|
||||
.de-notification-desktop-status--success { border-color: rgba(35,128,93,0.2); background: rgba(240,250,246,0.9); }
|
||||
.de-notification-desktop-status--warning { border-color: rgba(204,130,31,0.24); background: rgba(255,248,235,0.92); }
|
||||
.de-notification-desktop-copy { min-width: 0; display: grid; gap: 3px; }
|
||||
.de-notification-desktop-copy span { color: #7890a7; font-size: 11px; font-weight: 800; }
|
||||
.de-notification-desktop-copy strong { color: #12344f; font-size: 13px; line-height: 1.35; }
|
||||
.de-notification-desktop-copy small { color: #6a8094; font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.de-notification-desktop-actions { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.de-notification-desktop-btn {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 1px solid rgba(61,132,184,0.16);
|
||||
border-radius: 999px;
|
||||
padding: 0 10px;
|
||||
color: #31526d;
|
||||
background: rgba(247,251,255,0.96);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.de-notification-desktop-btn:disabled { cursor: not-allowed; opacity: 0.62; }
|
||||
.de-notification-preference-group { display: grid; gap: 7px; }
|
||||
.de-notification-preference-group > span { color: #7890a7; font-size: 11px; font-weight: 800; }
|
||||
.de-notification-segment-row,
|
||||
|
||||
@@ -19,6 +19,9 @@ import type {
|
||||
PlanStepDispatchPolicyPullResult,
|
||||
ReadyStepDispatchResult,
|
||||
RiskApprovalPolicy,
|
||||
DesktopNotificationSettingsOpenResult,
|
||||
DesktopNotificationShowResult,
|
||||
DesktopNotificationStatus,
|
||||
NotificationPreferences,
|
||||
NotificationUpdatesPullResult,
|
||||
UserNotification,
|
||||
@@ -569,7 +572,7 @@ export function getEventLog(params: {
|
||||
// Notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type NotificationUpdatesPullResult, type PlanStepDispatchPolicy, type RiskApprovalPolicy, type UserNotification };
|
||||
export { type ApprovalSubscriptionPreferences, type DesktopNotificationSettingsOpenResult, type DesktopNotificationShowResult, type DesktopNotificationStatus, type NotificationPreferences, type NotificationUpdatesPullResult, type PlanStepDispatchPolicy, type RiskApprovalPolicy, type UserNotification };
|
||||
|
||||
export function getNotifications(
|
||||
status: string = 'unread',
|
||||
@@ -604,13 +607,24 @@ export function pullNotificationUpdates(): Promise<NotificationUpdatesPullResult
|
||||
});
|
||||
}
|
||||
|
||||
export function showDesktopNotification(input: Pick<UserNotification, 'notification_id' | 'title' | 'body' | 'level' | 'kind' | 'status'>): Promise<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }> {
|
||||
return apiFetch<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }>('/api/notifications/desktop', {
|
||||
export function showDesktopNotification(input: Pick<UserNotification, 'notification_id' | 'title' | 'body' | 'level' | 'kind' | 'status'>): Promise<DesktopNotificationShowResult> {
|
||||
return apiFetch<DesktopNotificationShowResult>('/api/notifications/desktop', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDesktopNotificationStatus(): Promise<DesktopNotificationStatus> {
|
||||
return apiFetch<DesktopNotificationStatus>('/api/notifications/desktop/status');
|
||||
}
|
||||
|
||||
export function openDesktopNotificationSettings(): Promise<DesktopNotificationSettingsOpenResult> {
|
||||
return apiFetch<DesktopNotificationSettingsOpenResult>('/api/notifications/desktop/settings/open', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export function getApprovalSubscriptionPreferences(): Promise<ApprovalSubscriptionPreferences> {
|
||||
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions');
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ declare global {
|
||||
pullNotificationUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
||||
submitNotificationAction?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
||||
showDesktopNotification?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; shown?: boolean; reason?: string | null; error?: string | null }>;
|
||||
getDesktopNotificationStatus?: () => Promise<{ supported?: boolean; platform?: string; can_open_settings?: boolean; reason?: string | null }>;
|
||||
openDesktopNotificationSettings?: () => Promise<{ success?: boolean; platform?: string; reason?: string | null; error?: string | null }>;
|
||||
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
|
||||
pullRouteDecisionPolicy?: () => Promise<RouteDecisionPolicyPullResult>;
|
||||
evaluateRouteDecisionPolicy?: (input: QimingclawRouteDecisionPolicyInput) => Promise<QimingclawRouteDecisionPolicyResult>;
|
||||
@@ -1046,6 +1048,12 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'POST' && pathname === '/api/notifications/desktop') {
|
||||
return await showBridgeDesktopNotification(parseOptionalJsonBody(options.body)) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/notifications/desktop/status') {
|
||||
return await readBridgeDesktopNotificationStatus() as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/notifications/desktop/settings/open') {
|
||||
return await openBridgeDesktopNotificationSettings() as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/notifications/unread-count') {
|
||||
const snapshot = await readQimingclawSnapshot();
|
||||
return { count: buildNotifications(snapshot, notificationAdapterState(snapshot)).filter((notification) => notification.status === 'unread').length } as T;
|
||||
@@ -1368,6 +1376,52 @@ async function showBridgeDesktopNotification(input: Record<string, unknown>): Pr
|
||||
}
|
||||
}
|
||||
|
||||
async function readBridgeDesktopNotificationStatus(): Promise<{ supported: boolean; platform: string; can_open_settings: boolean; reason?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.getDesktopNotificationStatus) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: 'unknown',
|
||||
can_open_settings: false,
|
||||
reason: 'qimingclaw_bridge_unavailable',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await bridge.getDesktopNotificationStatus();
|
||||
return {
|
||||
supported: result?.supported === true,
|
||||
platform: stringValue(result?.platform) || 'unknown',
|
||||
can_open_settings: result?.can_open_settings === true,
|
||||
reason: stringValue(result?.reason) || null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: 'unknown',
|
||||
can_open_settings: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function openBridgeDesktopNotificationSettings(): Promise<{ success: boolean; platform?: string | null; reason?: string | null; error?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.openDesktopNotificationSettings) {
|
||||
return { success: false, reason: 'qimingclaw_bridge_unavailable' };
|
||||
}
|
||||
try {
|
||||
const result = await bridge.openDesktopNotificationSettings();
|
||||
return {
|
||||
success: result?.success === true,
|
||||
platform: stringValue(result?.platform) || null,
|
||||
reason: stringValue(result?.reason) || null,
|
||||
error: stringValue(result?.error) || null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApprovedRouteInterventions(snapshot: QimingclawSnapshot | null): Promise<void> {
|
||||
const timeline = await readRouteInterventions(120);
|
||||
const approved = timeline.interventions.filter((item) => (
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Bell, Check, Mail, MessageSquare, RefreshCw, Settings2, X } from 'lucide-react';
|
||||
import { Bell, BellRing, Check, ExternalLink, Mail, MessageSquare, RefreshCw, Settings2, X } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { RoleProvider } from '@/contexts/RoleContext';
|
||||
import {
|
||||
dismissNotification,
|
||||
getDesktopNotificationStatus,
|
||||
getNotificationPreferences,
|
||||
getNotifications,
|
||||
getUnreadNotificationCount,
|
||||
markNotificationRead,
|
||||
openDesktopNotificationSettings,
|
||||
patchNotificationPreferences,
|
||||
pullNotificationUpdates,
|
||||
replyToNotification,
|
||||
showDesktopNotification,
|
||||
type DesktopNotificationShowResult,
|
||||
type DesktopNotificationStatus,
|
||||
type NotificationPreferences,
|
||||
type UserNotification,
|
||||
} from '@/lib/api';
|
||||
@@ -56,6 +60,12 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
const DESKTOP_NOTIFICATION_DEDUPE_KEY = 'qimingclaw:digital-desktop-notified:v1';
|
||||
const DESKTOP_NOTIFICATION_LEVELS = new Set<UserNotification['level']>(['action', 'warn', 'error']);
|
||||
|
||||
type DesktopNotificationFeedback = {
|
||||
tone: 'success' | 'warning';
|
||||
message: string;
|
||||
detail?: string | null;
|
||||
};
|
||||
|
||||
function isDigitalTab(value: string | null): value is DigitalTab {
|
||||
return Boolean(value && TABS.some(tab => tab.id === value));
|
||||
}
|
||||
@@ -126,6 +136,18 @@ function notificationTime(value: string): string {
|
||||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date);
|
||||
}
|
||||
|
||||
function desktopNotificationReasonLabel(reason?: string | null): string | null {
|
||||
if (!reason) return null;
|
||||
if (reason === 'notification_unavailable') return '系统桌面通知不可用或未授权';
|
||||
if (reason === 'notification_settings_unavailable') return '当前平台无法自动打开系统通知设置';
|
||||
if (reason === 'qimingclaw_bridge_unavailable') return '客户端通知桥接尚不可用';
|
||||
return reason;
|
||||
}
|
||||
|
||||
function desktopNotificationResultDetail(result: Pick<DesktopNotificationShowResult, 'reason' | 'error'>): string {
|
||||
return desktopNotificationReasonLabel(result.reason) ?? result.error ?? '通知未能发送';
|
||||
}
|
||||
|
||||
function readDesktopNotificationDedupe(): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DESKTOP_NOTIFICATION_DEDUPE_KEY);
|
||||
@@ -152,23 +174,28 @@ function notificationMatchesDesktopPreferences(notification: UserNotification, p
|
||||
return !preferences.muted_kinds.includes(notification.kind);
|
||||
}
|
||||
|
||||
async function showUnreadDesktopNotifications(notifications: UserNotification[], preferences: NotificationPreferences): Promise<void> {
|
||||
async function showUnreadDesktopNotifications(notifications: UserNotification[], preferences: NotificationPreferences): Promise<DesktopNotificationShowResult | null> {
|
||||
const notified = readDesktopNotificationDedupe();
|
||||
const candidates = notifications.filter((notification) => (
|
||||
notificationMatchesDesktopPreferences(notification, preferences)
|
||||
&& !notified.has(notification.notification_id)
|
||||
));
|
||||
if (candidates.length === 0) return;
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.forEach((notification) => notified.add(notification.notification_id));
|
||||
writeDesktopNotificationDedupe(notified);
|
||||
await Promise.all(candidates.slice(0, 3).map((notification) => showDesktopNotification({
|
||||
const results = await Promise.all(candidates.slice(0, 3).map((notification) => showDesktopNotification({
|
||||
notification_id: notification.notification_id,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
level: notification.level,
|
||||
kind: notification.kind,
|
||||
status: notification.status,
|
||||
}).catch(() => null)));
|
||||
}).catch((error): DesktopNotificationShowResult => ({
|
||||
ok: false,
|
||||
shown: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}))));
|
||||
return results.find((result) => result.ok === false || result.shown !== true) ?? null;
|
||||
}
|
||||
|
||||
export default function DigitalEmployeeShell() {
|
||||
@@ -182,8 +209,29 @@ export default function DigitalEmployeeShell() {
|
||||
const [notificationsSyncing, setNotificationsSyncing] = useState(false);
|
||||
const [notificationSettingsOpen, setNotificationSettingsOpen] = useState(false);
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
const [desktopNotificationStatus, setDesktopNotificationStatus] = useState<DesktopNotificationStatus | null>(null);
|
||||
const [desktopNotificationStatusLoading, setDesktopNotificationStatusLoading] = useState(false);
|
||||
const [desktopNotificationTesting, setDesktopNotificationTesting] = useState(false);
|
||||
const [desktopNotificationSettingsOpening, setDesktopNotificationSettingsOpening] = useState(false);
|
||||
const [desktopNotificationFeedback, setDesktopNotificationFeedback] = useState<DesktopNotificationFeedback | null>(null);
|
||||
const [replyDraftById, setReplyDraftById] = useState<Record<string, string>>({});
|
||||
|
||||
const refreshDesktopNotificationStatus = useCallback(async () => {
|
||||
setDesktopNotificationStatusLoading(true);
|
||||
try {
|
||||
setDesktopNotificationStatus(await getDesktopNotificationStatus());
|
||||
} catch (error) {
|
||||
setDesktopNotificationStatus({
|
||||
supported: false,
|
||||
platform: 'unknown',
|
||||
can_open_settings: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setDesktopNotificationStatusLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshNotifications = useCallback(async () => {
|
||||
setNotificationsLoading(true);
|
||||
try {
|
||||
@@ -195,7 +243,15 @@ export default function DigitalEmployeeShell() {
|
||||
setNotifications(items);
|
||||
setUnreadCount(count);
|
||||
setNotificationPreferences(preferences);
|
||||
void showUnreadDesktopNotifications(items, preferences);
|
||||
void showUnreadDesktopNotifications(items, preferences).then((failure) => {
|
||||
if (failure) {
|
||||
setDesktopNotificationFeedback({
|
||||
tone: 'warning',
|
||||
message: '自动桌面通知发送失败',
|
||||
detail: desktopNotificationResultDetail(failure),
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
@@ -213,6 +269,12 @@ export default function DigitalEmployeeShell() {
|
||||
void refreshNotifications();
|
||||
}, [refreshNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notificationSettingsOpen) {
|
||||
void refreshDesktopNotificationStatus();
|
||||
}
|
||||
}, [notificationSettingsOpen, refreshDesktopNotificationStatus]);
|
||||
|
||||
const navigateDigital = useCallback((nextTarget: DigitalNavigationTarget) => {
|
||||
setTarget(nextTarget);
|
||||
const params = new URLSearchParams();
|
||||
@@ -276,6 +338,63 @@ export default function DigitalEmployeeShell() {
|
||||
void updateNotificationPreferences({ muted_kinds: Array.from(muted) });
|
||||
}, [notificationPreferences.muted_kinds, updateNotificationPreferences]);
|
||||
|
||||
const handleDesktopNotificationTest = useCallback(async () => {
|
||||
setDesktopNotificationTesting(true);
|
||||
try {
|
||||
const result = await showDesktopNotification({
|
||||
notification_id: `desktop-notification-test-${Date.now()}`,
|
||||
title: '数字员工桌面通知测试',
|
||||
body: '如果看到这条通知,系统桌面通知入口已可用。',
|
||||
level: 'info',
|
||||
kind: 'task_progress',
|
||||
status: 'unread',
|
||||
});
|
||||
setDesktopNotificationFeedback(result.ok !== false && result.shown === true
|
||||
? { tone: 'success', message: '测试桌面通知已发送', detail: null }
|
||||
: { tone: 'warning', message: '测试桌面通知发送失败', detail: desktopNotificationResultDetail(result) });
|
||||
await refreshDesktopNotificationStatus();
|
||||
} catch (error) {
|
||||
setDesktopNotificationFeedback({
|
||||
tone: 'warning',
|
||||
message: '测试桌面通知发送失败',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setDesktopNotificationTesting(false);
|
||||
}
|
||||
}, [refreshDesktopNotificationStatus]);
|
||||
|
||||
const handleDesktopNotificationSettingsOpen = useCallback(async () => {
|
||||
setDesktopNotificationSettingsOpening(true);
|
||||
try {
|
||||
const result = await openDesktopNotificationSettings();
|
||||
setDesktopNotificationFeedback(result.success
|
||||
? { tone: 'success', message: '已打开系统设置', detail: null }
|
||||
: { tone: 'warning', message: '无法打开系统设置', detail: desktopNotificationReasonLabel(result.reason) ?? result.error ?? '当前平台不支持自动打开' });
|
||||
await refreshDesktopNotificationStatus();
|
||||
} catch (error) {
|
||||
setDesktopNotificationFeedback({
|
||||
tone: 'warning',
|
||||
message: '无法打开系统设置',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setDesktopNotificationSettingsOpening(false);
|
||||
}
|
||||
}, [refreshDesktopNotificationStatus]);
|
||||
|
||||
const desktopNotificationTone = desktopNotificationFeedback?.tone === 'warning' || desktopNotificationStatus?.supported === false ? 'warning' : 'success';
|
||||
const desktopNotificationMessage = desktopNotificationFeedback?.message
|
||||
?? (desktopNotificationStatusLoading
|
||||
? '正在检查桌面通知'
|
||||
: desktopNotificationStatus?.supported === false
|
||||
? '系统桌面通知不可用'
|
||||
: '系统桌面通知可用');
|
||||
const desktopNotificationDetail = desktopNotificationFeedback?.detail
|
||||
?? desktopNotificationReasonLabel(desktopNotificationStatus?.reason)
|
||||
?? (desktopNotificationStatus?.platform ? `平台:${desktopNotificationStatus.platform}` : null);
|
||||
const shouldShowDesktopSettingsButton = desktopNotificationStatus?.can_open_settings === true || desktopNotificationTone === 'warning';
|
||||
|
||||
return (
|
||||
<RoleProvider>
|
||||
<div className="de-shell">
|
||||
@@ -363,6 +482,37 @@ export default function DigitalEmployeeShell() {
|
||||
/>
|
||||
<span>桌面通知</span>
|
||||
</label>
|
||||
<div className={`de-notification-desktop-status de-notification-desktop-status--${desktopNotificationTone}`}>
|
||||
<div className="de-notification-desktop-copy">
|
||||
<span>桌面通知状态</span>
|
||||
<strong>{desktopNotificationMessage}</strong>
|
||||
{desktopNotificationDetail && <small>{desktopNotificationDetail}</small>}
|
||||
</div>
|
||||
<div className="de-notification-desktop-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="de-notification-desktop-btn"
|
||||
title="测试桌面通知"
|
||||
onClick={() => { void handleDesktopNotificationTest(); }}
|
||||
disabled={desktopNotificationTesting}
|
||||
>
|
||||
<BellRing size={13} />
|
||||
<span>测试桌面通知</span>
|
||||
</button>
|
||||
{shouldShowDesktopSettingsButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="de-notification-desktop-btn"
|
||||
title="打开系统设置"
|
||||
onClick={() => { void handleDesktopNotificationSettingsOpen(); }}
|
||||
disabled={desktopNotificationSettingsOpening}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
<span>打开系统设置</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="de-notification-preference-group">
|
||||
<span>静音等级</span>
|
||||
<div className="de-notification-segment-row">
|
||||
|
||||
@@ -198,6 +198,27 @@ export interface NotificationUpdatesPullResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationStatus {
|
||||
supported: boolean;
|
||||
platform: string;
|
||||
can_open_settings: boolean;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationSettingsOpenResult {
|
||||
success: boolean;
|
||||
platform?: string | null;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationShowResult {
|
||||
ok: boolean;
|
||||
shown?: boolean;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalSubscriptionPreferences {
|
||||
enabled: boolean;
|
||||
subscribed_actions: Array<'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'decision'>;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,8 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-BCqhbj_N.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DQBQCK-i.css">
|
||||
<script type="module" crossorigin src="./assets/index-luMzZfek.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CEcWFgGW.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -233,7 +233,11 @@ function checkDigitalNotificationPreferences() {
|
||||
[adapter, "submitBridgeNotificationAction", "adapter notification action bridge submit helper"],
|
||||
[adapter, "showDesktopNotification", "adapter desktop notification bridge declaration"],
|
||||
[adapter, "/api/notifications/desktop", "adapter desktop notification endpoint"],
|
||||
[adapter, "/api/notifications/desktop/status", "adapter desktop notification status endpoint"],
|
||||
[adapter, "/api/notifications/desktop/settings/open", "adapter desktop notification settings endpoint"],
|
||||
[adapter, "showBridgeDesktopNotification", "adapter desktop notification helper"],
|
||||
[adapter, "readBridgeDesktopNotificationStatus", "adapter desktop notification status helper"],
|
||||
[adapter, "openBridgeDesktopNotificationSettings", "adapter desktop notification settings helper"],
|
||||
[adapter, "notificationMatchesPreferences", "adapter notification preference filter"],
|
||||
[adapter, "desktop_enabled", "adapter notification desktop preference"],
|
||||
[adapter, "update_notification_preferences", "adapter notification preference save action"],
|
||||
@@ -241,10 +245,18 @@ function checkDigitalNotificationPreferences() {
|
||||
[api, "patchNotificationPreferences", "notification preferences API patcher"],
|
||||
[api, "pullNotificationUpdates", "embedded notification updates API"],
|
||||
[api, "showDesktopNotification", "embedded desktop notification API"],
|
||||
[api, "getDesktopNotificationStatus", "embedded desktop notification status API"],
|
||||
[api, "openDesktopNotificationSettings", "embedded desktop notification settings API"],
|
||||
[types, "NotificationPreferences", "notification preferences type"],
|
||||
[types, "DesktopNotificationStatus", "desktop notification status type"],
|
||||
[types, "DesktopNotificationSettingsOpenResult", "desktop notification settings result type"],
|
||||
[types, "desktop_enabled", "notification desktop preference type field"],
|
||||
[types, "NotificationUpdatesPullResult", "notification updates pull result type"],
|
||||
[shell, "de-notification-preferences", "notification preferences UI"],
|
||||
[shell, "桌面通知状态", "desktop notification status UI"],
|
||||
[shell, "测试桌面通知", "desktop notification test UI"],
|
||||
[shell, "打开系统设置", "desktop notification settings UI"],
|
||||
[shell, "自动桌面通知发送失败", "automatic desktop notification failure feedback"],
|
||||
[shell, "handleNotificationSync", "notification manual sync UI action"],
|
||||
[shell, "showUnreadDesktopNotifications", "notification desktop trigger helper"],
|
||||
[shell, "qimingclaw:digital-desktop-notified", "notification desktop dedupe key"],
|
||||
@@ -253,7 +265,10 @@ function checkDigitalNotificationPreferences() {
|
||||
[stateService, "update_notification_preferences", "notification preference runtime event"],
|
||||
[stateService, "desktop_enabled", "notification desktop preference state field"],
|
||||
[desktopNotificationService, "showDigitalEmployeeDesktopNotification", "desktop notification service"],
|
||||
[desktopNotificationService, "getDigitalEmployeeDesktopNotificationStatus", "desktop notification status service"],
|
||||
[desktopNotificationService, "openDigitalEmployeeDesktopNotificationSettings", "desktop notification settings service"],
|
||||
[desktopNotificationService, "Notification", "Electron Notification usage"],
|
||||
[desktopNotificationService, "shell.openExternal", "Electron notification settings opener"],
|
||||
[desktopNotificationService, "notification_unavailable", "desktop notification unavailable fallback"],
|
||||
[stateService, "notification_updates_pulled", "notification updates pulled runtime event"],
|
||||
[stateService, "notification_action_submitted", "notification action submitted runtime event"],
|
||||
@@ -274,9 +289,13 @@ function checkDigitalNotificationPreferences() {
|
||||
[ipcHandlers, "digitalEmployee:pullNotificationUpdates", "IPC notification updates pull handler"],
|
||||
[ipcHandlers, "digitalEmployee:submitNotificationAction", "IPC notification action submit handler"],
|
||||
[ipcHandlers, "digitalEmployee:showDesktopNotification", "IPC desktop notification handler"],
|
||||
[ipcHandlers, "digitalEmployee:getDesktopNotificationStatus", "IPC desktop notification status handler"],
|
||||
[ipcHandlers, "digitalEmployee:openDesktopNotificationSettings", "IPC desktop notification settings handler"],
|
||||
[preload, "pullNotificationUpdates", "preload notification updates bridge"],
|
||||
[preload, "submitNotificationAction", "preload notification action bridge"],
|
||||
[preload, "showDesktopNotification", "preload desktop notification bridge"],
|
||||
[preload, "getDesktopNotificationStatus", "preload desktop notification status bridge"],
|
||||
[preload, "openDesktopNotificationSettings", "preload desktop notification settings bridge"],
|
||||
];
|
||||
const missing = requiredSnippets
|
||||
.filter(([content, snippet]) => !content.includes(snippet))
|
||||
|
||||
@@ -13,6 +13,8 @@ const mockArtifactAccess = vi.hoisted(() => ({
|
||||
|
||||
const mockDesktopNotification = vi.hoisted(() => ({
|
||||
showDigitalEmployeeDesktopNotification: vi.fn(() => ({ ok: true, shown: true })),
|
||||
getDigitalEmployeeDesktopNotificationStatus: vi.fn(() => ({ supported: true, platform: "darwin", can_open_settings: true, reason: null })),
|
||||
openDigitalEmployeeDesktopNotificationSettings: vi.fn(async () => ({ success: true, platform: "darwin" })),
|
||||
}));
|
||||
|
||||
const mockServiceManager = vi.hoisted(() => ({
|
||||
@@ -127,6 +129,8 @@ vi.mock("../services/digitalEmployee/artifactAccessService", () => ({
|
||||
|
||||
vi.mock("../services/digitalEmployee/desktopNotificationService", () => ({
|
||||
showDigitalEmployeeDesktopNotification: mockDesktopNotification.showDigitalEmployeeDesktopNotification,
|
||||
getDigitalEmployeeDesktopNotificationStatus: mockDesktopNotification.getDigitalEmployeeDesktopNotificationStatus,
|
||||
openDigitalEmployeeDesktopNotificationSettings: mockDesktopNotification.openDigitalEmployeeDesktopNotificationSettings,
|
||||
}));
|
||||
|
||||
vi.mock("../services/memory", () => ({
|
||||
@@ -419,20 +423,34 @@ describe("digital employee managed service actions", () => {
|
||||
|
||||
it("registers desktop notification requests through IPC", async () => {
|
||||
const electron = await import("electron");
|
||||
const { showDigitalEmployeeDesktopNotification } = await import("../services/digitalEmployee/desktopNotificationService");
|
||||
const {
|
||||
getDigitalEmployeeDesktopNotificationStatus,
|
||||
openDigitalEmployeeDesktopNotificationSettings,
|
||||
showDigitalEmployeeDesktopNotification,
|
||||
} = await import("../services/digitalEmployee/desktopNotificationService");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
registerDigitalEmployeeHandlers(createCtx());
|
||||
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
|
||||
const notifyCall = calls.find(([channel]) => channel === "digitalEmployee:showDesktopNotification");
|
||||
const statusCall = calls.find(([channel]) => channel === "digitalEmployee:getDesktopNotificationStatus");
|
||||
const openSettingsCall = calls.find(([channel]) => channel === "digitalEmployee:openDesktopNotificationSettings");
|
||||
expect(notifyCall).toBeTruthy();
|
||||
expect(statusCall).toBeTruthy();
|
||||
expect(openSettingsCall).toBeTruthy();
|
||||
|
||||
const notifyResult = await (notifyCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { notification_id: "task:1", title: "任务异常" });
|
||||
const fallbackResult = await (notifyCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, null);
|
||||
const statusResult = await (statusCall?.[1] as (_event: unknown) => Promise<unknown>)({});
|
||||
const openResult = await (openSettingsCall?.[1] as (_event: unknown) => Promise<unknown>)({});
|
||||
|
||||
expect(notifyResult).toMatchObject({ ok: true, shown: true });
|
||||
expect(fallbackResult).toMatchObject({ ok: true, shown: true });
|
||||
expect(statusResult).toMatchObject({ supported: true, can_open_settings: true });
|
||||
expect(openResult).toMatchObject({ success: true, platform: "darwin" });
|
||||
expect(showDigitalEmployeeDesktopNotification).toHaveBeenCalledWith({ notification_id: "task:1", title: "任务异常" });
|
||||
expect(showDigitalEmployeeDesktopNotification).toHaveBeenCalledWith({});
|
||||
expect(getDigitalEmployeeDesktopNotificationStatus).toHaveBeenCalled();
|
||||
expect(openDigitalEmployeeDesktopNotificationSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,11 @@ import {
|
||||
accessDigitalEmployeeArtifact,
|
||||
type DigitalEmployeeArtifactAccessRequest,
|
||||
} from "../services/digitalEmployee/artifactAccessService";
|
||||
import { showDigitalEmployeeDesktopNotification } from "../services/digitalEmployee/desktopNotificationService";
|
||||
import {
|
||||
getDigitalEmployeeDesktopNotificationStatus,
|
||||
openDigitalEmployeeDesktopNotificationSettings,
|
||||
showDigitalEmployeeDesktopNotification,
|
||||
} from "../services/digitalEmployee/desktopNotificationService";
|
||||
import { memoryService } from "../services/memory";
|
||||
|
||||
interface RestartManagedServiceRequest {
|
||||
@@ -238,6 +242,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
return showDigitalEmployeeDesktopNotification(typeof input === "object" && input ? input as Record<string, unknown> : {});
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:getDesktopNotificationStatus", async () => {
|
||||
return getDigitalEmployeeDesktopNotificationStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:openDesktopNotificationSettings", async () => {
|
||||
return openDigitalEmployeeDesktopNotificationSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:evaluateRiskPolicy", async (_, input: unknown) => {
|
||||
return evaluateDigitalEmployeeRiskPolicy(typeof input === "object" && input ? input as Parameters<typeof evaluateDigitalEmployeeRiskPolicy>[0] : {});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const notificationMock = vi.hoisted(() => ({
|
||||
instances: [] as Array<{ options: Record<string, unknown> }>,
|
||||
show: vi.fn(),
|
||||
isSupported: vi.fn(() => true),
|
||||
openExternal: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => {
|
||||
@@ -24,16 +25,29 @@ vi.mock("electron", () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { Notification: MockNotification };
|
||||
return { Notification: MockNotification, shell: { openExternal: notificationMock.openExternal } };
|
||||
});
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", { value: platform, configurable: true });
|
||||
}
|
||||
|
||||
describe("digital employee desktop notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
setPlatform(originalPlatform);
|
||||
notificationMock.instances = [];
|
||||
notificationMock.show.mockReset();
|
||||
notificationMock.isSupported.mockReset();
|
||||
notificationMock.isSupported.mockReturnValue(true);
|
||||
notificationMock.openExternal.mockReset();
|
||||
notificationMock.openExternal.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
it("shows unread action notifications through Electron Notification", async () => {
|
||||
@@ -120,4 +134,52 @@ describe("digital employee desktop notifications", () => {
|
||||
error: "system denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports desktop notification status from Electron support", async () => {
|
||||
const { getDigitalEmployeeDesktopNotificationStatus } = await import("./desktopNotificationService");
|
||||
|
||||
expect(getDigitalEmployeeDesktopNotificationStatus()).toMatchObject({
|
||||
supported: true,
|
||||
platform: originalPlatform,
|
||||
can_open_settings: ["darwin", "win32"].includes(originalPlatform),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
notificationMock.isSupported.mockReturnValue(false);
|
||||
expect(getDigitalEmployeeDesktopNotificationStatus()).toMatchObject({
|
||||
supported: false,
|
||||
reason: "notification_unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("opens macOS and Windows notification settings", async () => {
|
||||
const { openDigitalEmployeeDesktopNotificationSettings } = await import("./desktopNotificationService");
|
||||
|
||||
setPlatform("darwin");
|
||||
await expect(openDigitalEmployeeDesktopNotificationSettings()).resolves.toMatchObject({ success: true, platform: "darwin" });
|
||||
expect(notificationMock.openExternal).toHaveBeenLastCalledWith(expect.stringContaining("x-apple.systempreferences"));
|
||||
|
||||
setPlatform("win32");
|
||||
await expect(openDigitalEmployeeDesktopNotificationSettings()).resolves.toMatchObject({ success: true, platform: "win32" });
|
||||
expect(notificationMock.openExternal).toHaveBeenLastCalledWith("ms-settings:notifications");
|
||||
});
|
||||
|
||||
it("returns a safe failure when notification settings cannot be opened", async () => {
|
||||
const { openDigitalEmployeeDesktopNotificationSettings } = await import("./desktopNotificationService");
|
||||
|
||||
setPlatform("linux");
|
||||
await expect(openDigitalEmployeeDesktopNotificationSettings()).resolves.toMatchObject({
|
||||
success: false,
|
||||
platform: "linux",
|
||||
reason: "notification_settings_unavailable",
|
||||
});
|
||||
expect(notificationMock.openExternal).not.toHaveBeenCalled();
|
||||
|
||||
setPlatform("darwin");
|
||||
notificationMock.openExternal.mockRejectedValueOnce(new Error("settings denied"));
|
||||
await expect(openDigitalEmployeeDesktopNotificationSettings()).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: "settings denied",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Notification } from "electron";
|
||||
import { Notification, shell } from "electron";
|
||||
|
||||
const IMPORTANT_LEVELS = new Set(["action", "warn", "error"]);
|
||||
const TITLE_MAX_LENGTH = 80;
|
||||
@@ -22,6 +22,25 @@ export interface DigitalEmployeeDesktopNotificationResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDesktopNotificationStatus {
|
||||
supported: boolean;
|
||||
platform: NodeJS.Platform;
|
||||
can_open_settings: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDesktopNotificationSettingsResult {
|
||||
success: boolean;
|
||||
platform: NodeJS.Platform;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const NOTIFICATION_SETTINGS_URLS: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "x-apple.systempreferences:com.apple.Notifications-Settings.extension",
|
||||
win32: "ms-settings:notifications",
|
||||
};
|
||||
|
||||
export function showDigitalEmployeeDesktopNotification(
|
||||
input: DigitalEmployeeDesktopNotificationInput,
|
||||
): DigitalEmployeeDesktopNotificationResult {
|
||||
@@ -45,6 +64,29 @@ export function showDigitalEmployeeDesktopNotification(
|
||||
}
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeDesktopNotificationStatus(): DigitalEmployeeDesktopNotificationStatus {
|
||||
const supported = Boolean(Notification && typeof Notification.isSupported === "function" && Notification.isSupported());
|
||||
return {
|
||||
supported,
|
||||
platform: process.platform,
|
||||
can_open_settings: Boolean(NOTIFICATION_SETTINGS_URLS[process.platform]),
|
||||
reason: supported ? null : "notification_unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
export async function openDigitalEmployeeDesktopNotificationSettings(): Promise<DigitalEmployeeDesktopNotificationSettingsResult> {
|
||||
const url = NOTIFICATION_SETTINGS_URLS[process.platform];
|
||||
if (!url) {
|
||||
return { success: false, platform: process.platform, reason: "notification_settings_unavailable" };
|
||||
}
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true, platform: process.platform };
|
||||
} catch (error) {
|
||||
return { success: false, platform: process.platform, error: normalizeError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
@@ -155,6 +155,12 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async showDesktopNotification(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:showDesktopNotification", input);
|
||||
},
|
||||
async getDesktopNotificationStatus() {
|
||||
return ipcRenderer.invoke("digitalEmployee:getDesktopNotificationStatus");
|
||||
},
|
||||
async openDesktopNotificationSettings() {
|
||||
return ipcRenderer.invoke("digitalEmployee:openDesktopNotificationSettings");
|
||||
},
|
||||
async evaluateRiskPolicy(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:evaluateRiskPolicy", input);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user