cc工作前备份

This commit is contained in:
iven
2026-03-12 00:23:42 +08:00
parent f75a2b798b
commit ef849c62ab
98 changed files with 12110 additions and 568 deletions

View File

@@ -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 正在回复...' : '发送给 ZCLAWShift+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>
);
}