吸收数字员工通知投影
This commit is contained in:
@@ -31,6 +31,7 @@ import type {
|
||||
RouteDecisionRecord,
|
||||
RouteDecisionTimelineResponse,
|
||||
SkillSpec,
|
||||
UserNotification,
|
||||
} from '@/types/api';
|
||||
|
||||
declare global {
|
||||
@@ -73,6 +74,7 @@ interface AdapterState {
|
||||
reportScheduleTime: string;
|
||||
reportGeneratedAtByDate: Record<string, string>;
|
||||
decisionResultsByDate: Record<string, Record<string, string>>;
|
||||
notificationStateById?: Record<string, NotificationState>;
|
||||
consoleRole?: string;
|
||||
rolePreferences?: Record<string, unknown>;
|
||||
profile: {
|
||||
@@ -83,6 +85,14 @@ interface AdapterState {
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
status?: UserNotification['status'];
|
||||
read_at?: string | null;
|
||||
dismissed_at?: string | null;
|
||||
reply?: string | null;
|
||||
replied_at?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawServiceStatus {
|
||||
running?: boolean;
|
||||
error?: string;
|
||||
@@ -644,6 +654,22 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && approvalTimelineMatch) {
|
||||
return buildApprovalTimeline(decodeURIComponent(approvalTimelineMatch[1] || ''), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/notifications') {
|
||||
return buildNotificationsResponse(url.searchParams.get('status'), await readQimingclawSnapshot()) 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;
|
||||
}
|
||||
const notificationActionMatch = pathname.match(/^\/api\/notifications\/([^/]+)\/(read|dismiss|reply)$/);
|
||||
if (method === 'POST' && notificationActionMatch) {
|
||||
return await handleNotificationAction(
|
||||
decodeURIComponent(notificationActionMatch[1] || ''),
|
||||
notificationActionMatch[2] as 'read' | 'dismiss' | 'reply',
|
||||
parseOptionalJsonBody(options.body),
|
||||
await readQimingclawSnapshot(),
|
||||
) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/memory') {
|
||||
return { entries: await readMemoryEntries(url.searchParams.get('query'), url.searchParams.get('category')) } as T;
|
||||
}
|
||||
@@ -716,6 +742,7 @@ function defaultState(): AdapterState {
|
||||
reportScheduleTime: DEFAULT_REPORT_TIME,
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: {},
|
||||
notificationStateById: {},
|
||||
consoleRole: 'sales',
|
||||
rolePreferences: {},
|
||||
profile: {
|
||||
@@ -2069,6 +2096,161 @@ function dedupeApprovalTimelineEvents(events: ApprovalTimelineResponse['events']
|
||||
});
|
||||
}
|
||||
|
||||
function notificationAdapterState(snapshot: QimingclawSnapshot | null): AdapterState {
|
||||
return snapshot?.uiState ?? readState();
|
||||
}
|
||||
|
||||
function buildNotificationsResponse(status: string | null, snapshot: QimingclawSnapshot | null): { notifications: UserNotification[] } {
|
||||
const notifications = buildNotifications(snapshot, notificationAdapterState(snapshot));
|
||||
const normalizedStatus = (status || 'all').toLowerCase();
|
||||
if (normalizedStatus === 'all') {
|
||||
return { notifications: notifications.filter((notification) => notification.status !== 'dismissed') };
|
||||
}
|
||||
return { notifications: notifications.filter((notification) => notification.status === normalizedStatus) };
|
||||
}
|
||||
|
||||
function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterState): UserNotification[] {
|
||||
const notifications = dedupeNotifications([
|
||||
...approvalNotifications(snapshot),
|
||||
...syncFailureNotifications(snapshot),
|
||||
...managedServiceNotifications(snapshot),
|
||||
...taskNotifications(snapshot),
|
||||
]);
|
||||
const overlays = state.notificationStateById ?? {};
|
||||
return notifications
|
||||
.map((notification) => applyNotificationOverlay(notification, overlays[notification.notification_id]))
|
||||
.sort((left, right) => right.created_at.localeCompare(left.created_at) || right.notification_id.localeCompare(left.notification_id));
|
||||
}
|
||||
|
||||
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
return buildApprovals(snapshot)
|
||||
.filter((approval) => stringValue(approval.source) === 'qimingclaw-runtime')
|
||||
.map((approval) => {
|
||||
const status = stringValue(approval.status) || 'pending';
|
||||
const open = isDecisionOpenStatus(status);
|
||||
return {
|
||||
notification_id: `approval:${stringValue(approval.approval_id)}`,
|
||||
plan_id: stringValue(approval.plan_id),
|
||||
task_id: stringValue(approval.task_id),
|
||||
kind: open ? 'need_approval' : 'confirmation_resolved',
|
||||
title: open ? '审批待处理' : '审批已处理',
|
||||
body: stringValue(approval.title) || '待确认事项',
|
||||
level: open ? 'action' : 'info',
|
||||
status: open ? 'unread' : 'resolved',
|
||||
correlation_id: stringValue(approval.approval_id),
|
||||
created_at: stringValue(approval.created_at) || new Date().toISOString(),
|
||||
} satisfies UserNotification;
|
||||
});
|
||||
}
|
||||
|
||||
function syncFailureNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
return (snapshot?.sync?.recentFailures ?? []).map((failure) => ({
|
||||
notification_id: `sync_failure:${failure.id}`,
|
||||
plan_id: '',
|
||||
task_id: failure.entityId,
|
||||
kind: 'task_failed',
|
||||
title: `同步失败:${failure.entityType}`,
|
||||
body: syncFailureLine(failure),
|
||||
level: failure.dueForRetry === false ? 'error' : 'warn',
|
||||
status: 'unread',
|
||||
correlation_id: failure.id,
|
||||
created_at: failure.lastAttemptAt ?? failure.updatedAt ?? failure.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function managedServiceNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
return buildManagedServices(snapshot)
|
||||
.filter((service) => service.requires_human_action || service.status === 'degraded' || service.status === 'stopped')
|
||||
.map((service) => ({
|
||||
notification_id: `service:${service.service_id}`,
|
||||
plan_id: '',
|
||||
task_id: service.dependent_tasks?.[0] ?? '',
|
||||
kind: 'need_input',
|
||||
title: `服务需要处理:${service.name}`,
|
||||
body: service.error || `${service.name} 当前状态:${service.status_label || service.status}`,
|
||||
level: service.status === 'stopped' ? 'error' : 'warn',
|
||||
status: 'unread',
|
||||
correlation_id: service.service_id,
|
||||
created_at: service.last_seen_at || new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
function taskNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
return runtimeTasks(snapshot).flatMap((task) => {
|
||||
const normalized = task.status.toLowerCase();
|
||||
const success = ['completed', 'succeeded', 'success', 'done'].includes(normalized);
|
||||
const failed = ['failed', 'error', 'rejected', 'cancelled', 'skipped'].includes(normalized);
|
||||
if (!success && !failed) return [];
|
||||
const timestamp = task.updatedAt || task.createdAt || new Date().toISOString();
|
||||
return [{
|
||||
notification_id: `task:${task.id}:${slugifyIdPart(timestamp)}`,
|
||||
plan_id: task.planId ?? '',
|
||||
task_id: task.id,
|
||||
kind: success ? 'task_finished' : 'task_failed',
|
||||
title: success ? '任务已完成' : '任务需要关注',
|
||||
body: `${task.title}:${taskStatusLabel(task.status)}`,
|
||||
level: success ? 'info' : 'error',
|
||||
status: 'unread',
|
||||
correlation_id: task.id,
|
||||
created_at: timestamp,
|
||||
} satisfies UserNotification];
|
||||
});
|
||||
}
|
||||
|
||||
function applyNotificationOverlay(notification: UserNotification, overlay?: NotificationState): UserNotification {
|
||||
if (!overlay) return notification;
|
||||
return {
|
||||
...notification,
|
||||
status: overlay.status ?? notification.status,
|
||||
read_at: overlay.read_at ?? notification.read_at ?? null,
|
||||
dismissed_at: overlay.dismissed_at ?? notification.dismissed_at ?? null,
|
||||
reply: overlay.reply ?? notification.reply ?? null,
|
||||
replied_at: overlay.replied_at ?? notification.replied_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeNotifications(notifications: UserNotification[]): UserNotification[] {
|
||||
const seen = new Set<string>();
|
||||
return notifications.filter((notification) => {
|
||||
if (!notification.notification_id || seen.has(notification.notification_id)) return false;
|
||||
seen.add(notification.notification_id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNotificationAction(
|
||||
notificationId: string,
|
||||
action: 'read' | 'dismiss' | 'reply',
|
||||
body: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<{ ok: boolean; task_id?: string; status?: string }> {
|
||||
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
|
||||
const current = buildNotifications(snapshot, state).find((notification) => notification.notification_id === notificationId);
|
||||
const now = new Date().toISOString();
|
||||
const overlay: NotificationState = { ...(state.notificationStateById?.[notificationId] ?? {}) };
|
||||
if (action === 'read') {
|
||||
overlay.status = current?.status === 'resolved' ? 'resolved' : 'read';
|
||||
overlay.read_at = overlay.read_at ?? now;
|
||||
} else if (action === 'dismiss') {
|
||||
overlay.status = 'dismissed';
|
||||
overlay.dismissed_at = now;
|
||||
} else {
|
||||
overlay.status = current?.status === 'resolved' ? 'resolved' : 'read';
|
||||
overlay.read_at = overlay.read_at ?? now;
|
||||
overlay.reply = stringValue(body.content ?? body.reply ?? body.message);
|
||||
overlay.replied_at = now;
|
||||
}
|
||||
const nextState: AdapterState = {
|
||||
...state,
|
||||
notificationStateById: {
|
||||
...(state.notificationStateById ?? {}),
|
||||
[notificationId]: overlay,
|
||||
},
|
||||
};
|
||||
await saveState(nextState, `notification_${action}`, undefined, { notification_id: notificationId, action });
|
||||
return { ok: true, task_id: current?.task_id, status: overlay.status };
|
||||
}
|
||||
|
||||
function buildWorkdayDecisions(
|
||||
state: AdapterState,
|
||||
date: string,
|
||||
|
||||
Reference in New Issue
Block a user