吸收数字员工通知跨端同步
This commit is contained in:
@@ -19,6 +19,7 @@ import type {
|
||||
PlanStepDispatchPolicyPullResult,
|
||||
ReadyStepDispatchResult,
|
||||
NotificationPreferences,
|
||||
NotificationUpdatesPullResult,
|
||||
UserNotification,
|
||||
DebugStoreResponse,
|
||||
DebugPlan,
|
||||
@@ -567,7 +568,7 @@ export function getEventLog(params: {
|
||||
// Notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type PlanStepDispatchPolicy, type UserNotification };
|
||||
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type NotificationUpdatesPullResult, type PlanStepDispatchPolicy, type UserNotification };
|
||||
|
||||
export function getNotifications(
|
||||
status: string = 'unread',
|
||||
@@ -596,6 +597,12 @@ export function patchNotificationPreferences(
|
||||
});
|
||||
}
|
||||
|
||||
export function pullNotificationUpdates(): Promise<NotificationUpdatesPullResult> {
|
||||
return apiFetch<NotificationUpdatesPullResult>('/api/notifications/updates/pull', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getApprovalSubscriptionPreferences(): Promise<ApprovalSubscriptionPreferences> {
|
||||
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions');
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ declare global {
|
||||
pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
|
||||
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
||||
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
||||
pullNotificationUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
||||
submitNotificationAction?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
||||
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
|
||||
pullRouteDecisionPolicy?: () => Promise<RouteDecisionPolicyPullResult>;
|
||||
evaluateRouteDecisionPolicy?: (input: QimingclawRouteDecisionPolicyInput) => Promise<QimingclawRouteDecisionPolicyResult>;
|
||||
@@ -1026,6 +1028,9 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
await readQimingclawSnapshot(),
|
||||
) as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/notifications/updates/pull') {
|
||||
return await pullBridgeNotificationUpdates() 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;
|
||||
@@ -1308,6 +1313,25 @@ async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: num
|
||||
}
|
||||
}
|
||||
|
||||
async function pullBridgeNotificationUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.pullNotificationUpdates) {
|
||||
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
|
||||
}
|
||||
try {
|
||||
const result = await bridge.pullNotificationUpdates();
|
||||
return {
|
||||
ok: result?.ok !== false,
|
||||
applied: Number(result?.applied ?? 0),
|
||||
ignored: Number(result?.ignored ?? 0),
|
||||
...(result?.skipped ? { skipped: true } : {}),
|
||||
...(result?.error ? { error: result.error } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApprovedRouteInterventions(snapshot: QimingclawSnapshot | null): Promise<void> {
|
||||
const timeline = await readRouteInterventions(120);
|
||||
const approved = timeline.interventions.filter((item) => (
|
||||
@@ -4753,7 +4777,7 @@ async function handleNotificationAction(
|
||||
action: 'read' | 'dismiss' | 'reply',
|
||||
body: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<{ ok: boolean; task_id?: string; status?: string; task_input_recorded?: boolean; governance_command_id?: string; governance_event_id?: string }> {
|
||||
): Promise<{ ok: boolean; task_id?: string; status?: string; notification_sync_error?: string | null; 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();
|
||||
@@ -4782,6 +4806,18 @@ async function handleNotificationAction(
|
||||
},
|
||||
};
|
||||
await saveState(nextState, `notification_${action}`, undefined, { notification_id: notificationId, action });
|
||||
const writeback = await submitBridgeNotificationAction({
|
||||
notification_id: notificationId,
|
||||
action,
|
||||
status: overlay.status ?? current?.status ?? null,
|
||||
read_at: overlay.read_at ?? null,
|
||||
dismissed_at: overlay.dismissed_at ?? null,
|
||||
reply: action === 'reply' ? replyContent : overlay.reply ?? null,
|
||||
replied_at: overlay.replied_at ?? null,
|
||||
acted_at: action === 'dismiss' ? overlay.dismissed_at ?? now : action === 'reply' ? overlay.replied_at ?? now : overlay.read_at ?? now,
|
||||
actor: 'digital_employee_ui',
|
||||
source: 'sgrobot-digital-adapter',
|
||||
});
|
||||
const taskInput = action === 'reply' && current?.kind === 'need_input' && current.task_id && replyContent && !alreadyReplied
|
||||
? await recordNotificationTaskInput(current, replyContent, snapshot)
|
||||
: null;
|
||||
@@ -4789,12 +4825,24 @@ async function handleNotificationAction(
|
||||
ok: true,
|
||||
task_id: current?.task_id,
|
||||
status: overlay.status,
|
||||
...(writeback.ok ? {} : { notification_sync_error: writeback.error }),
|
||||
task_input_recorded: Boolean(taskInput?.accepted),
|
||||
governance_command_id: taskInput?.command_id,
|
||||
governance_event_id: governanceEventIdFromCommand(taskInput),
|
||||
};
|
||||
}
|
||||
|
||||
async function submitBridgeNotificationAction(input: Record<string, unknown>): Promise<{ ok: boolean; error?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.submitNotificationAction) return { ok: false, error: 'qimingclaw_bridge_unavailable' };
|
||||
try {
|
||||
const result = await bridge.submitNotificationAction(input);
|
||||
return { ok: result?.ok !== false, ...(result?.error ? { error: result.error } : {}) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function recordNotificationTaskInput(
|
||||
notification: UserNotification,
|
||||
replyContent: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Bell, Check, Mail, MessageSquare, Settings2, X } from 'lucide-react';
|
||||
import { Bell, Check, Mail, MessageSquare, RefreshCw, Settings2, X } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { RoleProvider } from '@/contexts/RoleContext';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getUnreadNotificationCount,
|
||||
markNotificationRead,
|
||||
patchNotificationPreferences,
|
||||
pullNotificationUpdates,
|
||||
replyToNotification,
|
||||
type NotificationPreferences,
|
||||
type UserNotification,
|
||||
@@ -129,6 +130,7 @@ export default function DigitalEmployeeShell() {
|
||||
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notificationsLoading, setNotificationsLoading] = useState(false);
|
||||
const [notificationsSyncing, setNotificationsSyncing] = useState(false);
|
||||
const [notificationSettingsOpen, setNotificationSettingsOpen] = useState(false);
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
const [replyDraftById, setReplyDraftById] = useState<Record<string, string>>({});
|
||||
@@ -195,6 +197,16 @@ export default function DigitalEmployeeShell() {
|
||||
await refreshNotifications();
|
||||
}, [refreshNotifications, replyDraftById]);
|
||||
|
||||
const handleNotificationSync = useCallback(async () => {
|
||||
setNotificationsSyncing(true);
|
||||
try {
|
||||
await pullNotificationUpdates();
|
||||
} finally {
|
||||
setNotificationsSyncing(false);
|
||||
await refreshNotifications();
|
||||
}
|
||||
}, [refreshNotifications]);
|
||||
|
||||
const updateNotificationPreferences = useCallback(async (patch: Partial<NotificationPreferences>) => {
|
||||
const next = await patchNotificationPreferences(patch);
|
||||
setNotificationPreferences(next);
|
||||
@@ -262,6 +274,15 @@ export default function DigitalEmployeeShell() {
|
||||
<span>{unreadCount > 0 ? `${unreadCount} 条未读` : '暂无未读'}</span>
|
||||
</div>
|
||||
<div className="de-notification-head-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`de-header-icon de-notification-close ${notificationsSyncing ? 'active' : ''}`}
|
||||
title="同步通知"
|
||||
onClick={() => { void handleNotificationSync(); }}
|
||||
disabled={notificationsSyncing}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`de-header-icon de-notification-close ${notificationSettingsOpen ? 'active' : ''}`}
|
||||
|
||||
@@ -187,6 +187,16 @@ export interface NotificationPreferences {
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationUpdatesPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint?: string | null;
|
||||
pulled_at?: string | null;
|
||||
applied?: number;
|
||||
ignored?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalSubscriptionPreferences {
|
||||
enabled: boolean;
|
||||
subscribed_actions: Array<'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'decision'>;
|
||||
|
||||
File diff suppressed because one or more lines are too long
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-CEkePwhY.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-BX3uWr8J.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -203,12 +203,16 @@ function checkDigitalNotificationPreferences() {
|
||||
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 ipcHandlersPath = path.join(packageRoot, "src", "main", "ipc", "digitalEmployeeHandlers.ts");
|
||||
const preloadPath = path.join(packageRoot, "src", "preload", "webviewPerfBridge.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 ipcHandlers = fs.readFileSync(ipcHandlersPath, "utf8");
|
||||
const preload = fs.readFileSync(preloadPath, "utf8");
|
||||
const requiredSnippets = [
|
||||
[adapter, "/api/notifications/preferences", "adapter notification preference endpoint"],
|
||||
[adapter, "route-decisions|notifications|audits", "management notification business route"],
|
||||
@@ -220,20 +224,41 @@ function checkDigitalNotificationPreferences() {
|
||||
[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, "/api/notifications/updates/pull", "adapter notification updates pull endpoint"],
|
||||
[adapter, "pullBridgeNotificationUpdates", "adapter notification updates bridge pull helper"],
|
||||
[adapter, "submitNotificationAction", "adapter notification action bridge declaration"],
|
||||
[adapter, "submitBridgeNotificationAction", "adapter notification action bridge submit helper"],
|
||||
[adapter, "notificationMatchesPreferences", "adapter notification preference filter"],
|
||||
[adapter, "update_notification_preferences", "adapter notification preference save action"],
|
||||
[api, "getNotificationPreferences", "notification preferences API getter"],
|
||||
[api, "patchNotificationPreferences", "notification preferences API patcher"],
|
||||
[api, "pullNotificationUpdates", "embedded notification updates API"],
|
||||
[types, "NotificationPreferences", "notification preferences type"],
|
||||
[types, "NotificationUpdatesPullResult", "notification updates pull result type"],
|
||||
[shell, "de-notification-preferences", "notification preferences UI"],
|
||||
[shell, "handleNotificationSync", "notification manual sync UI action"],
|
||||
[shell, "NOTIFICATION_KIND_OPTIONS", "notification kind mute UI options"],
|
||||
[stateService, "update_notification_preferences", "notification preference runtime event"],
|
||||
[stateService, "notification_updates_pulled", "notification updates pulled runtime event"],
|
||||
[stateService, "notification_action_submitted", "notification action submitted runtime event"],
|
||||
[syncService, "pullDigitalEmployeeNotificationUpdates", "remote notification updates pull service"],
|
||||
[syncService, "submitDigitalEmployeeNotificationAction", "remote notification action submit service"],
|
||||
[syncService, "/api/digital-employee/notifications/updates", "remote notification updates default path"],
|
||||
[syncService, "/api/digital-employee/notifications/actions", "remote notification actions default path"],
|
||||
[syncService, "notificationUpdatesEndpoint", "notification updates endpoint config"],
|
||||
[syncService, "notificationActionsEndpoint", "notification actions endpoint config"],
|
||||
[syncService, "notificationBusinessView", "notification sync business view"],
|
||||
[syncService, "digital_workday_notification_updates_pulled", "notification updates sync event kind"],
|
||||
[syncService, "digital_workday_notification_action_submitted", "notification action sync event kind"],
|
||||
[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"],
|
||||
[ipcHandlers, "digitalEmployee:pullNotificationUpdates", "IPC notification updates pull handler"],
|
||||
[ipcHandlers, "digitalEmployee:submitNotificationAction", "IPC notification action submit handler"],
|
||||
[preload, "pullNotificationUpdates", "preload notification updates bridge"],
|
||||
[preload, "submitNotificationAction", "preload notification action bridge"],
|
||||
];
|
||||
const missing = requiredSnippets
|
||||
.filter(([content, snippet]) => !content.includes(snippet))
|
||||
|
||||
@@ -104,6 +104,8 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
|
||||
pullDigitalEmployeeRouteDecisionPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, auto_create_governance_commands: true, approval_required_intents: [], blocked_intents: [], preferred_route_kinds: [], updated_at: null } })),
|
||||
pullDigitalEmployeeApprovalUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
|
||||
submitDigitalEmployeeApprovalDecision: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
|
||||
pullDigitalEmployeeNotificationUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
|
||||
submitDigitalEmployeeNotificationAction: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
|
||||
@@ -381,4 +383,28 @@ describe("digital employee managed service actions", () => {
|
||||
expect(syncService.pullDigitalEmployeeApprovalUpdates).toHaveBeenCalledWith({ force: true });
|
||||
expect(syncService.submitDigitalEmployeeApprovalDecision).toHaveBeenCalledWith({ approval_id: "approval-1", decision: "approved" });
|
||||
});
|
||||
|
||||
it("registers notification update pull and action submit through IPC", async () => {
|
||||
const electron = await import("electron");
|
||||
const syncService = await import("../services/digitalEmployee/syncService");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
registerDigitalEmployeeHandlers(createCtx());
|
||||
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
|
||||
const pullCall = calls.find(([channel]) => channel === "digitalEmployee:pullNotificationUpdates");
|
||||
const submitCall = calls.find(([channel]) => channel === "digitalEmployee:submitNotificationAction");
|
||||
expect(pullCall).toBeTruthy();
|
||||
expect(submitCall).toBeTruthy();
|
||||
|
||||
const pullResult = await (pullCall?.[1] as (_event: unknown) => Promise<unknown>)({});
|
||||
const submitResult = await (submitCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { notification_id: "task:1", action: "read" });
|
||||
const fallbackSubmitResult = await (submitCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, null);
|
||||
|
||||
expect(pullResult).toMatchObject({ ok: true, applied: 1 });
|
||||
expect(submitResult).toMatchObject({ ok: true });
|
||||
expect(fallbackSubmitResult).toMatchObject({ ok: true });
|
||||
expect(syncService.pullDigitalEmployeeNotificationUpdates).toHaveBeenCalledWith({ force: true });
|
||||
expect(syncService.submitDigitalEmployeeNotificationAction).toHaveBeenCalledWith({ notification_id: "task:1", action: "read" });
|
||||
expect(syncService.submitDigitalEmployeeNotificationAction).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,8 @@ import {
|
||||
pullDigitalEmployeeRiskApprovalPolicy,
|
||||
pullDigitalEmployeeRouteDecisionPolicy,
|
||||
submitDigitalEmployeeApprovalDecision,
|
||||
pullDigitalEmployeeNotificationUpdates,
|
||||
submitDigitalEmployeeNotificationAction,
|
||||
} from "../services/digitalEmployee/syncService";
|
||||
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
||||
import {
|
||||
@@ -223,6 +225,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
return submitDigitalEmployeeApprovalDecision(typeof input === "object" && input ? input as Record<string, unknown> : {});
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:pullNotificationUpdates", async () => {
|
||||
return pullDigitalEmployeeNotificationUpdates({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:submitNotificationAction", async (_, input: unknown) => {
|
||||
return submitDigitalEmployeeNotificationAction(typeof input === "object" && input ? input as Record<string, unknown> : {});
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:evaluateRiskPolicy", async (_, input: unknown) => {
|
||||
return evaluateDigitalEmployeeRiskPolicy(typeof input === "object" && input ? input as Parameters<typeof evaluateDigitalEmployeeRiskPolicy>[0] : {});
|
||||
});
|
||||
|
||||
@@ -1209,6 +1209,14 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
|
||||
return "数字员工审批处理已回写管理端";
|
||||
case "approval_decision_submit_failed":
|
||||
return "数字员工审批处理回写失败";
|
||||
case "notification_updates_pulled":
|
||||
return "数字员工通知更新已拉取";
|
||||
case "notification_updates_pull_failed":
|
||||
return "数字员工通知更新拉取失败";
|
||||
case "notification_action_submitted":
|
||||
return "数字员工通知动作已回写管理端";
|
||||
case "notification_action_submit_failed":
|
||||
return "数字员工通知动作回写失败";
|
||||
default:
|
||||
return `数字员工页面状态已更新${suffix}`;
|
||||
}
|
||||
|
||||
@@ -446,6 +446,157 @@ describe("digital employee sync service", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("pulls remote notification updates into local notification overlays", async () => {
|
||||
mockState.settings.set("digital_employee_ui_state_v1", {
|
||||
notificationStateById: {
|
||||
"task:existing": { status: "read", read_at: "2026-06-07T07:30:00.000Z" },
|
||||
},
|
||||
});
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
notifications: [
|
||||
{
|
||||
notification_id: "task:need-input:1",
|
||||
status: "read",
|
||||
read_at: "2026-06-07T08:01:00.000Z",
|
||||
remote_updated_at: "2026-06-07T08:02:00.000Z",
|
||||
source: "management-inbox",
|
||||
},
|
||||
{
|
||||
notificationId: "task:reply:1",
|
||||
status: "resolved",
|
||||
reply: "已补充客户编号",
|
||||
repliedAt: "2026-06-07T08:03:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const { pullDigitalEmployeeNotificationUpdates } = await import("./syncService");
|
||||
const { readDigitalEmployeeUiState } = await import("./stateService");
|
||||
const result = await pullDigitalEmployeeNotificationUpdates({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/notifications/updates",
|
||||
applied: 2,
|
||||
ignored: 0,
|
||||
pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
});
|
||||
expect(mockState.fetch).toHaveBeenCalledWith(
|
||||
"https://manage.example.com/api/digital-employee/notifications/updates",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(readDigitalEmployeeUiState().notificationStateById).toMatchObject({
|
||||
"task:existing": { status: "read", read_at: "2026-06-07T07:30:00.000Z" },
|
||||
"task:need-input:1": {
|
||||
status: "read",
|
||||
read_at: "2026-06-07T08:01:00.000Z",
|
||||
remote_updated_at: "2026-06-07T08:02:00.000Z",
|
||||
source: "management-inbox",
|
||||
},
|
||||
"task:reply:1": {
|
||||
status: "resolved",
|
||||
reply: "已补充客户编号",
|
||||
replied_at: "2026-06-07T08:03:00.000Z",
|
||||
source: "management-api",
|
||||
},
|
||||
});
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "digital_workday_notification_updates_pulled" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("keeps local notification overlays when remote notification update pull fails", async () => {
|
||||
mockState.settings.set("digital_employee_ui_state_v1", {
|
||||
notificationStateById: {
|
||||
"task:existing": { status: "read", read_at: "2026-06-07T07:30:00.000Z" },
|
||||
},
|
||||
});
|
||||
mockState.fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "notification service unavailable" })),
|
||||
} as unknown as Response);
|
||||
|
||||
const { pullDigitalEmployeeNotificationUpdates } = await import("./syncService");
|
||||
const { readDigitalEmployeeUiState } = await import("./stateService");
|
||||
const result = await pullDigitalEmployeeNotificationUpdates({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/notifications/updates",
|
||||
applied: 0,
|
||||
ignored: 0,
|
||||
error: "notification service unavailable",
|
||||
});
|
||||
expect(readDigitalEmployeeUiState().notificationStateById).toMatchObject({
|
||||
"task:existing": { status: "read", read_at: "2026-06-07T07:30:00.000Z" },
|
||||
});
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "digital_workday_notification_updates_pull_failed" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("submits notification actions to management and records submission events", async () => {
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({ success: true, data: { accepted: true } }));
|
||||
|
||||
const { submitDigitalEmployeeNotificationAction } = await import("./syncService");
|
||||
const result = await submitDigitalEmployeeNotificationAction({
|
||||
notification_id: "task:need-input:1",
|
||||
action: "reply",
|
||||
status: "read",
|
||||
reply: "已补充客户编号",
|
||||
replied_at: "2026-06-07T08:03:00.000Z",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/notifications/actions",
|
||||
submitted_at: "2026-06-07T08:00:00.000Z",
|
||||
});
|
||||
expect(mockState.fetch).toHaveBeenCalledWith(
|
||||
"https://manage.example.com/api/digital-employee/notifications/actions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining("task:need-input:1"),
|
||||
}),
|
||||
);
|
||||
expect(lastSyncRequestBody()).toMatchObject({
|
||||
notification_id: "task:need-input:1",
|
||||
action: "reply",
|
||||
status: "read",
|
||||
reply: "已补充客户编号",
|
||||
submitted_at: "2026-06-07T08:00:00.000Z",
|
||||
device_id: "device-001",
|
||||
});
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "digital_workday_notification_action_submitted" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("records notification action submit failures without throwing", async () => {
|
||||
mockState.fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "notification action rejected" })),
|
||||
} as unknown as Response);
|
||||
|
||||
const { submitDigitalEmployeeNotificationAction } = await import("./syncService");
|
||||
const result = await submitDigitalEmployeeNotificationAction({ notification_id: "task:need-input:1", action: "read" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/notifications/actions",
|
||||
submitted_at: "2026-06-07T08:00:00.000Z",
|
||||
error: "notification action rejected",
|
||||
});
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "digital_workday_notification_action_submit_failed" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["skillPolicies"],
|
||||
["policies"],
|
||||
|
||||
@@ -28,6 +28,8 @@ const DEFAULT_RISK_APPROVAL_POLICY_PATH = "/api/digital-employee/policies/risk-a
|
||||
const DEFAULT_ROUTE_DECISION_POLICY_PATH = "/api/digital-employee/policies/route-decision";
|
||||
const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
|
||||
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
|
||||
const DEFAULT_NOTIFICATION_UPDATES_PATH = "/api/digital-employee/notifications/updates";
|
||||
const DEFAULT_NOTIFICATION_ACTIONS_PATH = "/api/digital-employee/notifications/actions";
|
||||
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
|
||||
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||
"/system/content/content-digital-employee-sync";
|
||||
@@ -54,6 +56,8 @@ let routeDecisionPolicyPulling = false;
|
||||
let lastRouteDecisionPolicyPullAttemptMs = 0;
|
||||
let approvalUpdatesPulling = false;
|
||||
let lastApprovalUpdatesPullAttemptMs = 0;
|
||||
let notificationUpdatesPulling = false;
|
||||
let lastNotificationUpdatesPullAttemptMs = 0;
|
||||
|
||||
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||
if (!ensureDigitalEmployeeSchema()) return null;
|
||||
@@ -69,6 +73,8 @@ interface DigitalEmployeeSyncConfig {
|
||||
routeDecisionPolicyEndpoint: string | null;
|
||||
approvalUpdatesEndpoint: string | null;
|
||||
approvalDecisionsEndpoint: string | null;
|
||||
notificationUpdatesEndpoint: string | null;
|
||||
notificationActionsEndpoint: string | null;
|
||||
leaseEndpoint: string | null;
|
||||
clientKey: string | null;
|
||||
authToken: string | null;
|
||||
@@ -128,6 +134,23 @@ export interface DigitalEmployeeApprovalDecisionSubmitResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeNotificationUpdatesPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint: string | null;
|
||||
pulled_at: string | null;
|
||||
applied: number;
|
||||
ignored: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeNotificationActionSubmitResult {
|
||||
ok: boolean;
|
||||
endpoint: string | null;
|
||||
submitted_at: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePlanStepRemoteLease {
|
||||
lease_id: string;
|
||||
state: "active" | "released" | "expired";
|
||||
@@ -652,6 +675,154 @@ export async function submitDigitalEmployeeApprovalDecision(
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullDigitalEmployeeNotificationUpdates(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DigitalEmployeeNotificationUpdatesPullResult> {
|
||||
if (notificationUpdatesPulling) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().notificationUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (!options.force && nowMs - lastNotificationUpdatesPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().notificationUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
|
||||
}
|
||||
lastNotificationUpdatesPullAttemptMs = nowMs;
|
||||
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
if (!config.notificationUpdatesEndpoint || missingCredentials.length > 0) {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const error = !config.notificationUpdatesEndpoint ? "management notification updates endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
||||
recordNotificationSyncEvent("notification_updates_pull_failed", pulledAt, {
|
||||
action: "pull_notification_updates",
|
||||
endpoint: config.notificationUpdatesEndpoint,
|
||||
ok: false,
|
||||
applied: 0,
|
||||
ignored: 0,
|
||||
error,
|
||||
source: "management-api",
|
||||
});
|
||||
return { ok: false, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error };
|
||||
}
|
||||
|
||||
notificationUpdatesPulling = true;
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.notificationUpdatesEndpoint);
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
const updates = readRemoteNotificationUpdates(response);
|
||||
const pulledAt = new Date().toISOString();
|
||||
const currentState = readDigitalEmployeeUiState();
|
||||
const notificationStateById: Record<string, unknown> = { ...(currentState.notificationStateById ?? {}) };
|
||||
let applied = 0;
|
||||
let ignored = 0;
|
||||
for (const update of updates) {
|
||||
if (!update.notificationId) {
|
||||
ignored += 1;
|
||||
continue;
|
||||
}
|
||||
const currentOverlay = objectRecord(notificationStateById[update.notificationId]) ?? {};
|
||||
notificationStateById[update.notificationId] = {
|
||||
...currentOverlay,
|
||||
...update.overlay,
|
||||
};
|
||||
applied += 1;
|
||||
}
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "notification_updates_pulled",
|
||||
metadata: {
|
||||
action: "pull_notification_updates",
|
||||
endpoint: config.notificationUpdatesEndpoint,
|
||||
ok: true,
|
||||
applied,
|
||||
ignored,
|
||||
source: "management-api",
|
||||
},
|
||||
state: {
|
||||
...currentState,
|
||||
notificationStateById,
|
||||
},
|
||||
});
|
||||
return { ok: true, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, error: null };
|
||||
} catch (error) {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const message = normalizeError(error);
|
||||
recordNotificationSyncEvent("notification_updates_pull_failed", pulledAt, {
|
||||
action: "pull_notification_updates",
|
||||
endpoint: config.notificationUpdatesEndpoint,
|
||||
ok: false,
|
||||
applied: 0,
|
||||
ignored: 0,
|
||||
error: message,
|
||||
source: "management-api",
|
||||
});
|
||||
return { ok: false, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error: message };
|
||||
} finally {
|
||||
notificationUpdatesPulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitDigitalEmployeeNotificationAction(
|
||||
input: Record<string, unknown>,
|
||||
): Promise<DigitalEmployeeNotificationActionSubmitResult> {
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
const submittedAt = new Date().toISOString();
|
||||
const notificationId = stringField(input.notification_id ?? input.notificationId) || null;
|
||||
const notificationAction = stringField(input.action) || null;
|
||||
const status = stringField(input.status) || null;
|
||||
const reply = stringField(input.reply ?? input.content ?? input.message);
|
||||
if (!config.notificationActionsEndpoint || missingCredentials.length > 0) {
|
||||
const error = !config.notificationActionsEndpoint ? "management notification actions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
||||
recordNotificationSyncEvent("notification_action_submit_failed", submittedAt, {
|
||||
action: "submit_notification_action",
|
||||
ok: false,
|
||||
endpoint: config.notificationActionsEndpoint,
|
||||
error,
|
||||
notification_id: notificationId,
|
||||
notification_action: notificationAction,
|
||||
status,
|
||||
reply_length: reply ? reply.length : null,
|
||||
source: "qimingclaw-notification-sync",
|
||||
});
|
||||
return { ok: false, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error };
|
||||
}
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.notificationActionsEndpoint, {
|
||||
...input,
|
||||
acted_at: stringField(input.acted_at ?? input.actedAt) || submittedAt,
|
||||
submitted_at: submittedAt,
|
||||
device_id: getDeviceId(),
|
||||
});
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
recordNotificationSyncEvent("notification_action_submitted", submittedAt, {
|
||||
action: "submit_notification_action",
|
||||
ok: true,
|
||||
endpoint: config.notificationActionsEndpoint,
|
||||
notification_id: notificationId,
|
||||
notification_action: notificationAction,
|
||||
status,
|
||||
reply_length: reply ? reply.length : null,
|
||||
source: "qimingclaw-notification-sync",
|
||||
});
|
||||
return { ok: true, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error: null };
|
||||
} catch (error) {
|
||||
const message = normalizeError(error);
|
||||
recordNotificationSyncEvent("notification_action_submit_failed", submittedAt, {
|
||||
action: "submit_notification_action",
|
||||
ok: false,
|
||||
endpoint: config.notificationActionsEndpoint,
|
||||
error: message,
|
||||
notification_id: notificationId,
|
||||
notification_action: notificationAction,
|
||||
status,
|
||||
reply_length: reply ? reply.length : null,
|
||||
source: "qimingclaw-notification-sync",
|
||||
});
|
||||
return { ok: false, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireDigitalEmployeePlanStepRemoteLease(
|
||||
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
|
||||
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
||||
@@ -881,6 +1052,16 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
? config.approvalDecisionsEndpoint.trim()
|
||||
: null;
|
||||
const approvalDecisionsEndpoint = approvalDecisionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_APPROVAL_DECISIONS_PATH);
|
||||
const notificationUpdatesEndpointOverride =
|
||||
typeof config.notificationUpdatesEndpoint === "string" && config.notificationUpdatesEndpoint.trim()
|
||||
? config.notificationUpdatesEndpoint.trim()
|
||||
: null;
|
||||
const notificationUpdatesEndpoint = notificationUpdatesEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_UPDATES_PATH);
|
||||
const notificationActionsEndpointOverride =
|
||||
typeof config.notificationActionsEndpoint === "string" && config.notificationActionsEndpoint.trim()
|
||||
? config.notificationActionsEndpoint.trim()
|
||||
: null;
|
||||
const notificationActionsEndpoint = notificationActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_ACTIONS_PATH);
|
||||
const leaseEndpointOverride =
|
||||
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
|
||||
? config.planStepDispatchLeaseEndpoint.trim()
|
||||
@@ -901,6 +1082,8 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
routeDecisionPolicyEndpoint,
|
||||
approvalUpdatesEndpoint,
|
||||
approvalDecisionsEndpoint,
|
||||
notificationUpdatesEndpoint,
|
||||
notificationActionsEndpoint,
|
||||
leaseEndpoint,
|
||||
clientKey: readClientKey(serverHost),
|
||||
authToken: readAuthToken(serverHost),
|
||||
@@ -1098,6 +1281,41 @@ function readRemoteApprovalUpdates(response: Record<string, unknown>): DigitalEm
|
||||
.filter((item) => Boolean(item.approvalId || item.remoteId));
|
||||
}
|
||||
|
||||
function readRemoteNotificationUpdates(response: Record<string, unknown>): Array<{ notificationId: string | null; overlay: Record<string, unknown> }> {
|
||||
const data = objectRecord(response.data) ?? response;
|
||||
const raw = Array.isArray(data.notifications)
|
||||
? data.notifications
|
||||
: Array.isArray(data.updates)
|
||||
? data.updates
|
||||
: Array.isArray(data.items)
|
||||
? data.items
|
||||
: Array.isArray(response)
|
||||
? response
|
||||
: [];
|
||||
return raw
|
||||
.map((item) => objectRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||
.map((item) => {
|
||||
const notificationId = stringField(item.notification_id ?? item.notificationId ?? item.id) || null;
|
||||
const overlay: Record<string, unknown> = {};
|
||||
const status = stringField(item.status);
|
||||
const readAt = stringField(item.read_at ?? item.readAt);
|
||||
const dismissedAt = stringField(item.dismissed_at ?? item.dismissedAt);
|
||||
const reply = stringField(item.reply ?? item.content ?? item.message);
|
||||
const repliedAt = stringField(item.replied_at ?? item.repliedAt);
|
||||
const remoteUpdatedAt = stringField(item.remote_updated_at ?? item.remoteUpdatedAt ?? item.updated_at ?? item.updatedAt);
|
||||
const source = stringField(item.source) || "management-api";
|
||||
if (status) overlay.status = status;
|
||||
if (readAt) overlay.read_at = readAt;
|
||||
if (dismissedAt) overlay.dismissed_at = dismissedAt;
|
||||
if (reply) overlay.reply = reply;
|
||||
if (repliedAt) overlay.replied_at = repliedAt;
|
||||
if (remoteUpdatedAt) overlay.remote_updated_at = remoteUpdatedAt;
|
||||
overlay.source = source;
|
||||
return { notificationId, overlay };
|
||||
});
|
||||
}
|
||||
|
||||
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
|
||||
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
|
||||
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
|
||||
@@ -1304,6 +1522,15 @@ function recordApprovalSyncEvent(action: string, occurredAt: string, metadata: R
|
||||
});
|
||||
}
|
||||
|
||||
function recordNotificationSyncEvent(action: string, occurredAt: string, metadata: Record<string, unknown>): void {
|
||||
const state = readDigitalEmployeeUiState();
|
||||
saveDigitalEmployeeUiState({
|
||||
action,
|
||||
metadata: { ...metadata, occurred_at: occurredAt },
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
|
||||
const payload = parsePayload(row.payload);
|
||||
const summary = readEntitySummary(row.entity_type, row.entity_id);
|
||||
@@ -1783,11 +2010,18 @@ function notificationBusinessView(payload: Record<string, unknown>): Record<stri
|
||||
return {
|
||||
notification_id: notificationId,
|
||||
action: stringField(metadata.action ?? payload.action) || null,
|
||||
notification_action: stringField(metadata.notification_action ?? payload.notification_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,
|
||||
source: stringField(metadata.source) || null,
|
||||
endpoint: stringField(metadata.endpoint) || null,
|
||||
ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
|
||||
applied: numberField(metadata.applied),
|
||||
ignored: numberField(metadata.ignored),
|
||||
error: stringField(metadata.error) || null,
|
||||
reply_length: numberField(metadata.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),
|
||||
@@ -1839,7 +2073,11 @@ 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";
|
||||
|| kind === "digital_workday_notification_reply"
|
||||
|| kind === "digital_workday_notification_updates_pulled"
|
||||
|| kind === "digital_workday_notification_updates_pull_failed"
|
||||
|| kind === "digital_workday_notification_action_submitted"
|
||||
|| kind === "digital_workday_notification_action_submit_failed";
|
||||
}
|
||||
|
||||
function routeDecisionAttentionLevel(policyResult: string, reasonCodes: string[], createdCommandId: string | null): "info" | "action" | "warn" | "error" {
|
||||
|
||||
@@ -146,6 +146,12 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async submitApprovalDecision(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:submitApprovalDecision", input);
|
||||
},
|
||||
async pullNotificationUpdates() {
|
||||
return ipcRenderer.invoke("digitalEmployee:pullNotificationUpdates");
|
||||
},
|
||||
async submitNotificationAction(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:submitNotificationAction", input);
|
||||
},
|
||||
async evaluateRiskPolicy(input: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:evaluateRiskPolicy", input);
|
||||
},
|
||||
|
||||
@@ -452,6 +452,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
||||
- qimingclaw 主进程记录 `/computer/chat` 结果和 runtime event 时,会从 payload 的 `artifacts`、`outputs`、`files`、`attachments`、`outputPath`、`uri` 以及 `approvals`、`approval_requests`、`needApproval` 等字段轻量提取正式 `digital_artifacts` / `digital_approvals` 记录,并进入 outbox 同步。
|
||||
- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action,把处理结果写入 SQLite UI state,回写正式 approval 状态并进入 approval outbox,同时生成 `approval_decision` / `digital_workday_decide_approval` runtime event。approval payload 已支持 `workflow`、`steps`、`current_step_id`、`participants` 和 `decision_history`,旧单步审批会自动规范化为单步 workflow;多步审批未完成时保持 `pending`,全部通过才进入 `approved`,任一步拒绝进入 `rejected`。同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。
|
||||
- 审批跨端更新与回写已开始闭环:客户端可通过 `POST /api/approval/updates/pull` 拉取管理端 `GET /api/digital-employee/approvals/updates` 审批更新,也会在本地处理审批后通过 `POST /api/digital-employee/approvals/decisions` 尝试回写管理端;远端异常会写入 `digital_workday_approval_*` 事件,不阻断本地审批状态推进。
|
||||
- 通知跨端未读同步已开始闭环:客户端可通过 Bell 弹层的同步按钮或 embedded `POST /api/notifications/updates/pull` 拉取管理端 `GET /api/digital-employee/notifications/updates`,把 read/dismiss/reply overlay 合并进本地 `notificationStateById`;本地 read/dismiss/reply 后会尽力回写 `POST /api/digital-employee/notifications/actions`,回写失败只记录 `digital_workday_notification_*` 事件,不回滚本地 Inbox 状态。
|
||||
- 高风险动作审批策略已沉淀到 UI state 的 `riskApprovalPolicy`,可通过 `POST /api/risk-approval/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/risk-approval` 下发策略;受控的服务重启、产物访问/保留、审批动作、治理命令和 ready PlanStep 派发前会先评估风险策略,命中审批会写入 `risk_approval_required` approval/event,命中拒绝会写入 `risk_policy_rejected` event,管理端同步业务视图会提炼 action、risk level、decision、approval ID 和策略来源。
|
||||
- 入口路由治理策略已沉淀到 UI state 的 `routeDecisionPolicy`,可通过 `POST /api/route-decision/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/route-decision` 下发策略;`/api/ingress/route` 在生成 route decision 后、映射 governance command 前会先评估策略,命中阻断会写入 `route_decision_blocked` event 并停止执行,命中审批会创建 `route_decision_approval_required` approval/event 并等待人工处理,放行时才继续按 `auto_create_governance_commands` 映射命令;route decision 与管理端 business view 会携带 `policy_result`、`policy_source`、`blocked_reason`、`approval_id` 和 `matched_intent`;embedded 已新增 `/api/route-decisions/interventions` 与 `/api/route-decisions/:routeDecisionId/intervention`,审批通过后会按 `approval_id + route_decision_id` 幂等恢复对应 governance command,处理结果写入 `route_decision_intervention_resolved`。
|
||||
- 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan`、`submit_plan`、`approve_plan`、`activate_plan`、`create_schedule`、`run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。
|
||||
@@ -592,9 +593,9 @@ GET /api/digital-employee/approvals
|
||||
- 建议:下一轮先补审批策略管理 UI 或管理端审批工作台,再评估系统级桌面通知的跨端策略。
|
||||
|
||||
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 并提炼状态字段;`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox,管理端 sync `business_view` 只同步来源、通知 ID 和输入长度,通知投影、本地订阅策略与任务输入链路已开始闭环。
|
||||
- 缺口:仍缺少系统级桌面通知、真实管理端通知页面和跨端未读拉取回写。
|
||||
- 建议:下一轮围绕跨端未读同步和系统桌面通知,把本地通知业务视图推进到真正跨端协同入口。
|
||||
- 已吸收:运行事件和同步失败能在数字员工页面展示;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;客户端已支持管理端通知状态拉取与本地通知动作回写,`digital_workday_notification_*` 同步事件只暴露通知 ID、状态、endpoint、错误和回复长度,通知投影、本地订阅策略、跨端未读状态与任务输入链路已开始闭环。
|
||||
- 缺口:仍缺少系统级桌面通知和真实管理端通知页面。
|
||||
- 建议:下一轮围绕系统桌面通知与正式管理端通知工作台,把 Inbox 从嵌入页投影推进到完整跨端协作入口。
|
||||
|
||||
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
|
||||
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实;embedded `/api/ingress/route`、`/api/route-decisions` 和 `/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。
|
||||
@@ -1071,6 +1072,7 @@ Approval
|
||||
- 用户处理审批后,主进程会同步写入 `approval_decision` runtime event;embedded adapter 新增 `/api/approval/:approvalId/timeline`,可从 approval 记录和相关 runtime event 投影只读审批动作时间线;多步审批 workflow 会在 timeline 和首页决策卡展示当前步骤、总步骤数、处理人和下一步。
|
||||
- 管理端可通过 `/api/digital-employee/approval-history` 查询审批动作历史,客户端可通过 `/api/approval/subscriptions` 维护审批订阅策略;订阅更新会写入 `digital_workday_update_approval_subscriptions` runtime event 与同步业务视图。
|
||||
- 客户端可通过 `POST /api/approval/updates/pull` 手动拉取管理端审批更新,审批处理后会尝试回写 `POST /api/digital-employee/approvals/decisions`;回写失败只进入 runtime event 和 outbox 诊断,不回滚本地决策。
|
||||
- 客户端可通过 `POST /api/notifications/updates/pull` 手动拉取管理端通知状态,通知 read/dismiss/reply 后会尝试回写 `POST /api/digital-employee/notifications/actions`;回写失败只进入 runtime event 和同步业务视图,不回滚本地通知 overlay。
|
||||
|
||||
用户可操作:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user