Phase 1 complete
This commit is contained in:
230
apps/frontend/src/pages/Chat.tsx
Normal file
230
apps/frontend/src/pages/Chat.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useCharacterStore } from '../stores/characterStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import type { Message } from '../types';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
const WS_URL = (import.meta.env as unknown as ImportMetaEnv).VITE_WS_URL || 'http://localhost:3000';
|
||||
|
||||
export function Chat() {
|
||||
const { characterId, conversationId } = useParams<{ characterId?: string; conversationId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuthStore();
|
||||
const { currentCharacter, getCharacter } = useCharacterStore();
|
||||
const {
|
||||
currentConversation,
|
||||
isStreaming,
|
||||
error,
|
||||
createConversation,
|
||||
getConversation,
|
||||
addMessage,
|
||||
setStreaming,
|
||||
clearError
|
||||
} = useChatStore();
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
// Initialize socket connection
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(`${WS_URL}/chat`, {
|
||||
auth: { token: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to chat server');
|
||||
});
|
||||
|
||||
socket.on('message_chunk', (data: { conversationId: string; chunk: { content: string } }) => {
|
||||
if (data.conversationId === conversationId) {
|
||||
setStreamingContent((prev) => prev + data.chunk.content);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('message_complete', () => {
|
||||
setStreaming(false);
|
||||
setStreamingContent('');
|
||||
if (conversationId) {
|
||||
getConversation(conversationId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('message', (data: { message: { assistantMessage?: Message } }) => {
|
||||
if (data.message.assistantMessage) {
|
||||
addMessage(data.message.assistantMessage);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (data: { message: string }) => {
|
||||
console.error('Socket error:', data.message);
|
||||
setStreaming(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [conversationId, addMessage, getConversation, setStreaming]);
|
||||
|
||||
// Join conversation room
|
||||
useEffect(() => {
|
||||
if (socketRef.current && conversationId) {
|
||||
socketRef.current.emit('join_conversation', { conversationId });
|
||||
return () => {
|
||||
socketRef.current?.emit('leave_conversation', { conversationId });
|
||||
};
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
// Load character and conversation
|
||||
useEffect(() => {
|
||||
if (characterId) {
|
||||
getCharacter(characterId);
|
||||
// Create new conversation
|
||||
createConversation({ characterId }).then((conv) => {
|
||||
navigate(`/conversations/${conv.id}`, { replace: true });
|
||||
});
|
||||
} else if (conversationId) {
|
||||
getConversation(conversationId);
|
||||
}
|
||||
}, [characterId, conversationId, getCharacter, createConversation, getConversation, navigate]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [currentConversation?.messages, streamingContent]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message.trim() || !conversationId || isStreaming) return;
|
||||
|
||||
const content = message.trim();
|
||||
setMessage('');
|
||||
setStreamingContent('');
|
||||
setStreaming(true);
|
||||
|
||||
// Send via socket for streaming
|
||||
socketRef.current?.emit('send_message', {
|
||||
conversationId,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const messages = currentConversation?.messages || [];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-50">
|
||||
<header className="bg-white shadow flex-shrink-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/conversations" className="text-gray-600 hover:text-gray-900">
|
||||
← Back
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
{currentConversation?.title || 'Chat'}
|
||||
</h1>
|
||||
{currentCharacter && (
|
||||
<p className="text-sm text-gray-500">
|
||||
with {currentCharacter.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-hidden flex flex-col max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-4">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 text-red-700 p-3 rounded flex-shrink-0">
|
||||
{error}
|
||||
<button onClick={clearError} className="ml-2 text-sm underline">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-white rounded-lg shadow p-4 space-y-4">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<p>Start a conversation with {currentCharacter?.name || 'your character'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg px-4 py-2 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
<span className={`text-xs ${msg.role === 'user' ? 'text-indigo-200' : 'text-gray-500'} block mt-1`}>
|
||||
{formatTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isStreaming && streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[70%] rounded-lg px-4 py-2 bg-gray-100 text-gray-900">
|
||||
<p className="whitespace-pre-wrap">{streamingContent}</p>
|
||||
<span className="text-xs text-gray-500 block mt-1">typing...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-4 flex-shrink-0 flex space-x-4">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!message.trim() || isStreaming}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isStreaming ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user