吸收数字员工通知权限引导
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);
|
||||
},
|
||||
|
||||
@@ -452,7 +452,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
||||
- qimingclaw 主进程记录 `/computer/chat` 结果和 runtime event 时,会从 payload 的 `artifacts`、`outputs`、`files`、`attachments`、`outputPath`、`uri` 以及 `approvals`、`approval_requests`、`needApproval` 等字段轻量提取正式 `digital_artifacts` / `digital_approvals` 记录,并进入 outbox 同步。
|
||||
- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action,把处理结果写入 SQLite UI state,回写正式 approval 状态并进入 approval outbox,同时生成 `approval_decision` / `digital_workday_decide_approval` runtime event。approval payload 已支持 `workflow`、`steps`、`current_step_id`、`participants` 和 `decision_history`,旧单步审批会自动规范化为单步 workflow;多步审批未完成时保持 `pending`,全部通过才进入 `approved`,任一步拒绝进入 `rejected`。同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。
|
||||
- 审批跨端更新与回写已开始闭环:客户端可通过 `POST /api/approval/updates/pull` 拉取管理端 `GET /api/digital-employee/approvals/updates` 审批更新,也会在本地处理审批后通过 `POST /api/digital-employee/approvals/decisions` 尝试回写管理端;远端异常会写入 `digital_workday_approval_*` 事件,不阻断本地审批状态推进;若管理端终态、本地较新状态或多步 current step 与本地事实冲突,客户端会保留本地审批状态,把远端快照写入 approval payload `conflict`,生成 `approval_conflict_detected` event,并在首页审批卡展示“跨端冲突/冲突保护”。
|
||||
- 通知跨端未读同步已开始闭环:客户端可通过 Bell 弹层的同步按钮或 embedded `POST /api/notifications/updates/pull` 拉取管理端 `GET /api/digital-employee/notifications/updates`,把 read/dismiss/reply overlay 合并进本地 `notificationStateById`;本地 read/dismiss/reply 后会尽力回写 `POST /api/digital-employee/notifications/actions`,回写失败只记录 `digital_workday_notification_*` 事件,不回滚本地 Inbox 状态;未读且重要的 Inbox 通知可通过受控 Electron `Notification` 弹出系统级桌面提醒,并用本地去重 key 避免重复打扰。
|
||||
- 通知跨端未读同步已开始闭环:客户端可通过 Bell 弹层的同步按钮或 embedded `POST /api/notifications/updates/pull` 拉取管理端 `GET /api/digital-employee/notifications/updates`,把 read/dismiss/reply overlay 合并进本地 `notificationStateById`;本地 read/dismiss/reply 后会尽力回写 `POST /api/digital-employee/notifications/actions`,回写失败只记录 `digital_workday_notification_*` 事件,不回滚本地 Inbox 状态;未读且重要的 Inbox 通知可通过受控 Electron `Notification` 弹出系统级桌面提醒,并用本地去重 key 避免重复打扰;系统权限引导 UI 已开始吸收,embedded 可通过 `GET /api/notifications/desktop/status` 查询桌面通知可用性,通过 `POST /api/notifications/desktop/settings/open` 打开系统通知设置,并在 Bell 设置面板提供测试桌面通知、失败原因和修复入口。
|
||||
- 高风险动作审批策略已沉淀到 UI state 的 `riskApprovalPolicy`,可通过 `POST /api/risk-approval/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/risk-approval` 下发策略;受控的服务重启、产物访问/保留、审批动作、治理命令和 ready PlanStep 派发前会先评估风险策略,命中审批会写入 `risk_approval_required` approval/event,命中拒绝会写入 `risk_policy_rejected` event,管理端同步业务视图会提炼 action、risk level、decision、approval ID 和策略来源。
|
||||
- 入口路由治理策略已沉淀到 UI state 的 `routeDecisionPolicy`,可通过 `POST /api/route-decision/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/route-decision` 下发策略;`/api/ingress/route` 在生成 route decision 后、映射 governance command 前会先评估策略,命中阻断会写入 `route_decision_blocked` event 并停止执行,命中审批会创建 `route_decision_approval_required` approval/event 并等待人工处理,放行时才继续按 `auto_create_governance_commands` 映射命令;route decision 与管理端 business view 会携带 `policy_result`、`policy_source`、`blocked_reason`、`approval_id` 和 `matched_intent`;embedded 已新增 `/api/route-decisions/interventions` 与 `/api/route-decisions/:routeDecisionId/intervention`,审批通过后会按 `approval_id + route_decision_id` 幂等恢复对应 governance command,处理结果写入 `route_decision_intervention_resolved`。
|
||||
- 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan`、`submit_plan`、`approve_plan`、`activate_plan`、`create_schedule`、`run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。
|
||||
@@ -593,8 +593,8 @@ GET /api/digital-employee/approvals
|
||||
- 建议:下一轮围绕管理端审批工作台,把冲突排查从客户端可视化推进到管理端裁决与回写。
|
||||
|
||||
9. **通知 / Inbox / 用户消息**
|
||||
- 已吸收:运行事件和同步失败能在数字员工页面展示;embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply,本地 UI state 会保存通知已读、忽略和回复 overlay;顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知;本地通知订阅偏好已接入 `/api/notifications/preferences`,可按总开关、桌面通知开关、等级和常用类型过滤 Inbox;`/api/digital-employee/notifications` 已提供管理端可消费的本地通知业务视图,通知动作和订阅偏好事件的 sync `business_view` 会归类为 notification 并提炼状态字段;`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox;客户端已支持管理端通知状态拉取与本地通知动作回写,未读 action/warn/error 通知会按偏好触发一次系统级桌面提醒,`digital_workday_notification_*` 同步事件只暴露通知 ID、状态、endpoint、错误和回复长度,通知投影、本地订阅策略、桌面提醒、跨端未读状态与任务输入链路已开始闭环。
|
||||
- 缺口:仍缺少真实管理端通知页面和系统权限引导 UI。
|
||||
- 已吸收:运行事件和同步失败能在数字员工页面展示;embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply,本地 UI state 会保存通知已读、忽略和回复 overlay;顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知;本地通知订阅偏好已接入 `/api/notifications/preferences`,可按总开关、桌面通知开关、等级和常用类型过滤 Inbox;`/api/digital-employee/notifications` 已提供管理端可消费的本地通知业务视图,通知动作和订阅偏好事件的 sync `business_view` 会归类为 notification 并提炼状态字段;`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox;客户端已支持管理端通知状态拉取与本地通知动作回写,未读 action/warn/error 通知会按偏好触发一次系统级桌面提醒,系统权限引导 UI 已开始吸收,Bell 设置面板可展示桌面通知可用性、测试发送结果、自动通知失败原因,并通过系统设置入口辅助修复,`digital_workday_notification_*` 同步事件只暴露通知 ID、状态、endpoint、错误和回复长度,通知投影、本地订阅策略、桌面提醒、系统权限引导、跨端未读状态与任务输入链路已开始闭环。
|
||||
- 缺口:仍缺少真实管理端通知页面。
|
||||
- 建议:下一轮围绕正式管理端通知工作台,把 Inbox 从嵌入页投影推进到完整跨端协作入口。
|
||||
|
||||
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
|
||||
|
||||
Reference in New Issue
Block a user