吸收数字员工系统桌面通知
This commit is contained in:
@@ -603,6 +603,13 @@ 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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function getApprovalSubscriptionPreferences(): Promise<ApprovalSubscriptionPreferences> {
|
||||
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions');
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ declare global {
|
||||
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
||||
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 }>;
|
||||
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
|
||||
pullRouteDecisionPolicy?: () => Promise<RouteDecisionPolicyPullResult>;
|
||||
evaluateRouteDecisionPolicy?: (input: QimingclawRouteDecisionPolicyInput) => Promise<QimingclawRouteDecisionPolicyResult>;
|
||||
@@ -1031,6 +1032,9 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'POST' && pathname === '/api/notifications/updates/pull') {
|
||||
return await pullBridgeNotificationUpdates() as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/notifications/desktop') {
|
||||
return await showBridgeDesktopNotification(parseOptionalJsonBody(options.body)) 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;
|
||||
@@ -1332,6 +1336,24 @@ async function pullBridgeNotificationUpdates(): Promise<{ ok: boolean; applied?:
|
||||
}
|
||||
}
|
||||
|
||||
async function showBridgeDesktopNotification(input: Record<string, unknown>): Promise<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.showDesktopNotification) {
|
||||
return { ok: false, shown: false, reason: 'qimingclaw_bridge_unavailable' };
|
||||
}
|
||||
try {
|
||||
const result = await bridge.showDesktopNotification(input);
|
||||
return {
|
||||
ok: result?.ok !== false,
|
||||
shown: result?.shown === true,
|
||||
...(result?.reason ? { reason: result.reason } : {}),
|
||||
...(result?.error ? { error: result.error } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, shown: 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) => (
|
||||
@@ -4332,6 +4354,7 @@ function buildNotificationCandidates(snapshot: QimingclawSnapshot | null, state:
|
||||
function defaultNotificationPreferences(): NotificationPreferences {
|
||||
return {
|
||||
enabled: true,
|
||||
desktop_enabled: true,
|
||||
muted_kinds: [],
|
||||
muted_levels: [],
|
||||
updated_at: null,
|
||||
@@ -4446,6 +4469,7 @@ function notificationPreferences(state: Partial<AdapterState> | null | undefined
|
||||
: [];
|
||||
return {
|
||||
enabled: stored.enabled !== false,
|
||||
desktop_enabled: stored.desktop_enabled !== false,
|
||||
muted_kinds: [...new Set(mutedKinds)],
|
||||
muted_levels: [...new Set(mutedLevels)],
|
||||
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
patchNotificationPreferences,
|
||||
pullNotificationUpdates,
|
||||
replyToNotification,
|
||||
showDesktopNotification,
|
||||
type NotificationPreferences,
|
||||
type UserNotification,
|
||||
} from '@/lib/api';
|
||||
@@ -47,10 +48,13 @@ const NOTIFICATION_KIND_OPTIONS: Array<{ id: UserNotification['kind']; label: st
|
||||
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
enabled: true,
|
||||
desktop_enabled: true,
|
||||
muted_kinds: [],
|
||||
muted_levels: [],
|
||||
updated_at: null,
|
||||
};
|
||||
const DESKTOP_NOTIFICATION_DEDUPE_KEY = 'qimingclaw:digital-desktop-notified:v1';
|
||||
const DESKTOP_NOTIFICATION_LEVELS = new Set<UserNotification['level']>(['action', 'warn', 'error']);
|
||||
|
||||
function isDigitalTab(value: string | null): value is DigitalTab {
|
||||
return Boolean(value && TABS.some(tab => tab.id === value));
|
||||
@@ -122,6 +126,51 @@ function notificationTime(value: string): string {
|
||||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date);
|
||||
}
|
||||
|
||||
function readDesktopNotificationDedupe(): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DESKTOP_NOTIFICATION_DEDUPE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeDesktopNotificationDedupe(ids: Set<string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_NOTIFICATION_DEDUPE_KEY, JSON.stringify(Array.from(ids).slice(-300)));
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted webviews; desktop notification is best effort.
|
||||
}
|
||||
}
|
||||
|
||||
function notificationMatchesDesktopPreferences(notification: UserNotification, preferences: NotificationPreferences): boolean {
|
||||
if (!preferences.enabled || !preferences.desktop_enabled) return false;
|
||||
if (notification.status !== 'unread') return false;
|
||||
if (!DESKTOP_NOTIFICATION_LEVELS.has(notification.level)) return false;
|
||||
if (preferences.muted_levels.includes(notification.level)) return false;
|
||||
return !preferences.muted_kinds.includes(notification.kind);
|
||||
}
|
||||
|
||||
async function showUnreadDesktopNotifications(notifications: UserNotification[], preferences: NotificationPreferences): Promise<void> {
|
||||
const notified = readDesktopNotificationDedupe();
|
||||
const candidates = notifications.filter((notification) => (
|
||||
notificationMatchesDesktopPreferences(notification, preferences)
|
||||
&& !notified.has(notification.notification_id)
|
||||
));
|
||||
if (candidates.length === 0) return;
|
||||
candidates.forEach((notification) => notified.add(notification.notification_id));
|
||||
writeDesktopNotificationDedupe(notified);
|
||||
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)));
|
||||
}
|
||||
|
||||
export default function DigitalEmployeeShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -138,15 +187,15 @@ export default function DigitalEmployeeShell() {
|
||||
const refreshNotifications = useCallback(async () => {
|
||||
setNotificationsLoading(true);
|
||||
try {
|
||||
const [items, count] = await Promise.all([
|
||||
const [items, count, preferences] = await Promise.all([
|
||||
getNotifications('all'),
|
||||
getUnreadNotificationCount(),
|
||||
getNotificationPreferences().catch(() => DEFAULT_NOTIFICATION_PREFERENCES),
|
||||
]);
|
||||
setNotifications(items);
|
||||
setUnreadCount(count);
|
||||
void getNotificationPreferences()
|
||||
.then(setNotificationPreferences)
|
||||
.catch(() => setNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES));
|
||||
setNotificationPreferences(preferences);
|
||||
void showUnreadDesktopNotifications(items, preferences);
|
||||
} catch {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
@@ -306,6 +355,14 @@ export default function DigitalEmployeeShell() {
|
||||
/>
|
||||
<span>接收通知</span>
|
||||
</label>
|
||||
<label className="de-notification-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.desktop_enabled}
|
||||
onChange={(event) => { void updateNotificationPreferences({ desktop_enabled: event.target.checked }); }}
|
||||
/>
|
||||
<span>桌面通知</span>
|
||||
</label>
|
||||
<div className="de-notification-preference-group">
|
||||
<span>静音等级</span>
|
||||
<div className="de-notification-segment-row">
|
||||
|
||||
@@ -182,6 +182,7 @@ export interface UserNotification {
|
||||
|
||||
export interface NotificationPreferences {
|
||||
enabled: boolean;
|
||||
desktop_enabled: boolean;
|
||||
muted_kinds: UserNotification['kind'][];
|
||||
muted_levels: UserNotification['level'][];
|
||||
updated_at: string | null;
|
||||
|
||||
Reference in New Issue
Block a user