Fix UX problems sharing chats and using Nut without being logged in (#87)

This commit is contained in:
Brian Hackett 2025-03-30 09:04:59 -07:00 committed by GitHub
parent ac1858b84e
commit 8fb01c5586
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 262 additions and 163 deletions

View File

@ -32,7 +32,8 @@ interface BaseChatProps {
scrollRef?: RefCallback<HTMLDivElement> | undefined; scrollRef?: RefCallback<HTMLDivElement> | undefined;
showChat?: boolean; showChat?: boolean;
chatStarted?: boolean; chatStarted?: boolean;
isStreaming?: boolean; hasPendingMessage?: boolean;
pendingMessageStatus?: string;
messages?: Message[]; messages?: Message[];
enhancingPrompt?: boolean; enhancingPrompt?: boolean;
promptEnhanced?: boolean; promptEnhanced?: boolean;
@ -61,7 +62,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
scrollRef, scrollRef,
showChat = true, showChat = true,
chatStarted = false, chatStarted = false,
isStreaming = false, hasPendingMessage = false,
pendingMessageStatus = '',
input = '', input = '',
handleInputChange, handleInputChange,
@ -211,7 +213,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
}; };
const showApproveChange = (() => { const showApproveChange = (() => {
if (isStreaming) { if (hasPendingMessage) {
return false; return false;
} }
@ -288,7 +290,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
event.preventDefault(); event.preventDefault();
if (isStreaming) { if (hasPendingMessage) {
handleStop?.(); handleStop?.();
return; return;
} }
@ -316,10 +318,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<ClientOnly> <ClientOnly>
{() => ( {() => (
<SendButton <SendButton
show={(isStreaming || input.length > 0 || uploadedFiles.length > 0) && chatStarted} show={(hasPendingMessage || input.length > 0 || uploadedFiles.length > 0) && chatStarted}
isStreaming={isStreaming} hasPendingMessage={hasPendingMessage}
onClick={(event) => { onClick={(event) => {
if (isStreaming) { if (hasPendingMessage) {
handleStop?.(); handleStop?.();
return; return;
} }
@ -341,7 +343,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
isListening={isListening} isListening={isListening}
onStart={startListening} onStart={startListening}
onStop={stopListening} onStop={stopListening}
disabled={isStreaming} disabled={hasPendingMessage}
/> />
</div> </div>
{input.length > 3 ? ( {input.length > 3 ? (
@ -386,7 +388,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
ref={messageRef} ref={messageRef}
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1" className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
messages={messages} messages={messages}
isStreaming={isStreaming} hasPendingMessage={hasPendingMessage}
pendingMessageStatus={pendingMessageStatus}
onRewind={onRewind} onRewind={onRewind}
/> />
) : null; ) : null;
@ -470,7 +473,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
{!chatStarted && <div className="flex justify-center gap-2">{ImportButtons(importChat)}</div>} {!chatStarted && <div className="flex justify-center gap-2">{ImportButtons(importChat)}</div>}
{!chatStarted && {!chatStarted &&
ExamplePrompts((event, messageInput) => { ExamplePrompts((event, messageInput) => {
if (isStreaming) { if (hasPendingMessage) {
handleStop?.(); handleStop?.();
return; return;
} }

View File

@ -7,14 +7,13 @@ import { useAnimate } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react'; import { memo, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify'; import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useSnapScroll } from '~/lib/hooks'; import { useSnapScroll } from '~/lib/hooks';
import { useChatHistory } from '~/lib/persistence'; import { currentChatId, handleChatTitleUpdate, useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { cubicEasingFn } from '~/utils/easings'; import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger'; import { renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat'; import { BaseChat } from './BaseChat';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { useSearchParams } from '@remix-run/react'; import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import { import {
simulationAddData, simulationAddData,
simulationFinishData, simulationFinishData,
@ -33,6 +32,7 @@ import type { RejectChangeData } from './ApproveChange';
import { assert, generateRandomId } from '~/lib/replay/ReplayProtocolClient'; import { assert, generateRandomId } from '~/lib/replay/ReplayProtocolClient';
import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/message'; import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/message';
import { useAuthStatus } from '~/lib/stores/auth'; import { useAuthStatus } from '~/lib/stores/auth';
import { debounce } from '~/utils/debounce';
const toastAnimation = cssTransition({ const toastAnimation = cssTransition({
enter: 'animated fadeInRight', enter: 'animated fadeInRight',
@ -111,24 +111,9 @@ export function Chat() {
); );
} }
const processSampledMessages = createSampler(
(options: {
messages: Message[];
initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>;
}) => {
const { messages, initialMessages, storeMessageHistory } = options;
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
}
},
50,
);
interface ChatProps { interface ChatProps {
initialMessages: Message[]; initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>; storeMessageHistory: (messages: Message[]) => void;
importChat: (description: string, messages: Message[]) => Promise<void>; importChat: (description: string, messages: Message[]) => Promise<void>;
} }
@ -156,8 +141,10 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
* This is set when the user has triggered a chat message and the response hasn't finished * This is set when the user has triggered a chat message and the response hasn't finished
* being generated. * being generated.
*/ */
const [activeChatId, setActiveChatId] = useState<string | undefined>(undefined); const [pendingMessageId, setPendingMessageId] = useState<string | undefined>(undefined);
const isLoading = activeChatId !== undefined;
// Last status we heard for the pending message.
const [pendingMessageStatus, setPendingMessageStatus] = useState('');
const [messages, setMessages] = useState<Message[]>(initialMessages); const [messages, setMessages] = useState<Message[]>(initialMessages);
@ -190,18 +177,14 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
}, []); }, []);
useEffect(() => { useEffect(() => {
processSampledMessages({ storeMessageHistory(messages);
messages, }, [messages]);
initialMessages,
storeMessageHistory,
});
}, [messages, isLoading]);
const abort = () => { const abort = () => {
stop(); stop();
gNumAborts++; gNumAborts++;
chatStore.setKey('aborted', true); chatStore.setKey('aborted', true);
setActiveChatId(undefined); setPendingMessageId(undefined);
if (gActiveChatMessageTelemetry) { if (gActiveChatMessageTelemetry) {
gActiveChatMessageTelemetry.abort('StopButtonClicked'); gActiveChatMessageTelemetry.abort('StopButtonClicked');
@ -242,7 +225,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
const _input = messageInput || input; const _input = messageInput || input;
const numAbortsAtStart = gNumAborts; const numAbortsAtStart = gNumAborts;
if (_input.length === 0 || isLoading) { if (_input.length === 0 || pendingMessageId) {
return; return;
} }
@ -262,7 +245,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
} }
const chatId = generateRandomId(); const chatId = generateRandomId();
setActiveChatId(chatId); setPendingMessageId(chatId);
const userMessage: Message = { const userMessage: Message = {
id: `user-${chatId}`, id: `user-${chatId}`,
@ -331,6 +314,16 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
} }
}; };
const onChatTitle = (title: string) => {
console.log('ChatTitle', title);
handleChatTitleUpdate(currentChatId.get() as string, title);
};
const onChatStatus = debounce((status: string) => {
console.log('ChatStatus', status);
setPendingMessageStatus(status);
}, 500);
const references: ChatReference[] = []; const references: ChatReference[] = [];
const mouseData = getCurrentMouseData(); const mouseData = getCurrentMouseData();
@ -347,7 +340,11 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
} }
try { try {
await sendChatMessage(newMessages, references, addResponseMessage); await sendChatMessage(newMessages, references, {
onResponsePart: addResponseMessage,
onTitle: onChatTitle,
onStatus: onChatStatus,
});
} catch (e) { } catch (e) {
toast.error('Error sending message'); toast.error('Error sending message');
console.error('Error sending message', e); console.error('Error sending message', e);
@ -360,7 +357,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
gActiveChatMessageTelemetry.finish(); gActiveChatMessageTelemetry.finish();
clearActiveChat(); clearActiveChat();
setActiveChatId(undefined); setPendingMessageId(undefined);
setInput(''); setInput('');
@ -493,7 +490,8 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
input={input} input={input}
showChat={showChat} showChat={showChat}
chatStarted={chatStarted} chatStarted={chatStarted}
isStreaming={isLoading} hasPendingMessage={pendingMessageId !== undefined}
pendingMessageStatus={pendingMessageStatus}
sendMessage={sendMessage} sendMessage={sendMessage}
messageRef={messageRef} messageRef={messageRef}
scrollRef={scrollRef} scrollRef={scrollRef}

View File

@ -8,7 +8,7 @@ import type { Message } from '~/lib/persistence/message';
interface ImportFolderButtonProps { interface ImportFolderButtonProps {
className?: string; className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>; importChat?: (title: string, messages: Message[]) => Promise<void>;
} }
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => { export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
@ -85,7 +85,7 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
const messages = createChatFromFolder(folderName, repositoryId); const messages = createChatFromFolder(folderName, repositoryId);
if (importChat) { if (importChat) {
await importChat(folderName, [...messages]); await importChat(folderName, messages);
} }
logStore.logSystem('Folder imported successfully', { logStore.logSystem('Folder imported successfully', {

View File

@ -1,11 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { createChatFromFolder } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; import { logStore } from '~/lib/stores/logs';
import { assert } from '~/lib/replay/ReplayProtocolClient'; import { assert } from '~/lib/replay/ReplayProtocolClient';
import type { NutProblem } from '~/lib/replay/Problems'; import type { NutProblem } from '~/lib/replay/Problems';
import { getProblem } from '~/lib/replay/Problems'; import { getProblem } from '~/lib/replay/Problems';
import type { Message } from '~/lib/persistence/message'; import { createMessagesForRepository, type Message } from '~/lib/persistence/message';
interface LoadProblemButtonProps { interface LoadProblemButtonProps {
className?: string; className?: string;
@ -48,8 +47,8 @@ export async function loadProblem(
const { repositoryId, title: problemTitle } = problem; const { repositoryId, title: problemTitle } = problem;
try { try {
const messages = createChatFromFolder('problem', repositoryId); const messages = createMessagesForRepository(`Problem: ${problemTitle}`, repositoryId);
await importChat(`Problem: ${problemTitle}`, [...messages]); await importChat(`Problem: ${problemTitle}`, messages);
logStore.logSystem('Problem loaded successfully', { logStore.logSystem('Problem loaded successfully', {
problemId, problemId,

View File

@ -7,13 +7,14 @@ import { MessageContents } from './MessageContents';
interface MessagesProps { interface MessagesProps {
id?: string; id?: string;
className?: string; className?: string;
isStreaming?: boolean; hasPendingMessage?: boolean;
pendingMessageStatus?: string;
messages?: Message[]; messages?: Message[];
onRewind?: (messageId: string) => void; onRewind?: (messageId: string) => void;
} }
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => { export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, isStreaming = false, messages = [], onRewind } = props; const { id, hasPendingMessage = false, pendingMessageStatus = '', messages = [], onRewind } = props;
return ( return (
<div id={id} ref={ref} className={props.className}> <div id={id} ref={ref} className={props.className}>
@ -30,9 +31,10 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
data-testid="message" data-testid="message"
key={index} key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', { className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast), 'bg-bolt-elements-messages-background':
isUserMessage || !hasPendingMessage || (hasPendingMessage && !isLast),
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent': 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
isStreaming && isLast, hasPendingMessage && isLast,
'mt-4': !isFirst, 'mt-4': !isFirst,
})} })}
> >
@ -70,8 +72,11 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
); );
}) })
: null} : null}
{isStreaming && ( {hasPendingMessage && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div> <div className="w-full text-bolt-elements-textSecondary flex items-center">
<span className="i-svg-spinners:3-dots-fade inline-block w-[1em] h-[1em] mr-2 text-4xl"></span>
<span className="text-lg">{pendingMessageStatus ? `${pendingMessageStatus}...` : ''}</span>
</div>
)} )}
</div> </div>
); );

View File

@ -2,7 +2,7 @@ import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
interface SendButtonProps { interface SendButtonProps {
show: boolean; show: boolean;
isStreaming?: boolean; hasPendingMessage?: boolean;
disabled?: boolean; disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onImagesSelected?: (images: File[]) => void; onImagesSelected?: (images: File[]) => void;
@ -10,12 +10,12 @@ interface SendButtonProps {
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1); const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonProps) => { export const SendButton = ({ show, hasPendingMessage, disabled, onClick }: SendButtonProps) => {
const className = const className =
'absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme disabled:opacity-50 disabled:cursor-not-allowed'; 'absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme disabled:opacity-50 disabled:cursor-not-allowed';
// Determine tooltip text based on button state // Determine tooltip text based on button state
const tooltipText = isStreaming ? 'Stop Generation' : 'Chat'; const tooltipText = hasPendingMessage ? 'Stop Generation' : 'Chat';
return ( return (
<AnimatePresence> <AnimatePresence>
@ -37,7 +37,11 @@ export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonP
}} }}
> >
<div className="text-lg"> <div className="text-lg">
{!isStreaming ? <div className="i-ph:hand-fill"></div> : <div className="i-ph:stop-circle-bold"></div>} {!hasPendingMessage ? (
<div className="i-ph:hand-fill"></div>
) : (
<div className="i-ph:stop-circle-bold"></div>
)}
</div> </div>
</motion.button> </motion.button>
) : null} ) : null}

View File

@ -20,7 +20,6 @@ export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) {
useEditChatTitle({ useEditChatTitle({
initialTitle: item.title, initialTitle: item.title,
customChatId: item.id, customChatId: item.id,
syncWithGlobalStore: isActiveChat,
}); });
const renderDescriptionForm = ( const renderDescriptionForm = (

View File

@ -1,12 +1,11 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { currentChatId, currentChatTitle, getChatContents, updateChatTitle } from '~/lib/persistence'; import { currentChatId, currentChatTitle, getChatContents, handleChatTitleUpdate } from '~/lib/persistence';
interface EditChatDescriptionOptions { interface EditChatDescriptionOptions {
initialTitle?: string; initialTitle?: string;
customChatId?: string; customChatId?: string;
syncWithGlobalStore?: boolean;
} }
type EditChatDescriptionHook = { type EditChatDescriptionHook = {
@ -30,13 +29,11 @@ type EditChatDescriptionHook = {
* @param {Object} options * @param {Object} options
* @param {string} options.initialDescription - The current chat description. * @param {string} options.initialDescription - The current chat description.
* @param {string} options.customChatId - Optional ID for updating the description via the sidebar. * @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
* @param {boolean} options.syncWithGlobalStore - Flag to indicate global description store synchronization.
* @returns {EditChatDescriptionHook} Methods and state for managing description edits. * @returns {EditChatDescriptionHook} Methods and state for managing description edits.
*/ */
export function useEditChatTitle({ export function useEditChatTitle({
initialTitle = currentChatTitle.get()!, initialTitle = currentChatTitle.get()!,
customChatId, customChatId,
syncWithGlobalStore,
}: EditChatDescriptionOptions): EditChatDescriptionHook { }: EditChatDescriptionOptions): EditChatDescriptionHook {
const chatIdFromStore = useStore(currentChatId); const chatIdFromStore = useStore(currentChatId);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@ -117,12 +114,7 @@ export function useEditChatTitle({
return; return;
} }
await updateChatTitle(chatId, currentTitle); await handleChatTitleUpdate(chatId, currentTitle);
if (syncWithGlobalStore) {
currentChatTitle.set(currentTitle);
}
toast.success('Chat title updated successfully'); toast.success('Chat title updated successfully');
} catch (error) { } catch (error) {
toast.error('Failed to update chat title: ' + (error as Error).message); toast.error('Failed to update chat title: ' + (error as Error).message);

View File

@ -10,7 +10,6 @@ export function ChatDescription() {
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentTitle, toggleEditMode } = const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentTitle, toggleEditMode } =
useEditChatTitle({ useEditChatTitle({
initialTitle, initialTitle,
syncWithGlobalStore: true,
}); });
if (!initialTitle) { if (!initialTitle) {

View File

@ -1,6 +1,11 @@
import { getSupabase } from '~/lib/supabase/client'; // When the user is not logged in, chats are kept in local storage.
// Otherwise, they are kept in the database. The first time the user logs in,
// any local chats are added to the database and then deleted.
import { getSupabase, getCurrentUserId } from '~/lib/supabase/client';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { getMessagesRepositoryId, type Message } from './message'; import { getMessagesRepositoryId, type Message } from './message';
import { assert } from '~/lib/replay/ReplayProtocolClient';
export interface ChatContents { export interface ChatContents {
id: string; id: string;
@ -22,7 +27,31 @@ function databaseRowToChatContents(d: any): ChatContents {
}; };
} }
const localStorageKey = 'nut-chats';
function getLocalChats(): ChatContents[] {
const chatJSON = localStorage.getItem(localStorageKey);
if (!chatJSON) {
return [];
}
return JSON.parse(chatJSON);
}
function setLocalChats(chats: ChatContents[] | undefined): void {
if (chats) {
localStorage.setItem(localStorageKey, JSON.stringify(chats));
} else {
localStorage.removeItem(localStorageKey);
}
}
export async function getAllChats(): Promise<ChatContents[]> { export async function getAllChats(): Promise<ChatContents[]> {
const userId = await getCurrentUserId();
if (!userId) {
return getLocalChats();
}
const { data, error } = await getSupabase().from('chats').select('*'); const { data, error } = await getSupabase().from('chats').select('*');
if (error) { if (error) {
@ -32,12 +61,33 @@ export async function getAllChats(): Promise<ChatContents[]> {
return data.map(databaseRowToChatContents); return data.map(databaseRowToChatContents);
} }
export async function syncLocalChats(): Promise<void> {
const userId = await getCurrentUserId();
const localChats = getLocalChats();
if (userId && localChats.length) {
for (const chat of localChats) {
await setChatContents(chat.id, chat.title, chat.messages);
}
setLocalChats(undefined);
}
}
export async function setChatContents(id: string, title: string, messages: Message[]): Promise<void> { export async function setChatContents(id: string, title: string, messages: Message[]): Promise<void> {
const { data: user } = await getSupabase().auth.getUser(); const userId = await getCurrentUserId();
const userId = user.user?.id;
if (!userId) { if (!userId) {
throw new Error('Not logged in'); const localChats = getLocalChats().filter((c) => c.id != id);
localChats.push({
id,
title,
messages,
repositoryId: getMessagesRepositoryId(messages),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
setLocalChats(localChats);
return;
} }
const repositoryId = getMessagesRepositoryId(messages); const repositoryId = getMessagesRepositoryId(messages);
@ -55,7 +105,30 @@ export async function setChatContents(id: string, title: string, messages: Messa
} }
} }
export async function getChatContents(id: string): Promise<ChatContents> { export 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) {
throw error;
}
if (data.length != 1) {
throw new Error(`Unknown chat ${id}`);
}
return {
repositoryId: data[0].repository_id,
title: data[0].title,
};
}
export async function getChatContents(id: string): Promise<ChatContents | undefined> {
const userId = await getCurrentUserId();
if (!userId) {
return getLocalChats().find((c) => c.id == id);
}
const { data, error } = await getSupabase().from('chats').select('*').eq('id', id); const { data, error } = await getSupabase().from('chats').select('*').eq('id', id);
if (error) { if (error) {
@ -63,13 +136,21 @@ export async function getChatContents(id: string): Promise<ChatContents> {
} }
if (data.length != 1) { if (data.length != 1) {
throw new Error('Unexpected chat contents returned'); return undefined;
} }
return databaseRowToChatContents(data[0]); return databaseRowToChatContents(data[0]);
} }
export async function deleteById(id: string): Promise<void> { export async function deleteById(id: string): Promise<void> {
const userId = await getCurrentUserId();
if (!userId) {
const localChats = getLocalChats().filter((c) => c.id != id);
setLocalChats(localChats);
return;
}
const { error } = await getSupabase().from('chats').delete().eq('id', id); const { error } = await getSupabase().from('chats').delete().eq('id', id);
if (error) { if (error) {
@ -83,8 +164,9 @@ export async function createChat(title: string, messages: Message[]): Promise<st
return id; return id;
} }
export async function updateChatTitle(id: string, title: string): Promise<void> { export async function databaseUpdateChatTitle(id: string, title: string): Promise<void> {
const chat = await getChatContents(id); const chat = await getChatContents(id);
assert(chat, 'Unknown chat');
if (!title.trim()) { if (!title.trim()) {
throw new Error('Title cannot be empty'); throw new Error('Title cannot be empty');

View File

@ -1,5 +1,7 @@
// Client messages match the format used by the Nut protocol. // Client messages match the format used by the Nut protocol.
import { generateId } from '~/utils/fileUtils';
type MessageRole = 'user' | 'assistant'; type MessageRole = 'user' | 'assistant';
interface MessageBase { interface MessageBase {
@ -36,3 +38,27 @@ export function getPreviousRepositoryId(messages: Message[], index: number): str
export function getMessagesRepositoryId(messages: Message[]): string | undefined { export function getMessagesRepositoryId(messages: Message[]): string | undefined {
return getPreviousRepositoryId(messages, messages.length); return getPreviousRepositoryId(messages, messages.length);
} }
// Return a couple messages for a new chat operating on a repository.
export function createMessagesForRepository(title: string, repositoryId: string): Message[] {
const filesContent = `I've copied the "${title}" chat.`;
const userMessage: Message = {
role: 'user',
id: generateId(),
content: `Copy the "${title}" chat`,
type: 'text',
};
const filesMessage: Message = {
role: 'assistant',
content: filesContent,
id: generateId(),
repositoryId,
type: 'text',
};
const messages = [userMessage, filesMessage];
return messages;
}

View File

@ -1,25 +1,26 @@
import { useLoaderData, useNavigate } from '@remix-run/react'; import { useLoaderData } from '@remix-run/react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { logStore } from '~/lib/stores/logs'; // Import logStore import { logStore } from '~/lib/stores/logs'; // Import logStore
import { createChat, getChatContents, setChatContents } from './db'; import { createChat, getChatContents, setChatContents, getChatPublicData, databaseUpdateChatTitle } from './db';
import { loadProblem } from '~/components/chat/LoadProblemButton'; import { loadProblem } from '~/components/chat/LoadProblemButton';
import type { Message } from './message'; 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 currentChatId = atom<string | undefined>(undefined);
export const currentChatTitle = atom<string | undefined>(undefined); export const currentChatTitle = atom<string | undefined>(undefined);
export function useChatHistory() { export function useChatHistory() {
const navigate = useNavigate();
const { id: mixedId, problemId } = useLoaderData<{ id?: string; problemId?: string }>() ?? {}; const { id: mixedId, problemId } = useLoaderData<{ id?: string; problemId?: string }>() ?? {};
const [initialMessages, setInitialMessages] = useState<Message[]>([]); const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [ready, setReady] = useState<boolean>(!mixedId && !problemId); const [ready, setReady] = useState<boolean>(!mixedId && !problemId);
const importChat = async (description: string, messages: Message[]) => { const importChat = async (title: string, messages: Message[]) => {
try { try {
const newId = await createChat(description, messages); const newId = await createChat(title, messages);
window.location.href = `/chat/${newId}`; window.location.href = `/chat/${newId}`;
toast.success('Chat imported successfully'); toast.success('Chat imported successfully');
} catch (error) { } catch (error) {
@ -32,46 +33,49 @@ export function useChatHistory() {
}; };
useEffect(() => { useEffect(() => {
if (mixedId) { (async () => {
getChatContents(mixedId) try {
.then((chatContents) => { if (mixedId) {
if (chatContents && chatContents.messages.length > 0) { const chatContents = await getChatContents(mixedId);
if (chatContents) {
setInitialMessages(chatContents.messages); setInitialMessages(chatContents.messages);
currentChatTitle.set(chatContents.title); currentChatTitle.set(chatContents.title);
currentChatId.set(mixedId); currentChatId.set(mixedId);
} else { setReady(true);
navigate('/', { replace: true }); return;
} }
const publicData = await getChatPublicData(mixedId);
const messages = createMessagesForRepository(publicData.title, publicData.repositoryId);
await importChat(publicData.title, messages);
} else if (problemId) {
await loadProblem(problemId, importChat);
setReady(true); setReady(true);
}) }
.catch((error) => { } catch (error) {
logStore.logError('Failed to load chat messages', error); logStore.logError('Failed to load chat messages', error);
toast.error(error.message); toast.error((error as any).message);
}); }
} else if (problemId) { })();
loadProblem(problemId, importChat).then(() => setReady(true));
}
}, []); }, []);
return { return {
ready, ready,
initialMessages, initialMessages,
storeMessageHistory: async (messages: Message[]) => { storeMessageHistory: debounce(async (messages: Message[]) => {
if (messages.length === 0) { if (messages.length === 0) {
return; return;
} }
const title = currentChatTitle.get() ?? 'New Chat';
if (!currentChatId.get()) { if (!currentChatId.get()) {
const id = await createChat(title, initialMessages); const id = await createChat('New Chat', initialMessages);
currentChatId.set(id); currentChatId.set(id);
currentChatTitle.set('New Chat');
navigateChat(id); navigateChat(id);
} }
await setChatContents(currentChatId.get() as string, title, messages); await setChatContents(currentChatId.get() as string, currentChatTitle.get() as string, messages);
}, }, 1000),
importChat, importChat,
}; };
} }
@ -87,3 +91,10 @@ function navigateChat(nextId: string) {
window.history.replaceState({}, '', url); window.history.replaceState({}, '', url);
} }
export async function handleChatTitleUpdate(id: string, title: string) {
await databaseUpdateChatTitle(id, title);
if (currentChatId.get() == id) {
currentChatTitle.set(title);
}
}

View File

@ -28,7 +28,11 @@ interface ChatReferenceElement {
export type ChatReference = ChatReferenceElement; export type ChatReference = ChatReferenceElement;
type ChatResponsePartCallback = (message: Message) => void; interface ChatMessageCallbacks {
onResponsePart: (message: Message) => void;
onTitle: (title: string) => void;
onStatus: (status: string) => void;
}
class ChatManager { class ChatManager {
// Empty if this chat has been destroyed. // Empty if this chat has been destroyed.
@ -138,7 +142,7 @@ class ChatManager {
}); });
} }
async sendChatMessage(messages: Message[], references: ChatReference[], onResponsePart: ChatResponsePartCallback) { async sendChatMessage(messages: Message[], references: ChatReference[], callbacks: ChatMessageCallbacks) {
assert(this.client, 'Chat has been destroyed'); assert(this.client, 'Chat has been destroyed');
this._pendingMessages++; this._pendingMessages++;
@ -150,7 +154,27 @@ class ChatManager {
({ responseId: eventResponseId, message }: { responseId: string; message: Message }) => { ({ responseId: eventResponseId, message }: { responseId: string; message: Message }) => {
if (responseId == eventResponseId) { if (responseId == eventResponseId) {
console.log('ChatResponse', chatId, message); console.log('ChatResponse', chatId, message);
onResponsePart(message); callbacks.onResponsePart(message);
}
},
);
const removeTitleListener = this.client.listenForMessage(
'Nut.chatTitle',
({ responseId: eventResponseId, title }: { responseId: string; title: string }) => {
if (responseId == eventResponseId) {
console.log('ChatTitle', title);
callbacks.onTitle(title);
}
},
);
const removeStatusListener = this.client.listenForMessage(
'Nut.chatStatus',
({ responseId: eventResponseId, status }: { responseId: string; status: string }) => {
if (responseId == eventResponseId) {
console.log('ChatStatus', status);
callbacks.onStatus(status);
} }
}, },
); );
@ -167,6 +191,8 @@ class ChatManager {
console.log('ChatMessageFinished', new Date().toISOString(), chatId); console.log('ChatMessageFinished', new Date().toISOString(), chatId);
removeResponseListener(); removeResponseListener();
removeTitleListener();
removeStatusListener();
if (--this._pendingMessages == 0 && this._mustDestroyAfterChatFinishes) { if (--this._pendingMessages == 0 && this._mustDestroyAfterChatFinishes) {
this._destroy(); this._destroy();
@ -266,7 +292,7 @@ export function getLastSimulationChatReferences(): ChatReference[] | undefined {
export async function sendChatMessage( export async function sendChatMessage(
messages: Message[], messages: Message[],
references: ChatReference[], references: ChatReference[],
onResponsePart: ChatResponsePartCallback, callbacks: ChatMessageCallbacks,
) { ) {
if (!gChatManager) { if (!gChatManager) {
gChatManager = new ChatManager(); gChatManager = new ChatManager();
@ -275,5 +301,5 @@ export async function sendChatMessage(
gLastSimulationChatMessages = messages; gLastSimulationChatMessages = messages;
gLastSimulationChatReferences = references; gLastSimulationChatReferences = references;
await gChatManager.sendChatMessage(messages, references, onResponsePart); await gChatManager.sendChatMessage(messages, references, callbacks);
} }

View File

@ -4,6 +4,7 @@ import type { User, Session } from '@supabase/supabase-js';
import { logStore } from './logs'; import { logStore } from './logs';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isAuthenticated } from '~/lib/supabase/client'; import { isAuthenticated } from '~/lib/supabase/client';
import { syncLocalChats } from '~/lib/persistence/db';
export const userStore = atom<User | null>(null); export const userStore = atom<User | null>(null);
export const sessionStore = atom<Session | null>(null); export const sessionStore = atom<Session | null>(null);
@ -68,6 +69,8 @@ export async function initializeAuth() {
sessionStore.set(null); sessionStore.set(null);
logStore.logSystem('User signed out'); logStore.logSystem('User signed out');
} }
await syncLocalChats();
}); });
return () => { return () => {

View File

@ -77,6 +77,11 @@ export async function getCurrentUser(): Promise<SupabaseUser | null> {
} }
} }
export async function getCurrentUserId(): Promise<string | null> {
const user = await getCurrentUser();
return user?.id || null;
}
export async function getNutIsAdmin(): Promise<boolean> { export async function getNutIsAdmin(): Promise<boolean> {
const user = await getCurrentUser(); const user = await getCurrentUser();
@ -137,8 +142,3 @@ export function getSupabase() {
return supabaseClientInstance; return supabaseClientInstance;
} }
// Helper function to check if Supabase is properly initialized
export const isSupabaseInitialized = (): boolean => {
return Boolean(supabaseUrl && supabaseAnonKey);
};

View File

@ -36,6 +36,7 @@ export async function getFileRepositoryContents(files: File[]): Promise<string>
return await zip.generateAsync({ type: 'base64' }); return await zip.generateAsync({ type: 'base64' });
} }
// TODO: Common up with createMessagesForRepository.
export function createChatFromFolder(folderName: string, repositoryId: string): Message[] { export function createChatFromFolder(folderName: string, repositoryId: string): Message[] {
const filesContent = `I've imported the contents of the "${folderName}" folder.`; const filesContent = `I've imported the contents of the "${folderName}" folder.`;

View File

@ -1,49 +0,0 @@
/**
* Creates a function that samples calls at regular intervals and captures trailing calls.
* - Drops calls that occur between sampling intervals
* - Takes one call per sampling interval if available
* - Captures the last call if no call was made during the interval
*
* @param fn The function to sample
* @param sampleInterval How often to sample calls (in ms)
* @returns The sampled function
*/
export function createSampler<T extends (...args: any[]) => any>(fn: T, sampleInterval: number): T {
let lastArgs: Parameters<T> | null = null;
let lastTime = 0;
let timeout: NodeJS.Timeout | null = null;
// Create a function with the same type as the input function
const sampled = function (this: any, ...args: Parameters<T>) {
const now = Date.now();
lastArgs = args;
// If we're within the sample interval, just store the args
if (now - lastTime < sampleInterval) {
// Set up trailing call if not already set
if (!timeout) {
timeout = setTimeout(
() => {
timeout = null;
lastTime = Date.now();
if (lastArgs) {
fn.apply(this, lastArgs);
lastArgs = null;
}
},
sampleInterval - (now - lastTime),
);
}
return;
}
// If we're outside the interval, execute immediately
lastTime = now;
fn.apply(this, args);
lastArgs = null;
} as T;
return sampled;
}

View File

@ -78,4 +78,4 @@ BEGIN
FROM chats p FROM chats p
WHERE p.id = chat_id; WHERE p.id = chat_id;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql SECURITY DEFINER;