吸收数字员工管理端通知视图

This commit is contained in:
baiyanyun
2026-06-10 17:05:57 +08:00
parent 5b2f14123e
commit 1271e4967b
7 changed files with 234 additions and 46 deletions

View File

@@ -687,7 +687,7 @@ const PLAN_SCHEDULES: Record<string, { expression: string; nextRun: string; sche
const BUSINESS_VIEW_SOURCE = 'qimingclaw-runtime-projection';
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'route-decisions' | 'audits';
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'route-decisions' | 'notifications' | 'audits';
type BusinessViewRow = Record<string, unknown>;
export function isQimingclawDigitalApiEnabled(): boolean {
@@ -771,7 +771,7 @@ export async function handleQimingclawDigitalApi<T>(
const approvalId = decodeURIComponent(approvalActionMatch[1] || '');
return await handleApprovalAction(approvalId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
}
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals|route-decisions|audits)$/);
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals|route-decisions|notifications|audits)$/);
if (method === 'GET' && businessViewMatch) {
return buildBusinessViewResponse(businessViewMatch[1] as BusinessViewKind, await readQimingclawSnapshot(), url.searchParams) as T;
}
@@ -2507,13 +2507,16 @@ function buildBusinessViewResponse(kind: BusinessViewKind, snapshot: QimingclawS
artifacts: businessArtifactRows(snapshot),
approvals: businessApprovalRows(snapshot),
'route-decisions': routeDecisionRows(snapshot),
notifications: notificationRows(snapshot, notificationAdapterState(snapshot)),
audits: auditRows(snapshot),
};
const rows = kind === 'route-decisions'
? filterRouteDecisionRows(rowsByKind[kind], searchParams)
: kind === 'audits'
? filterAuditRows(rowsByKind[kind], searchParams)
: filterBusinessRows(rowsByKind[kind], searchParams);
: kind === 'notifications'
? filterNotificationRows(rowsByKind[kind], searchParams)
: kind === 'audits'
? filterAuditRows(rowsByKind[kind], searchParams)
: filterBusinessRows(rowsByKind[kind], searchParams);
return {
...paginateBusinessRows(rows, searchParams),
source: BUSINESS_VIEW_SOURCE,
@@ -2555,6 +2558,50 @@ function filterRouteDecisionRows(rows: BusinessViewRow[], searchParams: URLSearc
.filter((row) => !policyResult || stringValue(row.policy_result) === policyResult);
}
function notificationRows(snapshot: QimingclawSnapshot | null, state: AdapterState): BusinessViewRow[] {
const preferences = notificationPreferences(state);
return buildNotificationCandidates(snapshot, state)
.map((notification) => ({
notification_id: notification.notification_id,
kind: notification.kind,
level: notification.level,
status: notification.status,
title: notification.title,
body: notification.body,
plan_id: notification.plan_id,
task_id: notification.task_id,
correlation_id: notification.correlation_id,
created_at: notification.created_at,
updated_at: notification.replied_at || notification.dismissed_at || notification.read_at || notification.created_at,
read_at: notification.read_at ?? null,
dismissed_at: notification.dismissed_at ?? null,
replied_at: notification.replied_at ?? null,
muted_by_preferences: !notificationMatchesPreferences(notification, preferences),
entity_type: 'notification',
entity_id: notification.notification_id,
source: BUSINESS_VIEW_SOURCE,
}))
.sort((left, right) => stringValue(right.created_at).localeCompare(stringValue(left.created_at)) || stringValue(right.notification_id).localeCompare(stringValue(left.notification_id)));
}
function filterNotificationRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
const id = stringValue(searchParams.get('id'));
const kind = stringValue(searchParams.get('kind'));
const level = stringValue(searchParams.get('level'));
const muted = stringValue(searchParams.get('muted')).toLowerCase();
return filterBusinessRows(rows, searchParams)
.filter((row) => !id || stringValue(row.notification_id) === id || stringValue(row.correlation_id) === id || stringValue(row.entity_id) === id)
.filter((row) => !kind || stringValue(row.kind) === kind)
.filter((row) => !level || stringValue(row.level) === level)
.filter((row) => {
if (!muted) return true;
const mutedValue = row.muted_by_preferences === true;
if (['1', 'true', 'yes'].includes(muted)) return mutedValue;
if (['0', 'false', 'no'].includes(muted)) return !mutedValue;
return true;
});
}
function routeDecisionFromRuntimePayload(payload: Record<string, unknown>, event: QimingclawEventRecord): RouteDecisionRecord {
const decision = asRecord(payload.routeDecision ?? payload.route_decision ?? payload) ?? {};
return {
@@ -3185,6 +3232,7 @@ function businessRowEntityIds(row: BusinessViewRow): string[] {
row.event_id,
row.artifact_id,
row.approval_id,
row.notification_id,
row.report_id,
]);
}
@@ -3468,6 +3516,11 @@ function buildNotificationsResponse(status: string | null, snapshot: QimingclawS
}
function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterState): UserNotification[] {
return buildNotificationCandidates(snapshot, state)
.filter((notification) => notificationMatchesPreferences(notification, state.notificationPreferences));
}
function buildNotificationCandidates(snapshot: QimingclawSnapshot | null, state: AdapterState): UserNotification[] {
const notifications = dedupeNotifications([
...approvalNotifications(snapshot),
...approvalActionNotifications(snapshot),
@@ -3482,7 +3535,6 @@ function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterS
const overlays = state.notificationStateById ?? {};
return notifications
.map((notification) => applyNotificationOverlay(notification, overlays[notification.notification_id]))
.filter((notification) => notificationMatchesPreferences(notification, state.notificationPreferences))
.sort((left, right) => right.created_at.localeCompare(left.created_at) || right.notification_id.localeCompare(left.notification_id));
}

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-DDRkk0GO.js"></script>
<script type="module" crossorigin src="./assets/index-CP7OsYRU.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DqdYYbRq.css">
</head>
<body>

View File

@@ -202,13 +202,19 @@ function checkDigitalNotificationPreferences() {
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const shellPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "DigitalEmployeeShell.tsx");
const stateServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "stateService.ts");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const shell = fs.readFileSync(shellPath, "utf8");
const stateService = fs.readFileSync(stateServicePath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
const requiredSnippets = [
[adapter, "/api/notifications/preferences", "adapter notification preference endpoint"],
[adapter, "route-decisions|notifications|audits", "management notification business route"],
[adapter, "notificationRows", "management notification rows"],
[adapter, "filterNotificationRows", "management notification filters"],
[adapter, "muted_by_preferences", "management notification muted marker"],
[adapter, "handleNotificationPreferencesPatch", "adapter notification preference patch handler"],
[adapter, "notificationMatchesPreferences", "adapter notification preference filter"],
[adapter, "update_notification_preferences", "adapter notification preference save action"],
@@ -218,6 +224,9 @@ function checkDigitalNotificationPreferences() {
[shell, "de-notification-preferences", "notification preferences UI"],
[shell, "NOTIFICATION_KIND_OPTIONS", "notification kind mute UI options"],
[stateService, "update_notification_preferences", "notification preference runtime event"],
[syncService, "notificationBusinessView", "notification sync business view"],
[syncService, "isNotificationEvent", "notification event classifier"],
[syncService, "return \"notification\"", "notification business category"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))

View File

@@ -533,6 +533,68 @@ describe("digital employee sync service", () => {
status: "failed",
reason: "computer chat failed",
});
insertEventEntity("event-notification-read", "digital_workday_notification_read", {
metadata: {
notification_id: "task:task-1",
action: "read",
},
state: {
notificationStateById: {
"task:task-1": {
status: "read",
read_at: "2026-06-07T08:01:00.000Z",
},
},
},
});
insertEventEntity("event-notification-dismiss", "digital_workday_notification_dismiss", {
metadata: {
notification_id: "task:task-2",
action: "dismiss",
},
state: {
notificationStateById: {
"task:task-2": {
status: "dismissed",
dismissed_at: "2026-06-07T08:02:00.000Z",
},
},
},
});
insertEventEntity("event-notification-reply", "digital_workday_notification_reply", {
metadata: {
notification_id: "input:task-3",
action: "reply",
},
state: {
notificationStateById: {
"input:task-3": {
status: "read",
read_at: "2026-06-07T08:03:00.000Z",
reply: "请改用客户备用手机号",
replied_at: "2026-06-07T08:03:30.000Z",
},
},
},
});
insertEventEntity("event-notification-preferences", "digital_workday_update_notification_preferences", {
metadata: {
notification_preferences: {
enabled: false,
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
updated_at: "2026-06-07T08:04:00.000Z",
},
},
state: {
notificationPreferences: {
enabled: false,
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
updated_at: "2026-06-07T08:04:00.000Z",
},
},
});
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
@@ -542,6 +604,10 @@ describe("digital employee sync service", () => {
{ entity_type: "event", entity_id: "event-artifact" },
{ entity_type: "event", entity_id: "event-skill" },
{ entity_type: "event", entity_id: "event-dispatch" },
{ entity_type: "event", entity_id: "event-notification-read" },
{ entity_type: "event", entity_id: "event-notification-dismiss" },
{ entity_type: "event", entity_id: "event-notification-reply" },
{ entity_type: "event", entity_id: "event-notification-preferences" },
],
}),
);
@@ -602,6 +668,36 @@ describe("digital employee sync service", () => {
status: "failed",
reason: "computer chat failed",
});
expect(businessView("event-notification-read")).toMatchObject({
category: "notification",
notification_id: "task:task-1",
action: "read",
status: "read",
read_at: "2026-06-07T08:01:00.000Z",
});
expect(businessView("event-notification-dismiss")).toMatchObject({
category: "notification",
notification_id: "task:task-2",
action: "dismiss",
status: "dismissed",
dismissed_at: "2026-06-07T08:02:00.000Z",
});
expect(businessView("event-notification-reply")).toMatchObject({
category: "notification",
notification_id: "input:task-3",
action: "reply",
status: "read",
read_at: "2026-06-07T08:03:00.000Z",
replied_at: "2026-06-07T08:03:30.000Z",
reply_length: 10,
});
expect(businessView("event-notification-reply")).not.toHaveProperty("reply");
expect(businessView("event-notification-preferences")).toMatchObject({
category: "notification",
preferences_enabled: false,
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
});
expect(readEntity("event", "event-route")).toMatchObject({
sync_status: "synced",
sync_error: null,

View File

@@ -568,6 +568,7 @@ function eventSpecificBusinessView(
}
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
if (isNotificationEvent(kind)) return notificationBusinessView(payload);
return {};
}
@@ -637,16 +638,46 @@ function planStepDispatchBusinessView(payload: Record<string, unknown>): Record<
};
}
function notificationBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? {};
const state = objectRecord(payload.state) ?? {};
const preferences = objectRecord(metadata.notification_preferences) ?? objectRecord(state.notificationPreferences) ?? {};
const overlays = objectRecord(state.notificationStateById) ?? {};
const notificationId = stringField(metadata.notification_id ?? payload.notification_id ?? payload.notificationId) || null;
const overlay = notificationId ? objectRecord(overlays[notificationId]) ?? {} : {};
const reply = stringField(overlay.reply ?? metadata.reply ?? payload.reply);
return {
notification_id: notificationId,
action: stringField(metadata.action ?? payload.action) || null,
status: stringField(overlay.status ?? payload.status) || null,
read_at: stringField(overlay.read_at ?? payload.read_at ?? payload.readAt) || null,
dismissed_at: stringField(overlay.dismissed_at ?? payload.dismissed_at ?? payload.dismissedAt) || null,
replied_at: stringField(overlay.replied_at ?? payload.replied_at ?? payload.repliedAt) || null,
reply_length: reply ? reply.length : null,
preferences_enabled: typeof preferences.enabled === "boolean" ? preferences.enabled : null,
muted_kinds: stringArrayField(preferences.muted_kinds ?? preferences.mutedKinds),
muted_levels: stringArrayField(preferences.muted_levels ?? preferences.mutedLevels),
};
}
function eventBusinessCategory(kind: string): string {
if (kind === "route_decision") return "route";
if (kind.startsWith("approval_")) return "approval";
if (kind.startsWith("artifact_")) return "artifact";
if (kind.startsWith("skill_call_")) return "skill";
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
if (isNotificationEvent(kind)) return "notification";
if (kind.startsWith("governance_")) return "governance";
return "runtime";
}
function isNotificationEvent(kind: string): boolean {
return kind === "digital_workday_update_notification_preferences"
|| kind === "digital_workday_notification_read"
|| kind === "digital_workday_notification_dismiss"
|| kind === "digital_workday_notification_reply";
}
function routeDecisionAttentionLevel(policyResult: string, reasonCodes: string[], createdCommandId: string | null): "info" | "action" | "warn" | "error" {
if (policyResult && policyResult !== "accepted") return "error";
if (reasonCodes.includes("candidate_skills_disabled") || reasonCodes.includes("route_action_missing_context")) return "warn";

View File

@@ -583,9 +583,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮先补管理端审批历史与通知订阅策略,再做多步审批和风险策略联动。
9. **通知 / Inbox / 用户消息**
- 已吸收运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知本地通知订阅偏好已接入 `/api/notifications/preferences`,可按总开关、等级和常用类型过滤 Inbox通知投影本地订阅策略已开始闭环。
- 缺口:仍缺少系统级桌面通知、管理端通知视图、跨端未读同步和任务输入自动转化。
- 建议:下一轮围绕管理端通知视图和跨端订阅同步,把通知从本地 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 并提炼状态字段,通知投影本地订阅策略与管理端消费模型已开始闭环。
- 缺口:仍缺少系统级桌面通知、真实管理端通知页面、跨端未读拉取回写和任务输入自动转化。
- 建议:下一轮围绕跨端未读同步和任务输入自动转化,把本地通知业务视图推进到真正跨端协同入口。
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions``/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。