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