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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user