吸收数字员工通知回复任务输入

This commit is contained in:
baiyanyun
2026-06-10 17:49:14 +08:00
parent 1271e4967b
commit 5b78189c9c
8 changed files with 164 additions and 50 deletions

View File

@@ -3819,11 +3819,14 @@ async function handleNotificationAction(
action: 'read' | 'dismiss' | 'reply',
body: Record<string, unknown>,
snapshot: QimingclawSnapshot | null,
): Promise<{ ok: boolean; task_id?: string; status?: string }> {
): Promise<{ ok: boolean; task_id?: string; status?: string; 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();
const overlay: NotificationState = { ...(state.notificationStateById?.[notificationId] ?? {}) };
const previousOverlay = state.notificationStateById?.[notificationId];
const alreadyReplied = Boolean(previousOverlay?.replied_at);
const overlay: NotificationState = { ...(previousOverlay ?? {}) };
let replyContent = '';
if (action === 'read') {
overlay.status = current?.status === 'resolved' ? 'resolved' : 'read';
overlay.read_at = overlay.read_at ?? now;
@@ -3833,7 +3836,8 @@ async function handleNotificationAction(
} else {
overlay.status = current?.status === 'resolved' ? 'resolved' : 'read';
overlay.read_at = overlay.read_at ?? now;
overlay.reply = stringValue(body.content ?? body.reply ?? body.message);
replyContent = stringValue(body.content ?? body.reply ?? body.message);
overlay.reply = replyContent;
overlay.replied_at = now;
}
const nextState: AdapterState = {
@@ -3844,7 +3848,51 @@ async function handleNotificationAction(
},
};
await saveState(nextState, `notification_${action}`, undefined, { notification_id: notificationId, action });
return { ok: true, task_id: current?.task_id, status: overlay.status };
const taskInput = action === 'reply' && current?.kind === 'need_input' && current.task_id && replyContent && !alreadyReplied
? await recordNotificationTaskInput(current, replyContent, snapshot)
: null;
return {
ok: true,
task_id: current?.task_id,
status: overlay.status,
task_input_recorded: Boolean(taskInput?.accepted),
governance_command_id: taskInput?.command_id,
governance_event_id: governanceEventIdFromCommand(taskInput),
};
}
async function recordNotificationTaskInput(
notification: UserNotification,
replyContent: string,
snapshot: QimingclawSnapshot | null,
): Promise<GovernanceCommandResponse> {
const stamp = Date.now();
return handleGovernanceCommand({
command_kind: 'provide_task_input',
context_refs: {
plan_id: notification.plan_id || undefined,
task_id: notification.task_id,
},
payload: {
input: replyContent,
source: 'notification_reply',
notification_id: notification.notification_id,
plan_id: notification.plan_id || undefined,
task_id: notification.task_id,
},
correlation_id: `qimingclaw-notification-reply-${slugifyIdPart(notification.notification_id)}-${stamp}`,
idempotency_key: `qimingclaw-notification-reply-${notification.notification_id}`,
}, snapshot);
}
function governanceEventIdFromCommand(response: GovernanceCommandResponse | null): string | undefined {
const runId = stringFromUnknown(response?.result?.run_id);
if (!response?.accepted || !runId || !response.command_id) return undefined;
return `${runId}:provide_task_input:${safeEventIdPart(response.command_id)}`;
}
function safeEventIdPart(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
}
function buildWorkdayDecisions(

View File

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

View File

@@ -215,6 +215,10 @@ function checkDigitalNotificationPreferences() {
[adapter, "notificationRows", "management notification rows"],
[adapter, "filterNotificationRows", "management notification filters"],
[adapter, "muted_by_preferences", "management notification muted marker"],
[adapter, "recordNotificationTaskInput", "notification reply task input bridge"],
[adapter, "notification_reply", "notification reply task input source"],
[adapter, "task_input_recorded", "notification reply task input result"],
[adapter, "provide_task_input", "notification reply governance command"],
[adapter, "handleNotificationPreferencesPatch", "adapter notification preference patch handler"],
[adapter, "notificationMatchesPreferences", "adapter notification preference filter"],
[adapter, "update_notification_preferences", "adapter notification preference save action"],
@@ -225,6 +229,9 @@ function checkDigitalNotificationPreferences() {
[shell, "NOTIFICATION_KIND_OPTIONS", "notification kind mute UI options"],
[stateService, "update_notification_preferences", "notification preference runtime event"],
[syncService, "notificationBusinessView", "notification sync business view"],
[syncService, "governanceProvideTaskInputBusinessView", "notification reply task input sync view"],
[syncService, "governance_provide_task_input", "task input sync event kind"],
[syncService, "input_length", "task input sync redacted length"],
[syncService, "isNotificationEvent", "notification event classifier"],
[syncService, "return \"notification\"", "notification business category"],
];

View File

@@ -1517,14 +1517,22 @@ describe("digital employee state service", () => {
runId: "run-governance",
stepId: "step-governance",
accepted: true,
payload: { input: "继续使用最新客户名单" },
payload: {
input: "继续使用最新客户名单",
source: "notification_reply",
notification_id: "input:task-governance",
},
});
expect(mockState.db?.tasks.get("task-governance")).toMatchObject({ status: "paused" });
expect(JSON.parse(mockState.db?.tasks.get("task-governance")?.payload ?? "{}")).toMatchObject({
lastGovernanceCommand: {
commandKind: "provide_task_input",
payload: { input: "继续使用最新客户名单" },
payload: {
input: "继续使用最新客户名单",
source: "notification_reply",
notification_id: "input:task-governance",
},
},
});
@@ -1545,6 +1553,10 @@ describe("digital employee state service", () => {
expect(mockState.db?.events.get("run-governance:provide_task_input:command-provide-input")).toMatchObject({
kind: "governance_provide_task_input",
});
expect(mockState.db?.outbox.get("event:insert:run-governance:provide_task_input:command-provide-input")).toMatchObject({
entity_type: "event",
entity_id: "run-governance:provide_task_input:command-provide-input",
});
expect(mockState.db?.events.get("run-governance:resume_task:command-resume-task")).toMatchObject({
kind: "governance_resume_task",
});

View File

@@ -533,6 +533,27 @@ describe("digital employee sync service", () => {
status: "failed",
reason: "computer chat failed",
});
insertEventEntity("event-governance-input", "governance_provide_task_input", {
commandKind: "provide_task_input",
commandId: "command-provide-input",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
accepted: true,
payload: {
input: "请改用客户备用手机号",
source: "notification_reply",
notification_id: "input:task-1",
plan_id: "plan-1",
task_id: "task-1",
},
result: {
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
source: "qimingclaw-task-agent",
},
});
insertEventEntity("event-notification-read", "digital_workday_notification_read", {
metadata: {
notification_id: "task:task-1",
@@ -604,6 +625,7 @@ 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-governance-input" },
{ entity_type: "event", entity_id: "event-notification-read" },
{ entity_type: "event", entity_id: "event-notification-dismiss" },
{ entity_type: "event", entity_id: "event-notification-reply" },
@@ -668,6 +690,16 @@ describe("digital employee sync service", () => {
status: "failed",
reason: "computer chat failed",
});
expect(businessView("event-governance-input")).toMatchObject({
category: "governance",
command_kind: "provide_task_input",
plan_id: "plan-1",
task_id: "task-1",
source: "notification_reply",
notification_id: "input:task-1",
input_length: 10,
});
expect(businessView("event-governance-input")).not.toHaveProperty("input");
expect(businessView("event-notification-read")).toMatchObject({
category: "notification",
notification_id: "task:task-1",

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 (kind === "governance_provide_task_input") return governanceProvideTaskInputBusinessView(payload);
if (isNotificationEvent(kind)) return notificationBusinessView(payload);
return {};
}
@@ -638,6 +639,20 @@ function planStepDispatchBusinessView(payload: Record<string, unknown>): Record<
};
}
function governanceProvideTaskInputBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const commandPayload = objectRecord(payload.payload) ?? {};
const result = objectRecord(payload.result) ?? {};
const input = stringField(commandPayload.input ?? payload.input);
return {
command_kind: stringField(payload.commandKind ?? payload.command_kind) || "provide_task_input",
plan_id: stringField(payload.planId ?? payload.plan_id ?? commandPayload.plan_id ?? result.plan_id) || null,
task_id: stringField(payload.taskId ?? payload.task_id ?? commandPayload.task_id ?? result.task_id) || null,
source: stringField(commandPayload.source ?? result.source ?? payload.source) || null,
notification_id: stringField(commandPayload.notification_id ?? payload.notification_id) || null,
input_length: input ? input.length : null,
};
}
function notificationBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? {};
const state = objectRecord(payload.state) ?? {};

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`/api/digital-employee/notifications` 已提供管理端可消费的本地通知业务视图,通知动作和订阅偏好事件的 sync `business_view` 会归类为 notification 并提炼状态字段,通知投影、本地订阅策略与管理端消费模型已开始闭环。
- 缺口:仍缺少系统级桌面通知、真实管理端通知页面跨端未读拉取回写和任务输入自动转化
- 建议:下一轮围绕跨端未读同步和任务输入自动转化,把本地通知业务视图推进到真正跨端协同入口。
- 已吸收运行事件和同步失败能在数字员工页面展示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 并提炼状态字段`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox管理端 sync `business_view` 只同步来源、通知 ID 和输入长度,通知投影、本地订阅策略与任务输入链路已开始闭环。
- 缺口:仍缺少系统级桌面通知、真实管理端通知页面跨端未读拉取回写。
- 建议:下一轮围绕跨端未读同步和系统桌面通知,把本地通知业务视图推进到真正跨端协同入口。
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions``/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。