Spaces:
Running
Running
| /** | |
| * AI Town — Chat Interface with AI Characters | |
| * ============================================== | |
| * RAG-powered conversation with persistent memory. | |
| */ | |
| import React, { useState, useRef, useEffect } from "react"; | |
| import { View, Text, TextInput, TouchableOpacity, FlatList, KeyboardAvoidingView, Platform, ActivityIndicator } from "react-native"; | |
| import { useAction, useQuery } from "convex/react"; | |
| import { api } from "@/convex/_generated/api"; | |
| import { Id } from "@/convex/_generated/dataModel"; | |
| interface ChatScreenProps { | |
| characterId: Id<"characters">; | |
| userId: string; | |
| onBack: () => void; | |
| } | |
| export function ChatScreen({ characterId, userId, onBack }: ChatScreenProps) { | |
| const [input, setInput] = useState(""); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const flatListRef = useRef<FlatList>(null); | |
| // Realtime data from Convex | |
| const character = useQuery(api.characters.get, { characterId }); | |
| const chatHistory = useQuery(api.characters.getUserChat, { userId, characterId }); | |
| const chatWithCharacter = useAction(api.characters.chatWithCharacter); | |
| const messages = chatHistory?.messages || []; | |
| const sendMessage = async () => { | |
| if (!input.trim() || isLoading) return; | |
| const userMessage = input.trim(); | |
| setInput(""); | |
| setIsLoading(true); | |
| try { | |
| const result = await chatWithCharacter({ | |
| userId, | |
| characterId, | |
| message: userMessage, | |
| }); | |
| // Response is automatically saved and will appear via realtime query | |
| } catch (error) { | |
| console.error("Chat error:", error); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| // Scroll to bottom on new message | |
| if (messages.length > 0) { | |
| setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100); | |
| } | |
| }, [messages.length]); | |
| if (!character) { | |
| return ( | |
| <View className="flex-1 bg-gray-900 items-center justify-center"> | |
| <ActivityIndicator color="#60a5fa" /> | |
| </View> | |
| ); | |
| } | |
| return ( | |
| <KeyboardAvoidingView | |
| className="flex-1 bg-gray-900" | |
| behavior={Platform.OS === "ios" ? "padding" : undefined} | |
| > | |
| {/* Header */} | |
| <View className="flex-row items-center px-4 py-3 border-b border-gray-800"> | |
| <TouchableOpacity onPress={onBack} className="mr-3"> | |
| <Text className="text-blue-400 text-lg">← Back</Text> | |
| </TouchableOpacity> | |
| <View className="w-10 h-10 rounded-full bg-purple-600 items-center justify-center mr-3"> | |
| <Text className="text-lg">{character.avatar}</Text> | |
| </View> | |
| <View className="flex-1"> | |
| <Text className="text-white font-bold">{character.name}</Text> | |
| <Text className="text-gray-400 text-xs"> | |
| {character.mood} • {character.currentAction} | |
| </Text> | |
| </View> | |
| </View> | |
| {/* Messages */} | |
| <FlatList | |
| ref={flatListRef} | |
| data={messages} | |
| keyExtractor={(_, i) => i.toString()} | |
| contentContainerStyle={{ padding: 16, paddingBottom: 8 }} | |
| renderItem={({ item }) => ( | |
| <View | |
| className={`mb-3 max-w-[80%] ${ | |
| item.role === "user" ? "self-end" : "self-start" | |
| }`} | |
| > | |
| <View | |
| className={`px-4 py-2.5 rounded-2xl ${ | |
| item.role === "user" | |
| ? "bg-blue-600 rounded-br-sm" | |
| : "bg-gray-800 rounded-bl-sm border border-gray-700" | |
| }`} | |
| > | |
| <Text className={`text-sm ${item.role === "user" ? "text-white" : "text-gray-200"}`}> | |
| {item.content} | |
| </Text> | |
| </View> | |
| <Text className="text-[10px] text-gray-600 mt-1 px-1"> | |
| {item.role === "user" ? "You" : character.name} •{" "} | |
| {new Date(item.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} | |
| </Text> | |
| </View> | |
| )} | |
| ListEmptyComponent={ | |
| <View className="items-center py-20"> | |
| <Text className="text-4xl mb-4">{character.avatar}</Text> | |
| <Text className="text-white font-bold text-lg mb-1"> | |
| Chat with {character.name} | |
| </Text> | |
| <Text className="text-gray-400 text-sm text-center px-8"> | |
| {character.name} is a {character.occupation}. They're currently{" "} | |
| {character.currentAction}. Start a conversation! | |
| </Text> | |
| <View className="flex-row flex-wrap gap-2 mt-4 px-4 justify-center"> | |
| {["Hello!", "What are you up to?", "Tell me about yourself", "How's your day?"].map( | |
| (suggestion, i) => ( | |
| <TouchableOpacity | |
| key={i} | |
| onPress={() => setInput(suggestion)} | |
| className="bg-gray-800 px-3 py-1.5 rounded-full border border-gray-700" | |
| > | |
| <Text className="text-gray-300 text-xs">{suggestion}</Text> | |
| </TouchableOpacity> | |
| ) | |
| )} | |
| </View> | |
| </View> | |
| } | |
| /> | |
| {/* Typing indicator */} | |
| {isLoading && ( | |
| <View className="px-4 pb-2"> | |
| <View className="bg-gray-800 self-start px-4 py-2 rounded-2xl rounded-bl-sm flex-row items-center gap-2"> | |
| <ActivityIndicator size="small" color="#9ca3af" /> | |
| <Text className="text-gray-400 text-sm">{character.name} is thinking...</Text> | |
| </View> | |
| </View> | |
| )} | |
| {/* Input */} | |
| <View className="flex-row items-center px-4 py-3 border-t border-gray-800 gap-2"> | |
| <TextInput | |
| value={input} | |
| onChangeText={setInput} | |
| placeholder={`Say something to ${character.name}...`} | |
| placeholderTextColor="#6b7280" | |
| className="flex-1 bg-gray-800 text-white px-4 py-2.5 rounded-full text-sm border border-gray-700" | |
| onSubmitEditing={sendMessage} | |
| returnKeyType="send" | |
| editable={!isLoading} | |
| /> | |
| <TouchableOpacity | |
| onPress={sendMessage} | |
| disabled={!input.trim() || isLoading} | |
| className={`w-10 h-10 rounded-full items-center justify-center ${ | |
| input.trim() && !isLoading ? "bg-blue-600" : "bg-gray-700" | |
| }`} | |
| > | |
| <Text className="text-white text-lg">↑</Text> | |
| </TouchableOpacity> | |
| </View> | |
| </KeyboardAvoidingView> | |
| ); | |
| } | |