init: React frontend + PHP/Slim backend chat app
This commit is contained in:
Executable
+364
@@ -0,0 +1,364 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import {
|
||||
Send,
|
||||
Square,
|
||||
Bot,
|
||||
User,
|
||||
Copy,
|
||||
Check,
|
||||
Menu,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import type { Conversation, Message } from '@/types';
|
||||
|
||||
interface ChatAreaProps {
|
||||
conversation: Conversation | null;
|
||||
isStreaming: boolean;
|
||||
onSendMessage: (content: string) => void;
|
||||
onStopStreaming: () => void;
|
||||
sidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
function CodeBlock({ language, value }: { language: string; value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group my-3 rounded-lg overflow-hidden border border-[#e0e0e0]">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[#f5f5f5] border-b border-[#e0e0e0]">
|
||||
<span className="text-[10px] text-[#888] font-mono uppercase">{language || 'text'}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 text-[#888] hover:text-[#16a34a] transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language || 'text'}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '12px 16px',
|
||||
background: '#1e1e1e',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message, isStreaming }: { message: Message; isStreaming: boolean }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end mb-4">
|
||||
<div className="max-w-[80%] md:max-w-[70%]">
|
||||
<div className="flex items-center justify-end gap-2 mb-1">
|
||||
<span className="text-[10px] text-[#aaa]">
|
||||
{new Date(message.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<div className="w-5 h-5 rounded-full bg-[#0f0]/10 flex items-center justify-center">
|
||||
<User className="w-3 h-3 text-[#16a34a]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#f0f0f0] text-[#1a1a1a] px-4 py-3 rounded-lg border border-[#e0e0e0]">
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-start mb-4">
|
||||
<div className="max-w-[90%] md:max-w-[80%]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-full bg-[#dcfce7] flex items-center justify-center">
|
||||
<Bot className="w-3 h-3 text-[#16a34a]" />
|
||||
</div>
|
||||
<span className="text-[10px] text-[#aaa]">
|
||||
{new Date(message.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="group relative">
|
||||
{message.content || isStreaming ? (
|
||||
<div className="text-[#d4d4d4] text-sm leading-relaxed">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({ inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const value = String(children).replace(/\n$/, '');
|
||||
if (!inline && match) {
|
||||
return <CodeBlock language={match[1]} value={value} />;
|
||||
}
|
||||
return (
|
||||
<code className="bg-[#f0f0f0] text-[#333] px-1.5 py-0.5 rounded text-xs font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
p({ children }) {
|
||||
return <p className="mb-3 last:mb-0">{children}</p>;
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc list-inside mb-3 space-y-1">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="list-decimal list-inside mb-3 space-y-1">{children}</ol>;
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="text-[#333]">{children}</li>;
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h1 className="text-lg font-bold mb-3 text-[#000]">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-base font-bold mb-2 text-[#1a1a1a]">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-sm font-bold mb-2 text-[#333]">{children}</h3>;
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-2 border-[#0f0]/30 pl-3 my-3 text-[#888]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-3">
|
||||
<table className="w-full text-xs border-collapse">{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="bg-[#f0f0f0]">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="border border-[#e0e0e0] px-3 py-2 text-left text-[#333]">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="border border-[#e0e0e0] px-3 py-2 text-[#aaa]">{children}</td>;
|
||||
},
|
||||
hr() {
|
||||
return <hr className="border-[#e0e0e0] my-4" />;
|
||||
},
|
||||
a({ children, href }) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-[#16a34a] hover:underline">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{message.content || (isStreaming ? '' : '')}
|
||||
</ReactMarkdown>
|
||||
|
||||
{/* Streaming cursor */}
|
||||
{isStreaming && !message.content && (
|
||||
<span className="inline-block w-2 h-4 bg-[#0f0] animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 py-2">
|
||||
<div className="w-2 h-2 bg-[#0f0] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<div className="w-2 h-2 bg-[#0f0] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-2 h-2 bg-[#0f0] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy button for assistant messages */}
|
||||
{message.content && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute -right-8 top-0 p-1 text-[#ccc] hover:text-[#16a34a] opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatArea({
|
||||
conversation,
|
||||
isStreaming,
|
||||
onSendMessage,
|
||||
onStopStreaming,
|
||||
sidebarOpen,
|
||||
onToggleSidebar,
|
||||
}: ChatAreaProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [conversation?.messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
onSendMessage(trimmed);
|
||||
setInput('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}, [input, isStreaming, onSendMessage]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [handleSubmit]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white relative">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[#1a1a1a]">
|
||||
<div className="flex items-center gap-3">
|
||||
{!sidebarOpen && (
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-1.5 text-[#888] hover:text-[#000] rounded transition-colors"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-sm text-[#333] font-medium">
|
||||
{conversation?.title || '新对话'}
|
||||
</h2>
|
||||
{conversation && (
|
||||
<p className="text-[10px] text-[#aaa] mt-0.5">
|
||||
{conversation.modelConfig.model} · temp: {conversation.modelConfig.temperature}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{conversation && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-[#bbb]">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span>{conversation.modelConfig.baseUrl.replace(/^https?:\/\//, '').split('/')[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-8 py-6 custom-scrollbar">
|
||||
{!conversation || conversation.messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-[#bbb]">
|
||||
<div className="w-16 h-16 rounded-full bg-[#f5f5f5] border border-[#e5e5e5] flex items-center justify-center mb-4">
|
||||
<Bot className="w-8 h-8 text-[#222]" />
|
||||
</div>
|
||||
<p className="text-sm text-[#aaa] mb-1">开始一段新的对话</p>
|
||||
<p className="text-xs text-[#bbb]">在下方输入框中输入你的问题</p>
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-2 w-full max-w-md">
|
||||
{[
|
||||
'解释一下React的useEffect原理',
|
||||
'写一段Python快速排序算法',
|
||||
'分析一下Linux内存管理机制',
|
||||
'帮我写一个Dockerfile模板',
|
||||
].map((prompt, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setInput(prompt);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
className="text-left px-3 py-2 text-xs text-[#888] bg-[#f5f5f5] border border-[#e0e0e0] rounded-md hover:border-[#bbb] hover:text-[#333] transition-all"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{conversation.messages.map(message => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming && message.role === 'assistant' && message.id === conversation.messages[conversation.messages.length - 1]?.id}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="px-4 md:px-8 pb-6 pt-4">
|
||||
<div className="max-w-3xl mx-auto relative">
|
||||
<div className="flex items-end gap-3 bg-white border border-[#d0d0d0] rounded-xl p-4 shadow-md transition-all">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isStreaming ? 'AI正在思考...' : '输入消息,Shift+Enter 换行...'}
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[#1a1a1a] placeholder:text-[#bbb] resize-none outline-none text-base leading-relaxed max-h-[200px] min-h-[52px] disabled:opacity-50 font-sans py-1"
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={onStopStreaming}
|
||||
className="p-2.5 text-[#ef4444] hover:bg-[#ef4444]/10 rounded-lg transition-all flex-shrink-0"
|
||||
title="停止生成"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
className="p-2.5 text-[#333] hover:bg-[#f0f0f0] rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
|
||||
title="发送"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-xs text-[#ccc] mt-3">
|
||||
自托管大模型控制台 · API 调用在浏览器端完成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user