Files
zclaw_openfang/desktop/src/components/ChatArea.tsx
iven a0d1392371
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(ui): 5 项 E2E 测试 Bug 修复 — Agent 502 / 错误持久化 / 模型标记 / 侧面板 / 记忆页
- BUG-01: createFromTemplate 在 saas-relay 模式下 try-catch 跳过本地 Kernel
- BUG-02: upsertActiveConversation 持久化前剥离 error/streaming/optimistic 字段
- BUG-04: ModelSelector 添加 available 标记,ChatArea 追踪失败模型 ID
- BUG-05: VikingPanel 移除 status?.available 门控,不可用时 disabled + 重连按钮
- BUG-06: 侧面板 tooltip 改为"查看产物文件",空状态增加图标和说明
2026-04-16 19:12:21 +08:00

879 lines
33 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObject, type RefObject, type CSSProperties, createElement } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { List, type ListImperativeAPI } from 'react-window';
import { useChatStore, type Message } from '../store/chatStore';
import { useConversationStore } from '../store/chat/conversationStore';
import { useArtifactStore } from '../store/chat/artifactStore';
import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { useSaaSStore } from '../store/saasStore';
import { type UnlistenFn } from '@tauri-apps/api/event';
import { safeListenEvent } from '../lib/safe-tauri';
import { Paperclip, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search, ClipboardList, Square } from 'lucide-react';
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
import { ResizableChatLayout, PanelToggleButton } from './ai/ResizableChatLayout';
import { ArtifactPanel } from './ai/ArtifactPanel';
import { ToolCallChain, type ToolCallStep } from './ai/ToolCallChain';
import { TaskProgress, type Subtask } from './ai/TaskProgress';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { ClassroomPlayer } from './classroom_player';
import { useClassroomStore } from '../store/classroomStore';
import { MessageSearch } from './MessageSearch';
import { OfflineIndicator } from './OfflineIndicator';
import {
useVirtualizedMessages,
type VirtualizedMessageItem
} from '../lib/message-virtualization';
import { Conversation } from './ai/Conversation';
import { ReasoningBlock } from './ai/ReasoningBlock';
import { StreamingText } from './ai/StreamingText';
import { ChatMode } from './ai/ChatMode';
import { ModelSelector } from './ai/ModelSelector';
import { isTauriRuntime } from '../lib/tauri-gateway';
import { SuggestionChips } from './ai/SuggestionChips';
import { PipelineResultPreview } from './pipeline/PipelineResultPreview';
import { PresentationContainer } from './presentation/PresentationContainer';
// TokenMeter temporarily unused — using inline text counter instead
// Default heights for virtualized messages
const DEFAULT_MESSAGE_HEIGHTS: Record<string, number> = {
user: 80,
assistant: 150,
tool: 120,
hand: 120,
workflow: 100,
system: 60,
};
// Threshold for enabling virtualization (messages count)
const VIRTUALIZATION_THRESHOLD = 100;
export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenDetail?: () => void }) {
const {
messages, isStreaming, isLoading,
sendMessage: sendToGateway, initStreamListener,
chatMode, setChatMode, suggestions,
totalInputTokens, totalOutputTokens,
cancelStream,
} = useChatStore();
const currentAgent = useConversationStore((s) => s.currentAgent);
const currentModel = useConversationStore((s) => s.currentModel);
const setCurrentModel = useConversationStore((s) => s.setCurrentModel);
const {
artifacts, selectedArtifactId, artifactPanelOpen,
selectArtifact, setArtifactPanelOpen,
} = useArtifactStore();
const connectionState = useConnectionStore((s) => s.connectionState);
const { activeClassroom, classroomOpen, closeClassroom, generating, progressPercent, progressActivity, error: classroomError, clearError: clearClassroomError } = useClassroomStore();
const clones = useAgentStore((s) => s.clones);
const configModels = useConfigStore((s) => s.models);
const saasModels = useSaaSStore((s) => s.availableModels);
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
// Track models that failed with API key errors in this session
const failedModelIds = useRef<Set<string>>(new Set());
// Scan messages for API key errors to populate failedModelIds
useEffect(() => {
for (const msg of messages) {
if (msg.error && (msg.error.includes('没有可用的 API Key') || msg.error.includes('Key Pool'))) {
failedModelIds.current.add(currentModel);
}
}
}, [messages, currentModel]);
// Merge models: SaaS available models take priority when logged in
const models = useMemo(() => {
const failed = failedModelIds.current;
if (isLoggedIn && saasModels.length > 0) {
return saasModels.map(m => ({
id: m.alias || m.id,
name: m.alias || m.id,
provider: m.provider_id,
available: !failed.has(m.alias || m.id),
}));
}
if (configModels.length > 0) {
return configModels;
}
// Fallback: provide common models when no backend is connected
return [
{ id: 'glm-4-flash-250414', name: 'GLM-4 Flash (免费)', provider: 'zhipu' },
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'openai' },
{ id: 'deepseek-chat', name: 'DeepSeek V3', provider: 'deepseek' },
{ id: 'qwen-max', name: 'Qwen Max', provider: 'qwen' },
{ id: 'claude-3-5-sonnet', name: 'Claude 3.5 Sonnet', provider: 'anthropic' },
];
}, [isLoggedIn, saasModels, configModels]);
const [input, setInput] = useState('');
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [searchOpen, setSearchOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const fileInputRef = useRef<HTMLInputElement>(null);
// Convert messages to virtualization format
const virtualizedMessages: VirtualizedMessageItem[] = useMemo(
() => messages.map((msg) => ({
id: msg.id,
height: DEFAULT_MESSAGE_HEIGHTS[msg.role] ?? 100,
role: msg.role,
})),
[messages]
);
// Use virtualization hook
const {
listRef,
getHeight,
setHeight,
scrollToBottom,
} = useVirtualizedMessages(virtualizedMessages, DEFAULT_MESSAGE_HEIGHTS);
// Whether to use virtualization
const useVirtualization = messages.length >= VIRTUALIZATION_THRESHOLD;
// Get current clone for first conversation prompt
const currentClone = useMemo(() => {
if (!currentAgent) return null;
return clones.find((c) => c.id === currentAgent.id) || null;
}, [currentAgent, clones]);
// Check if should show first conversation prompt
const showFirstPrompt = messages.length === 0 && currentClone && !currentClone.onboardingCompleted;
// Handle suggestion click from first conversation prompt
const handleSelectSuggestion = (text: string) => {
setInput(text);
textareaRef.current?.focus();
};
// Auto-resize textarea
const adjustTextarea = useCallback(() => {
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
}, []);
// File handling
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter((f) => f.size <= MAX_FILE_SIZE);
setPendingFiles((prev) => {
const combined = [...prev, ...incoming];
return combined.slice(0, MAX_FILES);
});
}, []);
// Paste handler for images/files
useEffect(() => {
const handler = (e: ClipboardEvent) => {
if (e.clipboardData?.files.length) {
e.preventDefault();
addFiles(e.clipboardData.files);
}
};
document.addEventListener('paste', handler);
return () => document.removeEventListener('paste', handler);
}, [addFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.files.length) {
addFiles(e.dataTransfer.files);
}
}, [addFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
// Init agent stream listener on mount
useEffect(() => {
const unsub = initStreamListener();
return unsub;
}, []);
// Listen for hand-execution-complete Tauri events
useEffect(() => {
let unlisten: UnlistenFn | undefined;
safeListenEvent<{ approvalId: string; handId: string; success: boolean; error?: string | null }>(
'hand-execution-complete',
(event) => {
const { handId, success, error } = event.payload;
useChatStore.getState().addMessage({
id: crypto.randomUUID(),
role: 'hand',
content: success
? `Hand ${handId} 执行完成`
: `Hand ${handId} 执行失败: ${error || '未知错误'}`,
timestamp: new Date(),
handName: handId,
handStatus: success ? 'completed' : 'failed',
handResult: event.payload,
});
},
).then((fn) => { unlisten = fn; });
return () => { unlisten?.(); };
}, []);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current && !useVirtualization) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} else if (useVirtualization && messages.length > 0) {
scrollToBottom();
}
}, [messages, useVirtualization, scrollToBottom]);
const handleSend = () => {
if ((!input.trim() && pendingFiles.length === 0) || isStreaming) return;
// Attach file names as metadata in the message
const fileContext = pendingFiles.length > 0
? `\n\n[附件: ${pendingFiles.map((f) => f.name).join(', ')}]`
: '';
sendToGateway(input + fileContext);
setInput('');
setPendingFiles([]);
};
const createRetryHandler = (msgId: string) => () => {
if (isStreaming) return;
// Find the user message immediately before this error
const idx = messages.findIndex(m => m.id === msgId);
if (idx > 0) {
const prevMsg = messages[idx - 1];
if (prevMsg.role === 'user' && prevMsg.content) {
sendToGateway(prevMsg.content);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const connected = connectionState === 'connected';
// Build artifact panel content
const artifactRightPanel = (
<ArtifactPanel
artifacts={artifacts}
selectedId={selectedArtifactId}
onSelect={selectArtifact}
/>
);
return (
<div className="relative flex-1 min-h-0">
{/* Generation progress overlay */}
<AnimatePresence>
{generating && (
<motion.div
key="generation-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-40 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm flex items-center justify-center"
>
<div className="text-center space-y-4">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-500 rounded-full animate-spin mx-auto" />
<div>
<p className="text-lg font-medium text-gray-900 dark:text-white">
...
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{progressActivity || '准备中...'}
</p>
</div>
{progressPercent > 0 && (
<div className="w-64 mx-auto">
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{progressPercent}%</p>
</div>
)}
<button
onClick={() => useClassroomStore.getState().cancelGeneration()}
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg"
>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ClassroomPlayer overlay */}
<AnimatePresence>
{classroomOpen && activeClassroom && (
<motion.div
key="classroom-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 bg-white dark:bg-gray-900"
>
<ClassroomPlayer
onClose={closeClassroom}
/>
</motion.div>
)}
</AnimatePresence>
<ResizableChatLayout
chatPanel={
<div className="flex flex-col h-full">
{/* Classroom generation error banner */}
{classroomError && (
<div className="mx-4 mt-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center justify-between text-sm">
<span className="text-red-600 dark:text-red-400">: {classroomError}</span>
<button onClick={clearClassroomError} className="text-red-400 hover:text-red-600 ml-3 text-xs"></button>
</div>
)}
{/* Header — DeerFlow-style: minimal */}
<div className="h-14 border-b border-transparent flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{currentAgent?.name || '新对话'}</span>
</div>
<div className="flex items-center gap-4">
{/* Token usage counter — DeerFlow-style plain text */}
{!compact && (totalInputTokens + totalOutputTokens) > 0 && (() => {
const total = totalInputTokens + totalOutputTokens;
const display = total >= 1000 ? `${(total / 1000).toFixed(1)}K` : String(total);
return (
<span className="text-sm text-gray-500 flex items-center gap-1.5">
{display}
</span>
);
})()}
<OfflineIndicator compact />
{!compact && messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchOpen((prev) => !prev)}
className={`flex items-center gap-1 rounded-lg transition-colors ${searchOpen ? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'}`}
title="搜索消息"
>
<Search className="w-3.5 h-3.5" />
</Button>
)}
{/* Side panel toggle — Trae Solo style, always in header */}
<PanelToggleButton
panelOpen={artifactPanelOpen}
onToggle={() => setArtifactPanelOpen(!artifactPanelOpen)}
/>
{/* 详情按钮 (简洁模式) */}
{compact && onOpenDetail && (
<Button
variant="ghost"
size="sm"
onClick={onOpenDetail}
className="flex items-center gap-1 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="详情"
>
<ClipboardList className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
{/* MessageSearch panel */}
<AnimatePresence>
{searchOpen && messages.length > 0 && (
<motion.div
key="message-search"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 overflow-hidden"
>
<div className="px-6 py-3 max-w-4xl mx-auto">
<MessageSearch onNavigateToMessage={(id) => {
const el = messageRefs.current.get(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}} />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Messages */}
<Conversation className="flex-1 bg-white dark:bg-gray-900 px-6 py-4">
<AnimatePresence mode="popLayout">
{/* 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}
initial="initial"
animate="animate"
exit="exit"
>
{showFirstPrompt && currentClone ? (
<FirstConversationPrompt
clone={currentClone}
onSelectSuggestion={handleSelectSuggestion}
/>
) : (
<EmptyState
icon={<MessageSquare className="w-8 h-8" />}
title="欢迎使用 ZCLAW"
description={connected ? '发送消息开始对话。' : '请先在设置中连接 Gateway。'}
/>
)}
</motion.div>
)}
{/* Virtualized list for large message counts, smooth scroll for small counts */}
{useVirtualization && messages.length > 0 ? (
<VirtualizedMessageList
messages={messages}
listRef={listRef}
getHeight={getHeight}
onHeightChange={setHeight}
messageRefs={messageRefs}
setInput={setInput}
retryForMessage={(msgId: string) => {
const idx = messages.findIndex(m => m.id === msgId);
if (idx > 0) {
const prevMsg = messages[idx - 1];
if (prevMsg.role === 'user' && prevMsg.content) {
return () => { if (!isStreaming) sendToGateway(prevMsg.content); };
}
}
return undefined;
}}
/>
) : (
messages.map((message) => (
<motion.div
key={message.id}
ref={(el) => { if (el) messageRefs.current.set(message.id, el); }}
variants={listItemVariants}
initial="hidden"
animate="visible"
layout
transition={defaultTransition}
className="mb-5"
>
<MessageBubble message={message} onRetry={createRetryHandler(message.id)} />
</motion.div>
))
)}
</AnimatePresence>
</Conversation>
{/* Input */}
<div className="flex-shrink-0 p-4 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto">
{/* Suggestion chips */}
{!isStreaming && suggestions.length > 0 && !messages.some(m => m.error) && (
<SuggestionChips
suggestions={suggestions}
onSelect={(text) => { setInput(text); textareaRef.current?.focus(); }}
className="mb-3"
/>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
/>
{/* Pending file previews */}
{pendingFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{pendingFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-lg text-xs text-gray-700 dark:text-gray-300 max-w-[200px]"
>
{file.type.startsWith('image/') ? (
<ImageIcon className="w-3.5 h-3.5 flex-shrink-0 text-orange-500" />
) : (
<FileText className="w-3.5 h-3.5 flex-shrink-0 text-gray-500" />
)}
<span className="truncate">{file.name}</span>
<span className="text-gray-400 flex-shrink-0">({(file.size / 1024).toFixed(0)}K)</span>
<button
onClick={() => setPendingFiles((prev) => prev.filter((_, i) => i !== idx))}
className="p-0.5 text-gray-400 hover:text-red-500 flex-shrink-0"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{/* Input card — DeerFlow-style: white card, textarea top, actions bottom */}
<div
className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm transition-all"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Textarea area */}
<div className="px-4 pt-4 pb-1">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
onKeyDown={handleKeyDown}
placeholder={
isStreaming
? 'Agent 正在回复...'
: '今天我能为你做些什么?'
}
disabled={isStreaming}
rows={2}
className="w-full bg-transparent border-none outline-none ring-0 focus:outline-none focus:ring-0 text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed"
style={{ minHeight: '48px', maxHeight: '160px', border: 'none', outline: 'none', boxShadow: 'none' }}
/>
</div>
{/* Bottom action bar */}
<div className="flex items-center justify-between px-3 pb-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="添加附件"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="w-5 h-5" />
</Button>
{!compact && <ChatMode
value={chatMode}
onChange={setChatMode}
disabled={isStreaming}
/>
}
</div>
<div className="flex items-center gap-2">
{models.length > 0 && (!isTauriRuntime() || isLoggedIn) && (
<ModelSelector
models={models.map(m => ({ id: m.id, name: m.name, provider: m.provider }))}
currentModel={currentModel}
onSelect={setCurrentModel}
disabled={isStreaming}
/>
)}
{isStreaming ? (
<Button
variant="primary"
size="sm"
onClick={cancelStream}
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-gray-500 hover:bg-gray-600 text-white"
aria-label="停止生成"
>
<Square className="w-3.5 h-3.5 text-white fill-white" />
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={handleSend}
disabled={!input.trim() && pendingFiles.length === 0}
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" />
</Button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
}
rightPanel={artifactRightPanel}
rightPanelTitle="产物"
rightPanelOpen={artifactPanelOpen}
onRightPanelToggle={setArtifactPanelOpen}
/>
</div>
);
}
function MessageBubble({ message, onRetry }: { message: Message; setInput?: (text: string) => void; onRetry?: () => void }) {
if (message.role === 'tool') {
return null;
}
const isUser = message.role === 'user';
const isThinking = message.streaming && !message.content;
// Extract typed arrays for JSX rendering
const toolCallSteps: ToolCallStep[] | undefined = message.toolSteps;
const subtaskList: Subtask[] | undefined = message.subtasks;
// framer-motion 12 + React 19 type compat: motion.span/div return types resolve to `unknown`.
// Use createElement to bypass JSX child-context type inference.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderToolChain = (): any => {
if (isUser || !toolCallSteps || toolCallSteps.length === 0) return null;
return createElement(ToolCallChain, { steps: toolCallSteps, isStreaming: !!message.streaming });
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderSubtasks = (): any => {
if (isUser || !subtaskList || subtaskList.length === 0) return null;
return createElement(TaskProgress, { tasks: subtaskList, className: 'mb-3' });
};
// Download message as Markdown file
const handleDownloadMessage = () => {
if (!message.content) return;
const timestamp = new Date().toISOString().slice(0, 10);
const filename = `message-${timestamp}.md`;
const blob = new Blob([message.content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className={`flex gap-4 ${isUser ? 'justify-end' : ''}`}>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${isUser ? 'bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-200 order-last' : 'agent-avatar text-white'}`}
>
{isUser ? '用' : 'Z'}
</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">
<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`}>
{/* Optimistic sending indicator */}
{isUser && message.optimistic && (
<span className="text-xs text-blue-200 dark:text-blue-300 mb-1 block animate-pulse">
Sending...
</span>
)}
{/* Reasoning block for thinking content (DeerFlow-inspired) */}
{!isUser && message.thinkingContent && (
<ReasoningBlock
content={message.thinkingContent}
isStreaming={message.streaming}
/>
)}
{/* Tool call steps chain (DeerFlow-inspired) */}
{renderToolChain()}
{/* Subtask tracking (DeerFlow-inspired) */}
{renderSubtasks()}
{/* Message content with streaming support */}
<div className={`leading-relaxed ${isUser ? 'text-white whitespace-pre-wrap' : 'text-gray-700 dark:text-gray-200'}`}>
{message.content
? (isUser
? message.content
: <StreamingText
content={message.content}
isStreaming={!!message.streaming}
className="text-gray-700 dark:text-gray-200"
/>
)
: '...'}
</div>
{/* Pipeline / Hand result presentation */}
{!isUser && (message.role === 'workflow' || message.role === 'hand') && message.workflowResult && typeof message.workflowResult === 'object' && message.workflowResult !== null && (
<div className="mt-3">
<PipelineResultPreview
outputs={message.workflowResult as Record<string, unknown>}
pipelineId={message.workflowId}
/>
</div>
)}
{!isUser && message.role === 'hand' && message.handResult && typeof message.handResult === 'object' && message.handResult !== null && !message.workflowResult && (
<div className="mt-3">
<PresentationContainer data={message.handResult} />
</div>
)}
{message.error && (
<div className="flex items-center gap-2 mt-2">
<p className="text-xs text-red-500">{message.error}</p>
{onRetry && (
<button
onClick={onRetry}
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
>
</button>
)}
</div>
)}
{/* Download button for AI messages - show on hover */}
{!isUser && message.content && !message.streaming && (
<button
onClick={handleDownloadMessage}
className="absolute top-2 right-2 p-1.5 bg-gray-200/80 dark:bg-gray-700/80 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors opacity-0 group-hover:opacity-100"
title="下载为 Markdown"
>
<Download className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>
</div>
);
}
// === Virtualized Message Components ===
interface VirtualizedMessageRowProps {
message: Message;
onHeightChange: (height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
onRetry?: () => void;
}
/**
* Single row in the virtualized list.
* Measures actual height after render and reports back.
*/
function VirtualizedMessageRow({
message,
onHeightChange,
messageRefs,
onRetry,
style,
ariaAttributes,
}: VirtualizedMessageRowProps & {
style: CSSProperties;
ariaAttributes: {
'aria-posinset': number;
'aria-setsize': number;
role: 'listitem';
};
}) {
const rowRef = useRef<HTMLDivElement>(null);
// Measure height after mount
useEffect(() => {
if (rowRef.current) {
const height = rowRef.current.getBoundingClientRect().height;
if (height > 0) {
onHeightChange(height);
}
}
}, [message.content, message.streaming, onHeightChange]);
return (
<div
ref={(el) => {
if (el) {
(rowRef as MutableRefObject<HTMLDivElement | null>).current = el;
messageRefs.current.set(message.id, el);
}
}}
style={style}
className="py-3"
{...ariaAttributes}
>
<MessageBubble message={message} onRetry={onRetry} />
</div>
);
}
interface VirtualizedMessageListProps {
messages: Message[];
listRef: RefObject<ListImperativeAPI | null>;
getHeight: (id: string, role: string) => number;
onHeightChange: (id: string, height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
retryForMessage: (msgId: string) => (() => void) | undefined;
}
/**
* Virtualized message list for efficient rendering of large message counts.
* Uses react-window's List with dynamic height measurement.
*/
function VirtualizedMessageList({
messages,
listRef,
getHeight,
onHeightChange,
messageRefs,
setInput,
retryForMessage,
}: VirtualizedMessageListProps) {
// Row component for react-window v2
const RowComponent = (props: {
ariaAttributes: {
'aria-posinset': number;
'aria-setsize': number;
role: 'listitem';
};
index: number;
style: CSSProperties;
}) => (
<VirtualizedMessageRow
message={messages[props.index]}
onHeightChange={(h) => onHeightChange(messages[props.index].id, h)}
messageRefs={messageRefs}
setInput={setInput}
onRetry={retryForMessage(messages[props.index].id)}
style={props.style}
ariaAttributes={props.ariaAttributes}
/>
);
return (
<List
listRef={listRef}
rowComponent={RowComponent}
rowProps={{}}
rowHeight={(index: number) => getHeight(messages[index].id, messages[index].role)}
rowCount={messages.length}
defaultHeight={500}
overscanCount={5}
className="focus:outline-none"
/>
);
}