Frontend AI con React: Chat UI y Componentes
Construir un frontend para aplicaciones AI-First requiere componentes especializados: chat con streaming, renderizado de Markdown y código, auto-scroll inteligente, estados de carga progresivos y UX adaptada al comportamiento no-determinista de los LLMs.
Arquitectura de componentes
<App>
├── <Sidebar> # Lista de conversaciones
│ ├── <ConversationList>
│ └── <NewChatButton>
├── <ChatView> # Vista principal
│ ├── <ChatHeader> # Título + selector de modelo
│ ├── <MessageList> # Mensajes con scroll
│ │ ├── <UserMessage>
│ │ ├── <AssistantMessage> # Con Markdown + código
│ │ └── <ThinkingIndicator>
│ └── <ChatInput> # Input + botones
│ ├── <TextArea>
│ ├── <FileUpload>
│ └── <SendButton>
└── <Providers> # Context providers
├── AuthProvider
└── ChatProvider
Hook personalizado: useChat
// hooks/useChat.ts
import { useState, useCallback, useRef } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: Date;
}
interface UseChatOptions {
conversationId?: string;
model?: string;
onError?: (error: Error) => void;
}
export function useChat(options: UseChatOptions = {}) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string) => {
if (!content.trim() || isLoading) return;
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content,
createdAt: new Date(),
};
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
createdAt: new Date(),
};
setMessages(prev => [...prev, userMessage, assistantMessage]);
setIsLoading(true);
setError(null);
abortRef.current = new AbortController();
try {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify({
conversationId: options.conversationId,
message: content,
model: options.model ?? 'gpt-4o-mini',
}),
signal: abortRef.current.signal,
});
if (!response.ok) throw new Error('Error en la respuesta');
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const lines = text.split('\n').filter(l => l.startsWith('data: '));
for (const line of lines) {
try {
const data = JSON.parse(line.slice(6));
if (data.done) break;
if (data.error) throw new Error(data.error);
if (data.content) {
setMessages(prev => {
const updated = [...prev];
const last = updated[updated.length - 1];
updated[updated.length - 1] = {
...last,
content: last.content + data.content,
};
return updated;
});
}
} catch (e) {
// Ignorar líneas mal formadas
}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
const error = err as Error;
setError(error);
options.onError?.(error);
}
} finally {
setIsLoading(false);
}
}, [isLoading, options]);
const stop = useCallback(() => {
abortRef.current?.abort();
setIsLoading(false);
}, []);
const reset = useCallback(() => {
setMessages([]);
setError(null);
}, []);
return { messages, isLoading, error, sendMessage, stop, reset };
}
Componente MessageList
// components/MessageList.tsx
import { useEffect, useRef } from 'react';
import { Message } from '../types';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
import { ThinkingIndicator } from './ThinkingIndicator';
interface Props {
messages: Message[];
isLoading: boolean;
}
export function MessageList({ messages, isLoading }: Props) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Auto-scroll inteligente: solo si el usuario está cerca del fondo
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const isNearBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 100;
if (isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);
if (messages.length === 0) {
return <EmptyState />;
}
return (
<div ref={containerRef} className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-3xl mx-auto space-y-6">
{messages.map(msg =>
msg.role === 'user' ? (
<UserMessage key={msg.id} message={msg} />
) : (
<AssistantMessage key={msg.id} message={msg} />
)
)}
{isLoading && messages[messages.length - 1]?.content === '' && (
<ThinkingIndicator />
)}
<div ref={bottomRef} />
</div>
</div>
);
}
function EmptyState() {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4">
<h2 className="text-2xl font-semibold text-gray-700">
¿En qué puedo ayudarte?
</h2>
<div className="grid grid-cols-2 gap-3 max-w-md">
{[
'Explica async/await en TypeScript',
'Crea un hook de React para fetch',
'Diseña un schema de base de datos',
'Revisa mi código y sugiere mejoras',
].map(suggestion => (
<button
key={suggestion}
className="p-3 text-sm text-left border rounded-lg hover:bg-gray-50 transition"
>
{suggestion}
</button>
))}
</div>
</div>
</div>
);
}
Renderizado de Markdown con Syntax Highlighting
// components/AssistantMessage.tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useState } from 'react';
interface Props {
message: Message;
}
export function AssistantMessage({ message }: Props) {
return (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500 flex items-center
justify-center text-white text-sm font-bold shrink-0">
AI
</div>
<div className="prose prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const code = String(children).replace(/\n$/, '');
if (match) {
return (
<CodeBlock language={match[1]} code={code} />
);
}
return (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm" {...props}>
{children}
</code>
);
},
// Tablas con estilo
table({ children }) {
return (
<div className="overflow-x-auto">
<table className="min-w-full border-collapse border">
{children}
</table>
</div>
);
},
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
);
}
function CodeBlock({ language, code }: { language: string; code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative group">
<div className="flex items-center justify-between bg-gray-800
text-gray-200 px-4 py-2 text-xs rounded-t-lg">
<span>{language}</span>
<button
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition"
>
{copied ? '✓ Copiado' : 'Copiar'}
</button>
</div>
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{ margin: 0, borderTopLeftRadius: 0, borderTopRightRadius: 0 }}
>
{code}
</SyntaxHighlighter>
</div>
);
}
ChatInput con TextArea auto-expandible
// components/ChatInput.tsx
import { useRef, useEffect, KeyboardEvent } from 'react';
interface Props {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
isLoading: boolean;
}
export function ChatInput({ value, onChange, onSubmit, onStop, isLoading }: Props) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, [value]);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
};
return (
<div className="border-t bg-white p-4">
<div className="max-w-3xl mx-auto flex items-end gap-2">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe tu mensaje... (Shift+Enter para nueva línea)"
rows={1}
className="flex-1 resize-none border rounded-lg px-4 py-3 focus:outline-none
focus:ring-2 focus:ring-blue-500 max-h-[200px]"
disabled={isLoading}
/>
{isLoading ? (
<button
onClick={onStop}
className="px-4 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
⬛ Detener
</button>
) : (
<button
onClick={onSubmit}
disabled={!value.trim()}
className="px-4 py-3 bg-blue-500 text-white rounded-lg
hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Enviar ↑
</button>
)}
</div>
<p className="text-xs text-gray-400 text-center mt-2">
La IA puede cometer errores. Verifica la información importante.
</p>
</div>
);
}
Selector de modelo
// components/ModelSelector.tsx
const MODELS = [
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', description: 'Rápido y económico' },
{ id: 'gpt-4o', name: 'GPT-4o', description: 'Más capaz' },
{ id: 'claude-sonnet', name: 'Claude Sonnet', description: 'Mejor para código' },
] as const;
interface Props {
selected: string;
onChange: (model: string) => void;
}
export function ModelSelector({ selected, onChange }: Props) {
return (
<select
value={selected}
onChange={(e) => onChange(e.target.value)}
className="border rounded-lg px-3 py-1.5 text-sm bg-white"
>
{MODELS.map(model => (
<option key={model.id} value={model.id}>
{model.name} — {model.description}
</option>
))}
</select>
);
}
Componente principal: ChatView
// components/ChatView.tsx
import { useState } from 'react';
import { useChat } from '../hooks/useChat';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { ModelSelector } from './ModelSelector';
export function ChatView({ conversationId }: { conversationId?: string }) {
const [model, setModel] = useState('gpt-4o-mini');
const [input, setInput] = useState('');
const { messages, isLoading, error, sendMessage, stop } = useChat({
conversationId,
model,
onError: (err) => console.error('Chat error:', err),
});
const handleSubmit = () => {
if (input.trim()) {
sendMessage(input);
setInput('');
}
};
return (
<div className="flex flex-col h-screen">
{/* Header */}
<div className="border-b px-4 py-3 flex items-center justify-between">
<h1 className="font-semibold">Chat AI</h1>
<ModelSelector selected={model} onChange={setModel} />
</div>
{/* Messages */}
<MessageList messages={messages} isLoading={isLoading} />
{/* Error */}
{error && (
<div className="px-4 py-2 bg-red-50 text-red-600 text-sm text-center">
Error: {error.message}
</div>
)}
{/* Input */}
<ChatInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
onStop={stop}
isLoading={isLoading}
/>
</div>
);
}