Major changes: - Shift from "OpenFang desktop client" to "independent AI Agent desktop app" - Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?" - Simplify project structure and tech stack sections - Replace OpenClaw vs OpenFang comparison with unified backend approach - Consolidate troubleshooting from scattered sections into organized FAQ - Update Hands system documentation with 8 capabilities and status - Stream
518 lines
20 KiB
TypeScript
518 lines
20 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { useChatStore, Message } from '../store/chatStore';
|
||
import { useGatewayStore } from '../store/gatewayStore';
|
||
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
|
||
import { Button, EmptyState } from './ui';
|
||
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
|
||
import { FirstConversationPrompt } from './FirstConversationPrompt';
|
||
import { MessageSearch } from './MessageSearch';
|
||
|
||
export function ChatArea() {
|
||
const {
|
||
messages, currentAgent, isStreaming, currentModel,
|
||
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
||
newConversation,
|
||
} = useChatStore();
|
||
const { connectionState, clones, models } = useGatewayStore();
|
||
|
||
const [input, setInput] = useState('');
|
||
const [showModelPicker, setShowModelPicker] = useState(false);
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||
|
||
// 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';
|
||
}
|
||
}, []);
|
||
|
||
// Init agent stream listener on mount
|
||
useEffect(() => {
|
||
const unsub = initStreamListener();
|
||
return unsub;
|
||
}, []);
|
||
|
||
// Auto-scroll to bottom on new messages
|
||
useEffect(() => {
|
||
if (scrollRef.current) {
|
||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||
}
|
||
}, [messages]);
|
||
|
||
const handleSend = () => {
|
||
if (!input.trim() || isStreaming || !connected) return;
|
||
sendToGateway(input);
|
||
setInput('');
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
const connected = connectionState === 'connected';
|
||
|
||
// Navigate to a specific message by ID
|
||
const handleNavigateToMessage = useCallback((messageId: string) => {
|
||
const messageEl = messageRefs.current.get(messageId);
|
||
if (messageEl && scrollRef.current) {
|
||
messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
// Add highlight effect
|
||
messageEl.classList.add('ring-2', 'ring-orange-400', 'ring-offset-2');
|
||
setTimeout(() => {
|
||
messageEl.classList.remove('ring-2', 'ring-orange-400', 'ring-offset-2');
|
||
}, 2000);
|
||
}
|
||
}, []);
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* 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">
|
||
<h2 className="font-semibold text-gray-900 dark:text-gray-100">{currentAgent?.name || 'ZCLAW'}</h2>
|
||
{isStreaming ? (
|
||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||
<span className="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-400 rounded-full thinking-dot"></span>
|
||
正在输入中
|
||
</span>
|
||
) : (
|
||
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-500' : 'text-gray-500 dark:text-gray-400'}`}>
|
||
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300 dark:bg-gray-600'}`}></span>
|
||
{connected ? 'Gateway 已连接' : 'Gateway 未连接'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{messages.length > 0 && (
|
||
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
|
||
)}
|
||
{messages.length > 0 && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={newConversation}
|
||
title="新对话"
|
||
aria-label="开始新对话"
|
||
className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20"
|
||
>
|
||
<SquarePen className="w-3.5 h-3.5" />
|
||
新对话
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6 bg-white dark:bg-gray-900">
|
||
<AnimatePresence mode="popLayout">
|
||
{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>
|
||
)}
|
||
|
||
{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} />
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div className="border-t border-gray-100 dark:border-gray-800 p-4 bg-white dark:bg-gray-900">
|
||
<div className="max-w-4xl mx-auto">
|
||
<div className="relative flex items-end gap-2 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-2 focus-within:border-orange-300 dark:focus-within:border-orange-600 focus-within:ring-2 focus-within:ring-orange-100 dark:focus-within:ring-orange-900/30 transition-all">
|
||
<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="添加附件"
|
||
>
|
||
<Paperclip className="w-5 h-5" />
|
||
</Button>
|
||
<div className="flex-1 py-1">
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={input}
|
||
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={
|
||
!connected
|
||
? '请先连接 Gateway'
|
||
: isStreaming
|
||
? 'Agent 正在回复...'
|
||
: `发送给 ${currentAgent?.name || 'ZCLAW'}`
|
||
}
|
||
disabled={isStreaming || !connected}
|
||
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' }}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2 pr-2 pb-1 relative">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowModelPicker(!showModelPicker)}
|
||
className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||
aria-label="选择模型"
|
||
aria-expanded={showModelPicker}
|
||
>
|
||
<span>{currentModel}</span>
|
||
<ChevronDown className="w-3 h-3" />
|
||
</Button>
|
||
{showModelPicker && (
|
||
<div className="absolute bottom-full right-8 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[160px] max-h-48 overflow-y-auto z-10">
|
||
{models.length > 0 ? (
|
||
models.map((model) => (
|
||
<button
|
||
key={model.id}
|
||
onClick={() => { setCurrentModel(model.id); setShowModelPicker(false); }}
|
||
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-gray-700 ${model.id === currentModel ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-700 dark:text-gray-300'}`}
|
||
>
|
||
{model.name}
|
||
</button>
|
||
))
|
||
) : (
|
||
<div className="px-3 py-2 text-xs text-gray-400">
|
||
{connected ? '加载中...' : '未连接 Gateway'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<Button
|
||
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"
|
||
aria-label="发送消息"
|
||
>
|
||
<ArrowUp className="w-4 h-4 text-white" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="text-center mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
Agent 在本地运行,内容由 AI 生成
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Code block with copy and download functionality */
|
||
function CodeBlock({ code, language, index }: { code: string; language: string; index: number }) {
|
||
const [copied, setCopied] = useState(false);
|
||
const [downloading, setDownloading] = useState(false);
|
||
|
||
// Infer filename from language or content
|
||
const inferFilename = (): string => {
|
||
const extMap: Record<string, string> = {
|
||
javascript: 'js', typescript: 'ts', python: 'py', rust: 'rs',
|
||
go: 'go', java: 'java', cpp: 'cpp', c: 'c', csharp: 'cs',
|
||
html: 'html', css: 'css', scss: 'scss', json: 'json',
|
||
yaml: 'yaml', yml: 'yaml', xml: 'xml', sql: 'sql',
|
||
shell: 'sh', bash: 'sh', powershell: 'ps1',
|
||
markdown: 'md', md: 'md', dockerfile: 'dockerfile',
|
||
};
|
||
|
||
// Check if language contains a filename (e.g., ```app.tsx)
|
||
if (language.includes('.') || language.includes('/')) {
|
||
return language;
|
||
}
|
||
|
||
// Check for common patterns in code
|
||
const codeLower = code.toLowerCase();
|
||
if (codeLower.includes('<!doctype html') || codeLower.includes('<html')) {
|
||
return 'index.html';
|
||
}
|
||
if (codeLower.includes('package.json') || (codeLower.includes('"name"') && codeLower.includes('"version"'))) {
|
||
return 'package.json';
|
||
}
|
||
if (codeLower.startsWith('{') && (codeLower.includes('"import"') || codeLower.includes('"export"'))) {
|
||
return 'config.json';
|
||
}
|
||
|
||
// Use language extension
|
||
const ext = extMap[language.toLowerCase()] || language.toLowerCase();
|
||
return `code-${index + 1}.${ext || 'txt'}`;
|
||
};
|
||
|
||
const handleCopy = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(code);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
} catch (err) {
|
||
console.error('Failed to copy:', err);
|
||
}
|
||
};
|
||
|
||
const handleDownload = () => {
|
||
setDownloading(true);
|
||
try {
|
||
const filename = inferFilename();
|
||
const blob = new Blob([code], { type: 'text/plain;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);
|
||
} catch (err) {
|
||
console.error('Failed to download:', err);
|
||
}
|
||
setTimeout(() => setDownloading(false), 500);
|
||
};
|
||
|
||
return (
|
||
<div className="relative group my-2">
|
||
<pre className="bg-gray-900 text-gray-100 rounded-lg p-3 overflow-x-auto text-xs font-mono leading-relaxed">
|
||
{language && (
|
||
<div className="text-gray-500 text-[10px] mb-1 uppercase flex items-center justify-between">
|
||
<span>{language}</span>
|
||
</div>
|
||
)}
|
||
<code>{code}</code>
|
||
</pre>
|
||
{/* Action buttons - show on hover */}
|
||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={handleCopy}
|
||
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
|
||
title="复制代码"
|
||
>
|
||
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||
</button>
|
||
<button
|
||
onClick={handleDownload}
|
||
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
|
||
title="下载文件"
|
||
disabled={downloading}
|
||
>
|
||
<Download className={`w-3.5 h-3.5 ${downloading ? 'animate-pulse' : ''}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
|
||
function renderMarkdown(text: string): React.ReactNode[] {
|
||
const nodes: React.ReactNode[] = [];
|
||
const lines = text.split('\n');
|
||
let i = 0;
|
||
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
|
||
// Fenced code block
|
||
if (line.startsWith('```')) {
|
||
const lang = line.slice(3).trim();
|
||
const codeLines: string[] = [];
|
||
i++;
|
||
while (i < lines.length && !lines[i].startsWith('```')) {
|
||
codeLines.push(lines[i]);
|
||
i++;
|
||
}
|
||
i++; // skip closing ```
|
||
nodes.push(
|
||
<CodeBlock key={nodes.length} code={codeLines.join('\n')} language={lang} index={nodes.length} />
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// Normal line — parse inline markdown
|
||
nodes.push(
|
||
<span key={nodes.length}>
|
||
{i > 0 && lines[i - 1] !== undefined && !nodes[nodes.length - 1]?.toString().includes('pre') && '\n'}
|
||
{renderInline(line)}
|
||
</span>
|
||
);
|
||
i++;
|
||
}
|
||
|
||
return nodes;
|
||
}
|
||
|
||
function renderInline(text: string): React.ReactNode[] {
|
||
const parts: React.ReactNode[] = [];
|
||
// Pattern: **bold**, *italic*, `code`, [text](url)
|
||
const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|(\[(.+?)\]\((.+?)\))/g;
|
||
let lastIndex = 0;
|
||
let match: RegExpExecArray | null;
|
||
|
||
while ((match = regex.exec(text)) !== null) {
|
||
// Text before match
|
||
if (match.index > lastIndex) {
|
||
parts.push(text.slice(lastIndex, match.index));
|
||
}
|
||
|
||
if (match[1]) {
|
||
// **bold**
|
||
parts.push(<strong key={parts.length} className="font-semibold">{match[2]}</strong>);
|
||
} else if (match[3]) {
|
||
// *italic*
|
||
parts.push(<em key={parts.length}>{match[4]}</em>);
|
||
} else if (match[5]) {
|
||
// `code`
|
||
parts.push(
|
||
<code key={parts.length} className="bg-gray-100 dark:bg-gray-700 text-orange-700 dark:text-orange-400 px-1 py-0.5 rounded text-[0.85em] font-mono">
|
||
{match[6]}
|
||
</code>
|
||
);
|
||
} else if (match[7]) {
|
||
// [text](url)
|
||
parts.push(
|
||
<a key={parts.length} href={match[9]} target="_blank" rel="noopener noreferrer"
|
||
className="text-orange-600 dark:text-orange-400 underline hover:text-orange-700 dark:hover:text-orange-300">{match[8]}</a>
|
||
);
|
||
}
|
||
|
||
lastIndex = match.index + match[0].length;
|
||
}
|
||
|
||
if (lastIndex < text.length) {
|
||
parts.push(text.slice(lastIndex));
|
||
}
|
||
|
||
return parts.length > 0 ? parts : [text];
|
||
}
|
||
|
||
function MessageBubble({ message }: { message: Message }) {
|
||
if (message.role === 'tool') {
|
||
return (
|
||
<div className="ml-12 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono">
|
||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 mb-1">
|
||
<Terminal className="w-3.5 h-3.5" />
|
||
<span className="font-semibold">{message.toolName || 'tool'}</span>
|
||
</div>
|
||
{message.toolInput && (
|
||
<pre className="text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-900 rounded p-2 mb-1 overflow-x-auto">{message.toolInput}</pre>
|
||
)}
|
||
{message.content && (
|
||
<pre className="text-green-700 dark:text-green-400 bg-white dark:bg-gray-900 rounded p-2 overflow-x-auto">{message.content}</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isUser = message.role === 'user';
|
||
|
||
// 思考中状态:streaming 且内容为空时显示思考指示器
|
||
const isThinking = message.streaming && !message.content;
|
||
|
||
// 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 ? (
|
||
// 思考中指示器
|
||
<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>
|
||
</div>
|
||
) : (
|
||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
|
||
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700 dark:text-gray-200'}`}>
|
||
{message.content
|
||
? (isUser ? message.content : renderMarkdown(message.content))
|
||
: '...'}
|
||
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
|
||
</div>
|
||
{message.error && (
|
||
<p className="text-xs text-red-500 mt-2">{message.error}</p>
|
||
)}
|
||
{/* 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>
|
||
);
|
||
}
|