吸收数字员工审批历史订阅策略

This commit is contained in:
baiyanyun
2026-06-10 18:22:53 +08:00
parent 5b78189c9c
commit 3ac350eb27
12 changed files with 615 additions and 196 deletions

View File

@@ -12,6 +12,7 @@ import type {
Session,
ChannelDetail,
BrowserSession,
ApprovalSubscriptionPreferences,
NotificationPreferences,
UserNotification,
DebugStoreResponse,
@@ -543,7 +544,7 @@ export function getEventLog(params: {
// Notifications
// ---------------------------------------------------------------------------
export { type NotificationPreferences, type UserNotification };
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type UserNotification };
export function getNotifications(
status: string = 'unread',
@@ -572,6 +573,28 @@ export function patchNotificationPreferences(
});
}
export function getApprovalSubscriptionPreferences(): Promise<ApprovalSubscriptionPreferences> {
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions');
}
export function patchApprovalSubscriptionPreferences(
patch: Partial<ApprovalSubscriptionPreferences>,
): Promise<ApprovalSubscriptionPreferences> {
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export function getApprovalHistory(params: Record<string, string | number | null | undefined> = {}): Promise<any> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
});
const suffix = query.toString();
return apiFetch(`/api/digital-employee/approval-history${suffix ? `?${suffix}` : ''}`);
}
export function markNotificationRead(id: string): Promise<boolean> {
return apiFetch<{ ok: boolean }>(`/api/notifications/${encodeURIComponent(id)}/read`, {
method: 'POST',

View File

@@ -1,5 +1,6 @@
import type {
ApprovalEntry,
ApprovalSubscriptionPreferences,
ApprovalTimelineResponse,
ArtifactEntry,
CanonicalEvent,
@@ -87,6 +88,7 @@ interface AdapterState {
decisionResultsByDate: Record<string, Record<string, string>>;
notificationStateById?: Record<string, NotificationState>;
notificationPreferences?: NotificationPreferences;
approvalSubscriptionPreferences?: ApprovalSubscriptionPreferences;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -116,6 +118,8 @@ const NOTIFICATION_KINDS: UserNotification['kind'][] = [
'confirmation_pending',
'confirmation_resolved',
];
const APPROVAL_SUBSCRIPTION_ACTIONS: ApprovalSubscriptionPreferences['subscribed_actions'] = ['comment', 'delegate', 'mark_timeout', 'mark_risk', 'clear_risk', 'decision'];
const APPROVAL_SUBSCRIPTION_RISK_LEVELS: ApprovalSubscriptionPreferences['subscribed_risk_levels'] = ['normal', 'medium', 'high', 'critical'];
interface QimingclawServiceStatus {
running?: boolean;
@@ -687,7 +691,7 @@ const PLAN_SCHEDULES: Record<string, { expression: string; nextRun: string; sche
const BUSINESS_VIEW_SOURCE = 'qimingclaw-runtime-projection';
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'route-decisions' | 'notifications' | 'audits';
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'approval-history' | 'route-decisions' | 'notifications' | 'audits';
type BusinessViewRow = Record<string, unknown>;
export function isQimingclawDigitalApiEnabled(): boolean {
@@ -771,7 +775,20 @@ export async function handleQimingclawDigitalApi<T>(
const approvalId = decodeURIComponent(approvalActionMatch[1] || '');
return await handleApprovalAction(approvalId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
}
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals|route-decisions|notifications|audits)$/);
if (method === 'GET' && pathname === '/api/approval/subscriptions') {
return approvalSubscriptionPreferences(notificationAdapterState(await readQimingclawSnapshot())) as T;
}
if (method === 'PATCH' && pathname === '/api/approval/subscriptions') {
return await handleApprovalSubscriptionPreferencesPatch(
parseJsonBody<Partial<ApprovalSubscriptionPreferences>>(options.body),
await readQimingclawSnapshot(),
) as T;
}
if (method === 'GET' && pathname === '/api/digital-employee/approval-history') {
const rows = filterApprovalHistoryRows(approvalHistoryRows(await readQimingclawSnapshot()), url.searchParams);
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
}
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals|approval-history|route-decisions|notifications|audits)$/);
if (method === 'GET' && businessViewMatch) {
return buildBusinessViewResponse(businessViewMatch[1] as BusinessViewKind, await readQimingclawSnapshot(), url.searchParams) as T;
}
@@ -946,6 +963,7 @@ function defaultState(): AdapterState {
decisionResultsByDate: {},
notificationStateById: {},
notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
consoleRole: 'sales',
rolePreferences: {},
profile: {
@@ -971,6 +989,7 @@ function normalizeAdapterState(value: Partial<AdapterState>): AdapterState {
...defaultState(),
...value,
notificationPreferences: notificationPreferences(value),
approvalSubscriptionPreferences: approvalSubscriptionPreferences(value),
};
}
@@ -2506,17 +2525,20 @@ function buildBusinessViewResponse(kind: BusinessViewKind, snapshot: QimingclawS
events: businessEventRows(snapshot),
artifacts: businessArtifactRows(snapshot),
approvals: businessApprovalRows(snapshot),
'approval-history': approvalHistoryRows(snapshot),
'route-decisions': routeDecisionRows(snapshot),
notifications: notificationRows(snapshot, notificationAdapterState(snapshot)),
audits: auditRows(snapshot),
};
const rows = kind === 'route-decisions'
? filterRouteDecisionRows(rowsByKind[kind], searchParams)
: kind === 'notifications'
? filterNotificationRows(rowsByKind[kind], searchParams)
: kind === 'audits'
? filterAuditRows(rowsByKind[kind], searchParams)
: filterBusinessRows(rowsByKind[kind], searchParams);
: kind === 'approval-history'
? filterApprovalHistoryRows(rowsByKind[kind], searchParams)
: kind === 'notifications'
? filterNotificationRows(rowsByKind[kind], searchParams)
: kind === 'audits'
? filterAuditRows(rowsByKind[kind], searchParams)
: filterBusinessRows(rowsByKind[kind], searchParams);
return {
...paginateBusinessRows(rows, searchParams),
source: BUSINESS_VIEW_SOURCE,
@@ -2887,6 +2909,7 @@ function approvalActionRows(snapshot: QimingclawSnapshot | null): BusinessViewRo
action: stringValue(payload.action) || null,
status: stringValue(payload.status) || null,
comment_preview: stringValue(comment?.preview) || null,
comment_length: numberValue(comment?.length),
delegate_to: stringValue(delegate?.label ?? delegate?.target) || null,
sla_status: stringValue(sla?.sla_status) || null,
due_at: stringValue(sla?.due_at) || null,
@@ -2905,6 +2928,77 @@ function approvalActionRows(snapshot: QimingclawSnapshot | null): BusinessViewRo
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
}
function approvalHistoryRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
const approvalRows = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
const actionRows = approvalActionRows(snapshot).map((action) => {
const approvalId = stringValue(action.approval_id);
const approval = approvalRows.get(approvalId);
return {
history_id: stringValue(action.action_id),
event_id: stringValue(action.event_id),
approval_id: approvalId || null,
action: stringValue(action.action) || null,
status: stringValue(action.status) || null,
actor: stringValue(action.actor) || null,
risk_level: stringValue(action.risk_level) || null,
due_at: stringValue(action.due_at) || null,
delegate_to: stringValue(action.delegate_to) || null,
comment_length: numberValue(action.comment_length),
message: stringValue(action.message) || null,
plan_id: stringValue(action.plan_id) || stringValue(approval?.plan_id) || null,
task_id: stringValue(action.task_id) || stringValue(approval?.task_id) || null,
run_id: stringValue(action.run_id) || stringValue(approval?.run_id) || null,
occurred_at: stringValue(action.occurred_at) || null,
updated_at: stringValue(action.occurred_at) || null,
sync_status: stringValue(action.sync_status) || null,
entity_type: 'approval_history',
entity_id: stringValue(action.action_id),
source: BUSINESS_VIEW_SOURCE,
};
});
const decisionRows = runtimeEvents(snapshot)
.filter((event) => event.kind === 'approval_decision')
.map((event) => {
const payload = asRecord(event.payload) ?? {};
const approvalId = stringValue(payload.approvalId ?? payload.approval_id) || event.id.split(':decision:')[0] || '';
const approval = approvalRows.get(approvalId);
return {
history_id: event.id,
event_id: event.id,
approval_id: approvalId || null,
action: 'decision',
status: stringValue(payload.nextStatus ?? payload.next_status ?? payload.decision) || null,
actor: stringValue(payload.actor) || stringValue(payload.source) || 'qimingclaw-approval-decision',
risk_level: stringValue(approval?.risk_level) || null,
due_at: stringValue(approval?.due_at) || null,
delegate_to: stringValue(approval?.delegate_to) || null,
comment_length: null,
message: event.message || null,
plan_id: event.planId || stringValue(payload.planId ?? payload.plan_id) || stringValue(approval?.plan_id) || null,
task_id: event.taskId || stringValue(payload.taskId ?? payload.task_id) || stringValue(approval?.task_id) || null,
run_id: event.runId || stringValue(payload.runId ?? payload.run_id) || stringValue(approval?.run_id) || null,
occurred_at: event.occurredAt,
updated_at: event.occurredAt,
sync_status: event.syncStatus ?? null,
entity_type: 'approval_history',
entity_id: event.id,
source: BUSINESS_VIEW_SOURCE,
};
});
return [...actionRows, ...decisionRows]
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)) || stringValue(right.history_id).localeCompare(stringValue(left.history_id)));
}
function filterApprovalHistoryRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
const approvalId = stringValue(searchParams.get('approval_id')) || stringValue(searchParams.get('id'));
const action = stringValue(searchParams.get('action'));
const riskLevel = stringValue(searchParams.get('risk_level'));
return filterBusinessRows(rows, searchParams)
.filter((row) => !approvalId || stringValue(row.approval_id) === approvalId || stringValue(row.history_id) === approvalId || stringValue(row.event_id) === approvalId)
.filter((row) => !action || stringValue(row.action) === action)
.filter((row) => !riskLevel || stringValue(row.risk_level) === riskLevel);
}
function approvalActionSummary(
snapshot: QimingclawSnapshot | null,
approvalId: string,
@@ -3230,6 +3324,7 @@ function businessRowEntityIds(row: BusinessViewRow): string[] {
row.task_id,
row.run_id,
row.event_id,
row.history_id,
row.artifact_id,
row.approval_id,
row.notification_id,
@@ -3547,6 +3642,35 @@ function defaultNotificationPreferences(): NotificationPreferences {
};
}
function defaultApprovalSubscriptionPreferences(): ApprovalSubscriptionPreferences {
return {
enabled: true,
subscribed_actions: [...APPROVAL_SUBSCRIPTION_ACTIONS],
subscribed_risk_levels: ['high', 'critical'],
notify_on_timeout: true,
updated_at: null,
};
}
function approvalSubscriptionPreferences(state: Partial<AdapterState> | null | undefined): ApprovalSubscriptionPreferences {
const stored = state?.approvalSubscriptionPreferences;
const fallback = defaultApprovalSubscriptionPreferences();
if (!stored || typeof stored !== 'object') return fallback;
const actions = Array.isArray(stored.subscribed_actions)
? stored.subscribed_actions.filter((action): action is ApprovalSubscriptionPreferences['subscribed_actions'][number] => APPROVAL_SUBSCRIPTION_ACTIONS.includes(action as ApprovalSubscriptionPreferences['subscribed_actions'][number]))
: fallback.subscribed_actions;
const riskLevels = Array.isArray(stored.subscribed_risk_levels)
? stored.subscribed_risk_levels.filter((level): level is ApprovalSubscriptionPreferences['subscribed_risk_levels'][number] => APPROVAL_SUBSCRIPTION_RISK_LEVELS.includes(level as ApprovalSubscriptionPreferences['subscribed_risk_levels'][number]))
: fallback.subscribed_risk_levels;
return {
enabled: stored.enabled !== false,
subscribed_actions: [...new Set(actions)],
subscribed_risk_levels: [...new Set(riskLevels)],
notify_on_timeout: stored.notify_on_timeout !== false,
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
};
}
function notificationPreferences(state: Partial<AdapterState> | null | undefined): NotificationPreferences {
const stored = state?.notificationPreferences;
const fallback = defaultNotificationPreferences();
@@ -3600,6 +3724,31 @@ async function handleNotificationPreferencesPatch(
return next;
}
async function handleApprovalSubscriptionPreferencesPatch(
patch: Partial<ApprovalSubscriptionPreferences>,
snapshot: QimingclawSnapshot | null,
): Promise<ApprovalSubscriptionPreferences> {
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
const current = approvalSubscriptionPreferences(state);
const next = approvalSubscriptionPreferences({
approvalSubscriptionPreferences: {
...current,
...patch,
updated_at: new Date().toISOString(),
},
});
await saveState(
{
...normalizeAdapterState(state),
approvalSubscriptionPreferences: next,
},
'update_approval_subscriptions',
undefined,
{ approval_subscriptions: next },
);
return next;
}
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
return buildApprovals(snapshot)

View File

@@ -187,6 +187,14 @@ export interface NotificationPreferences {
updated_at: string | null;
}
export interface ApprovalSubscriptionPreferences {
enabled: boolean;
subscribed_actions: Array<'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'decision'>;
subscribed_risk_levels: Array<'normal' | 'medium' | 'high' | 'critical'>;
notify_on_timeout: boolean;
updated_at: string | null;
}
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
export interface WsMessage {