feat: production readiness improvements

## Error Handling
- Add GlobalErrorBoundary with error classification and recovery
- Add custom error types (SecurityError, ConnectionError, TimeoutError)
- Fix ErrorAlert component syntax errors

## Offline Mode
- Add offlineStore for offline state management
- Implement message queue with localStorage persistence
- Add exponential backoff reconnection (1s→60s)
- Add OfflineIndicator component with status display
- Queue messages when offline, auto-retry on reconnect

## Security Hardening
- Add AES-256-GCM encryption for chat history storage
- Add secure API key storage with OS keychain integration
- Add security audit logging system
- Add XSS prevention and input validation utilities
- Add rate limiting and token generation helpers

## CI/CD (Gitea Actions)
- Add .gitea/workflows/ci.yml for continuous integration
- Add .gitea/workflows/release.yml for release automation
- Support Windows Tauri build and release

## UI Components
- Add LoadingSpinner, LoadingOverlay, LoadingDots components
- Add MessageSkeleton, ConversationListSkeleton skeletons
- Add EmptyMessages, EmptyConversations empty states
- Integrate loading states in ChatArea and ConversationList

## E2E Tests
- Fix WebSocket mock for streaming response tests
- Fix approval endpoint route matching
- Add store state exposure for testing
- All 19 core-features tests now passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-22 00:03:22 +08:00
parent ce562e8bfc
commit 185763868a
27 changed files with 5725 additions and 268 deletions

View File

