release(v0.2.0): streaming, MCP protocol, Browser Hand, security enhancements
## Major Features ### Streaming Response System - Implement LlmDriver trait with `stream()` method returning async Stream - Add SSE parsing for Anthropic and OpenAI API streaming - Integrate Tauri event system for frontend streaming (`stream:chunk` events) - Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error ### MCP Protocol Implementation - Add MCP JSON-RPC 2.0 types (mcp_types.rs) - Implement stdio-based MCP transport (mcp_transport.rs) - Support tool discovery, execution, and resource operations ### Browser Hand Implementation - Complete browser automation with Playwright-style actions - Support Navigate, Click, Type, Scrape, Screenshot, Wait actions - Add educational Hands: Whiteboard, Slideshow, Speech, Quiz ### Security Enhancements - Implement command whitelist/blacklist for shell_exec tool - Add SSRF protection with private IP blocking - Create security.toml configuration file ## Test Improvements - Fix test import paths (security-utils, setup) - Fix vi.mock hoisting issues with vi.hoisted() - Update test expectations for validateUrl and sanitizeFilename - Add getUnsupportedLocalGatewayStatus mock ## Documentation Updates - Update architecture documentation - Improve configuration reference - Add quick-start guide updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,7 @@ export interface MemoryStats {
|
||||
by_agent: Record<string, number>;
|
||||
oldest_memory: string | null;
|
||||
newest_memory: string | null;
|
||||
storage_size_bytes: number;
|
||||
}
|
||||
|
||||
// Heartbeat types
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import {
|
||||
intelligence,
|
||||
type MemoryEntryInput,
|
||||
@@ -49,6 +51,9 @@ import {
|
||||
type CompactionCheck,
|
||||
type CompactionConfig,
|
||||
type MemoryEntryForAnalysis,
|
||||
type PatternObservation,
|
||||
type ImprovementSuggestion,
|
||||
type ReflectionIdentityProposal,
|
||||
type ReflectionResult,
|
||||
type ReflectionState,
|
||||
type ReflectionConfig,
|
||||
@@ -101,6 +106,7 @@ export interface MemoryStats {
|
||||
byAgent: Record<string, number>;
|
||||
oldestEntry: string | null;
|
||||
newestEntry: string | null;
|
||||
storageSizeBytes: number;
|
||||
}
|
||||
|
||||
// === Re-export types from intelligence-backend ===
|
||||
@@ -184,6 +190,7 @@ export function toFrontendStats(backend: BackendMemoryStats): MemoryStats {
|
||||
byAgent: backend.by_agent,
|
||||
oldestEntry: backend.oldest_memory,
|
||||
newestEntry: backend.newest_memory,
|
||||
storageSizeBytes: backend.storage_size_bytes ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,6 +331,7 @@ const fallbackMemory = {
|
||||
byAgent,
|
||||
oldestEntry: sorted[0]?.createdAt ?? null,
|
||||
newestEntry: sorted[sorted.length - 1]?.createdAt ?? null,
|
||||
storageSizeBytes: 0, // localStorage-based fallback doesn't track storage size
|
||||
};
|
||||
},
|
||||
|
||||
@@ -403,6 +411,7 @@ const fallbackCompactor = {
|
||||
const fallbackReflection = {
|
||||
_conversationCount: 0,
|
||||
_lastReflection: null as string | null,
|
||||
_history: [] as ReflectionResult[],
|
||||
|
||||
async init(_config?: ReflectionConfig): Promise<void> {
|
||||
// No-op
|
||||
@@ -416,21 +425,130 @@ const fallbackReflection = {
|
||||
return fallbackReflection._conversationCount >= 5;
|
||||
},
|
||||
|
||||
async reflect(_agentId: string, _memories: MemoryEntryForAnalysis[]): Promise<ReflectionResult> {
|
||||
async reflect(agentId: string, memories: MemoryEntryForAnalysis[]): Promise<ReflectionResult> {
|
||||
fallbackReflection._conversationCount = 0;
|
||||
fallbackReflection._lastReflection = new Date().toISOString();
|
||||
|
||||
return {
|
||||
patterns: [],
|
||||
improvements: [],
|
||||
identity_proposals: [],
|
||||
new_memories: 0,
|
||||
// Analyze patterns (simple rule-based implementation)
|
||||
const patterns: PatternObservation[] = [];
|
||||
const improvements: ImprovementSuggestion[] = [];
|
||||
const identityProposals: ReflectionIdentityProposal[] = [];
|
||||
|
||||
// Count memory types
|
||||
const typeCounts: Record<string, number> = {};
|
||||
for (const m of memories) {
|
||||
typeCounts[m.memory_type] = (typeCounts[m.memory_type] || 0) + 1;
|
||||
}
|
||||
|
||||
// Pattern: Too many tasks
|
||||
const taskCount = typeCounts['task'] || 0;
|
||||
if (taskCount >= 5) {
|
||||
const taskMemories = memories.filter(m => m.memory_type === 'task').slice(0, 3);
|
||||
patterns.push({
|
||||
observation: `积累了 ${taskCount} 个待办任务,可能存在任务管理不善`,
|
||||
frequency: taskCount,
|
||||
sentiment: 'negative',
|
||||
evidence: taskMemories.map(m => m.content),
|
||||
});
|
||||
improvements.push({
|
||||
area: '任务管理',
|
||||
suggestion: '清理已完成的任务记忆,对长期未处理的任务降低重要性',
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Strong preference accumulation
|
||||
const prefCount = typeCounts['preference'] || 0;
|
||||
if (prefCount >= 5) {
|
||||
const prefMemories = memories.filter(m => m.memory_type === 'preference').slice(0, 3);
|
||||
patterns.push({
|
||||
observation: `已记录 ${prefCount} 个用户偏好,对用户习惯有较好理解`,
|
||||
frequency: prefCount,
|
||||
sentiment: 'positive',
|
||||
evidence: prefMemories.map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Lessons learned
|
||||
const lessonCount = typeCounts['lesson'] || 0;
|
||||
if (lessonCount >= 5) {
|
||||
patterns.push({
|
||||
observation: `积累了 ${lessonCount} 条经验教训,知识库在成长`,
|
||||
frequency: lessonCount,
|
||||
sentiment: 'positive',
|
||||
evidence: memories.filter(m => m.memory_type === 'lesson').slice(0, 3).map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: High-access important memories
|
||||
const highAccessMemories = memories.filter(m => m.access_count >= 5 && m.importance >= 7);
|
||||
if (highAccessMemories.length >= 3) {
|
||||
patterns.push({
|
||||
observation: `有 ${highAccessMemories.length} 条高频访问的重要记忆,核心知识正在形成`,
|
||||
frequency: highAccessMemories.length,
|
||||
sentiment: 'positive',
|
||||
evidence: highAccessMemories.slice(0, 3).map(m => m.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern: Low importance memories accumulating
|
||||
const lowImportanceCount = memories.filter(m => m.importance <= 3).length;
|
||||
if (lowImportanceCount > 20) {
|
||||
patterns.push({
|
||||
observation: `有 ${lowImportanceCount} 条低重要性记忆,建议清理`,
|
||||
frequency: lowImportanceCount,
|
||||
sentiment: 'neutral',
|
||||
evidence: [],
|
||||
});
|
||||
improvements.push({
|
||||
area: '记忆管理',
|
||||
suggestion: '执行记忆清理,移除30天以上未访问且重要性低于3的记忆',
|
||||
priority: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate identity proposal if negative patterns exist
|
||||
const negativePatterns = patterns.filter(p => p.sentiment === 'negative');
|
||||
if (negativePatterns.length >= 2) {
|
||||
const additions = negativePatterns.map(p => `- 注意: ${p.observation}`).join('\n');
|
||||
identityProposals.push({
|
||||
agent_id: agentId,
|
||||
field: 'instructions',
|
||||
current_value: '...',
|
||||
proposed_value: `\n\n## 自我反思改进\n${additions}`,
|
||||
reason: `基于 ${negativePatterns.length} 个负面模式观察,建议在指令中增加自我改进提醒`,
|
||||
});
|
||||
}
|
||||
|
||||
// Suggestion: User profile enrichment
|
||||
if (prefCount < 3) {
|
||||
improvements.push({
|
||||
area: '用户理解',
|
||||
suggestion: '主动在对话中了解用户偏好(沟通风格、技术栈、工作习惯),丰富用户画像',
|
||||
priority: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
const result: ReflectionResult = {
|
||||
patterns,
|
||||
improvements,
|
||||
identity_proposals: identityProposals,
|
||||
new_memories: patterns.filter(p => p.frequency >= 3).length + improvements.filter(i => i.priority === 'high').length,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Store in history
|
||||
fallbackReflection._history.push(result);
|
||||
if (fallbackReflection._history.length > 20) {
|
||||
fallbackReflection._history = fallbackReflection._history.slice(-10);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async getHistory(_limit?: number): Promise<ReflectionResult[]> {
|
||||
return [];
|
||||
async getHistory(limit?: number): Promise<ReflectionResult[]> {
|
||||
const l = limit ?? 10;
|
||||
return fallbackReflection._history.slice(-l).reverse();
|
||||
},
|
||||
|
||||
async getState(): Promise<ReflectionState> {
|
||||
@@ -442,18 +560,87 @@ const fallbackReflection = {
|
||||
},
|
||||
};
|
||||
|
||||
// Fallback Identity API
|
||||
const fallbackIdentities = new Map<string, IdentityFiles>();
|
||||
const fallbackProposals: IdentityChangeProposal[] = [];
|
||||
// Fallback Identity API with localStorage persistence
|
||||
const IDENTITY_STORAGE_KEY = 'zclaw-fallback-identities';
|
||||
const PROPOSALS_STORAGE_KEY = 'zclaw-fallback-proposals';
|
||||
const SNAPSHOTS_STORAGE_KEY = 'zclaw-fallback-snapshots';
|
||||
|
||||
function loadIdentitiesFromStorage(): Map<string, IdentityFiles> {
|
||||
try {
|
||||
const stored = localStorage.getItem(IDENTITY_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Record<string, IdentityFiles>;
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to load identities from localStorage');
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function saveIdentitiesToStorage(identities: Map<string, IdentityFiles>): void {
|
||||
try {
|
||||
const obj = Object.fromEntries(identities);
|
||||
localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(obj));
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to save identities to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
function loadProposalsFromStorage(): IdentityChangeProposal[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(PROPOSALS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as IdentityChangeProposal[];
|
||||
}
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to load proposals from localStorage');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveProposalsToStorage(proposals: IdentityChangeProposal[]): void {
|
||||
try {
|
||||
localStorage.setItem(PROPOSALS_STORAGE_KEY, JSON.stringify(proposals));
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to save proposals to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
function loadSnapshotsFromStorage(): IdentitySnapshot[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(SNAPSHOTS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as IdentitySnapshot[];
|
||||
}
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to load snapshots from localStorage');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveSnapshotsToStorage(snapshots: IdentitySnapshot[]): void {
|
||||
try {
|
||||
localStorage.setItem(SNAPSHOTS_STORAGE_KEY, JSON.stringify(snapshots));
|
||||
} catch {
|
||||
console.warn('[IntelligenceClient] Failed to save snapshots to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackIdentities = loadIdentitiesFromStorage();
|
||||
let fallbackProposals = loadProposalsFromStorage();
|
||||
let fallbackSnapshots = loadSnapshotsFromStorage();
|
||||
|
||||
const fallbackIdentity = {
|
||||
async get(agentId: string): Promise<IdentityFiles> {
|
||||
if (!fallbackIdentities.has(agentId)) {
|
||||
fallbackIdentities.set(agentId, {
|
||||
const defaults: IdentityFiles = {
|
||||
soul: '# Agent Soul\n\nA helpful AI assistant.',
|
||||
instructions: '# Instructions\n\nBe helpful and concise.',
|
||||
user_profile: '# User Profile\n\nNo profile yet.',
|
||||
});
|
||||
};
|
||||
fallbackIdentities.set(agentId, defaults);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
}
|
||||
return fallbackIdentities.get(agentId)!;
|
||||
},
|
||||
@@ -476,12 +663,14 @@ const fallbackIdentity = {
|
||||
const files = await fallbackIdentity.get(agentId);
|
||||
files.user_profile = content;
|
||||
fallbackIdentities.set(agentId, files);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
},
|
||||
|
||||
async appendUserProfile(agentId: string, addition: string): Promise<void> {
|
||||
const files = await fallbackIdentity.get(agentId);
|
||||
files.user_profile += `\n\n${addition}`;
|
||||
fallbackIdentities.set(agentId, files);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
},
|
||||
|
||||
async proposeChange(
|
||||
@@ -502,6 +691,7 @@ const fallbackIdentity = {
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
fallbackProposals.push(proposal);
|
||||
saveProposalsToStorage(fallbackProposals);
|
||||
return proposal;
|
||||
},
|
||||
|
||||
@@ -509,10 +699,30 @@ const fallbackIdentity = {
|
||||
const proposal = fallbackProposals.find(p => p.id === proposalId);
|
||||
if (!proposal) throw new Error('Proposal not found');
|
||||
|
||||
proposal.status = 'approved';
|
||||
const files = await fallbackIdentity.get(proposal.agent_id);
|
||||
|
||||
// Create snapshot before applying change
|
||||
const snapshot: IdentitySnapshot = {
|
||||
id: `snap_${Date.now()}`,
|
||||
agent_id: proposal.agent_id,
|
||||
files: { ...files },
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: `Before applying: ${proposal.reason}`,
|
||||
};
|
||||
fallbackSnapshots.unshift(snapshot);
|
||||
// Keep only last 20 snapshots per agent
|
||||
const agentSnapshots = fallbackSnapshots.filter(s => s.agent_id === proposal.agent_id);
|
||||
if (agentSnapshots.length > 20) {
|
||||
const toRemove = agentSnapshots.slice(20);
|
||||
fallbackSnapshots = fallbackSnapshots.filter(s => !toRemove.includes(s));
|
||||
}
|
||||
saveSnapshotsToStorage(fallbackSnapshots);
|
||||
|
||||
proposal.status = 'approved';
|
||||
files[proposal.file] = proposal.suggested_content;
|
||||
fallbackIdentities.set(proposal.agent_id, files);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
saveProposalsToStorage(fallbackProposals);
|
||||
return files;
|
||||
},
|
||||
|
||||
@@ -520,6 +730,7 @@ const fallbackIdentity = {
|
||||
const proposal = fallbackProposals.find(p => p.id === proposalId);
|
||||
if (proposal) {
|
||||
proposal.status = 'rejected';
|
||||
saveProposalsToStorage(fallbackProposals);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -537,16 +748,35 @@ const fallbackIdentity = {
|
||||
if (key in files) {
|
||||
files[key] = content;
|
||||
fallbackIdentities.set(agentId, files);
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getSnapshots(_agentId: string, _limit?: number): Promise<IdentitySnapshot[]> {
|
||||
return [];
|
||||
async getSnapshots(agentId: string, limit?: number): Promise<IdentitySnapshot[]> {
|
||||
const agentSnapshots = fallbackSnapshots.filter(s => s.agent_id === agentId);
|
||||
return agentSnapshots.slice(0, limit ?? 10);
|
||||
},
|
||||
|
||||
async restoreSnapshot(_agentId: string, _snapshotId: string): Promise<void> {
|
||||
// No-op for fallback
|
||||
async restoreSnapshot(agentId: string, snapshotId: string): Promise<void> {
|
||||
const snapshot = fallbackSnapshots.find(s => s.id === snapshotId && s.agent_id === agentId);
|
||||
if (!snapshot) throw new Error('Snapshot not found');
|
||||
|
||||
// Create a snapshot of current state before restore
|
||||
const currentFiles = await fallbackIdentity.get(agentId);
|
||||
const beforeRestoreSnapshot: IdentitySnapshot = {
|
||||
id: `snap_${Date.now()}`,
|
||||
agent_id: agentId,
|
||||
files: { ...currentFiles },
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: 'Auto-backup before restore',
|
||||
};
|
||||
fallbackSnapshots.unshift(beforeRestoreSnapshot);
|
||||
saveSnapshotsToStorage(fallbackSnapshots);
|
||||
|
||||
// Restore the snapshot
|
||||
fallbackIdentities.set(agentId, { ...snapshot.files });
|
||||
saveIdentitiesToStorage(fallbackIdentities);
|
||||
},
|
||||
|
||||
async listAgents(): Promise<string[]> {
|
||||
@@ -755,6 +985,42 @@ export const intelligenceClient = {
|
||||
}
|
||||
return fallbackHeartbeat.getHistory(agentId, limit);
|
||||
},
|
||||
|
||||
updateMemoryStats: async (
|
||||
agentId: string,
|
||||
taskCount: number,
|
||||
totalEntries: number,
|
||||
storageSizeBytes: number
|
||||
): Promise<void> => {
|
||||
if (isTauriEnv()) {
|
||||
await invoke('heartbeat_update_memory_stats', {
|
||||
agentId,
|
||||
taskCount,
|
||||
totalEntries,
|
||||
storageSizeBytes,
|
||||
});
|
||||
}
|
||||
// Fallback: store in localStorage for non-Tauri environment
|
||||
const cache = {
|
||||
taskCount,
|
||||
totalEntries,
|
||||
storageSizeBytes,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(`zclaw-memory-stats-${agentId}`, JSON.stringify(cache));
|
||||
},
|
||||
|
||||
recordCorrection: async (agentId: string, correctionType: string): Promise<void> => {
|
||||
if (isTauriEnv()) {
|
||||
await invoke('heartbeat_record_correction', { agentId, correctionType });
|
||||
}
|
||||
// Fallback: store in localStorage for non-Tauri environment
|
||||
const key = `zclaw-corrections-${agentId}`;
|
||||
const stored = localStorage.getItem(key);
|
||||
const counters = stored ? JSON.parse(stored) : {};
|
||||
counters[correctionType] = (counters[correctionType] || 0) + 1;
|
||||
localStorage.setItem(key, JSON.stringify(counters));
|
||||
},
|
||||
},
|
||||
|
||||
compactor: {
|
||||
|
||||
183
desktop/src/lib/useProposalNotifications.ts
Normal file
183
desktop/src/lib/useProposalNotifications.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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 { useChatStore } from '../store/chatStore';
|
||||
import { intelligenceClient, type IdentityChangeProposal } from './intelligence-client';
|
||||
|
||||
// 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 {
|
||||
// Ignore errors
|
||||
}
|
||||
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 {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } = useChatStore();
|
||||
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) {
|
||||
console.warn('[ProposalNotifications] 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
|
||||
console.log(`[ProposalNotifications] ${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;
|
||||
@@ -192,8 +192,8 @@ function mapEventType(eventType: TeamEventType): CollaborationEvent['type'] {
|
||||
function getGatewayClientSafe() {
|
||||
try {
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { getGatewayClient } = require('../lib/gateway-client');
|
||||
return getGatewayClient();
|
||||
const { getClient } = require('../store/connectionStore');
|
||||
return getClient();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user