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,4 +1,4 @@
import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type RefObject, type MutableRefObject } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObject, type RefObject, type CSSProperties } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { List, type ListImperativeAPI } from 'react-window';
import { useChatStore, Message } from '../store/chatStore';
@@ -6,13 +6,14 @@ import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
import { Button, EmptyState } from './ui';
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { MessageSearch } from './MessageSearch';
import { OfflineIndicator } from './OfflineIndicator';
import {
useVirtualizedMessages,
type VirtualizedMessageItem,
type VirtualizedMessageItem
} from '../lib/message-virtualization';
// Default heights for virtualized messages
@@ -30,7 +31,7 @@ const VIRTUALIZATION_THRESHOLD = 100;
export function ChatArea() {
const {
messages, currentAgent, isStreaming, currentModel,
messages, currentAgent, isStreaming, isLoading, currentModel,
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation,
} = useChatStore();
@@ -105,7 +106,8 @@ export function ChatArea() {
}, [messages, useVirtualization, scrollToBottom]);
const handleSend = () => {
if (!input.trim() || isStreaming || !connected) return;
if (!input.trim() || isStreaming) return;
// Allow sending in offline mode - message will be queued
sendToGateway(input);
setInput('');
};
@@ -134,6 +136,7 @@ export function ChatArea() {
return (
<div className="flex flex-col h-full">
{/* Header */}
{/* Header */}
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2">
@@ -151,6 +154,8 @@ export function ChatArea() {
)}
</div>
<div className="flex items-center gap-2">
{/* Offline indicator in header */}
<OfflineIndicator compact />
{messages.length > 0 && (
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
)}
@@ -171,9 +176,23 @@ export function ChatArea() {
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6 bg-white dark:bg-gray-900">
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-gray-900">
<AnimatePresence mode="popLayout">
{messages.length === 0 && (
{/* Loading skeleton */}
{isLoading && messages.length === 0 && (
<motion.div
key="loading-skeleton"
variants={fadeInVariants}
initial="initial"
animate="animate"
exit="exit"
>
<MessageListSkeleton count={3} />
</motion.div>
)}
{/* Empty state */}
{!isLoading && messages.length === 0 && (
<motion.div
key="empty-state"
variants={fadeInVariants}
@@ -189,8 +208,8 @@ export function ChatArea() {
) : (
<EmptyState
icon={<MessageSquare className="w-8 h-8" />}
title="欢迎使用 ZCLAW"
description={connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}
title="Welcome to ZCLAW"
description={connected ? 'Send a message to start the conversation.' : 'Please connect to Gateway first in Settings.'}
/>
)}
</motion.div>
@@ -242,13 +261,11 @@ export function ChatArea() {
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
onKeyDown={handleKeyDown}
placeholder={
!connected
? '请先连接 Gateway'
: isStreaming
? 'Agent 正在回复...'
: `发送给 ${currentAgent?.name || 'ZCLAW'}`
isStreaming
? 'Agent 正在回复...'
: `发送给 ${currentAgent?.name || 'ZCLAW'}${!connected ? ' (离线模式)' : ''}`
}
disabled={isStreaming || !connected}
disabled={isStreaming}
rows={1}
className="w-full bg-transparent border-none focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed mt-1"
style={{ minHeight: '24px', maxHeight: '160px' }}
@@ -289,8 +306,8 @@ export function ChatArea() {
variant="primary"
size="sm"
onClick={handleSend}
disabled={isStreaming || !input.trim() || !connected}
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white"
disabled={isStreaming || !input.trim()}
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
aria-label="发送消息"
>
<ArrowUp className="w-4 h-4 text-white" />
@@ -549,14 +566,10 @@ function MessageBubble({ message }: { message: Message }) {
</div>
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
{isThinking ? (
// 思考中指示器
// Thinking indicator
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
<div className="flex gap-1">
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span className="text-sm">...</span>
<LoadingDots />
<span className="text-sm">Thinking...</span>
</div>
) : (
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>