mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Support resuming chat messages (#91)
This commit is contained in:
parent
16ee8276f9
commit
a0303c1102
@ -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}
|
||||
|
@ -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 (
|
||||
|
@ -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">
|
||||
|
@ -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);
|
||||
|
@ -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 = '/';
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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 () => {
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user