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:
iven
2026-03-24 03:24:24 +08:00
parent e49ba4460b
commit 3ff08faa56
78 changed files with 29575 additions and 1682 deletions

View File

@@ -25,6 +25,9 @@ import { Users, Loader2, Settings } from 'lucide-react';
import { EmptyState } from './components/ui';
import { isTauriRuntime, getLocalGatewayStatus, startLocalGateway } from './lib/tauri-gateway';
import { useOnboarding } from './lib/use-onboarding';
import { intelligenceClient } from './lib/intelligence-client';
import { useProposalNotifications, ProposalNotificationHandler } from './lib/useProposalNotifications';
import { useToast } from './components/ui/Toast';
import type { Clone } from './store/agentStore';
type View = 'main' | 'settings';
@@ -63,6 +66,24 @@ function App() {
const { setCurrentAgent, newConversation } = useChatStore();
const { isNeeded: onboardingNeeded, isLoading: onboardingLoading, markCompleted } = useOnboarding();
// Proposal notifications
const { toast } = useToast();
useProposalNotifications(); // Sets up polling for pending proposals
// Show toast when new proposals are available
useEffect(() => {
const handleProposalAvailable = (event: Event) => {
const customEvent = event as CustomEvent<{ count: number }>;
const { count } = customEvent.detail;
toast(`${count} 个新的人格变更提案待审批`, 'info');
};
window.addEventListener('zclaw:proposal-available', handleProposalAvailable);
return () => {
window.removeEventListener('zclaw:proposal-available', handleProposalAvailable);
};
}, [toast]);
useEffect(() => {
document.title = 'ZCLAW';
}, []);
@@ -160,6 +181,41 @@ function App() {
// Step 4: Initialize stores with gateway client
initializeStores();
// Step 4.5: Auto-start heartbeat engine for self-evolution
try {
const defaultAgentId = 'zclaw-main';
await intelligenceClient.heartbeat.init(defaultAgentId, {
enabled: true,
interval_minutes: 30,
quiet_hours_start: '22:00',
quiet_hours_end: '08:00',
notify_channel: 'ui',
proactivity_level: 'standard',
max_alerts_per_tick: 5,
});
// Sync memory stats to heartbeat engine
try {
const stats = await intelligenceClient.memory.stats();
const taskCount = stats.byType?.['task'] || 0;
await intelligenceClient.heartbeat.updateMemoryStats(
defaultAgentId,
taskCount,
stats.totalEntries,
stats.storageSizeBytes
);
console.log('[App] Memory stats synced to heartbeat engine');
} catch (statsErr) {
console.warn('[App] Failed to sync memory stats:', statsErr);
}
await intelligenceClient.heartbeat.start(defaultAgentId);
console.log('[App] Heartbeat engine started for self-evolution');
} catch (err) {
console.warn('[App] Failed to start heartbeat engine:', err);
// Non-critical, continue without heartbeat
}
// Step 5: Bootstrap complete
setBootstrapping(false);
} catch (err) {
@@ -364,6 +420,9 @@ function App() {
onReject={handleRejectHand}
onClose={handleCloseApprovalModal}
/>
{/* Proposal Notifications Handler */}
<ProposalNotificationHandler />
</div>
);
}

View File

@@ -29,8 +29,7 @@ import {
Loader2
} from 'lucide-react';
import { useSecurityStore, AuditLogEntry } from '../store/securityStore';
import { getGatewayClient } from '../lib/gateway-client';
import { getClient } from '../store/connectionStore';
// === Types ===
@@ -514,7 +513,7 @@ export function AuditLogsPanel() {
const auditLogs = useSecurityStore((s) => s.auditLogs);
const loadAuditLogs = useSecurityStore((s) => s.loadAuditLogs);
const isLoading = useSecurityStore((s) => s.auditLogsLoading);
const client = getGatewayClient();
const client = getClient();
// State
const [limit, setLimit] = useState(50);

View File

@@ -9,8 +9,7 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Wifi, WifiOff, Loader2, RefreshCw, Heart, HeartPulse } from 'lucide-react';
import { useConnectionStore } from '../store/connectionStore';
import { getGatewayClient } from '../lib/gateway-client';
import { useConnectionStore, getClient } from '../store/connectionStore';
import {
createHealthCheckScheduler,
getHealthStatusLabel,
@@ -90,7 +89,7 @@ export function ConnectionStatus({
// Listen for reconnect events
useEffect(() => {
const client = getGatewayClient();
const client = getClient();
const unsubReconnecting = client.on('reconnecting', (info) => {
setReconnectInfo(info as ReconnectInfo);

View File

@@ -331,7 +331,8 @@ export function IdentityChangeProposalPanel() {
setSnapshots(agentSnapshots);
} catch (err) {
console.error('[IdentityChangeProposal] Failed to approve:', err);
setError('审批失败');
const message = err instanceof Error ? err.message : '审批失败,请重试';
setError(`审批失败: ${message}`);
} finally {
setProcessingId(null);
}
@@ -348,7 +349,8 @@ export function IdentityChangeProposalPanel() {
setProposals(pendingProposals);
} catch (err) {
console.error('[IdentityChangeProposal] Failed to reject:', err);
setError('拒绝失败');
const message = err instanceof Error ? err.message : '拒绝失败,请重试';
setError(`拒绝失败: ${message}`);
} finally {
setProcessingId(null);
}
@@ -365,7 +367,8 @@ export function IdentityChangeProposalPanel() {
setSnapshots(agentSnapshots);
} catch (err) {
console.error('[IdentityChangeProposal] Failed to restore:', err);
setError('恢复失败');
const message = err instanceof Error ? err.message : '恢复失败,请重试';
setError(`恢复失败: ${message}`);
} finally {
setProcessingId(null);
}

View File

@@ -116,6 +116,58 @@ const PRIORITY_CONFIG: Record<string, { color: string; bgColor: string }> = {
},
};
// === Field to File Mapping ===
/**
* Maps reflection field names to identity file types.
* This ensures correct routing of identity change proposals.
*/
function mapFieldToFile(field: string): 'soul' | 'instructions' {
// Direct matches
if (field === 'soul' || field === 'instructions') {
return field;
}
// Known soul fields (core personality traits)
const soulFields = [
'personality',
'traits',
'values',
'identity',
'character',
'essence',
'core_behavior',
];
// Known instructions fields (operational guidelines)
const instructionsFields = [
'guidelines',
'rules',
'behavior_rules',
'response_format',
'communication_guidelines',
'task_handling',
];
const lowerField = field.toLowerCase();
// Check explicit mappings
if (soulFields.some((f) => lowerField.includes(f))) {
return 'soul';
}
if (instructionsFields.some((f) => lowerField.includes(f))) {
return 'instructions';
}
// Fallback heuristics
if (lowerField.includes('soul') || lowerField.includes('personality') || lowerField.includes('trait')) {
return 'soul';
}
// Default to instructions for operational changes
return 'instructions';
}
// === Components ===
function SentimentBadge({ sentiment }: { sentiment: string }) {
@@ -419,6 +471,7 @@ export function ReflectionLog({
const [isReflecting, setIsReflecting] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [config, setConfig] = useState<ReflectionConfig>(() => loadConfig());
const [error, setError] = useState<string | null>(null);
// Persist config changes
useEffect(() => {
@@ -446,8 +499,24 @@ export function ReflectionLog({
const handleReflect = useCallback(async () => {
setIsReflecting(true);
setError(null);
try {
const result = await intelligenceClient.reflection.reflect(agentId, []);
// Fetch recent memories for analysis
const memories = await intelligenceClient.memory.search({
agentId,
limit: 50, // Get enough memories for pattern analysis
});
// Convert to analysis format
const memoriesForAnalysis = memories.map((m) => ({
memory_type: m.type,
content: m.content,
importance: m.importance,
access_count: m.accessCount,
tags: m.tags,
}));
const result = await intelligenceClient.reflection.reflect(agentId, memoriesForAnalysis);
setHistory((prev) => [result, ...prev]);
// Convert reflection identity_proposals to actual identity proposals
@@ -455,13 +524,8 @@ export function ReflectionLog({
if (result.identity_proposals && result.identity_proposals.length > 0) {
for (const proposal of result.identity_proposals) {
try {
// Determine which file to modify based on the field
const file: 'soul' | 'instructions' =
proposal.field === 'soul' || proposal.field === 'instructions'
? (proposal.field as 'soul' | 'instructions')
: proposal.field.toLowerCase().includes('soul')
? 'soul'
: 'instructions';
// Map field to file type with explicit mapping rules
const file = mapFieldToFile(proposal.field);
// Persist the proposal to the identity system
await intelligenceClient.identity.proposeChange(
@@ -479,8 +543,10 @@ export function ReflectionLog({
const proposals = await intelligenceClient.identity.getPendingProposals(agentId);
setPendingProposals(proposals);
}
} catch (error) {
console.error('[ReflectionLog] Reflection failed:', error);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error('[ReflectionLog] Reflection failed:', err);
setError(`反思失败: ${errorMessage}`);
} finally {
setIsReflecting(false);
}
@@ -559,6 +625,31 @@ export function ReflectionLog({
</span>
</div>
{/* Error Banner */}
<AnimatePresence>
{error && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="flex items-center justify-between gap-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
<div className="flex items-center gap-2 text-red-700 dark:text-red-300 text-sm">
<AlertTriangle className="w-4 h-4" />
<span>{error}</span>
</div>
<button
onClick={() => setError(null)}
className="p-1 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200"
>
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Config Panel */}
<AnimatePresence>
{showConfig && (

View File

@@ -82,7 +82,7 @@ export const chatStore = proxy<ChatStore>({
agents: [DEFAULT_AGENT],
currentAgent: DEFAULT_AGENT,
isStreaming: false,
currentModel: 'glm-5',
currentModel: 'glm-4-flash',
sessionKey: null,
// === Actions ===

View File

@@ -163,6 +163,7 @@ export const intelligenceStore = proxy<IntelligenceStore>({
byAgent: rawStats.byAgent,
oldestEntry: rawStats.oldestEntry,
newestEntry: rawStats.newestEntry,
storageSizeBytes: rawStats.storageSizeBytes ?? 0,
};
intelligenceStore.memoryStats = stats;
} catch (err) {

View File

@@ -74,6 +74,7 @@ export interface MemoryStats {
byAgent: Record<string, number>;
oldestEntry: string | null;
newestEntry: string | null;
storageSizeBytes: number;
}
// === Cache Types ===

View File

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

View File

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

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

View File

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

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
import type { AgentStreamDelta } from '../lib/gateway-client';
import { getClient } from './connectionStore';
import { intelligenceClient } from '../lib/intelligence-client';
import { getMemoryExtractor } from '../lib/memory-extractor';
import { getAgentSwarm } from '../lib/agent-swarm';
@@ -190,7 +191,7 @@ export const useChatStore = create<ChatState>()(
currentAgent: DEFAULT_AGENT,
isStreaming: false,
isLoading: false,
currentModel: 'glm-5',
currentModel: 'glm-4-flash',
sessionKey: null,
addMessage: (message) =>
@@ -399,7 +400,8 @@ export const useChatStore = create<ChatState>()(
set({ isStreaming: true });
try {
const client = getGatewayClient();
// Use the connected client from connectionStore (supports both GatewayClient and KernelClient)
const client = getClient();
// Check connection state first
const connectionState = useConnectionStore.getState().connectionState;
@@ -409,11 +411,23 @@ export const useChatStore = create<ChatState>()(
throw new Error(`Not connected (state: ${connectionState})`);
}
// Declare runId before chatStream so callbacks can access it
let runId = `run_${Date.now()}`;
// Try streaming first (OpenFang WebSocket)
const { runId } = await client.chatStream(
const result = await client.chatStream(
enhancedContent,
{
onDelta: () => { /* Handled by initStreamListener to prevent duplication */ },
onDelta: (delta: string) => {
// Update message content directly (works for both KernelClient and GatewayClient)
set((s) => ({
messages: s.messages.map((m) =>
m.id === assistantId
? { ...m, content: m.content + delta }
: m
),
}));
},
onTool: (tool: string, input: string, output: string) => {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
@@ -494,6 +508,11 @@ export const useChatStore = create<ChatState>()(
}
);
// Update runId from the result if available
if (result?.runId) {
runId = result.runId;
}
if (!sessionKey) {
set({ sessionKey: effectiveSessionKey });
}
@@ -530,9 +549,9 @@ export const useChatStore = create<ChatState>()(
communicationStyle: style || 'parallel',
});
// Set up executor that uses gateway client
// Set up executor that uses the connected client
swarm.setExecutor(async (agentId: string, prompt: string, context?: string) => {
const client = getGatewayClient();
const client = getClient();
const fullPrompt = context ? `${context}\n\n${prompt}` : prompt;
const result = await client.chat(fullPrompt, { agentId: agentId.startsWith('clone_') ? undefined : agentId });
return result?.response || '(无响应)';
@@ -566,7 +585,13 @@ export const useChatStore = create<ChatState>()(
},
initStreamListener: () => {
const client = getGatewayClient();
const client = getClient();
// Check if client supports onAgentStream (GatewayClient does, KernelClient doesn't)
if (!('onAgentStream' in client)) {
// KernelClient handles streaming via chatStream callbacks, no separate listener needed
return () => {};
}
const unsubscribe = client.onAgentStream((delta: AgentStreamDelta) => {
const state = get();

View File

@@ -25,6 +25,7 @@ import { useSecurityStore } from './securityStore';
import { useSessionStore } from './sessionStore';
import { useChatStore } from './chatStore';
import type { GatewayClient, ConnectionState } from '../lib/gateway-client';
import type { KernelClient } from '../lib/kernel-client';
import type { GatewayModelChoice } from '../lib/gateway-config';
import type { LocalGatewayStatus } from '../lib/tauri-gateway';
import type { Hand, HandRun, Trigger, Approval, ApprovalStatus } from './handStore';
@@ -233,7 +234,7 @@ interface GatewayFacade {
localGateway: LocalGatewayStatus;
localGatewayBusy: boolean;
isLoading: boolean;
client: GatewayClient;
client: GatewayClient | KernelClient;
// Data
clones: Clone[];

View File

@@ -207,9 +207,9 @@ export const useOfflineStore = create<OfflineStore>()(
get().updateMessageStatus(msg.id, 'sending');
try {
// Import gateway client dynamically to avoid circular dependency
const { getGatewayClient } = await import('../lib/gateway-client');
const client = getGatewayClient();
// Use connected client from connectionStore (supports both GatewayClient and KernelClient)
const { getClient } = await import('./connectionStore');
const client = getClient();
await client.chat(msg.content, {
sessionKey: msg.sessionKey,