Files
zclaw_openfang/desktop/src/components/ChatArea.tsx
iven 6f72442531 docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective
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
2026-03-20 19:30:09 +08:00

518 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}