Support resuming chat messages (#91)

This commit is contained in:
Brian Hackett 2025-04-02 16:02:06 -07:00 committed by GitHub
parent 16ee8276f9
commit a0303c1102
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 342 additions and 120 deletions

View File

@ -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 && (
<ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} importChat={importChat} />
<ChatImpl
initialMessages={initialMessages}
resumeChat={resumeChat}
storeMessageHistory={storeMessageHistory}
importChat={importChat}
/>
)}
<ToastContainer
closeButton={({ closeToast }) => {
@ -113,6 +119,7 @@ export function Chat() {
interface ChatProps {
initialMessages: Message[];
resumeChat: ResumeChatInfo | undefined;
storeMessageHistory: (messages: Message[]) => void;
importChat: (description: string, messages: Message[]) => Promise<void>;
}
@ -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<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // 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<ResumeChatInfo | undefined>(initialResumeChat);
const [messages, setMessages] = useState<Message[]>(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<string>();
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}

View File

@ -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>(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 (

View File

@ -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 (
<header
className={classNames('flex items-center justify-between p-5 border-b h-[var(--header-height)]', {
'border-transparent': !chat.started,
'border-bolt-elements-borderColor': chat.started,
'border-transparent': !chatStarted,
'border-bolt-elements-borderColor': chatStarted,
})}
>
<div className="flex flex-1 items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
@ -28,20 +28,20 @@ export function Header() {
</div>
<div className="flex-1 flex items-center ">
{chat.started && (
{chatStarted && (
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
</span>
)}
{chat.started && (
{chatStarted && (
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
<ClientOnly>{() => <DeployChatButton />}</ClientOnly>
</span>
)}
<div className="flex items-center gap-4">
{chat.started && (
{chatStarted && (
<ClientOnly>
{() => (
<div className="mr-1">

View File

@ -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);

View File

@ -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 = '/';
}

View File

@ -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<string>();
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,
};
}

View File

@ -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({

View File

@ -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<ChatContents[]> {
async function getAllChats(): Promise<ChatContents[]> {
const userId = await getCurrentUserId();
if (!userId) {
@ -62,7 +66,7 @@ export async function getAllChats(): Promise<ChatContents[]> {
return data.map(databaseRowToChatContents);
}
export async function syncLocalChats(): Promise<void> {
async function syncLocalChats(): Promise<void> {
const userId = await getCurrentUserId();
const localChats = getLocalChats();
@ -70,7 +74,7 @@ export async function syncLocalChats(): Promise<void> {
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<void> {
}
}
export async function setChatContents(id: string, title: string, messages: Message[]): Promise<void> {
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<ChatContents | undefined> {
async function getChatContents(id: string): Promise<ChatContents | undefined> {
const userId = await getCurrentUserId();
if (!userId) {
@ -149,7 +151,7 @@ export async function getChatContents(id: string): Promise<ChatContents | undefi
return databaseRowToChatContents(data[0]);
}
export async function deleteById(id: string): Promise<void> {
async function deleteChat(id: string): Promise<void> {
const userId = await getCurrentUserId();
if (!userId) {
@ -165,13 +167,21 @@ export async function deleteById(id: string): Promise<void> {
}
}
export async function createChat(title: string, messages: Message[]): Promise<string> {
const id = uuid();
await setChatContents(id, title, messages);
return id;
async function createChat(title: string, messages: Message[]): Promise<ChatContents> {
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<void> {
async function updateChatTitle(id: string, title: string): Promise<void> {
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<DeploySettingsDatabase | undefined> {
async function getChatDeploySettings(id: string): Promise<DeploySettingsDatabase | undefined> {
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<DeployS
return data[0].deploy_settings;
}
export async function databaseUpdateChatDeploySettings(
id: string,
deploySettings: DeploySettingsDatabase,
): Promise<void> {
async function updateChatDeploySettings(id: string, deploySettings: DeploySettingsDatabase): Promise<void> {
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<void> {
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,
};

View File

@ -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<string | undefined>(undefined);
export const currentChatTitle = atom<string | undefined>(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<Message[]>([]);
const [resumeChat, setResumeChat] = useState<ResumeChatInfo | undefined>(undefined);
const [ready, setReady] = useState<boolean>(!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 });
}
}

View File

@ -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();
}
}

View File

@ -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<User | null>(null);
export const sessionStore = atom<Session | null>(null);
@ -70,7 +70,7 @@ export async function initializeAuth() {
logStore.logSystem('User signed out');
}
await syncLocalChats();
await database.syncLocalChats();
});
return () => {

View File

@ -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<ChatContents | undefined>(undefined);
started = atom<boolean>(false);
aborted = atom<boolean>(false);
showChat = atom<boolean>(true);
}
export const chatStore = new ChatStore();

View File

@ -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