- ChatArea: DeerFlow ai-elements annotations for accessibility - Conversation: remove unused Context, simplify message rendering - Delete dead modules: audit-logger.ts, gateway-reconnect.ts - Replace console.log with structured logger across components - Add idb dependency for IndexedDB persistence - Fix kernel-skills type safety improvements
187 lines
5.4 KiB
TypeScript
187 lines
5.4 KiB
TypeScript
/**
|
|
* Proposal Notifications Hook
|
|
*
|
|
* Periodically polls for pending identity change proposals and shows
|
|
* notifications when new proposals are available.
|
|
*
|
|
* Usage:
|
|
* ```tsx
|
|
* // In App.tsx or a top-level component
|
|
* useProposalNotifications();
|
|
* ```
|
|
*/
|
|
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
import { useConversationStore } from '../store/chat/conversationStore';
|
|
import { intelligenceClient, type IdentityChangeProposal } from './intelligence-client';
|
|
import { createLogger } from './logger';
|
|
|
|
const log = createLogger('ProposalNotifications');
|
|
|
|
// Configuration
|
|
const POLL_INTERVAL_MS = 60_000; // 1 minute
|
|
const NOTIFICATION_COOLDOWN_MS = 300_000; // 5 minutes - don't spam notifications
|
|
|
|
// Storage key for tracking notified proposals
|
|
const NOTIFIED_PROPOSALS_KEY = 'zclaw-notified-proposals';
|
|
|
|
/**
|
|
* Get set of already notified proposal IDs
|
|
*/
|
|
function getNotifiedProposals(): Set<string> {
|
|
try {
|
|
const stored = localStorage.getItem(NOTIFIED_PROPOSALS_KEY);
|
|
if (stored) {
|
|
return new Set(JSON.parse(stored) as string[]);
|
|
}
|
|
} catch (e) {
|
|
log.debug('Failed to parse notified proposals from localStorage', { error: e });
|
|
}
|
|
return new Set();
|
|
}
|
|
|
|
/**
|
|
* Save notified proposal IDs
|
|
*/
|
|
function saveNotifiedProposals(ids: Set<string>): void {
|
|
try {
|
|
// Keep only last 100 IDs to prevent storage bloat
|
|
const arr = Array.from(ids).slice(-100);
|
|
localStorage.setItem(NOTIFIED_PROPOSALS_KEY, JSON.stringify(arr));
|
|
} catch (e) {
|
|
log.debug('Failed to save notified proposals to localStorage', { error: e });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hook for showing proposal notifications
|
|
*
|
|
* This hook:
|
|
* 1. Polls for pending proposals every minute
|
|
* 2. Shows a toast notification when new proposals are found
|
|
* 3. Tracks which proposals have already been notified to avoid spam
|
|
*/
|
|
export function useProposalNotifications(): {
|
|
pendingCount: number;
|
|
refresh: () => Promise<void>;
|
|
} {
|
|
const currentAgent = useConversationStore((s) => s.currentAgent);
|
|
const agentId = currentAgent?.id;
|
|
|
|
const pendingCountRef = useRef(0);
|
|
const lastNotificationTimeRef = useRef(0);
|
|
const notifiedProposalsRef = useRef(getNotifiedProposals());
|
|
const isPollingRef = useRef(false);
|
|
|
|
const checkForNewProposals = useCallback(async () => {
|
|
if (!agentId || isPollingRef.current) return;
|
|
|
|
isPollingRef.current = true;
|
|
|
|
try {
|
|
const proposals = await intelligenceClient.identity.getPendingProposals(agentId);
|
|
pendingCountRef.current = proposals.length;
|
|
|
|
// Find proposals we haven't notified about
|
|
const newProposals = proposals.filter(
|
|
(p: IdentityChangeProposal) => !notifiedProposalsRef.current.has(p.id)
|
|
);
|
|
|
|
if (newProposals.length > 0) {
|
|
const now = Date.now();
|
|
|
|
// Check cooldown to avoid spam
|
|
if (now - lastNotificationTimeRef.current >= NOTIFICATION_COOLDOWN_MS) {
|
|
// Dispatch custom event for the app to handle
|
|
// This allows the app to show toast, play sound, etc.
|
|
const event = new CustomEvent('zclaw:proposal-available', {
|
|
detail: {
|
|
count: newProposals.length,
|
|
proposals: newProposals,
|
|
},
|
|
});
|
|
window.dispatchEvent(event);
|
|
|
|
lastNotificationTimeRef.current = now;
|
|
}
|
|
|
|
// Mark these proposals as notified
|
|
for (const p of newProposals) {
|
|
notifiedProposalsRef.current.add(p.id);
|
|
}
|
|
saveNotifiedProposals(notifiedProposalsRef.current);
|
|
}
|
|
} catch (err) {
|
|
log.warn('Failed to check proposals:', err);
|
|
} finally {
|
|
isPollingRef.current = false;
|
|
}
|
|
}, [agentId]);
|
|
|
|
// Set up polling
|
|
useEffect(() => {
|
|
if (!agentId) return;
|
|
|
|
// Initial check
|
|
checkForNewProposals();
|
|
|
|
// Set up interval
|
|
const intervalId = setInterval(checkForNewProposals, POLL_INTERVAL_MS);
|
|
|
|
return () => {
|
|
clearInterval(intervalId);
|
|
};
|
|
}, [agentId, checkForNewProposals]);
|
|
|
|
// Listen for visibility change to refresh when app becomes visible
|
|
useEffect(() => {
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === 'visible') {
|
|
checkForNewProposals();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
};
|
|
}, [checkForNewProposals]);
|
|
|
|
return {
|
|
pendingCount: pendingCountRef.current,
|
|
refresh: checkForNewProposals,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Component that sets up proposal notification handling
|
|
*
|
|
* Place this near the root of the app to enable proposal notifications
|
|
*/
|
|
export function ProposalNotificationHandler(): null {
|
|
// This effect sets up the global event listener for proposal notifications
|
|
useEffect(() => {
|
|
const handleProposalAvailable = (event: Event) => {
|
|
const customEvent = event as CustomEvent<{ count: number }>;
|
|
const { count } = customEvent.detail;
|
|
|
|
// You can integrate with a toast system here
|
|
log.debug(`${count} new proposal(s) available`);
|
|
|
|
// If using the Toast system from Toast.tsx, you would do:
|
|
// toast(`${count} 个新的人格变更提案待审批`, 'info');
|
|
};
|
|
|
|
window.addEventListener('zclaw:proposal-available', handleProposalAvailable);
|
|
|
|
return () => {
|
|
window.removeEventListener('zclaw:proposal-available', handleProposalAvailable);
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
}
|
|
|
|
export default useProposalNotifications;
|