吸收数字员工系统桌面通知
This commit is contained in:
@@ -603,6 +603,13 @@ export function pullNotificationUpdates(): Promise<NotificationUpdatesPullResult
|
||||
});
|
||||
}
|
||||
|
||||
export function showDesktopNotification(input: Pick<UserNotification, 'notification_id' | 'title' | 'body' | 'level' | 'kind' | 'status'>): Promise<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }> {
|
||||
return apiFetch<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }>('/api/notifications/desktop', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function getApprovalSubscriptionPreferences(): Promise<ApprovalSubscriptionPreferences> {
|
||||
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions');
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ declare global {
|
||||
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 }>;
|
||||
showDesktopNotification?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; shown?: boolean; reason?: string | null; error?: string | null }>;
|
||||
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
|
||||
pullRouteDecisionPolicy?: () => Promise<RouteDecisionPolicyPullResult>;
|
||||
evaluateRouteDecisionPolicy?: (input: QimingclawRouteDecisionPolicyInput) => Promise<QimingclawRouteDecisionPolicyResult>;
|
||||
@@ -1031,6 +1032,9 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'POST' && pathname === '/api/notifications/updates/pull') {
|
||||
return await pullBridgeNotificationUpdates() as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/notifications/desktop') {
|
||||
return await showBridgeDesktopNotification(parseOptionalJsonBody(options.body)) 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;
|
||||
@@ -1332,6 +1336,24 @@ async function pullBridgeNotificationUpdates(): Promise<{ ok: boolean; applied?:
|
||||
}
|
||||
}
|
||||
|
||||
async function showBridgeDesktopNotification(input: Record<string, unknown>): Promise<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.showDesktopNotification) {
|
||||
return { ok: false, shown: false, reason: 'qimingclaw_bridge_unavailable' };
|
||||
}
|
||||
try {
|
||||
const result = await bridge.showDesktopNotification(input);
|
||||
return {
|
||||
ok: result?.ok !== false,
|
||||
shown: result?.shown === true,
|
||||
...(result?.reason ? { reason: result.reason } : {}),
|
||||
...(result?.error ? { error: result.error } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, shown: 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) => (
|
||||
@@ -4332,6 +4354,7 @@ function buildNotificationCandidates(snapshot: QimingclawSnapshot | null, state:
|
||||
function defaultNotificationPreferences(): NotificationPreferences {
|
||||
return {
|
||||
enabled: true,
|
||||
desktop_enabled: true,
|
||||
muted_kinds: [],
|
||||
muted_levels: [],
|
||||
updated_at: null,
|
||||
@@ -4446,6 +4469,7 @@ function notificationPreferences(state: Partial<AdapterState> | null | undefined
|
||||
: [];
|
||||
return {
|
||||
enabled: stored.enabled !== false,
|
||||
desktop_enabled: stored.desktop_enabled !== false,
|
||||
muted_kinds: [...new Set(mutedKinds)],
|
||||
muted_levels: [...new Set(mutedLevels)],
|
||||
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
patchNotificationPreferences,
|
||||
pullNotificationUpdates,
|
||||
replyToNotification,
|
||||
showDesktopNotification,
|
||||
type NotificationPreferences,
|
||||
type UserNotification,
|
||||
} from '@/lib/api';
|
||||
@@ -47,10 +48,13 @@ const NOTIFICATION_KIND_OPTIONS: Array<{ id: UserNotification['kind']; label: st
|
||||
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
enabled: true,
|
||||
desktop_enabled: true,
|
||||
muted_kinds: [],
|
||||
muted_levels: [],
|
||||
updated_at: null,
|
||||
};
|
||||
const DESKTOP_NOTIFICATION_DEDUPE_KEY = 'qimingclaw:digital-desktop-notified:v1';
|
||||
const DESKTOP_NOTIFICATION_LEVELS = new Set<UserNotification['level']>(['action', 'warn', 'error']);
|
||||
|
||||
function isDigitalTab(value: string | null): value is DigitalTab {
|
||||
return Boolean(value && TABS.some(tab => tab.id === value));
|
||||
@@ -122,6 +126,51 @@ function notificationTime(value: string): string {
|
||||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date);
|
||||
}
|
||||
|
||||
function readDesktopNotificationDedupe(): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DESKTOP_NOTIFICATION_DEDUPE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeDesktopNotificationDedupe(ids: Set<string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_NOTIFICATION_DEDUPE_KEY, JSON.stringify(Array.from(ids).slice(-300)));
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted webviews; desktop notification is best effort.
|
||||
}
|
||||
}
|
||||
|
||||
function notificationMatchesDesktopPreferences(notification: UserNotification, preferences: NotificationPreferences): boolean {
|
||||
if (!preferences.enabled || !preferences.desktop_enabled) return false;
|
||||
if (notification.status !== 'unread') return false;
|
||||
if (!DESKTOP_NOTIFICATION_LEVELS.has(notification.level)) return false;
|
||||
if (preferences.muted_levels.includes(notification.level)) return false;
|
||||
return !preferences.muted_kinds.includes(notification.kind);
|
||||
}
|
||||
|
||||
async function showUnreadDesktopNotifications(notifications: UserNotification[], preferences: NotificationPreferences): Promise<void> {
|
||||
const notified = readDesktopNotificationDedupe();
|
||||
const candidates = notifications.filter((notification) => (
|
||||
notificationMatchesDesktopPreferences(notification, preferences)
|
||||
&& !notified.has(notification.notification_id)
|
||||
));
|
||||
if (candidates.length === 0) return;
|
||||
candidates.forEach((notification) => notified.add(notification.notification_id));
|
||||
writeDesktopNotificationDedupe(notified);
|
||||
await Promise.all(candidates.slice(0, 3).map((notification) => showDesktopNotification({
|
||||
notification_id: notification.notification_id,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
level: notification.level,
|
||||
kind: notification.kind,
|
||||
status: notification.status,
|
||||
}).catch(() => null)));
|
||||
}
|
||||
|
||||
export default function DigitalEmployeeShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -138,15 +187,15 @@ export default function DigitalEmployeeShell() {
|
||||
const refreshNotifications = useCallback(async () => {
|
||||
setNotificationsLoading(true);
|
||||
try {
|
||||
const [items, count] = await Promise.all([
|
||||
const [items, count, preferences] = await Promise.all([
|
||||
getNotifications('all'),
|
||||
getUnreadNotificationCount(),
|
||||
getNotificationPreferences().catch(() => DEFAULT_NOTIFICATION_PREFERENCES),
|
||||
]);
|
||||
setNotifications(items);
|
||||
setUnreadCount(count);
|
||||
void getNotificationPreferences()
|
||||
.then(setNotificationPreferences)
|
||||
.catch(() => setNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES));
|
||||
setNotificationPreferences(preferences);
|
||||
void showUnreadDesktopNotifications(items, preferences);
|
||||
} catch {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
@@ -306,6 +355,14 @@ export default function DigitalEmployeeShell() {
|
||||
/>
|
||||
<span>接收通知</span>
|
||||
</label>
|
||||
<label className="de-notification-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.desktop_enabled}
|
||||
onChange={(event) => { void updateNotificationPreferences({ desktop_enabled: event.target.checked }); }}
|
||||
/>
|
||||
<span>桌面通知</span>
|
||||
</label>
|
||||
<div className="de-notification-preference-group">
|
||||
<span>静音等级</span>
|
||||
<div className="de-notification-segment-row">
|
||||
|
||||
@@ -182,6 +182,7 @@ export interface UserNotification {
|
||||
|
||||
export interface NotificationPreferences {
|
||||
enabled: boolean;
|
||||
desktop_enabled: boolean;
|
||||
muted_kinds: UserNotification['kind'][];
|
||||
muted_levels: UserNotification['level'][];
|
||||
updated_at: string | null;
|
||||
|
||||
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-BX3uWr8J.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-D9KIP1Kc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -25,6 +25,7 @@ const testFiles = [
|
||||
"src/main/services/digitalEmployee/stateService.test.ts",
|
||||
"src/main/services/digitalEmployee/artifactAccessService.test.ts",
|
||||
"src/main/services/digitalEmployee/schedulerService.test.ts",
|
||||
"src/main/services/digitalEmployee/desktopNotificationService.test.ts",
|
||||
"src/main/ipc/digitalEmployeeHandlers.test.ts",
|
||||
"src/main/ipc/eventForwarders.test.ts",
|
||||
"src/main/services/engines/acp/acpEngine.test.ts",
|
||||
@@ -203,6 +204,7 @@ 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 desktopNotificationServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "desktopNotificationService.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");
|
||||
@@ -211,6 +213,7 @@ function checkDigitalNotificationPreferences() {
|
||||
const shell = fs.readFileSync(shellPath, "utf8");
|
||||
const stateService = fs.readFileSync(stateServicePath, "utf8");
|
||||
const syncService = fs.readFileSync(syncServicePath, "utf8");
|
||||
const desktopNotificationService = fs.readFileSync(desktopNotificationServicePath, "utf8");
|
||||
const ipcHandlers = fs.readFileSync(ipcHandlersPath, "utf8");
|
||||
const preload = fs.readFileSync(preloadPath, "utf8");
|
||||
const requiredSnippets = [
|
||||
@@ -228,17 +231,30 @@ function checkDigitalNotificationPreferences() {
|
||||
[adapter, "pullBridgeNotificationUpdates", "adapter notification updates bridge pull helper"],
|
||||
[adapter, "submitNotificationAction", "adapter notification action bridge declaration"],
|
||||
[adapter, "submitBridgeNotificationAction", "adapter notification action bridge submit helper"],
|
||||
[adapter, "showDesktopNotification", "adapter desktop notification bridge declaration"],
|
||||
[adapter, "/api/notifications/desktop", "adapter desktop notification endpoint"],
|
||||
[adapter, "showBridgeDesktopNotification", "adapter desktop notification helper"],
|
||||
[adapter, "notificationMatchesPreferences", "adapter notification preference filter"],
|
||||
[adapter, "desktop_enabled", "adapter notification desktop preference"],
|
||||
[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"],
|
||||
[api, "showDesktopNotification", "embedded desktop notification API"],
|
||||
[types, "NotificationPreferences", "notification preferences type"],
|
||||
[types, "desktop_enabled", "notification desktop preference type field"],
|
||||
[types, "NotificationUpdatesPullResult", "notification updates pull result type"],
|
||||
[shell, "de-notification-preferences", "notification preferences UI"],
|
||||
[shell, "handleNotificationSync", "notification manual sync UI action"],
|
||||
[shell, "showUnreadDesktopNotifications", "notification desktop trigger helper"],
|
||||
[shell, "qimingclaw:digital-desktop-notified", "notification desktop dedupe key"],
|
||||
[shell, "桌面通知", "notification desktop preference UI"],
|
||||
[shell, "NOTIFICATION_KIND_OPTIONS", "notification kind mute UI options"],
|
||||
[stateService, "update_notification_preferences", "notification preference runtime event"],
|
||||
[stateService, "desktop_enabled", "notification desktop preference state field"],
|
||||
[desktopNotificationService, "showDigitalEmployeeDesktopNotification", "desktop notification service"],
|
||||
[desktopNotificationService, "Notification", "Electron Notification usage"],
|
||||
[desktopNotificationService, "notification_unavailable", "desktop notification unavailable fallback"],
|
||||
[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"],
|
||||
@@ -257,8 +273,10 @@ function checkDigitalNotificationPreferences() {
|
||||
[syncService, "return \"notification\"", "notification business category"],
|
||||
[ipcHandlers, "digitalEmployee:pullNotificationUpdates", "IPC notification updates pull handler"],
|
||||
[ipcHandlers, "digitalEmployee:submitNotificationAction", "IPC notification action submit handler"],
|
||||
[ipcHandlers, "digitalEmployee:showDesktopNotification", "IPC desktop notification handler"],
|
||||
[preload, "pullNotificationUpdates", "preload notification updates bridge"],
|
||||
[preload, "submitNotificationAction", "preload notification action bridge"],
|
||||
[preload, "showDesktopNotification", "preload desktop notification bridge"],
|
||||
];
|
||||
const missing = requiredSnippets
|
||||
.filter(([content, snippet]) => !content.includes(snippet))
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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] : {});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -452,7 +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 状态。
|
||||
- 通知跨端未读同步已开始闭环:客户端可通过 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 状态;未读且重要的 Inbox 通知可通过受控 Electron `Notification` 弹出系统级桌面提醒,并用本地去重 key 避免重复打扰。
|
||||
- 高风险动作审批策略已沉淀到 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 状态。
|
||||
@@ -593,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;客户端已支持管理端通知状态拉取与本地通知动作回写,`digital_workday_notification_*` 同步事件只暴露通知 ID、状态、endpoint、错误和回复长度,通知投影、本地订阅策略、跨端未读状态与任务输入链路已开始闭环。
|
||||
- 缺口:仍缺少系统级桌面通知和真实管理端通知页面。
|
||||
- 建议:下一轮围绕系统桌面通知与正式管理端通知工作台,把 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;客户端已支持管理端通知状态拉取与本地通知动作回写,未读 action/warn/error 通知会按偏好触发一次系统级桌面提醒,`digital_workday_notification_*` 同步事件只暴露通知 ID、状态、endpoint、错误和回复长度,通知投影、本地订阅策略、桌面提醒、跨端未读状态与任务输入链路已开始闭环。
|
||||
- 缺口:仍缺少真实管理端通知页面和系统权限引导 UI。
|
||||
- 建议:下一轮围绕正式管理端通知工作台,把 Inbox 从嵌入页投影推进到完整跨端协作入口。
|
||||
|
||||
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
|
||||
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实;embedded `/api/ingress/route`、`/api/route-decisions` 和 `/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。
|
||||
@@ -1073,6 +1073,7 @@ Approval
|
||||
- 管理端可通过 `/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。
|
||||
- 未读且重要的 Inbox 通知会按 `desktop_enabled` 偏好触发一次本机 Electron 桌面通知;触发 ID 记录在 embedded localStorage,避免同一通知反复弹出。
|
||||
|
||||
用户可操作:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user