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