吸收数字员工通知跨端同步
This commit is contained in:
@@ -19,6 +19,7 @@ import type {
|
||||
PlanStepDispatchPolicyPullResult,
|
||||
ReadyStepDispatchResult,
|
||||
NotificationPreferences,
|
||||
NotificationUpdatesPullResult,
|
||||
UserNotification,
|
||||
DebugStoreResponse,
|
||||
DebugPlan,
|
||||
@@ -567,7 +568,7 @@ export function getEventLog(params: {
|
||||
// Notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type PlanStepDispatchPolicy, type UserNotification };
|
||||
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type NotificationUpdatesPullResult, type PlanStepDispatchPolicy, type UserNotification };
|
||||
|
||||
export function getNotifications(
|
||||
status: string = 'unread',
|
||||
@@ -596,6 +597,12 @@ export function patchNotificationPreferences(
|
||||
});
|
||||
}
|
||||
|
||||
export function pullNotificationUpdates(): Promise<NotificationUpdatesPullResult> {
|
||||
return apiFetch<NotificationUpdatesPullResult>('/api/notifications/updates/pull', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getApprovalSubscriptionPreferences(): Promise<ApprovalSubscriptionPreferences> {
|
||||
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions');
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ declare global {
|
||||
pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
|
||||
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
||||
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 }>;
|
||||
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
|
||||
pullRouteDecisionPolicy?: () => Promise<RouteDecisionPolicyPullResult>;
|
||||
evaluateRouteDecisionPolicy?: (input: QimingclawRouteDecisionPolicyInput) => Promise<QimingclawRouteDecisionPolicyResult>;
|
||||
@@ -1026,6 +1028,9 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
await readQimingclawSnapshot(),
|
||||
) as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/notifications/updates/pull') {
|
||||
return await pullBridgeNotificationUpdates() 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;
|
||||
@@ -1308,6 +1313,25 @@ async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: num
|
||||
}
|
||||
}
|
||||
|
||||
async function pullBridgeNotificationUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.pullNotificationUpdates) {
|
||||
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
|
||||
}
|
||||
try {
|
||||
const result = await bridge.pullNotificationUpdates();
|
||||
return {
|
||||
ok: result?.ok !== false,
|
||||
applied: Number(result?.applied ?? 0),
|
||||
ignored: Number(result?.ignored ?? 0),
|
||||
...(result?.skipped ? { skipped: true } : {}),
|
||||
...(result?.error ? { error: result.error } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: 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) => (
|
||||
@@ -4753,7 +4777,7 @@ async function handleNotificationAction(
|
||||
action: 'read' | 'dismiss' | 'reply',
|
||||
body: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<{ ok: boolean; task_id?: string; status?: string; task_input_recorded?: boolean; governance_command_id?: string; governance_event_id?: string }> {
|
||||
): Promise<{ ok: boolean; task_id?: string; status?: string; notification_sync_error?: string | null; task_input_recorded?: boolean; governance_command_id?: string; governance_event_id?: string }> {
|
||||
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
|
||||
const current = buildNotifications(snapshot, state).find((notification) => notification.notification_id === notificationId);
|
||||
const now = new Date().toISOString();
|
||||
@@ -4782,6 +4806,18 @@ async function handleNotificationAction(
|
||||
},
|
||||
};
|
||||
await saveState(nextState, `notification_${action}`, undefined, { notification_id: notificationId, action });
|
||||
const writeback = await submitBridgeNotificationAction({
|
||||
notification_id: notificationId,
|
||||
action,
|
||||
status: overlay.status ?? current?.status ?? null,
|
||||
read_at: overlay.read_at ?? null,
|
||||
dismissed_at: overlay.dismissed_at ?? null,
|
||||
reply: action === 'reply' ? replyContent : overlay.reply ?? null,
|
||||
replied_at: overlay.replied_at ?? null,
|
||||
acted_at: action === 'dismiss' ? overlay.dismissed_at ?? now : action === 'reply' ? overlay.replied_at ?? now : overlay.read_at ?? now,
|
||||
actor: 'digital_employee_ui',
|
||||
source: 'sgrobot-digital-adapter',
|
||||
});
|
||||
const taskInput = action === 'reply' && current?.kind === 'need_input' && current.task_id && replyContent && !alreadyReplied
|
||||
? await recordNotificationTaskInput(current, replyContent, snapshot)
|
||||
: null;
|
||||
@@ -4789,12 +4825,24 @@ async function handleNotificationAction(
|
||||
ok: true,
|
||||
task_id: current?.task_id,
|
||||
status: overlay.status,
|
||||
...(writeback.ok ? {} : { notification_sync_error: writeback.error }),
|
||||
task_input_recorded: Boolean(taskInput?.accepted),
|
||||
governance_command_id: taskInput?.command_id,
|
||||
governance_event_id: governanceEventIdFromCommand(taskInput),
|
||||
};
|
||||
}
|
||||
|
||||
async function submitBridgeNotificationAction(input: Record<string, unknown>): Promise<{ ok: boolean; error?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.submitNotificationAction) return { ok: false, error: 'qimingclaw_bridge_unavailable' };
|
||||
try {
|
||||
const result = await bridge.submitNotificationAction(input);
|
||||
return { ok: result?.ok !== false, ...(result?.error ? { error: result.error } : {}) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function recordNotificationTaskInput(
|
||||
notification: UserNotification,
|
||||
replyContent: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Bell, Check, Mail, MessageSquare, Settings2, X } from 'lucide-react';
|
||||
import { Bell, Check, Mail, MessageSquare, RefreshCw, Settings2, X } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { RoleProvider } from '@/contexts/RoleContext';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getUnreadNotificationCount,
|
||||
markNotificationRead,
|
||||
patchNotificationPreferences,
|
||||
pullNotificationUpdates,
|
||||
replyToNotification,
|
||||
type NotificationPreferences,
|
||||
type UserNotification,
|
||||
@@ -129,6 +130,7 @@ export default function DigitalEmployeeShell() {
|
||||
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notificationsLoading, setNotificationsLoading] = useState(false);
|
||||
const [notificationsSyncing, setNotificationsSyncing] = useState(false);
|
||||
const [notificationSettingsOpen, setNotificationSettingsOpen] = useState(false);
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
const [replyDraftById, setReplyDraftById] = useState<Record<string, string>>({});
|
||||
@@ -195,6 +197,16 @@ export default function DigitalEmployeeShell() {
|
||||
await refreshNotifications();
|
||||
}, [refreshNotifications, replyDraftById]);
|
||||
|
||||
const handleNotificationSync = useCallback(async () => {
|
||||
setNotificationsSyncing(true);
|
||||
try {
|
||||
await pullNotificationUpdates();
|
||||
} finally {
|
||||
setNotificationsSyncing(false);
|
||||
await refreshNotifications();
|
||||
}
|
||||
}, [refreshNotifications]);
|
||||
|
||||
const updateNotificationPreferences = useCallback(async (patch: Partial<NotificationPreferences>) => {
|
||||
const next = await patchNotificationPreferences(patch);
|
||||
setNotificationPreferences(next);
|
||||
@@ -262,6 +274,15 @@ export default function DigitalEmployeeShell() {
|
||||
<span>{unreadCount > 0 ? `${unreadCount} 条未读` : '暂无未读'}</span>
|
||||
</div>
|
||||
<div className="de-notification-head-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`de-header-icon de-notification-close ${notificationsSyncing ? 'active' : ''}`}
|
||||
title="同步通知"
|
||||
onClick={() => { void handleNotificationSync(); }}
|
||||
disabled={notificationsSyncing}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`de-header-icon de-notification-close ${notificationSettingsOpen ? 'active' : ''}`}
|
||||
|
||||
@@ -187,6 +187,16 @@ export interface NotificationPreferences {
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationUpdatesPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint?: string | null;
|
||||
pulled_at?: string | null;
|
||||
applied?: number;
|
||||
ignored?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalSubscriptionPreferences {
|
||||
enabled: boolean;
|
||||
subscribed_actions: Array<'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'decision'>;
|
||||
|
||||
Reference in New Issue
Block a user