@@ -1,10 +1,12 @@
import { create } from 'zustand';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
import { intelligenceClient } from '../lib/intelligence-client';
import { getMemoryExtractor } from '../lib/memory-extractor';
import { getAgentSwarm } from '../lib/agent-swarm';
import { getSkillDiscovery } from '../lib/skill-discovery';
import { useOfflineStore, isOffline } from './offlineStore';
import { useConnectionStore } from './connectionStore';
export interface MessageFile {
name: string;
@@ -21,7 +23,7 @@ export interface CodeBlock {
export interface Message {
id: string;
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow';
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow' | 'system';
content: string;
timestamp: Date;
runId?: string;
@@ -77,11 +79,13 @@ interface ChatState {
agents: Agent[];
currentAgent: Agent | null;
isStreaming: boolean;
isLoading: boolean;
currentModel: string;
sessionKey: string | null;
addMessage: (message: Message) => void;
updateMessage: (id: string, updates: Partial<Message>) => void;
setIsLoading: (loading: boolean) => void;
setCurrentAgent: (agent: Agent) => void;
syncAgents: (profiles: AgentProfileLike[]) => void;
setCurrentModel: (model: string) => void;
@@ -185,6 +189,7 @@ export const useChatStore = create<ChatState>()(
agents: [DEFAULT_AGENT],
currentAgent: DEFAULT_AGENT,
isStreaming: false,
isLoading: false,
currentModel: 'glm-5',
sessionKey: null,
@@ -198,6 +203,8 @@ export const useChatStore = create<ChatState>()(
),
})),
setIsLoading: (loading) => set({ isLoading: loading }),
setCurrentAgent: (agent) =>
set((state) => {
if (state.currentAgent?.id === agent.id) {
@@ -295,6 +302,32 @@ export const useChatStore = create<ChatState>()(
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
const agentId = currentAgent?.id || 'zclaw-main';
// Check if offline - queue message instead of sending
if (isOffline()) {
const { queueMessage } = useOfflineStore.getState();
const queueId = queueMessage(content, effectiveAgentId, effectiveSessionKey);
console.log(`[Chat] Offline - message queued: ${queueId}`);
// Show a system message about offline queueing
const systemMsg: Message = {
id: `system_${Date.now()}`,
role: 'system',
content: `后端服务不可用,消息已保存到本地队列。恢复连接后将自动发送。`,
timestamp: new Date(),
};
addMessage(systemMsg);
// Add user message for display
const userMsg: Message = {
id: `user_${Date.now()}`,
role: 'user',
content,
timestamp: new Date(),
};
addMessage(userMsg);
return;
}
// Check context compaction threshold before adding new message
try {
const messages = get().messages.map(m => ({ role: m.role, content: m.content }));
@@ -368,134 +401,107 @@ export const useChatStore = create<ChatState>()(
try {
const client = getGatewayClient();
// Try streaming first (OpenFang WebSocket)
// Note: onDelta is empty - stream updates handled by initStreamListener to avoid duplication
if (client.getState() === 'connected') {
const { runId } = await client.chatStream(
enhancedContent,
{
onDelta: () => { /* Handled by initStreamListener to prevent duplication */ },
onTool: (tool: string, input: string, output: string) => {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'tool',
content: output || input,
timestamp: new Date(),
runId,
toolName: tool,
toolInput: input,
toolOutput: output,
};
set((state) => ({ messages: [...state.messages, toolMsg] }));
},
onHand: (name: string, status: string, result?: unknown) => {
const handMsg: Message = {
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'hand',
content: result
? (typeof result === 'string' ? result : JSON.stringify(result, null, 2))
: `Hand: ${name} - ${status}`,
timestamp: new Date(),
runId,
handName: name,
handStatus: status,
handResult: result,
};
set((state) => ({ messages: [...state.messages, handMsg] }));
},
onComplete: () => {
const state = get();
// Check connection state first
const connectionState = useConnectionStore.getState().connectionState;
// Save conversation to persist across refresh
const conversations = upsertActiveConversation([...state.conversations], state);
const currentConvId = state.currentConversationId || conversations[0]?.id;
set({
isStreaming: false,
conversations,
currentConversationId: currentConvId,
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false, runId } : m
),
});
// Async memory extraction after stream completes
const msgs = get().messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content }));
getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err =>
console.warn('[Chat] Memory extraction failed:', err)
);
// Track conversation for reflection trigger
intelligenceClient.reflection.recordConversation().catch(err =>
console.warn('[Chat] Recording conversation failed:', err)
);
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
if (shouldReflect) {
intelligenceClient.reflection.reflect(agentId, []).catch(err =>
console.warn('[Chat] Reflection failed:', err)
);
}
});
},
onError: (error: string) => {
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ ${error}`, streaming: false, error }
: m
),
}));
},
},
{
sessionKey: effectiveSessionKey,
agentId: effectiveAgentId,
}
);
if (!sessionKey) {
set({ sessionKey: effectiveSessionKey });
}
// Store runId on the message for correlation
set((state) => ({
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, runId } : m
),
}));
return;
if (connectionState !== 'connected') {
// Connection lost during send - update error
throw new Error(`Not connected (state: ${connectionState})`);
}
// Fallback to REST API (non-streaming)
const result = await client.chat(enhancedContent, {
sessionKey: effectiveSessionKey,
agentId: effectiveAgentId,
});
// Try streaming first (OpenFang WebSocket)
const { runId } = await client.chatStream(
enhancedContent,
{
onDelta: () => { /* Handled by initStreamListener to prevent duplication */ },
onTool: (tool: string, input: string, output: string) => {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'tool',
content: output || input,
timestamp: new Date(),
runId,
toolName: tool,
toolInput: input,
toolOutput: output,
};
set((state) => ({ messages: [...state.messages, toolMsg] }));
},
onHand: (name: string, status: string, result?: unknown) => {
const handMsg: Message = {
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role: 'hand',
content: result
? (typeof result === 'string' ? result : JSON.stringify(result, null, 2))
: `Hand: ${name} - ${status}`,
timestamp: new Date(),
runId,
handName: name,
handStatus: status,
handResult: result,
};
set((state) => ({ messages: [...state.messages, handMsg] }));
},
onComplete: () => {
const state = get();
// Save conversation to persist across refresh
const conversations = upsertActiveConversation([...state.conversations], state);
const currentConvId = state.currentConversationId || conversations[0]?.id;
set({
isStreaming: false,
conversations,
currentConversationId: currentConvId,
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false, runId } : m
),
});
// Async memory extraction after stream completes
const msgs = get().messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content }));
getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err => {
console.warn('[Chat] Memory extraction failed:', err);
});
// Track conversation for reflection trigger
intelligenceClient.reflection.recordConversation().catch(err => {
console.warn('[Chat] Recording conversation failed:', err);
});
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
if (shouldReflect) {
intelligenceClient.reflection.reflect(agentId, []).catch(err => {
console.warn('[Chat] Reflection failed:', err);
});
}
});
},
onError: (error: string) => {
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ ${error}`, streaming: false, error }
: m
),
}));
},
},
{
sessionKey: effectiveSessionKey,
agentId: effectiveAgentId,
}
);
if (!sessionKey) {
set({ sessionKey: effectiveSessionKey });
}
// OpenFang returns response directly (no WebSocket streaming)
if (result.response) {
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId
? { ...m, content: result.response || '', streaming: false }
: m
),
}));
return;
}
// The actual streaming content comes via the 'agent' event listener
// set in initStreamListener(). The runId links events to this message.
// Store runId on the message for correlation
set((state) => ({
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, runId: result.runId } : m
m.id === assistantId ? { ...m, runId } : m
),
}));
} catch (err: unknown) {
@@ -686,3 +692,9 @@ export const useChatStore = create<ChatState>()(
},
),
);
// Dev-only: Expose chatStore to window for E2E testing
if (import.meta.env.DEV && typeof window !== 'undefined') {
(window as any).__ZCLAW_STORES__ = (window as any).__ZCLAW_STORES__ || {};
(window as any).__ZCLAW_STORES__.chat = useChatStore;
}

