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

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

@@ -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');
}

View File

@@ -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,

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>

View File

@@ -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))

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);
},

View File

@@ -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避免同一通知反复弹出。
用户可操作: