吸收数字员工系统桌面通知

This commit is contained in:
baiyanyun
2026-06-11 20:53:46 +08:00
parent 8278fdc81c
commit 245a210b25
14 changed files with 370 additions and 40 deletions

View File

@@ -11,6 +11,10 @@ const mockArtifactAccess = vi.hoisted(() => ({
accessDigitalEmployeeArtifact: vi.fn(),
}));
const mockDesktopNotification = vi.hoisted(() => ({
showDigitalEmployeeDesktopNotification: vi.fn(() => ({ ok: true, shown: true })),
}));
const mockServiceManager = vi.hoisted(() => ({
createServiceManager: vi.fn(),
startFileServer: vi.fn(),
@@ -121,6 +125,10 @@ vi.mock("../services/digitalEmployee/artifactAccessService", () => ({
accessDigitalEmployeeArtifact: mockArtifactAccess.accessDigitalEmployeeArtifact,
}));
vi.mock("../services/digitalEmployee/desktopNotificationService", () => ({
showDigitalEmployeeDesktopNotification: mockDesktopNotification.showDigitalEmployeeDesktopNotification,
}));
vi.mock("../services/memory", () => ({
memoryService: {
isInitialized: () => false,
@@ -152,6 +160,7 @@ describe("digital employee managed service actions", () => {
mockStateService.recordArtifactLifecycle.mockReturnValue({ eventId: "artifact-event-1", kind: "artifact_retention_marked", payload: {} });
mockStateService.recordApprovalAction.mockReturnValue({ eventId: "approval-event-1", kind: "approval_action_recorded", payload: {} });
mockArtifactAccess.accessDigitalEmployeeArtifact.mockResolvedValue({ ok: true, artifact_id: "artifact-1", action: "preview" });
mockDesktopNotification.showDigitalEmployeeDesktopNotification.mockReturnValue({ ok: true, shown: true });
mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" });
mockServiceManager.createServiceManager.mockReturnValue({
startFileServer: mockServiceManager.startFileServer,
@@ -407,4 +416,23 @@ describe("digital employee managed service actions", () => {
expect(syncService.submitDigitalEmployeeNotificationAction).toHaveBeenCalledWith({ notification_id: "task:1", action: "read" });
expect(syncService.submitDigitalEmployeeNotificationAction).toHaveBeenCalledWith({});
});
it("registers desktop notification requests through IPC", async () => {
const electron = await import("electron");
const { showDigitalEmployeeDesktopNotification } = await import("../services/digitalEmployee/desktopNotificationService");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const notifyCall = calls.find(([channel]) => channel === "digitalEmployee:showDesktopNotification");
expect(notifyCall).toBeTruthy();
const notifyResult = await (notifyCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { notification_id: "task:1", title: "任务异常" });
const fallbackResult = await (notifyCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, null);
expect(notifyResult).toMatchObject({ ok: true, shown: true });
expect(fallbackResult).toMatchObject({ ok: true, shown: true });
expect(showDigitalEmployeeDesktopNotification).toHaveBeenCalledWith({ notification_id: "task:1", title: "任务异常" });
expect(showDigitalEmployeeDesktopNotification).toHaveBeenCalledWith({});
});
});

View File

@@ -80,6 +80,7 @@ import {
accessDigitalEmployeeArtifact,
type DigitalEmployeeArtifactAccessRequest,
} from "../services/digitalEmployee/artifactAccessService";
import { showDigitalEmployeeDesktopNotification } from "../services/digitalEmployee/desktopNotificationService";
import { memoryService } from "../services/memory";
interface RestartManagedServiceRequest {
@@ -233,6 +234,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return submitDigitalEmployeeNotificationAction(typeof input === "object" && input ? input as Record<string, unknown> : {});
});
ipcMain.handle("digitalEmployee:showDesktopNotification", async (_, input: unknown) => {
return showDigitalEmployeeDesktopNotification(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] : {});
});

View File

@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const notificationMock = vi.hoisted(() => ({
instances: [] as Array<{ options: Record<string, unknown> }>,
show: vi.fn(),
isSupported: vi.fn(() => true),
}));
vi.mock("electron", () => {
class MockNotification {
options: Record<string, unknown>;
constructor(options: Record<string, unknown>) {
this.options = options;
notificationMock.instances.push(this);
}
show(): void {
notificationMock.show();
}
static isSupported(): boolean {
return notificationMock.isSupported();
}
}
return { Notification: MockNotification };
});
describe("digital employee desktop notifications", () => {
beforeEach(() => {
vi.resetModules();
notificationMock.instances = [];
notificationMock.show.mockReset();
notificationMock.isSupported.mockReset();
notificationMock.isSupported.mockReturnValue(true);
});
it("shows unread action notifications through Electron Notification", async () => {
const { showDigitalEmployeeDesktopNotification } = await import("./desktopNotificationService");
const result = showDigitalEmployeeDesktopNotification({
notification_id: "approval:1",
title: "待审批动作",
body: "客户导出需要主管确认",
level: "action",
kind: "need_approval",
status: "unread",
});
expect(result).toEqual({ ok: true, shown: true });
expect(notificationMock.instances).toHaveLength(1);
expect(notificationMock.instances[0]?.options).toMatchObject({
title: "待审批动作",
body: "客户导出需要主管确认",
});
expect(notificationMock.show).toHaveBeenCalledTimes(1);
});
it.each([
["read", "action"],
["dismissed", "action"],
["resolved", "action"],
["unread", "info"],
])("skips %s/%s notifications", async (status, level) => {
const { showDigitalEmployeeDesktopNotification } = await import("./desktopNotificationService");
const result = showDigitalEmployeeDesktopNotification({
notification_id: `notification:${status}:${level}`,
title: "普通消息",
body: "无需系统提醒",
status,
level,
});
expect(result).toMatchObject({ ok: true, shown: false });
expect(notificationMock.show).not.toHaveBeenCalled();
});
it("uses safe fallback text and truncates long notification content", async () => {
const { showDigitalEmployeeDesktopNotification } = await import("./desktopNotificationService");
const result = showDigitalEmployeeDesktopNotification({
notification_id: "warn:1",
title: "x".repeat(160),
body: "y".repeat(420),
level: "warn",
status: "unread",
});
expect(result).toMatchObject({ ok: true, shown: true });
const options = notificationMock.instances[0]?.options ?? {};
expect(String(options.title).length).toBeLessThanOrEqual(80);
expect(String(options.body).length).toBeLessThanOrEqual(240);
notificationMock.instances = [];
showDigitalEmployeeDesktopNotification({ notification_id: "warn:2", level: "warn", status: "unread" });
expect(notificationMock.instances[0]?.options).toMatchObject({
title: "数字员工通知",
body: "有新的数字员工通知需要处理",
});
});
it("returns failure when Electron Notification is unavailable or show fails", async () => {
const { showDigitalEmployeeDesktopNotification } = await import("./desktopNotificationService");
notificationMock.isSupported.mockReturnValue(false);
expect(showDigitalEmployeeDesktopNotification({ notification_id: "warn:1", level: "warn", status: "unread" })).toMatchObject({
ok: false,
shown: false,
reason: "notification_unavailable",
});
notificationMock.isSupported.mockReturnValue(true);
notificationMock.show.mockImplementationOnce(() => { throw new Error("system denied"); });
expect(showDigitalEmployeeDesktopNotification({ notification_id: "warn:2", level: "warn", status: "unread" })).toMatchObject({
ok: false,
shown: false,
error: "system denied",
});
});
});

View File

@@ -0,0 +1,60 @@
import { Notification } from "electron";
const IMPORTANT_LEVELS = new Set(["action", "warn", "error"]);
const TITLE_MAX_LENGTH = 80;
const BODY_MAX_LENGTH = 240;
export interface DigitalEmployeeDesktopNotificationInput {
notification_id?: string | null;
notificationId?: string | null;
title?: string | null;
body?: string | null;
level?: string | null;
kind?: string | null;
status?: string | null;
source?: string | null;
}
export interface DigitalEmployeeDesktopNotificationResult {
ok: boolean;
shown: boolean;
reason?: string | null;
error?: string | null;
}
export function showDigitalEmployeeDesktopNotification(
input: DigitalEmployeeDesktopNotificationInput,
): DigitalEmployeeDesktopNotificationResult {
const status = stringField(input.status) || "unread";
const level = stringField(input.level) || "info";
if (status !== "unread") return { ok: true, shown: false, reason: "notification_not_unread" };
if (!IMPORTANT_LEVELS.has(level)) return { ok: true, shown: false, reason: "notification_level_not_important" };
if (!Notification || typeof Notification.isSupported !== "function" || !Notification.isSupported()) {
return { ok: false, shown: false, reason: "notification_unavailable" };
}
const title = compactText(stringField(input.title) || "数字员工通知", TITLE_MAX_LENGTH);
const body = compactText(stringField(input.body) || "有新的数字员工通知需要处理", BODY_MAX_LENGTH);
try {
const notification = new Notification({ title, body, silent: false });
notification.show();
return { ok: true, shown: true };
} catch (error) {
return { ok: false, shown: false, error: normalizeError(error) };
}
}
function stringField(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function compactText(value: string, maxLength: number): string {
const compact = value.replace(/\s+/g, " ").trim();
if (compact.length <= maxLength) return compact;
return `${compact.slice(0, Math.max(0, maxLength - 3))}...`;
}
function normalizeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -720,6 +720,7 @@ export interface DigitalEmployeeUiState {
notificationStateById?: Record<string, unknown>;
notificationPreferences?: {
enabled: boolean;
desktop_enabled: boolean;
muted_kinds: string[];
muted_levels: string[];
updated_at: string | null;
@@ -970,6 +971,7 @@ function normalizeRecord(value: unknown): Record<string, unknown> {
function defaultNotificationPreferences(): NonNullable<DigitalEmployeeUiState["notificationPreferences"]> {
return {
enabled: true,
desktop_enabled: true,
muted_kinds: [],
muted_levels: [],
updated_at: null,
@@ -1041,6 +1043,7 @@ function normalizeNotificationPreferences(
: {};
return {
enabled: record.enabled !== false,
desktop_enabled: record.desktop_enabled !== false,
muted_kinds: normalizeStringArray(record.muted_kinds),
muted_levels: normalizeStringArray(record.muted_levels),
updated_at: typeof record.updated_at === "string" ? record.updated_at : null,

View File

@@ -152,6 +152,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async submitNotificationAction(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:submitNotificationAction", input);
},
async showDesktopNotification(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:showDesktopNotification", input);
},
async evaluateRiskPolicy(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:evaluateRiskPolicy", input);
},