吸收数字员工通知投影

This commit is contained in:
baiyanyun
2026-06-09 18:33:01 +08:00
parent dc8cd09535
commit 23c3577ef4
10 changed files with 617 additions and 149 deletions

View File

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