吸收数字员工系统桌面通知

This commit is contained in:
baiyanyun
2026-06-11 20:53:46 +08:00
parent 8278fdc81c
commit 245a210b25
14 changed files with 370 additions and 40 deletions

View File

@@ -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');
}

View File

@@ -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,

View File

@@ -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">

View File

@@ -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;