View File

@@ -347,5 +347,11 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
(window as any).__ZCLAW_STORES__.config = useConfigStore;
(window as any).__ZCLAW_STORES__.security = useSecurityStore;
(window as any).__ZCLAW_STORES__.session = useSessionStore;
// Dynamically import chatStore to avoid circular dependency
import('./chatStore').then(({ useChatStore }) => {
(window as any).__ZCLAW_STORES__.chat = useChatStore;
}).catch(() => {
// Ignore if chatStore is not available
});
}

View File

@@ -0,0 +1,358 @@
/**
* Offline Store
*
* Manages offline state, message queue, and reconnection logic.
* Provides graceful degradation when backend is unavailable.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useConnectionStore, getConnectionState } from './connectionStore';
// === Types ===
export interface QueuedMessage {
id: string;
content: string;
agentId?: string;
sessionKey?: string;
timestamp: number;
retryCount: number;
lastError?: string;
status: 'pending' | 'sending' | 'failed' | 'sent';
}
export interface OfflineState {
isOffline: boolean;
isReconnecting: boolean;
reconnectAttempt: number;
nextReconnectDelay: number;
lastOnlineTime: number | null;
queuedMessages: QueuedMessage[];
maxRetryCount: number;
maxQueueSize: number;
}
export interface OfflineActions {
// State management
setOffline: (offline: boolean) => void;
setReconnecting: (reconnecting: boolean, attempt?: number) => void;
// Message queue operations
queueMessage: (content: string, agentId?: string, sessionKey?: string) => string;
updateMessageStatus: (id: string, status: QueuedMessage['status'], error?: string) => void;
removeMessage: (id: string) => void;
clearQueue: () => void;
retryAllMessages: () => Promise<void>;
// Reconnection
scheduleReconnect: () => void;
cancelReconnect: () => void;
attemptReconnect: () => Promise<boolean>;
// Getters
getPendingMessages: () => QueuedMessage[];
hasPendingMessages: () => boolean;
}
export type OfflineStore = OfflineState & OfflineActions;
// === Constants ===
const INITIAL_RECONNECT_DELAY = 1000; // 1 second
const MAX_RECONNECT_DELAY = 60000; // 60 seconds
const RECONNECT_BACKOFF_FACTOR = 1.5;
const MAX_RETRY_COUNT = 5;
const MAX_QUEUE_SIZE = 100;
// === Helper Functions ===
function calculateNextDelay(currentDelay: number): number {
return Math.min(
currentDelay * RECONNECT_BACKOFF_FACTOR,
MAX_RECONNECT_DELAY
);
}
function generateMessageId(): string {
return `queued_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
// === Store Implementation ===
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let healthCheckInterval: ReturnType<typeof setInterval> | null = null;
export const useOfflineStore = create<OfflineStore>()(
persist(
(set, get) => ({
// === Initial State ===
isOffline: false,
isReconnecting: false,
reconnectAttempt: 0,
nextReconnectDelay: INITIAL_RECONNECT_DELAY,
lastOnlineTime: null,
queuedMessages: [],
maxRetryCount: MAX_RETRY_COUNT,
maxQueueSize: MAX_QUEUE_SIZE,
// === State Management ===
setOffline: (offline: boolean) => {
const wasOffline = get().isOffline;
set({
isOffline: offline,
lastOnlineTime: offline ? get().lastOnlineTime : Date.now(),
});
// Start reconnect process when going offline
if (offline && !wasOffline) {
get().scheduleReconnect();
} else if (!offline && wasOffline) {
// Back online - try to send queued messages
get().cancelReconnect();
set({ reconnectAttempt: 0, nextReconnectDelay: INITIAL_RECONNECT_DELAY });
get().retryAllMessages();
}
},
setReconnecting: (reconnecting: boolean, attempt?: number) => {
set({
isReconnecting: reconnecting,
reconnectAttempt: attempt ?? get().reconnectAttempt,
});
},
// === Message Queue Operations ===
queueMessage: (content: string, agentId?: string, sessionKey?: string) => {
const state = get();
// Check queue size limit
if (state.queuedMessages.length >= state.maxQueueSize) {
// Remove oldest pending message
const filtered = state.queuedMessages.filter((m, i) =>
i > 0 || m.status !== 'pending'
);
set({ queuedMessages: filtered });
}
const id = generateMessageId();
const message: QueuedMessage = {
id,
content,
agentId,
sessionKey,
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
};
set((s) => ({
queuedMessages: [...s.queuedMessages, message],
}));
console.log(`[OfflineStore] Message queued: ${id}`);
return id;
},
updateMessageStatus: (id: string, status: QueuedMessage['status'], error?: string) => {
set((s) => ({
queuedMessages: s.queuedMessages.map((m) =>
m.id === id
? {
...m,
status,
lastError: error,
retryCount: status === 'failed' ? m.retryCount + 1 : m.retryCount,
}
: m
),
}));
},
removeMessage: (id: string) => {
set((s) => ({
queuedMessages: s.queuedMessages.filter((m) => m.id !== id),
}));
},
clearQueue: () => {
set({ queuedMessages: [] });
},
retryAllMessages: async () => {
const state = get();
const pending = state.queuedMessages.filter(
(m) => m.status === 'pending' || m.status === 'failed'
);
if (pending.length === 0) return;
// Check if connected
if (getConnectionState() !== 'connected') {
console.log('[OfflineStore] Not connected, cannot retry messages');
return;
}
console.log(`[OfflineStore] Retrying ${pending.length} queued messages`);
for (const msg of pending) {
if (msg.retryCount >= state.maxRetryCount) {
console.log(`[OfflineStore] Message ${msg.id} exceeded max retries`);
get().updateMessageStatus(msg.id, 'failed', 'Max retry count exceeded');
continue;
}
get().updateMessageStatus(msg.id, 'sending');
try {
// Import gateway client dynamically to avoid circular dependency
const { getGatewayClient } = await import('../lib/gateway-client');
const client = getGatewayClient();
await client.chat(msg.content, {
sessionKey: msg.sessionKey,
agentId: msg.agentId,
});
get().updateMessageStatus(msg.id, 'sent');
// Remove sent message after a short delay
setTimeout(() => get().removeMessage(msg.id), 1000);
console.log(`[OfflineStore] Message ${msg.id} sent successfully`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Send failed';
get().updateMessageStatus(msg.id, 'failed', errorMessage);
console.warn(`[OfflineStore] Message ${msg.id} failed:`, errorMessage);
}
}
},
// === Reconnection ===
scheduleReconnect: () => {
const state = get();
// Don't schedule if already online
if (!state.isOffline) return;
// Cancel any existing timer
get().cancelReconnect();
const attempt = state.reconnectAttempt + 1;
const delay = state.nextReconnectDelay;
console.log(`[OfflineStore] Scheduling reconnect attempt ${attempt} in ${delay}ms`);
set({
isReconnecting: true,
reconnectAttempt: attempt,
nextReconnectDelay: calculateNextDelay(delay),
});
reconnectTimer = setTimeout(() => {
get().attemptReconnect();
}, delay);
},
cancelReconnect: () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
set({ isReconnecting: false });
},
attemptReconnect: async () => {
console.log('[OfflineStore] Attempting to reconnect...');
try {
// Try to connect via connection store
await useConnectionStore.getState().connect();
// Check if now connected
if (getConnectionState() === 'connected') {
console.log('[OfflineStore] Reconnection successful');
get().setOffline(false);
return true;
}
} catch (err) {
console.warn('[OfflineStore] Reconnection failed:', err);
}
// Still offline, schedule next attempt
get().setReconnecting(false);
get().scheduleReconnect();
return false;
},
// === Getters ===
getPendingMessages: () => {
return get().queuedMessages.filter(
(m) => m.status === 'pending' || m.status === 'failed'
);
},
hasPendingMessages: () => {
return get().queuedMessages.some(
(m) => m.status === 'pending' || m.status === 'failed'
);
},
}),
{
name: 'zclaw-offline-storage',
partialize: (state) => ({
queuedMessages: state.queuedMessages.filter(
(m) => m.status === 'pending' || m.status === 'failed'
),
lastOnlineTime: state.lastOnlineTime,
}),
}
)
);
// === Connection State Monitor ===
/**
* Start monitoring connection state and update offline store accordingly.
* Should be called once during app initialization.
*/
export function startOfflineMonitor(): () => void {
const checkConnection = () => {
const connectionState = getConnectionState();
const isOffline = connectionState !== 'connected';
if (isOffline !== useOfflineStore.getState().isOffline) {
useOfflineStore.getState().setOffline(isOffline);
}
};
// Initial check
checkConnection();
// Subscribe to connection state changes
const unsubscribe = useConnectionStore.subscribe((state, prevState) => {
if (state.connectionState !== prevState.connectionState) {
const isOffline = state.connectionState !== 'connected';
useOfflineStore.getState().setOffline(isOffline);
}
});
// Periodic health check (every 30 seconds)
healthCheckInterval = setInterval(checkConnection, 30000);
return () => {
unsubscribe();
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
}
};
}
// === Exported Accessors ===
export const isOffline = () => useOfflineStore.getState().isOffline;
export const getQueuedMessages = () => useOfflineStore.getState().queuedMessages;
export const hasPendingMessages = () => useOfflineStore.getState().hasPendingMessages();