From a0303c11025741fc268588672cd9e0658e3fba11 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 2 Apr 2025 16:02:06 -0700 Subject: [PATCH] Support resuming chat messages (#91) --- app/components/chat/Chat.client.tsx | 173 +++++++++++++++--- app/components/header/DeployChatButton.tsx | 14 +- app/components/header/Header.tsx | 12 +- .../header/HeaderActionButtons.client.tsx | 6 +- app/components/sidebar/Menu.client.tsx | 12 +- app/lib/hooks/useEditChatDescription.ts | 22 ++- .../persistence/ChatDescription.client.tsx | 5 +- app/lib/persistence/db.ts | 96 +++++++--- app/lib/persistence/useChatHistory.ts | 51 ++++-- app/lib/replay/SimulationPrompt.ts | 46 ++++- app/lib/stores/auth.ts | 4 +- app/lib/stores/chat.ts | 17 +- .../20250328175522_create_chats_table.sql | 4 +- 13 files changed, 342 insertions(+), 120 deletions(-) diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 3782753d..328ad237 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -7,7 +7,7 @@ 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 { currentChatId, handleChatTitleUpdate, useChatHistory } from '~/lib/persistence'; +import { database, handleChatTitleUpdate, useChatHistory, type ResumeChatInfo } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { cubicEasingFn } from '~/utils/easings'; import { renderLogger } from '~/utils/logger'; @@ -21,6 +21,7 @@ import { sendChatMessage, type ChatReference, simulationReset, + resumeChatMessage, } from '~/lib/replay/SimulationPrompt'; import { getIFrameSimulationData } from '~/lib/replay/Recording'; import { getCurrentIFrame } from '~/components/workbench/Preview'; @@ -73,12 +74,17 @@ setInterval(async () => { export function Chat() { renderLogger.trace('Chat'); - const { ready, initialMessages, storeMessageHistory, importChat } = useChatHistory(); + const { ready, initialMessages, resumeChat, storeMessageHistory, importChat } = useChatHistory(); return ( <> {ready && ( - + )} { @@ -113,6 +119,7 @@ export function Chat() { interface ChatProps { initialMessages: Message[]; + resumeChat: ResumeChatInfo | undefined; storeMessageHistory: (messages: Message[]) => void; importChat: (description: string, messages: Message[]) => Promise; } @@ -125,7 +132,25 @@ async function clearActiveChat() { gActiveChatMessageTelemetry = undefined; } -export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat }: ChatProps) => { +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 const ChatImpl = memo((props: ChatProps) => { + const { initialMessages, resumeChat: initialResumeChat, storeMessageHistory, importChat } = props; + const textareaRef = useRef(null); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); const [uploadedFiles, setUploadedFiles] = useState([]); // Move here @@ -146,9 +171,14 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat // 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); + const showChat = useStore(chatStore.showChat); const [animationScope, animate] = useAnimate(); @@ -173,7 +203,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; useEffect(() => { - chatStore.setKey('started', initialMessages.length > 0); + chatStore.started.set(initialMessages.length > 0); }, []); useEffect(() => { @@ -183,8 +213,14 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat const abort = () => { stop(); gNumAborts++; - chatStore.setKey('aborted', true); + chatStore.aborted.set(true); setPendingMessageId(undefined); + setResumeChat(undefined); + + const chatId = chatStore.currentChat.get()?.id; + if (chatId) { + database.updateChatLastMessage(chatId, null, null); + } if (gActiveChatMessageTelemetry) { gActiveChatMessageTelemetry.abort('StopButtonClicked'); @@ -216,7 +252,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), ]); - chatStore.setKey('started', true); + chatStore.started.set(true); setChatStarted(true); }; @@ -225,7 +261,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat const _input = messageInput || input; const numAbortsAtStart = gNumAborts; - if (_input.length === 0 || pendingMessageId) { + if (_input.length === 0 || pendingMessageId || resumeChat) { return; } @@ -275,11 +311,10 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat await flushSimulationData(); simulationFinishData(); - chatStore.setKey('aborted', false); + chatStore.aborted.set(false); runAnimation(); - const existingRepositoryId = getMessagesRepositoryId(messages); let updatedRepository = false; const addResponseMessage = (msg: Message) => { @@ -287,22 +322,9 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat return; } - newMessages = [...newMessages]; - - const lastMessage = newMessages[newMessages.length - 1]; - - if (lastMessage.id == msg.id) { - newMessages.pop(); - assert(lastMessage.type == 'text', 'Last message must be a text message'); - assert(msg.type == 'text', 'Message must be a text message'); - newMessages.push({ - ...msg, - content: lastMessage.content + msg.content, - }); - } else { - newMessages.push(msg); - } + const existingRepositoryId = getMessagesRepositoryId(newMessages); + newMessages = mergeResponseMessage(msg, [...newMessages]); setMessages(newMessages); // Update the repository as soon as it has changed. @@ -315,11 +337,22 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat }; const onChatTitle = (title: string) => { + if (gNumAborts != numAbortsAtStart) { + return; + } + console.log('ChatTitle', title); - handleChatTitleUpdate(currentChatId.get() as string, 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); @@ -371,6 +404,90 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat } }; + 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) { + 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]); + // Rewind far enough to erase the specified message. const onRewind = async (messageId: string) => { console.log('Rewinding', messageId); @@ -490,7 +607,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat input={input} showChat={showChat} chatStarted={chatStarted} - hasPendingMessage={pendingMessageId !== undefined} + hasPendingMessage={pendingMessageId !== undefined || resumeChat !== undefined} pendingMessageStatus={pendingMessageStatus} sendMessage={sendMessage} messageRef={messageRef} diff --git a/app/components/header/DeployChatButton.tsx b/app/components/header/DeployChatButton.tsx index 0667a0e0..e6eff0de 100644 --- a/app/components/header/DeployChatButton.tsx +++ b/app/components/header/DeployChatButton.tsx @@ -4,8 +4,8 @@ import { useState } from 'react'; import type { DeploySettingsDatabase } from '~/lib/replay/Deploy'; import { generateRandomId } from '~/lib/replay/ReplayProtocolClient'; import { workbenchStore } from '~/lib/stores/workbench'; -import { databaseGetChatDeploySettings, databaseUpdateChatDeploySettings } from '~/lib/persistence/db'; -import { currentChatId } from '~/lib/persistence/useChatHistory'; +import { chatStore } from '~/lib/stores/chat'; +import { database } from '~/lib/persistence/db'; import { deployRepository } from '~/lib/replay/Deploy'; ReactModal.setAppElement('#root'); @@ -25,13 +25,13 @@ export function DeployChatButton() { const [status, setStatus] = useState(DeployStatus.NotStarted); const handleOpenModal = async () => { - const chatId = currentChatId.get(); + const chatId = chatStore.currentChat.get()?.id; if (!chatId) { toast.error('No chat open'); return; } - const existingSettings = await databaseGetChatDeploySettings(chatId); + const existingSettings = await database.getChatDeploySettings(chatId); setIsModalOpen(true); setStatus(DeployStatus.NotStarted); @@ -46,7 +46,7 @@ export function DeployChatButton() { const handleDeploy = async () => { setError(null); - const chatId = currentChatId.get(); + const chatId = chatStore.currentChat.get()?.id; if (!chatId) { setError('No chat open'); return; @@ -100,7 +100,7 @@ export function DeployChatButton() { setStatus(DeployStatus.Started); // Write out to the database before we start trying to deploy. - await databaseUpdateChatDeploySettings(chatId, deploySettings); + await database.updateChatDeploySettings(chatId, deploySettings); console.log('DeploymentStarting', repositoryId, deploySettings); @@ -135,7 +135,7 @@ export function DeployChatButton() { setStatus(DeployStatus.Succeeded); // Update the database with the new settings. - await databaseUpdateChatDeploySettings(chatId, newSettings); + await database.updateChatDeploySettings(chatId, newSettings); }; return ( diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx index 5067d19f..6f8365ba 100644 --- a/app/components/header/Header.tsx +++ b/app/components/header/Header.tsx @@ -10,13 +10,13 @@ import { ClientAuth } from '~/components/auth/ClientAuth'; import { DeployChatButton } from './DeployChatButton'; export function Header() { - const chat = useStore(chatStore); + const chatStarted = useStore(chatStore.started); return (
@@ -28,20 +28,20 @@ export function Header() {
- {chat.started && ( + {chatStarted && ( {() => } )} - {chat.started && ( + {chatStarted && ( {() => } )}
- {chat.started && ( + {chatStarted && ( {() => (
diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index ac9382d6..ec470f22 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -8,7 +8,7 @@ interface HeaderActionButtonsProps {} export function HeaderActionButtons({}: HeaderActionButtonsProps) { const showWorkbench = useStore(workbenchStore.showWorkbench); - const { showChat } = useStore(chatStore); + const showChat = useStore(chatStore.showChat); const isSmallViewport = useViewport(1024); @@ -22,7 +22,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's not needed onClick={() => { if (canHideChat) { - chatStore.setKey('showChat', !showChat); + chatStore.showChat.set(!showChat); } }} > @@ -33,7 +33,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { active={showWorkbench} onClick={() => { if (showWorkbench && !showChat) { - chatStore.setKey('showChat', true); + chatStore.showChat.set(true); } workbenchStore.showWorkbench.set(!showWorkbench); diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx index d7a0faf9..ffb87c52 100644 --- a/app/components/sidebar/Menu.client.tsx +++ b/app/components/sidebar/Menu.client.tsx @@ -5,8 +5,8 @@ import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from import { ThemeSwitch } from '~/components/ui/ThemeSwitch'; import { SettingsWindow } from '~/components/settings/SettingsWindow'; import { SettingsButton } from '~/components/ui/SettingsButton'; -import { deleteById, getAllChats, currentChatId } from '~/lib/persistence'; -import type { ChatContents } from '~/lib/persistence/db'; +import { database, type ChatContents } from '~/lib/persistence/db'; +import { chatStore } from '~/lib/stores/chat'; import { cubicEasingFn } from '~/utils/easings'; import { logger } from '~/utils/logger'; import { HistoryItem } from './HistoryItem'; @@ -49,7 +49,8 @@ export const Menu = () => { }); const loadEntries = useCallback(() => { - getAllChats() + database + .getAllChats() .then(setList) .catch((error) => toast.error(error.message)); }, []); @@ -57,11 +58,12 @@ export const Menu = () => { const deleteItem = useCallback((event: React.UIEvent, item: ChatContents) => { event.preventDefault(); - deleteById(item.id) + database + .deleteChat(item.id) .then(() => { loadEntries(); - if (currentChatId.get() === item.id) { + if (chatStore.currentChat.get()?.id === item.id) { // hard page navigation to clear the stores window.location.pathname = '/'; } diff --git a/app/lib/hooks/useEditChatDescription.ts b/app/lib/hooks/useEditChatDescription.ts index c03521dd..6d4f9937 100644 --- a/app/lib/hooks/useEditChatDescription.ts +++ b/app/lib/hooks/useEditChatDescription.ts @@ -1,7 +1,8 @@ -import { useStore } from '@nanostores/react'; import { useCallback, useEffect, useState } from 'react'; import { toast } from 'react-toastify'; -import { currentChatId, currentChatTitle, getChatContents, handleChatTitleUpdate } from '~/lib/persistence'; +import { chatStore } from '~/lib/stores/chat'; +import { database } from '~/lib/persistence/db'; +import { handleChatTitleUpdate } from '~/lib/persistence/useChatHistory'; interface EditChatDescriptionOptions { initialTitle?: string; @@ -32,18 +33,19 @@ type EditChatDescriptionHook = { * @returns {EditChatDescriptionHook} Methods and state for managing description edits. */ export function useEditChatTitle({ - initialTitle = currentChatTitle.get()!, + initialTitle = chatStore.currentChat.get()?.title, customChatId, }: EditChatDescriptionOptions): EditChatDescriptionHook { - const chatIdFromStore = useStore(currentChatId); + const currentChat = chatStore.currentChat.get(); + const [editing, setEditing] = useState(false); const [currentTitle, setCurrentTitle] = useState(initialTitle); const [chatId, setChatId] = useState(); useEffect(() => { - setChatId(customChatId || chatIdFromStore); - }, [customChatId, chatIdFromStore]); + setChatId(customChatId || currentChat?.id); + }, [customChatId, currentChat]); useEffect(() => { setCurrentTitle(initialTitle); }, [initialTitle]); @@ -60,7 +62,7 @@ export function useEditChatTitle({ } try { - const chat = await getChatContents(chatId); + const chat = await database.getChatContents(chatId); return chat?.title || initialTitle; } catch (error) { console.error('Failed to fetch latest description:', error); @@ -104,6 +106,10 @@ export function useEditChatTitle({ async (event: React.FormEvent) => { event.preventDefault(); + if (!currentTitle) { + return; + } + if (!isValidTitle(currentTitle)) { return; } @@ -140,7 +146,7 @@ export function useEditChatTitle({ handleBlur, handleSubmit, handleKeyDown, - currentTitle, + currentTitle: currentTitle!, toggleEditMode, }; } diff --git a/app/lib/persistence/ChatDescription.client.tsx b/app/lib/persistence/ChatDescription.client.tsx index 24bf71b0..ecad31a8 100644 --- a/app/lib/persistence/ChatDescription.client.tsx +++ b/app/lib/persistence/ChatDescription.client.tsx @@ -2,10 +2,11 @@ import { useStore } from '@nanostores/react'; import { TooltipProvider } from '@radix-ui/react-tooltip'; import WithTooltip from '~/components/ui/Tooltip'; import { useEditChatTitle } from '~/lib/hooks/useEditChatDescription'; -import { currentChatTitle } from '~/lib/persistence'; +import { chatStore } from '~/lib/stores/chat'; export function ChatDescription() { - const initialTitle = useStore(currentChatTitle)!; + const currentChat = useStore(chatStore.currentChat); + const initialTitle = currentChat?.title; const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentTitle, toggleEditMode } = useEditChatTitle({ diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index 0ea61716..1e1d2481 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -15,6 +15,8 @@ export interface ChatContents { title: string; repositoryId: string | undefined; messages: Message[]; + lastProtocolChatId: string | undefined; + lastProtocolChatResponseId: string | undefined; } function databaseRowToChatContents(d: any): ChatContents { @@ -25,6 +27,8 @@ function databaseRowToChatContents(d: any): ChatContents { title: d.title, messages: d.messages, repositoryId: d.repository_id, + lastProtocolChatId: d.last_protocol_chat_id, + lastProtocolChatResponseId: d.last_protocol_chat_response_id, }; } @@ -46,7 +50,7 @@ function setLocalChats(chats: ChatContents[] | undefined): void { } } -export async function getAllChats(): Promise { +async function getAllChats(): Promise { const userId = await getCurrentUserId(); if (!userId) { @@ -62,7 +66,7 @@ export async function getAllChats(): Promise { return data.map(databaseRowToChatContents); } -export async function syncLocalChats(): Promise { +async function syncLocalChats(): Promise { const userId = await getCurrentUserId(); const localChats = getLocalChats(); @@ -70,7 +74,7 @@ export async function syncLocalChats(): Promise { try { for (const chat of localChats) { if (chat.title) { - await setChatContents(chat.id, chat.title, chat.messages); + await setChatContents(chat); } } setLocalChats(undefined); @@ -80,31 +84,29 @@ export async function syncLocalChats(): Promise { } } -export async function setChatContents(id: string, title: string, messages: Message[]): Promise { +async function setChatContents(chat: ChatContents) { const userId = await getCurrentUserId(); if (!userId) { - const localChats = getLocalChats().filter((c) => c.id != id); + const localChats = getLocalChats().filter((c) => c.id != chat.id); localChats.push({ - id, - title, - messages, - repositoryId: getMessagesRepositoryId(messages), - createdAt: new Date().toISOString(), + ...chat, updatedAt: new Date().toISOString(), }); setLocalChats(localChats); return; } - const repositoryId = getMessagesRepositoryId(messages); + const repositoryId = getMessagesRepositoryId(chat.messages); const { error } = await getSupabase().from('chats').upsert({ - id, - messages, - title, + id: chat.id, + messages: chat.messages, + title: chat.title, user_id: userId, repository_id: repositoryId, + last_protocol_chat_id: chat.lastProtocolChatId, + last_protocol_chat_response_id: chat.lastProtocolChatResponseId, }); if (error) { @@ -112,7 +114,7 @@ export async function setChatContents(id: string, title: string, messages: Messa } } -export async function getChatPublicData(id: string): Promise<{ repositoryId: string; title: string }> { +async function getChatPublicData(id: string): Promise<{ repositoryId: string; title: string }> { const { data, error } = await getSupabase().rpc('get_chat_public_data', { chat_id: id }); if (error) { @@ -129,7 +131,7 @@ export async function getChatPublicData(id: string): Promise<{ repositoryId: str }; } -export async function getChatContents(id: string): Promise { +async function getChatContents(id: string): Promise { const userId = await getCurrentUserId(); if (!userId) { @@ -149,7 +151,7 @@ export async function getChatContents(id: string): Promise { +async function deleteChat(id: string): Promise { const userId = await getCurrentUserId(); if (!userId) { @@ -165,13 +167,21 @@ export async function deleteById(id: string): Promise { } } -export async function createChat(title: string, messages: Message[]): Promise { - const id = uuid(); - await setChatContents(id, title, messages); - return id; +async function createChat(title: string, messages: Message[]): Promise { + const contents = { + id: uuid(), + title, + messages, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + repositoryId: getMessagesRepositoryId(messages), + lastProtocolChatId: undefined, + lastProtocolChatResponseId: undefined, + }; + await setChatContents(contents); + return contents; } - -export async function databaseUpdateChatTitle(id: string, title: string): Promise { +async function updateChatTitle(id: string, title: string): Promise { const chat = await getChatContents(id); assert(chat, 'Unknown chat'); @@ -179,10 +189,10 @@ export async function databaseUpdateChatTitle(id: string, title: string): Promis throw new Error('Title cannot be empty'); } - await setChatContents(id, title, chat.messages); + await setChatContents({ ...chat, title }); } -export async function databaseGetChatDeploySettings(id: string): Promise { +async function getChatDeploySettings(id: string): Promise { console.log('DatabaseGetChatDeploySettingsStart', id); const { data, error } = await getSupabase().from('chats').select('deploy_settings').eq('id', id); @@ -200,13 +210,39 @@ export async function databaseGetChatDeploySettings(id: string): Promise { +async function updateChatDeploySettings(id: string, deploySettings: DeploySettingsDatabase): Promise { const { error } = await getSupabase().from('chats').update({ deploy_settings: deploySettings }).eq('id', id); if (error) { - throw error; + console.error('DatabaseUpdateChatDeploySettingsError', id, deploySettings, error); } } + +async function updateChatLastMessage( + id: string, + protocolChatId: string | null, + protocolChatResponseId: string | null, +): Promise { + const { error } = await getSupabase() + .from('chats') + .update({ last_protocol_chat_id: protocolChatId, last_protocol_chat_response_id: protocolChatResponseId }) + .eq('id', id); + + if (error) { + console.error('DatabaseUpdateChatLastMessageError', id, protocolChatId, protocolChatResponseId, error); + } +} + +export const database = { + getAllChats, + syncLocalChats, + setChatContents, + getChatPublicData, + getChatContents, + deleteChat, + createChat, + updateChatTitle, + getChatDeploySettings, + updateChatDeploySettings, + updateChatLastMessage, +}; diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index d7d42fe0..1d909e55 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -1,16 +1,17 @@ import { useLoaderData } from '@remix-run/react'; import { useState, useEffect } from 'react'; -import { atom } from 'nanostores'; import { toast } from 'react-toastify'; import { logStore } from '~/lib/stores/logs'; // Import logStore -import { createChat, getChatContents, setChatContents, getChatPublicData, databaseUpdateChatTitle } from './db'; +import { chatStore } from '~/lib/stores/chat'; +import { database } from './db'; import { loadProblem } from '~/components/chat/LoadProblemButton'; import { createMessagesForRepository, type Message } from './message'; import { debounce } from '~/utils/debounce'; -// These must be kept in sync. -export const currentChatId = atom(undefined); -export const currentChatTitle = atom(undefined); +export interface ResumeChatInfo { + protocolChatId: string; + protocolChatResponseId: string; +} export function useChatHistory() { const { @@ -20,11 +21,12 @@ export function useChatHistory() { } = useLoaderData<{ id?: string; problemId?: string; repositoryId?: string }>() ?? {}; const [initialMessages, setInitialMessages] = useState([]); + const [resumeChat, setResumeChat] = useState(undefined); const [ready, setReady] = useState(!mixedId && !problemId && !repositoryId); const importChat = async (title: string, messages: Message[]) => { try { - const newId = await createChat(title, messages); + const newId = await database.createChat(title, messages); window.location.href = `/chat/${newId}`; toast.success('Chat imported successfully'); } catch (error) { @@ -43,23 +45,32 @@ export function useChatHistory() { }; const debouncedSetChatContents = debounce(async (messages: Message[]) => { - await setChatContents(currentChatId.get() as string, currentChatTitle.get() as string, messages); + const chat = chatStore.currentChat.get(); + if (!chat) { + return; + } + await database.setChatContents({ ...chat, messages }); }, 1000); useEffect(() => { (async () => { try { if (mixedId) { - const chatContents = await getChatContents(mixedId); + const chatContents = await database.getChatContents(mixedId); if (chatContents) { setInitialMessages(chatContents.messages); - currentChatTitle.set(chatContents.title); - currentChatId.set(mixedId); + chatStore.currentChat.set(chatContents); + if (chatContents.lastProtocolChatId && chatContents.lastProtocolChatResponseId) { + setResumeChat({ + protocolChatId: chatContents.lastProtocolChatId, + protocolChatResponseId: chatContents.lastProtocolChatResponseId, + }); + } setReady(true); return; } - const publicData = await getChatPublicData(mixedId); + const publicData = await database.getChatPublicData(mixedId); const messages = createMessagesForRepository(publicData.title, publicData.repositoryId); await importChat(publicData.title, messages); } else if (problemId) { @@ -79,16 +90,17 @@ export function useChatHistory() { return { ready, initialMessages, + resumeChat, storeMessageHistory: async (messages: Message[]) => { if (messages.length === 0) { return; } - if (!currentChatId.get()) { - const id = await createChat('New Chat', initialMessages); - currentChatId.set(id); - currentChatTitle.set('New Chat'); - navigateChat(id); + if (!chatStore.currentChat.get()) { + const title = 'New Chat'; + const chat = await database.createChat(title, initialMessages); + chatStore.currentChat.set(chat); + navigateChat(chat.id); } debouncedSetChatContents(messages); @@ -110,8 +122,9 @@ function navigateChat(nextId: string) { } export async function handleChatTitleUpdate(id: string, title: string) { - await databaseUpdateChatTitle(id, title); - if (currentChatId.get() == id) { - currentChatTitle.set(title); + await database.updateChatTitle(id, title); + const currentChat = chatStore.currentChat.get(); + if (currentChat?.id == id) { + chatStore.currentChat.set({ ...currentChat, title }); } } diff --git a/app/lib/replay/SimulationPrompt.ts b/app/lib/replay/SimulationPrompt.ts index 45b100f2..5f606fa8 100644 --- a/app/lib/replay/SimulationPrompt.ts +++ b/app/lib/replay/SimulationPrompt.ts @@ -8,6 +8,9 @@ import { simulationDataVersion } from './SimulationData'; import { assert, generateRandomId, ProtocolClient } from './ReplayProtocolClient'; import { updateDevelopmentServer } from './DevelopmentServer'; import type { Message } from '~/lib/persistence/message'; +import { database } from '~/lib/persistence/db'; +import { chatStore } from '~/lib/stores/chat'; +import { debounce } from '~/utils/debounce'; function createRepositoryIdPacket(repositoryId: string): SimulationPacket { return { @@ -28,7 +31,7 @@ interface ChatReferenceElement { export type ChatReference = ChatReferenceElement; -interface ChatMessageCallbacks { +export interface ChatMessageCallbacks { onResponsePart: (message: Message) => void; onTitle: (title: string) => void; onStatus: (status: string) => void; @@ -183,6 +186,10 @@ class ChatManager { console.log('ChatSendMessage', new Date().toISOString(), chatId, JSON.stringify({ messages, references })); + const id = chatStore.currentChat.get()?.id; + assert(id, 'Expected chat ID'); + database.updateChatLastMessage(id, chatId, responseId); + await this.client.sendCommand({ method: 'Nut.sendChatMessage', params: { chatId, responseId, messages, references }, @@ -228,10 +235,10 @@ function startChat(repositoryId: string | undefined, pageData: SimulationData) { * Called when the repository has changed. We'll start a new chat * and update the remote development server. */ -export function simulationRepositoryUpdated(repositoryId: string) { +export const simulationRepositoryUpdated = debounce((repositoryId: string) => { startChat(repositoryId, []); updateDevelopmentServer(repositoryId); -} +}, 500); /* * Called when the page gathering interaction data has been reloaded. We'll @@ -303,3 +310,36 @@ export async function sendChatMessage( await gChatManager.sendChatMessage(messages, references, callbacks); } + +export async function resumeChatMessage(chatId: string, chatResponseId: string, callbacks: ChatMessageCallbacks) { + const client = new ProtocolClient(); + await client.initialize(); + + try { + const removeResponseListener = client.listenForMessage( + 'Nut.chatResponsePart', + ({ message }: { message: Message }) => { + callbacks.onResponsePart(message); + }, + ); + + const removeTitleListener = client.listenForMessage('Nut.chatTitle', ({ title }: { title: string }) => { + callbacks.onTitle(title); + }); + + const removeStatusListener = client.listenForMessage('Nut.chatStatus', ({ status }: { status: string }) => { + callbacks.onStatus(status); + }); + + await client.sendCommand({ + method: 'Nut.resumeChatMessage', + params: { chatId, responseId: chatResponseId }, + }); + + removeResponseListener(); + removeTitleListener(); + removeStatusListener(); + } finally { + client.close(); + } +} diff --git a/app/lib/stores/auth.ts b/app/lib/stores/auth.ts index c691db24..96314dab 100644 --- a/app/lib/stores/auth.ts +++ b/app/lib/stores/auth.ts @@ -4,7 +4,7 @@ import type { User, Session } from '@supabase/supabase-js'; import { logStore } from './logs'; import { useEffect, useState } from 'react'; import { isAuthenticated } from '~/lib/supabase/client'; -import { syncLocalChats } from '~/lib/persistence/db'; +import { database } from '~/lib/persistence/db'; export const userStore = atom(null); export const sessionStore = atom(null); @@ -70,7 +70,7 @@ export async function initializeAuth() { logStore.logSystem('User signed out'); } - await syncLocalChats(); + await database.syncLocalChats(); }); return () => { diff --git a/app/lib/stores/chat.ts b/app/lib/stores/chat.ts index 0de23163..3c5a0e72 100644 --- a/app/lib/stores/chat.ts +++ b/app/lib/stores/chat.ts @@ -1,7 +1,12 @@ -import { map } from 'nanostores'; +import { atom } from 'nanostores'; +import type { ChatContents } from '~/lib/persistence/db'; -export const chatStore = map({ - started: false, - aborted: false, - showChat: true, -}); +export class ChatStore { + currentChat = atom(undefined); + + started = atom(false); + aborted = atom(false); + showChat = atom(true); +} + +export const chatStore = new ChatStore(); diff --git a/supabase/migrations/20250328175522_create_chats_table.sql b/supabase/migrations/20250328175522_create_chats_table.sql index f4c62069..31fb8359 100644 --- a/supabase/migrations/20250328175522_create_chats_table.sql +++ b/supabase/migrations/20250328175522_create_chats_table.sql @@ -8,7 +8,9 @@ CREATE TABLE IF NOT EXISTS public.chats ( repository_id UUID, messages JSONB DEFAULT '{}', deploy_settings JSONB DEFAULT '{}', - deleted BOOLEAN DEFAULT FALSE + deleted BOOLEAN DEFAULT FALSE, + last_protocol_chat_id UUID, + last_protocol_chat_response_id TEXT ); -- Create updated_at trigger for chats table