diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx deleted file mode 100644 index 36884904..00000000 --- a/app/components/chat/Chat.client.tsx +++ /dev/null @@ -1,623 +0,0 @@ -/* - * @ts-nocheck - * Preventing TS checks with files presented in the video for a better presentation. - */ -import { useStore } from '@nanostores/react'; -import { useAnimate } from 'framer-motion'; -import { memo, useEffect, useRef, useState } from 'react'; -import { cssTransition, toast, ToastContainer } from 'react-toastify'; -import { useSnapScroll } from '~/lib/hooks'; -import { handleChatTitleUpdate, useChatHistory, type ResumeChatInfo } from '~/lib/persistence'; -import { database } from '~/lib/persistence/chats'; -import { chatStore } from '~/lib/stores/chat'; -import { cubicEasingFn } from '~/utils/easings'; -import { renderLogger } from '~/utils/logger'; -import { BaseChat } from './BaseChat'; -import Cookies from 'js-cookie'; -import { useSearchParams } from '@remix-run/react'; -import { - simulationAddData, - simulationFinishData, - simulationRepositoryUpdated, - sendChatMessage, - type ChatReference, - abortChatMessage, - resumeChatMessage, -} from '~/lib/replay/ChatManager'; -import { getIFrameSimulationData } from '~/lib/replay/Recording'; -import { getCurrentIFrame } from '~/components/workbench/Preview'; -import { getCurrentMouseData } from '~/components/workbench/PointSelector'; -import { anthropicNumFreeUsesCookieName, maxFreeUses } from '~/utils/freeUses'; -import { ChatMessageTelemetry, pingTelemetry } from '~/lib/hooks/pingTelemetry'; -import type { RejectChangeData } from './ApproveChange'; -import { assert, generateRandomId } from '~/lib/replay/ReplayProtocolClient'; -import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/message'; -import { useAuthStatus } from '~/lib/stores/auth'; -import { debounce } from '~/utils/debounce'; -import { supabaseSubmitFeedback } from '~/lib/supabase/feedback'; -import { supabaseAddRefund } from '~/lib/supabase/peanuts'; - -const toastAnimation = cssTransition({ - enter: 'animated fadeInRight', - exit: 'animated fadeOutRight', -}); - -let gLastChatMessages: Message[] | undefined; - -export function getLastChatMessages() { - return gLastChatMessages; -} - -async function flushSimulationData() { - //console.log("FlushSimulationData"); - - const iframe = getCurrentIFrame(); - - if (!iframe) { - return; - } - - const simulationData = await getIFrameSimulationData(iframe); - - if (!simulationData.length) { - return; - } - - //console.log("HaveSimulationData", simulationData.length); - - // Add the simulation data to the chat. - simulationAddData(simulationData); -} - -setInterval(async () => { - flushSimulationData(); -}, 1000); - -export function Chat() { - renderLogger.trace('Chat'); - - const { ready, initialMessages, resumeChat, storeMessageHistory } = useChatHistory(); - - return ( - <> - {ready && ( - - )} - { - return ( - - ); - }} - icon={({ type }) => { - /** - * @todo Handle more types if we need them. This may require extra color palettes. - */ - switch (type) { - case 'success': { - return
; - } - case 'error': { - return
; - } - } - - return undefined; - }} - position="bottom-right" - pauseOnFocusLoss - transition={toastAnimation} - /> - - ); -} - -interface ChatProps { - initialMessages: Message[]; - resumeChat: ResumeChatInfo | undefined; - storeMessageHistory: (messages: Message[]) => void; -} - -let gNumAborts = 0; - -let gActiveChatMessageTelemetry: ChatMessageTelemetry | undefined; - -async function clearActiveChat() { - gActiveChatMessageTelemetry = undefined; -} - -function mergeResponseMessage(msg: Message, messages: Message[]): Message[] { - const lastMessage = messages[messages.length - 1]; - if (lastMessage.id == msg.id) { - messages.pop(); - assert(lastMessage.type == 'text', 'Last message must be a text message'); - assert(msg.type == 'text', 'Message must be a text message'); - messages.push({ - ...msg, - content: lastMessage.content + msg.content, - }); - } else { - messages.push(msg); - } - return messages; -} - -// Get the index of the last message that will be present after rewinding. -// This is the last message which is either a user message or has a repository change. -function getRewindMessageIndexAfterReject(messages: Message[], messageId: string): number { - for (let i = messages.length - 1; i >= 0; i--) { - const { id, role, repositoryId } = messages[i]; - if (role == 'user') { - return i; - } - if (repositoryId && id != messageId) { - return i; - } - } - console.error('No rewind message found', messages, messageId); - return -1; -} - -export const ChatImpl = memo((props: ChatProps) => { - const { initialMessages, resumeChat: initialResumeChat, storeMessageHistory } = props; - - const textareaRef = useRef(null); - const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); - const [uploadedFiles, setUploadedFiles] = useState([]); // Move here - const [imageDataList, setImageDataList] = useState([]); // Move here - const [searchParams] = useSearchParams(); - const { isLoggedIn } = useAuthStatus(); - - // Input currently in the textarea. - const [input, setInput] = useState(''); - - /* - * This is set when the user has triggered a chat message and the response hasn't finished - * being generated. - */ - const [pendingMessageId, setPendingMessageId] = useState(undefined); - - // Last status we heard for the pending message. - const [pendingMessageStatus, setPendingMessageStatus] = useState(''); - - // If we are listening to responses from a chat started in an earlier session, - // this will be set. This is equivalent to having a pending message but is - // handled differently. - const [resumeChat, setResumeChat] = useState(initialResumeChat); - - const [messages, setMessages] = useState(initialMessages); - - const showChat = useStore(chatStore.showChat); - - const [animationScope, animate] = useAnimate(); - - useEffect(() => { - const prompt = searchParams.get('prompt'); - - if (prompt) { - setInput(prompt); - } - }, [searchParams]); - - // Load any repository in the initial messages. - useEffect(() => { - const repositoryId = getMessagesRepositoryId(initialMessages); - - if (repositoryId) { - simulationRepositoryUpdated(repositoryId); - } - }, [initialMessages]); - - const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; - - useEffect(() => { - chatStore.started.set(initialMessages.length > 0); - }, []); - - useEffect(() => { - storeMessageHistory(messages); - }, [messages]); - - const abort = () => { - stop(); - gNumAborts++; - chatStore.aborted.set(true); - setPendingMessageId(undefined); - setPendingMessageStatus(''); - setResumeChat(undefined); - - const chatId = chatStore.currentChat.get()?.id; - if (chatId) { - database.updateChatLastMessage(chatId, null, null); - } - - if (gActiveChatMessageTelemetry) { - gActiveChatMessageTelemetry.abort('StopButtonClicked'); - clearActiveChat(); - abortChatMessage(); - } - }; - - useEffect(() => { - const textarea = textareaRef.current; - - if (textarea) { - textarea.style.height = 'auto'; - - const scrollHeight = textarea.scrollHeight; - - textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; - textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; - } - }, [input, textareaRef]); - - const runAnimation = async () => { - if (chatStarted) { - return; - } - - await Promise.all([ - animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }), - animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), - ]); - - chatStore.started.set(true); - - setChatStarted(true); - }; - - const sendMessage = async (messageInput?: string) => { - const _input = messageInput || input; - const numAbortsAtStart = gNumAborts; - - if (_input.length === 0 || pendingMessageId || resumeChat) { - return; - } - - gActiveChatMessageTelemetry = new ChatMessageTelemetry(messages.length); - - if (!isLoggedIn) { - const numFreeUses = +(Cookies.get(anthropicNumFreeUsesCookieName) || 0); - - if (numFreeUses >= maxFreeUses) { - toast.error('Please login to continue using Nut.'); - gActiveChatMessageTelemetry.abort('NoFreeUses'); - clearActiveChat(); - return; - } - - Cookies.set(anthropicNumFreeUsesCookieName, (numFreeUses + 1).toString()); - } - - const chatId = generateRandomId(); - setPendingMessageId(chatId); - - const userMessage: Message = { - id: `user-${chatId}`, - role: 'user', - type: 'text', - content: _input, - }; - - let newMessages = [...messages, userMessage]; - - imageDataList.forEach((imageData, index) => { - const imageMessage: Message = { - id: `image-${chatId}-${index}`, - role: 'user', - type: 'image', - dataURL: imageData, - }; - newMessages.push(imageMessage); - }); - - setMessages(newMessages); - - // Add file cleanup here - setUploadedFiles([]); - setImageDataList([]); - - await flushSimulationData(); - simulationFinishData(); - - chatStore.aborted.set(false); - - runAnimation(); - - const addResponseMessage = (msg: Message) => { - if (gNumAborts != numAbortsAtStart) { - return; - } - - const existingRepositoryId = getMessagesRepositoryId(newMessages); - - newMessages = mergeResponseMessage(msg, [...newMessages]); - setMessages(newMessages); - - // Update the repository as soon as it has changed. - const responseRepositoryId = getMessagesRepositoryId(newMessages); - - if (responseRepositoryId && existingRepositoryId != responseRepositoryId) { - simulationRepositoryUpdated(responseRepositoryId); - } - }; - - const onChatTitle = (title: string) => { - if (gNumAborts != numAbortsAtStart) { - return; - } - - console.log('ChatTitle', title); - const currentChat = chatStore.currentChat.get(); - if (currentChat) { - handleChatTitleUpdate(currentChat.id, title); - } - }; - - const onChatStatus = debounce((status: string) => { - if (gNumAborts != numAbortsAtStart) { - return; - } - - console.log('ChatStatus', status); - setPendingMessageStatus(status); - }, 500); - - const references: ChatReference[] = []; - - const mouseData = getCurrentMouseData(); - - if (mouseData) { - references.push({ - kind: 'element', - selector: mouseData.selector, - x: mouseData.x, - y: mouseData.y, - width: mouseData.width, - height: mouseData.height, - }); - } - - try { - await sendChatMessage(newMessages, references, { - onResponsePart: addResponseMessage, - onTitle: onChatTitle, - onStatus: onChatStatus, - }); - } catch (e) { - if (gNumAborts == numAbortsAtStart) { - toast.error('Error sending message'); - console.error('Error sending message', e); - } - } - - if (gNumAborts != numAbortsAtStart) { - return; - } - - gActiveChatMessageTelemetry.finish(); - clearActiveChat(); - - setPendingMessageId(undefined); - - setInput(''); - - textareaRef.current?.blur(); - }; - - useEffect(() => { - (async () => { - if (!initialResumeChat) { - return; - } - - const numAbortsAtStart = gNumAborts; - - let newMessages = messages; - - // The response messages we get may overlap with the ones we already have. - // Look for this and remove any existing message when we receive the first - // piece of a response message. - // - // Messages we have already received a portion of a response for. - const hasReceivedResponse = new Set(); - - const addResponseMessage = (msg: Message) => { - if (gNumAborts != numAbortsAtStart) { - return; - } - - if (!hasReceivedResponse.has(msg.id)) { - hasReceivedResponse.add(msg.id); - newMessages = newMessages.filter((m) => m.id != msg.id); - } - - const existingRepositoryId = getMessagesRepositoryId(newMessages); - - newMessages = mergeResponseMessage(msg, [...newMessages]); - setMessages(newMessages); - - // Update the repository as soon as it has changed. - const responseRepositoryId = getMessagesRepositoryId(newMessages); - - if (responseRepositoryId && existingRepositoryId != responseRepositoryId) { - simulationRepositoryUpdated(responseRepositoryId); - } - }; - - const onChatTitle = (title: string) => { - if (gNumAborts != numAbortsAtStart) { - return; - } - - console.log('ChatTitle', title); - const currentChat = chatStore.currentChat.get(); - if (currentChat) { - handleChatTitleUpdate(currentChat.id, title); - } - }; - - const onChatStatus = debounce((status: string) => { - if (gNumAborts != numAbortsAtStart) { - return; - } - - console.log('ChatStatus', status); - setPendingMessageStatus(status); - }, 500); - - try { - await resumeChatMessage(initialResumeChat.protocolChatId, initialResumeChat.protocolChatResponseId, { - onResponsePart: addResponseMessage, - onTitle: onChatTitle, - onStatus: onChatStatus, - }); - } catch (e) { - toast.error('Error resuming chat'); - console.error('Error resuming chat', e); - } - - if (gNumAborts != numAbortsAtStart) { - return; - } - - setResumeChat(undefined); - - const chatId = chatStore.currentChat.get()?.id; - if (chatId) { - database.updateChatLastMessage(chatId, null, null); - } - })(); - }, [initialResumeChat]); - - const flashScreen = async () => { - const flash = document.createElement('div'); - flash.style.position = 'fixed'; - flash.style.top = '0'; - flash.style.left = '0'; - flash.style.width = '100%'; - flash.style.height = '100%'; - flash.style.backgroundColor = 'rgba(0, 255, 0, 0.3)'; - flash.style.zIndex = '9999'; - flash.style.pointerEvents = 'none'; - document.body.appendChild(flash); - - // Fade out and remove after 500ms - setTimeout(() => { - flash.style.transition = 'opacity 0.5s'; - flash.style.opacity = '0'; - setTimeout(() => { - document.body.removeChild(flash); - }, 500); - }, 200); - }; - - const onApproveChange = async (messageId: string) => { - console.log('ApproveChange', messageId); - - setMessages( - messages.map((message) => { - if (message.id == messageId) { - return { - ...message, - approved: true, - }; - } - return message; - }), - ); - - await flashScreen(); - - pingTelemetry('ApproveChange', { - numMessages: messages.length, - }); - }; - - const onRejectChange = async (messageId: string, data: RejectChangeData) => { - console.log('RejectChange', messageId, data); - - // Find the last message that will be present after rewinding. This is the - // last message which is either a user message or has a repository change. - const messageIndex = getRewindMessageIndexAfterReject(messages, messageId); - - if (messageIndex < 0) { - toast.error('Rewind message not found'); - return; - } - - const message = messages.find((m) => m.id == messageId); - - if (!message) { - toast.error('Message not found'); - return; - } - - if (message.peanuts) { - await supabaseAddRefund(message.peanuts); - } - - const previousRepositoryId = getPreviousRepositoryId(messages, messageIndex + 1); - - setMessages(messages.slice(0, messageIndex + 1)); - - simulationRepositoryUpdated(previousRepositoryId); - - let shareProjectSuccess = false; - - if (data.shareProject) { - const feedbackData: any = { - explanation: data.explanation, - chatMessages: messages, - }; - - shareProjectSuccess = await supabaseSubmitFeedback(feedbackData); - } - - pingTelemetry('RejectChange', { - shareProject: data.shareProject, - shareProjectSuccess, - numMessages: messages.length, - }); - }; - - /** - * Handles the change event for the textarea and updates the input state. - * @param event - The change event from the textarea. - */ - const onTextareaChange = (event: React.ChangeEvent) => { - setInput(event.target.value); - }; - - const [messageRef, scrollRef] = useSnapScroll(); - - gLastChatMessages = messages; - - return ( - { - onTextareaChange(e); - }} - handleStop={abort} - messages={messages} - uploadedFiles={uploadedFiles} - setUploadedFiles={setUploadedFiles} - imageDataList={imageDataList} - setImageDataList={setImageDataList} - onApproveChange={onApproveChange} - onRejectChange={onRejectChange} - /> - ); -}); diff --git a/app/components/chat/ChatComponent/Chat.client.tsx b/app/components/chat/ChatComponent/Chat.client.tsx new file mode 100644 index 00000000..01888b46 --- /dev/null +++ b/app/components/chat/ChatComponent/Chat.client.tsx @@ -0,0 +1,56 @@ +/* + * @ts-nocheck + * Preventing TS checks with files presented in the video for a better presentation. + */ +import { cssTransition, ToastContainer } from 'react-toastify'; +import { useChatHistory } from '~/lib/persistence'; +import { renderLogger } from '~/utils/logger'; +import ChatImplementer from './components/ChatImplementer/ChatImplementer'; +import flushSimulationData from './functions/flushSimulation'; + +const toastAnimation = cssTransition({ + enter: 'animated fadeInRight', + exit: 'animated fadeOutRight', +}); + +setInterval(async () => { + flushSimulationData(); +}, 1000); + +export function Chat() { + renderLogger.trace('Chat'); + + const { ready, initialMessages, resumeChat, storeMessageHistory } = useChatHistory(); + + return ( + <> + {ready && ( + + )} + { + return ( + + ); + }} + icon={({ type }) => { + switch (type) { + case 'success': { + return
; + } + case 'error': { + return
; + } + } + + return undefined; + }} + position="bottom-right" + pauseOnFocusLoss + transition={toastAnimation} + /> + + ); +} diff --git a/app/components/chat/ChatComponent/components/ChatImplementer/ChatImplementer.tsx b/app/components/chat/ChatComponent/components/ChatImplementer/ChatImplementer.tsx new file mode 100644 index 00000000..19c0b3bf --- /dev/null +++ b/app/components/chat/ChatComponent/components/ChatImplementer/ChatImplementer.tsx @@ -0,0 +1,476 @@ +import { useStore } from '@nanostores/react'; +import { useAnimate } from 'framer-motion'; +import { memo, useEffect, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; +import { useSnapScroll } from '~/lib/hooks'; +import { handleChatTitleUpdate, type ResumeChatInfo } from '~/lib/persistence'; +import { database } from '~/lib/persistence/chats'; +import { chatStore } from '~/lib/stores/chat'; +import { cubicEasingFn } from '~/utils/easings'; +import { BaseChat } from '../../../BaseChat/BaseChat'; +import Cookies from 'js-cookie'; +import { useSearchParams } from '@remix-run/react'; +import { + simulationFinishData, + simulationRepositoryUpdated, + sendChatMessage, + type ChatReference, + abortChatMessage, + resumeChatMessage, +} from '~/lib/replay/ChatManager'; +import { getCurrentMouseData } from '~/components/workbench/PointSelector'; +import { anthropicNumFreeUsesCookieName, maxFreeUses } from '~/utils/freeUses'; +import { ChatMessageTelemetry, pingTelemetry } from '~/lib/hooks/pingTelemetry'; +import type { RejectChangeData } from '../../../ApproveChange'; +import { generateRandomId } from '~/lib/replay/ReplayProtocolClient'; +import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/message'; +import { useAuthStatus } from '~/lib/stores/auth'; +import { debounce } from '~/utils/debounce'; +import { supabaseSubmitFeedback } from '~/lib/supabase/feedback'; +import { supabaseAddRefund } from '~/lib/supabase/peanuts'; +import mergeResponseMessage from '../../functions/mergeResponseMessages'; +import flushSimulationData from '../../functions/flushSimulation'; +import getRewindMessageIndexAfterReject from '../../functions/getRewindMessageIndexAfterReject'; +import flashScreen from '../../functions/flashScreen'; + +interface ChatProps { + initialMessages: Message[]; + resumeChat: ResumeChatInfo | undefined; + storeMessageHistory: (messages: Message[]) => void; +} + +let gNumAborts = 0; + +let gActiveChatMessageTelemetry: ChatMessageTelemetry | undefined; + +async function clearActiveChat() { + gActiveChatMessageTelemetry = undefined; +} + +let gLastChatMessages: Message[] | undefined; + +export function getLastChatMessages() { + return gLastChatMessages; +} + +const ChatImplementer = memo((props: ChatProps) => { + const { initialMessages, resumeChat: initialResumeChat, storeMessageHistory } = props; + + const textareaRef = useRef(null); + const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); + const [uploadedFiles, setUploadedFiles] = useState([]); // Move here + const [imageDataList, setImageDataList] = useState([]); // Move here + const [searchParams] = useSearchParams(); + const { isLoggedIn } = useAuthStatus(); + const [input, setInput] = useState(''); + + const [pendingMessageId, setPendingMessageId] = useState(undefined); + + const [pendingMessageStatus, setPendingMessageStatus] = useState(''); + + const [resumeChat, setResumeChat] = useState(initialResumeChat); + + const [messages, setMessages] = useState(initialMessages); + + const showChat = useStore(chatStore.showChat); + + const [animationScope, animate] = useAnimate(); + + useEffect(() => { + const prompt = searchParams.get('prompt'); + + if (prompt) { + setInput(prompt); + } + }, [searchParams]); + + useEffect(() => { + const repositoryId = getMessagesRepositoryId(initialMessages); + + if (repositoryId) { + simulationRepositoryUpdated(repositoryId); + } + }, [initialMessages]); + + const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + + useEffect(() => { + chatStore.started.set(initialMessages.length > 0); + }, []); + + useEffect(() => { + storeMessageHistory(messages); + }, [messages]); + + const abort = () => { + stop(); + gNumAborts++; + chatStore.aborted.set(true); + setPendingMessageId(undefined); + setPendingMessageStatus(''); + setResumeChat(undefined); + + const chatId = chatStore.currentChat.get()?.id; + if (chatId) { + database.updateChatLastMessage(chatId, null, null); + } + + if (gActiveChatMessageTelemetry) { + gActiveChatMessageTelemetry.abort('StopButtonClicked'); + clearActiveChat(); + abortChatMessage(); + } + }; + + useEffect(() => { + const textarea = textareaRef.current; + + if (textarea) { + textarea.style.height = 'auto'; + + const scrollHeight = textarea.scrollHeight; + + textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; + textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; + } + }, [input, textareaRef]); + + const runAnimation = async () => { + if (chatStarted) { + return; + } + + await Promise.all([ + animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }), + animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), + ]); + + chatStore.started.set(true); + + setChatStarted(true); + }; + + const sendMessage = async (messageInput?: string) => { + const _input = messageInput || input; + const numAbortsAtStart = gNumAborts; + + if (_input.length === 0 || pendingMessageId || resumeChat) { + return; + } + + gActiveChatMessageTelemetry = new ChatMessageTelemetry(messages.length); + + if (!isLoggedIn) { + const numFreeUses = +(Cookies.get(anthropicNumFreeUsesCookieName) || 0); + + if (numFreeUses >= maxFreeUses) { + toast.error('Please login to continue using Nut.'); + gActiveChatMessageTelemetry.abort('NoFreeUses'); + clearActiveChat(); + return; + } + + Cookies.set(anthropicNumFreeUsesCookieName, (numFreeUses + 1).toString()); + } + + const chatId = generateRandomId(); + setPendingMessageId(chatId); + + const userMessage: Message = { + id: `user-${chatId}`, + role: 'user', + type: 'text', + content: _input, + }; + + let newMessages = [...messages, userMessage]; + + imageDataList.forEach((imageData, index) => { + const imageMessage: Message = { + id: `image-${chatId}-${index}`, + role: 'user', + type: 'image', + dataURL: imageData, + }; + newMessages.push(imageMessage); + }); + + setMessages(newMessages); + + setUploadedFiles([]); + setImageDataList([]); + + await flushSimulationData(); + simulationFinishData(); + + chatStore.aborted.set(false); + + runAnimation(); + + const addResponseMessage = (msg: Message) => { + if (gNumAborts != numAbortsAtStart) { + return; + } + + const existingRepositoryId = getMessagesRepositoryId(newMessages); + + newMessages = mergeResponseMessage(msg, [...newMessages]); + setMessages(newMessages); + + const responseRepositoryId = getMessagesRepositoryId(newMessages); + + if (responseRepositoryId && existingRepositoryId != responseRepositoryId) { + simulationRepositoryUpdated(responseRepositoryId); + } + }; + + const onChatTitle = (title: string) => { + if (gNumAborts != numAbortsAtStart) { + return; + } + + console.log('ChatTitle', title); + const currentChat = chatStore.currentChat.get(); + if (currentChat) { + handleChatTitleUpdate(currentChat.id, title); + } + }; + + const onChatStatus = debounce((status: string) => { + if (gNumAborts != numAbortsAtStart) { + return; + } + + console.log('ChatStatus', status); + setPendingMessageStatus(status); + }, 500); + + const references: ChatReference[] = []; + + const mouseData = getCurrentMouseData(); + + if (mouseData) { + references.push({ + kind: 'element', + selector: mouseData.selector, + x: mouseData.x, + y: mouseData.y, + width: mouseData.width, + height: mouseData.height, + }); + } + + try { + await sendChatMessage(newMessages, references, { + onResponsePart: addResponseMessage, + onTitle: onChatTitle, + onStatus: onChatStatus, + }); + } catch (e) { + if (gNumAborts == numAbortsAtStart) { + toast.error('Error sending message'); + console.error('Error sending message', e); + } + } + + if (gNumAborts != numAbortsAtStart) { + return; + } + + gActiveChatMessageTelemetry.finish(); + clearActiveChat(); + + setPendingMessageId(undefined); + + setInput(''); + + textareaRef.current?.blur(); + }; + + useEffect(() => { + (async () => { + if (!initialResumeChat) { + return; + } + + const numAbortsAtStart = gNumAborts; + + let newMessages = messages; + + const hasReceivedResponse = new Set(); + + const addResponseMessage = (msg: Message) => { + if (gNumAborts != numAbortsAtStart) { + return; + } + + if (!hasReceivedResponse.has(msg.id)) { + hasReceivedResponse.add(msg.id); + newMessages = newMessages.filter((m) => m.id != msg.id); + } + + const existingRepositoryId = getMessagesRepositoryId(newMessages); + + newMessages = mergeResponseMessage(msg, [...newMessages]); + setMessages(newMessages); + + const responseRepositoryId = getMessagesRepositoryId(newMessages); + + if (responseRepositoryId && existingRepositoryId != responseRepositoryId) { + simulationRepositoryUpdated(responseRepositoryId); + } + }; + + const onChatTitle = (title: string) => { + if (gNumAborts != numAbortsAtStart) { + return; + } + + console.log('ChatTitle', title); + const currentChat = chatStore.currentChat.get(); + if (currentChat) { + handleChatTitleUpdate(currentChat.id, title); + } + }; + + const onChatStatus = debounce((status: string) => { + if (gNumAborts != numAbortsAtStart) { + return; + } + + console.log('ChatStatus', status); + setPendingMessageStatus(status); + }, 500); + + try { + await resumeChatMessage(initialResumeChat.protocolChatId, initialResumeChat.protocolChatResponseId, { + onResponsePart: addResponseMessage, + onTitle: onChatTitle, + onStatus: onChatStatus, + }); + } catch (e) { + toast.error('Error resuming chat'); + console.error('Error resuming chat', e); + } + + if (gNumAborts != numAbortsAtStart) { + return; + } + + setResumeChat(undefined); + + const chatId = chatStore.currentChat.get()?.id; + if (chatId) { + database.updateChatLastMessage(chatId, null, null); + } + })(); + }, [initialResumeChat]); + + + const onApproveChange = async (messageId: string) => { + console.log('ApproveChange', messageId); + + setMessages( + messages.map((message) => { + if (message.id == messageId) { + return { + ...message, + approved: true, + }; + } + return message; + }), + ); + + await flashScreen(); + + pingTelemetry('ApproveChange', { + numMessages: messages.length, + }); + }; + + const onRejectChange = async (messageId: string, data: RejectChangeData) => { + console.log('RejectChange', messageId, data); + + const messageIndex = getRewindMessageIndexAfterReject(messages, messageId); + + if (messageIndex < 0) { + toast.error('Rewind message not found'); + return; + } + + const message = messages.find((m) => m.id == messageId); + + if (!message) { + toast.error('Message not found'); + return; + } + + if (message.peanuts) { + await supabaseAddRefund(message.peanuts); + } + + const previousRepositoryId = getPreviousRepositoryId(messages, messageIndex + 1); + + setMessages(messages.slice(0, messageIndex + 1)); + + simulationRepositoryUpdated(previousRepositoryId); + + let shareProjectSuccess = false; + + if (data.shareProject) { + const feedbackData: any = { + explanation: data.explanation, + chatMessages: messages, + }; + + shareProjectSuccess = await supabaseSubmitFeedback(feedbackData); + } + + pingTelemetry('RejectChange', { + shareProject: data.shareProject, + shareProjectSuccess, + numMessages: messages.length, + }); + }; + + /** + * Handles the change event for the textarea and updates the input state. + * @param event - The change event from the textarea. + */ + const onTextareaChange = (event: React.ChangeEvent) => { + setInput(event.target.value); + }; + + const [messageRef, scrollRef] = useSnapScroll(); + + gLastChatMessages = messages; + + return ( + { + onTextareaChange(e); + }} + handleStop={abort} + messages={messages} + uploadedFiles={uploadedFiles} + setUploadedFiles={setUploadedFiles} + imageDataList={imageDataList} + setImageDataList={setImageDataList} + onApproveChange={onApproveChange} + onRejectChange={onRejectChange} + /> + ); + }); + + export default ChatImplementer; \ No newline at end of file diff --git a/app/components/chat/ChatComponent/functions/flashScreen.ts b/app/components/chat/ChatComponent/functions/flashScreen.ts new file mode 100644 index 00000000..c5252f77 --- /dev/null +++ b/app/components/chat/ChatComponent/functions/flashScreen.ts @@ -0,0 +1,22 @@ +const flashScreen = async () => { + const flash = document.createElement('div'); + flash.style.position = 'fixed'; + flash.style.top = '0'; + flash.style.left = '0'; + flash.style.width = '100%'; + flash.style.height = '100%'; + flash.style.backgroundColor = 'rgba(0, 255, 0, 0.3)'; + flash.style.zIndex = '9999'; + flash.style.pointerEvents = 'none'; + document.body.appendChild(flash); + + setTimeout(() => { + flash.style.transition = 'opacity 0.5s'; + flash.style.opacity = '0'; + setTimeout(() => { + document.body.removeChild(flash); + }, 500); + }, 200); +}; + +export default flashScreen; \ No newline at end of file diff --git a/app/components/chat/ChatComponent/functions/flushSimulation.ts b/app/components/chat/ChatComponent/functions/flushSimulation.ts new file mode 100644 index 00000000..e919e979 --- /dev/null +++ b/app/components/chat/ChatComponent/functions/flushSimulation.ts @@ -0,0 +1,26 @@ +import { getIFrameSimulationData } from "~/lib/replay/Recording"; +import { simulationAddData } from "~/lib/replay/ChatManager"; +import { getCurrentIFrame } from "~/components/workbench/Preview"; + +async function flushSimulationData() { + //console.log("FlushSimulationData"); + + const iframe = getCurrentIFrame(); + + if (!iframe) { + return; + } + + const simulationData = await getIFrameSimulationData(iframe); + + if (!simulationData.length) { + return; + } + + //console.log("HaveSimulationData", simulationData.length); + + // Add the simulation data to the chat. + simulationAddData(simulationData); +}; + +export default flushSimulationData; diff --git a/app/components/chat/ChatComponent/functions/getRewindMessageIndexAfterReject.ts b/app/components/chat/ChatComponent/functions/getRewindMessageIndexAfterReject.ts new file mode 100644 index 00000000..de1ad702 --- /dev/null +++ b/app/components/chat/ChatComponent/functions/getRewindMessageIndexAfterReject.ts @@ -0,0 +1,18 @@ +import type { Message } from "~/lib/persistence/message"; + +function getRewindMessageIndexAfterReject(messages: Message[], messageId: string): number { + for (let i = messages.length - 1; i >= 0; i--) { + const { id, role, repositoryId } = messages[i]; + if (role == 'user') { + return i; + } + if (repositoryId && id != messageId) { + return i; + } + } + + console.error('No rewind message found', messages, messageId); + return -1; + }; + +export default getRewindMessageIndexAfterReject; diff --git a/app/components/chat/ChatComponent/functions/mergeResponseMessages.ts b/app/components/chat/ChatComponent/functions/mergeResponseMessages.ts new file mode 100644 index 00000000..8f217834 --- /dev/null +++ b/app/components/chat/ChatComponent/functions/mergeResponseMessages.ts @@ -0,0 +1,20 @@ +import type { Message } from "~/lib/persistence/message"; +import { assert } from "~/lib/replay/ReplayProtocolClient"; + +function mergeResponseMessage(msg: Message, messages: Message[]): Message[] { + const lastMessage = messages[messages.length - 1]; + if (lastMessage.id == msg.id) { + messages.pop(); + assert(lastMessage.type == 'text', 'Last message must be a text message'); + assert(msg.type == 'text', 'Message must be a text message'); + messages.push({ + ...msg, + content: lastMessage.content + msg.content, + }); + } else { + messages.push(msg); + } + return messages; +}; + +export default mergeResponseMessage; diff --git a/app/components/chat/MessageInput/MessageInput.tsx b/app/components/chat/MessageInput/MessageInput.tsx new file mode 100644 index 00000000..ecd18143 --- /dev/null +++ b/app/components/chat/MessageInput/MessageInput.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { ClientOnly } from 'remix-utils/client-only'; +import { IconButton } from '~/components/ui/IconButton'; +import { classNames } from '~/utils/classNames'; +import { SendButton } from '../SendButton.client'; +import { SpeechRecognitionButton } from '../SpeechRecognition'; + +export interface MessageInputProps { + textareaRef?: React.RefObject; + input?: string; + handleInputChange?: (event: React.ChangeEvent) => void; + handleSendMessage?: (event: React.UIEvent) => void; + handleStop?: () => void; + hasPendingMessage?: boolean; + chatStarted?: boolean; + uploadedFiles?: File[]; + setUploadedFiles?: (files: File[]) => void; + imageDataList?: string[]; + setImageDataList?: (dataList: string[]) => void; + isListening?: boolean; + onStartListening?: () => void; + onStopListening?: () => void; + minHeight?: number; + maxHeight?: number; +} + +export const MessageInput: React.FC = ({ + textareaRef, + input = '', + handleInputChange = () => {}, + handleSendMessage = () => {}, + handleStop = () => {}, + hasPendingMessage = false, + chatStarted = false, + uploadedFiles = [], + setUploadedFiles = () => {}, + imageDataList = [], + setImageDataList = () => {}, + isListening = false, + onStartListening = () => {}, + onStopListening = () => {}, + minHeight = 76, + maxHeight = 200, +}) => { + const handleFileUpload = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + + if (file) { + const reader = new FileReader(); + + reader.onload = (e) => { + const base64Image = e.target?.result as string; + setUploadedFiles([...uploadedFiles, file]); + setImageDataList([...imageDataList, base64Image]); + }; + reader.readAsDataURL(file); + } + }; + + input.click(); + }; + + const handlePaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + + if (!items) { + return; + } + + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + + const file = item.getAsFile(); + + if (file) { + const reader = new FileReader(); + + reader.onload = (e) => { + const base64Image = e.target?.result as string; + setUploadedFiles([...uploadedFiles, file]); + setImageDataList([...imageDataList, base64Image]); + }; + reader.readAsDataURL(file); + } + + break; + } + } + }; + + return ( +
+