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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
358
desktop/src/store/offlineStore.ts
Normal file
358
desktop/src/store/offlineStore.ts
Normal 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();
|
||||
Reference in New Issue
Block a user