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
The ChatArea.tsx toolSteps/subtasks rendering uses helper functions to avoid TypeScript strict mode && chain producing unknown type in JSX children. Add SubscriptionPanel component for subscription status display in SaaS billing section.
835 lines
31 KiB
TypeScript
835 lines
31 KiB
TypeScript
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, 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, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search } from 'lucide-react';
|
|
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
|
|
import { ResizableChatLayout } 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 { 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() {
|
|
const {
|
|
messages, isStreaming, isLoading,
|
|
sendMessage: sendToGateway, initStreamListener,
|
|
newConversation, chatMode, setChatMode, suggestions,
|
|
totalInputTokens, totalOutputTokens,
|
|
} = 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);
|
|
|
|
// Merge models: SaaS available models take priority when logged in
|
|
const models = useMemo(() => {
|
|
if (isLoggedIn && saasModels.length > 0) {
|
|
return saasModels.map(m => ({
|
|
id: m.alias || m.id,
|
|
name: m.alias || m.id,
|
|
provider: m.provider_id,
|
|
}));
|
|
}
|
|
return configModels;
|
|
}, [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 handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
const connected = connectionState === 'connected';
|
|
|
|
// Export current conversation as Markdown
|
|
const exportCurrentConversation = () => {
|
|
const title = currentAgent?.name || 'ZCLAW 对话';
|
|
const lines = [`# ${title}`, '', `导出时间: ${new Date().toLocaleString('zh-CN')}`, ''];
|
|
for (const msg of messages) {
|
|
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role;
|
|
lines.push(`## ${label}`, '', msg.content, '');
|
|
}
|
|
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '_')}.md`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// Build artifact panel content
|
|
const artifactRightPanel = (
|
|
<ArtifactPanel
|
|
artifacts={artifacts}
|
|
selectedId={selectedArtifactId}
|
|
onSelect={selectArtifact}
|
|
onClose={() => setArtifactPanelOpen(false)}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<div className="relative h-full">
|
|
{/* 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 */}
|
|
{(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 />
|
|
{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>
|
|
)}
|
|
{messages.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={exportCurrentConversation}
|
|
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
title="导出对话"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span className="text-sm">导出</span>
|
|
</Button>
|
|
)}
|
|
{messages.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={newConversation}
|
|
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
title="新对话"
|
|
>
|
|
<SquarePen 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">
|
|
<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}
|
|
/>
|
|
) : (
|
|
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}
|
|
>
|
|
<MessageBubble message={message} setInput={setInput} />
|
|
</motion.div>
|
|
))
|
|
)}
|
|
</AnimatePresence>
|
|
</Conversation>
|
|
|
|
{/* Input */}
|
|
<div className="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>
|
|
<ChatMode
|
|
value={chatMode}
|
|
onChange={setChatMode}
|
|
disabled={isStreaming}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<ModelSelector
|
|
models={models.map(m => ({ id: m.id, name: m.name, provider: m.provider }))}
|
|
currentModel={currentModel}
|
|
onSelect={setCurrentModel}
|
|
disabled={isStreaming}
|
|
/>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={handleSend}
|
|
disabled={isStreaming || (!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, setInput }: { message: Message; setInput: (text: string) => void }) {
|
|
if (message.role === 'tool') {
|
|
return null;
|
|
}
|
|
|
|
const isUser = message.role === 'user';
|
|
const isThinking = message.streaming && !message.content;
|
|
|
|
// Extract typed arrays for JSX rendering (avoids TS2322 from && chain producing unknown)
|
|
const toolCallSteps: ToolCallStep[] | undefined = message.toolSteps;
|
|
const subtaskList: Subtask[] | undefined = message.subtasks;
|
|
|
|
const renderToolSteps = (): React.ReactNode => {
|
|
if (isUser || !toolCallSteps || toolCallSteps.length === 0) return null;
|
|
return <ToolCallChain steps={toolCallSteps} isStreaming={!!message.streaming} />;
|
|
};
|
|
|
|
const renderSubtasks = (): React.ReactNode => {
|
|
if (isUser || !subtaskList || subtaskList.length === 0) return null;
|
|
return <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) */}
|
|
{renderToolSteps()}
|
|
{/* 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>
|
|
<button
|
|
onClick={() => {
|
|
const text = typeof message.content === 'string' ? message.content : '';
|
|
if (text) {
|
|
setInput(text);
|
|
}
|
|
}}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Single row in the virtualized list.
|
|
* Measures actual height after render and reports back.
|
|
*/
|
|
function VirtualizedMessageRow({
|
|
message,
|
|
onHeightChange,
|
|
messageRefs,
|
|
setInput,
|
|
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} setInput={setInput} />
|
|
</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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
}: 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}
|
|
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"
|
|
/>
|
|
);
|
|
}
|