吸收数字员工管理端通知视图
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 接管。
|
||||
|
||||
Reference in New Issue
Block a user