cc工作前备份
This commit is contained in:
@@ -1,102 +1,151 @@
|
||||
import { useState } from 'react';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { Send, Paperclip, ChevronDown } from 'lucide-react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useChatStore, Message } from '../store/chatStore';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import { Send, Paperclip, ChevronDown, Terminal, Loader2, SquarePen } from 'lucide-react';
|
||||
|
||||
const MODELS = ['glm-5', 'qwen3.5-plus', 'kimi-k2.5', 'minimax-m2.5'];
|
||||
|
||||
export function ChatArea() {
|
||||
const { messages, currentAgent, addMessage } = useChatStore();
|
||||
const {
|
||||
messages, currentAgent, isStreaming, currentModel,
|
||||
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
||||
newConversation,
|
||||
} = useChatStore();
|
||||
const { connectionState } = useGatewayStore();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [showModelPicker, setShowModelPicker] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const sendMessage = () => {
|
||||
if (!input.trim()) return;
|
||||
// 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';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user' as const,
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
// Init agent stream listener on mount
|
||||
useEffect(() => {
|
||||
const unsub = initStreamListener();
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
addMessage(userMessage);
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isStreaming) return;
|
||||
sendToGateway(input);
|
||||
setInput('');
|
||||
|
||||
// TODO: 调用后端 API
|
||||
setTimeout(() => {
|
||||
const aiMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant' as const,
|
||||
content: '收到你的消息了!正在处理中...',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
addMessage(aiMessage);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const connected = connectionState === 'connected';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 顶部标题栏 */}
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-gray-100 flex items-center justify-between px-6 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-gray-900">{currentAgent?.name || 'ZCLAW'}</h2>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-green-400 rounded-full"></span>
|
||||
在线
|
||||
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-500' : 'text-gray-400'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300'}`}></span>
|
||||
{connected ? 'Gateway 已连接' : 'Gateway 未连接'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={newConversation}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 hover:text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
||||
title="新对话"
|
||||
>
|
||||
<SquarePen className="w-3.5 h-3.5" />
|
||||
新对话
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聊天内容区 */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-20">
|
||||
<p className="text-lg mb-2">欢迎使用 ZCLAW 🦞</p>
|
||||
<p className="text-sm">发送消息开始对话</p>
|
||||
<p className="text-sm">{connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={lex gap-4 }
|
||||
>
|
||||
<div
|
||||
className={w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 }
|
||||
>
|
||||
{message.role === 'user' ? '用' : '🦞'}
|
||||
</div>
|
||||
<div className={message.role === 'user' ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||
<div className={message.role === 'user' ? 'chat-bubble-user p-4 shadow-md' : 'chat-bubble-assistant p-4 shadow-sm'}>
|
||||
<p className="leading-relaxed text-gray-700">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-2 text-gray-400 text-xs pl-12">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Agent 思考中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部输入区 */}
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-100 p-4 bg-white">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="relative flex items-end gap-2 bg-gray-50 rounded-2xl border border-gray-200 p-2 focus-within:border-orange-300 focus-within:ring-2 focus-within:ring-orange-100 transition-all">
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex-1 py-2">
|
||||
<input
|
||||
type="text"
|
||||
<div className="flex-1 py-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="发送给 ZCLAW"
|
||||
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400"
|
||||
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isStreaming ? 'Agent 正在回复...' : '发送给 ZCLAW(Shift+Enter 换行)'}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
className="w-full bg-transparent border-none focus:outline-none text-gray-700 placeholder-gray-400 disabled:opacity-50 resize-none leading-relaxed"
|
||||
style={{ minHeight: '24px', maxHeight: '160px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pr-2 pb-1">
|
||||
<button className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 rounded-md transition-colors">
|
||||
<span>glm5</span>
|
||||
<div className="flex items-center gap-2 pr-2 pb-1 relative">
|
||||
<button
|
||||
onClick={() => setShowModelPicker(!showModelPicker)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
<span>{currentModel}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{showModelPicker && (
|
||||
<div className="absolute bottom-full right-8 mb-2 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[160px] z-10">
|
||||
{MODELS.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
onClick={() => { setCurrentModel(model); setShowModelPicker(false); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 ${model === currentModel ? 'text-orange-600 font-medium' : 'text-gray-700'}`}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
className="w-8 h-8 bg-gray-900 text-white rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors"
|
||||
onClick={handleSend}
|
||||
disabled={isStreaming || !input.trim()}
|
||||
className="w-8 h-8 bg-gray-900 text-white rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -110,3 +159,132 @@ export function ChatArea() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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(
|
||||
<pre key={nodes.length} className="bg-gray-900 text-gray-100 rounded-lg p-3 my-2 overflow-x-auto text-xs font-mono leading-relaxed">
|
||||
{lang && <div className="text-gray-500 text-[10px] mb-1 uppercase">{lang}</div>}
|
||||
<code>{codeLines.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
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 text-orange-700 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 underline hover:text-orange-700">{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 border border-gray-200 rounded-lg p-3 text-xs font-mono">
|
||||
<div className="flex items-center gap-2 text-gray-500 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 bg-white rounded p-2 mb-1 overflow-x-auto">{message.toolInput}</pre>
|
||||
)}
|
||||
{message.content && (
|
||||
<pre className="text-green-700 bg-white rounded p-2 overflow-x-auto">{message.content}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
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 text-gray-600 order-last' : 'agent-avatar text-white'}`}
|
||||
>
|
||||
{isUser ? '用' : '🦞'}
|
||||
</div>
|
||||
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'}`}>
|
||||
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700'}`}>
|
||||
{message.content
|
||||
? (isUser ? message.content : renderMarkdown(message.content))
|
||||
: (message.streaming ? '' : '...')}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user