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

View File

@ -7,14 +7,13 @@ 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 { useChatHistory } from '~/lib/persistence';
import { currentChatId, handleChatTitleUpdate, useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat';
import Cookies from 'js-cookie';
import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import {
simulationAddData,
simulationFinishData,
@ -33,6 +32,7 @@ import type { RejectChangeData } from './ApproveChange';
import { assert, generateRandomId } from '~/lib/replay/ReplayProtocolClient';
import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/message';
import { useAuthStatus } from '~/lib/stores/auth';
import { debounce } from '~/utils/debounce';
const toastAnimation = cssTransition({
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 {
initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>;
storeMessageHistory: (messages: Message[]) => 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
* being generated.
*/
const [activeChatId, setActiveChatId] = useState<string | undefined>(undefined);
const isLoading = activeChatId !== undefined;
const [pendingMessageId, setPendingMessageId] = useState<string | undefined>(undefined);
// Last status we heard for the pending message.
const [pendingMessageStatus, setPendingMessageStatus] = useState('');
const [messages, setMessages] = useState<Message[]>(initialMessages);
@ -190,18 +177,14 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
}, []);
useEffect(() => {
processSampledMessages({
messages,
initialMessages,
storeMessageHistory,
});
}, [messages, isLoading]);
storeMessageHistory(messages);
}, [messages]);
const abort = () => {
stop();
gNumAborts++;
chatStore.setKey('aborted', true);
setActiveChatId(undefined);
setPendingMessageId(undefined);
if (gActiveChatMessageTelemetry) {
gActiveChatMessageTelemetry.abort('StopButtonClicked');
@ -242,7 +225,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
const _input = messageInput || input;
const numAbortsAtStart = gNumAborts;
if (_input.length === 0 || isLoading) {
if (_input.length === 0 || pendingMessageId) {
return;
}
@ -262,7 +245,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
}
const chatId = generateRandomId();
setActiveChatId(chatId);
setPendingMessageId(chatId);
const userMessage: Message = {
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 mouseData = getCurrentMouseData();
@ -347,7 +340,11 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
}
try {
await sendChatMessage(newMessages, references, addResponseMessage);
await sendChatMessage(newMessages, references, {
onResponsePart: addResponseMessage,
onTitle: onChatTitle,
onStatus: onChatStatus,
});
} catch (e) {
toast.error('Error sending message');
console.error('Error sending message', e);
@ -360,7 +357,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
gActiveChatMessageTelemetry.finish();
clearActiveChat();
setActiveChatId(undefined);
setPendingMessageId(undefined);
setInput('');
@ -493,7 +490,8 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory, importChat
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading}
hasPendingMessage={pendingMessageId !== undefined}
pendingMessageStatus={pendingMessageStatus}
sendMessage={sendMessage}
messageRef={messageRef}
scrollRef={scrollRef}

View File

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

View File

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

View File

@ -7,13 +7,14 @@ import { MessageContents } from './MessageContents';
interface MessagesProps {
id?: string;
className?: string;
isStreaming?: boolean;
hasPendingMessage?: boolean;
pendingMessageStatus?: string;
messages?: Message[];
onRewind?: (messageId: string) => void;
}
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 (
<div id={id} ref={ref} className={props.className}>
@ -30,9 +31,10 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
data-testid="message"
key={index}
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':
isStreaming && isLast,
hasPendingMessage && isLast,
'mt-4': !isFirst,
})}
>
@ -70,8 +72,11 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
{hasPendingMessage && (
<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>
);

View File

@ -2,7 +2,7 @@ import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
interface SendButtonProps {
show: boolean;
isStreaming?: boolean;
hasPendingMessage?: boolean;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onImagesSelected?: (images: File[]) => void;
@ -10,12 +10,12 @@ interface SendButtonProps {
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 =
'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
const tooltipText = isStreaming ? 'Stop Generation' : 'Chat';
const tooltipText = hasPendingMessage ? 'Stop Generation' : 'Chat';
return (
<AnimatePresence>
@ -37,7 +37,11 @@ export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonP
}}
>
<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>
</motion.button>
) : null}

View File

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

View File

@ -1,12 +1,11 @@
import { useStore } from '@nanostores/react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { currentChatId, currentChatTitle, getChatContents, updateChatTitle } from '~/lib/persistence';
import { currentChatId, currentChatTitle, getChatContents, handleChatTitleUpdate } from '~/lib/persistence';
interface EditChatDescriptionOptions {
initialTitle?: string;
customChatId?: string;
syncWithGlobalStore?: boolean;
}
type EditChatDescriptionHook = {
@ -30,13 +29,11 @@ type EditChatDescriptionHook = {
* @param {Object} options
* @param {string} options.initialDescription - The current chat description.
* @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.
*/
export function useEditChatTitle({
initialTitle = currentChatTitle.get()!,
customChatId,
syncWithGlobalStore,
}: EditChatDescriptionOptions): EditChatDescriptionHook {
const chatIdFromStore = useStore(currentChatId);
const [editing, setEditing] = useState(false);
@ -117,12 +114,7 @@ export function useEditChatTitle({
return;
}
await updateChatTitle(chatId, currentTitle);
if (syncWithGlobalStore) {
currentChatTitle.set(currentTitle);
}
await handleChatTitleUpdate(chatId, currentTitle);
toast.success('Chat title updated successfully');
} catch (error) {
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 } =
useEditChatTitle({
initialTitle,
syncWithGlobalStore: true,
});
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 { getMessagesRepositoryId, type Message } from './message';
import { assert } from '~/lib/replay/ReplayProtocolClient';
export interface ChatContents {
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[]> {
const userId = await getCurrentUserId();
if (!userId) {
return getLocalChats();
}
const { data, error } = await getSupabase().from('chats').select('*');
if (error) {
@ -32,12 +61,33 @@ export async function getAllChats(): Promise<ChatContents[]> {
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> {
const { data: user } = await getSupabase().auth.getUser();
const userId = user.user?.id;
const userId = await getCurrentUserId();
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);
@ -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);
if (error) {
@ -63,13 +136,21 @@ export async function getChatContents(id: string): Promise<ChatContents> {
}
if (data.length != 1) {
throw new Error('Unexpected chat contents returned');
return undefined;
}
return databaseRowToChatContents(data[0]);
}
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);
if (error) {
@ -83,8 +164,9 @@ export async function createChat(title: string, messages: Message[]): Promise<st
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);
assert(chat, 'Unknown chat');
if (!title.trim()) {
throw new Error('Title cannot be empty');

View File

@ -1,5 +1,7 @@
// Client messages match the format used by the Nut protocol.
import { generateId } from '~/utils/fileUtils';
type MessageRole = 'user' | 'assistant';
interface MessageBase {
@ -36,3 +38,27 @@ export function getPreviousRepositoryId(messages: Message[], index: number): str
export function getMessagesRepositoryId(messages: Message[]): string | undefined {
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 { atom } from 'nanostores';
import { toast } from 'react-toastify';
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 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 currentChatTitle = atom<string | undefined>(undefined);
export function useChatHistory() {
const navigate = useNavigate();
const { id: mixedId, problemId } = useLoaderData<{ id?: string; problemId?: string }>() ?? {};
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [ready, setReady] = useState<boolean>(!mixedId && !problemId);
const importChat = async (description: string, messages: Message[]) => {
const importChat = async (title: string, messages: Message[]) => {
try {
const newId = await createChat(description, messages);
const newId = await createChat(title, messages);
window.location.href = `/chat/${newId}`;
toast.success('Chat imported successfully');
} catch (error) {
@ -32,46 +33,49 @@ export function useChatHistory() {
};
useEffect(() => {
if (mixedId) {
getChatContents(mixedId)
.then((chatContents) => {
if (chatContents && chatContents.messages.length > 0) {
(async () => {
try {
if (mixedId) {
const chatContents = await getChatContents(mixedId);
if (chatContents) {
setInitialMessages(chatContents.messages);
currentChatTitle.set(chatContents.title);
currentChatId.set(mixedId);
} else {
navigate('/', { replace: true });
setReady(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);
})
.catch((error) => {
logStore.logError('Failed to load chat messages', error);
toast.error(error.message);
});
} else if (problemId) {
loadProblem(problemId, importChat).then(() => setReady(true));
}
}
} catch (error) {
logStore.logError('Failed to load chat messages', error);
toast.error((error as any).message);
}
})();
}, []);
return {
ready,
initialMessages,
storeMessageHistory: async (messages: Message[]) => {
storeMessageHistory: debounce(async (messages: Message[]) => {
if (messages.length === 0) {
return;
}
const title = currentChatTitle.get() ?? 'New Chat';
if (!currentChatId.get()) {
const id = await createChat(title, initialMessages);
const id = await createChat('New Chat', initialMessages);
currentChatId.set(id);
currentChatTitle.set('New Chat');
navigateChat(id);
}
await setChatContents(currentChatId.get() as string, title, messages);
},
await setChatContents(currentChatId.get() as string, currentChatTitle.get() as string, messages);
}, 1000),
importChat,
};
}
@ -87,3 +91,10 @@ function navigateChat(nextId: string) {
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;
type ChatResponsePartCallback = (message: Message) => void;
interface ChatMessageCallbacks {
onResponsePart: (message: Message) => void;
onTitle: (title: string) => void;
onStatus: (status: string) => void;
}
class ChatManager {
// 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');
this._pendingMessages++;
@ -150,7 +154,27 @@ class ChatManager {
({ responseId: eventResponseId, message }: { responseId: string; message: Message }) => {
if (responseId == eventResponseId) {
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);
removeResponseListener();
removeTitleListener();
removeStatusListener();
if (--this._pendingMessages == 0 && this._mustDestroyAfterChatFinishes) {
this._destroy();
@ -266,7 +292,7 @@ export function getLastSimulationChatReferences(): ChatReference[] | undefined {
export async function sendChatMessage(
messages: Message[],
references: ChatReference[],
onResponsePart: ChatResponsePartCallback,
callbacks: ChatMessageCallbacks,
) {
if (!gChatManager) {
gChatManager = new ChatManager();
@ -275,5 +301,5 @@ export async function sendChatMessage(
gLastSimulationChatMessages = messages;
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 { useEffect, useState } from 'react';
import { isAuthenticated } from '~/lib/supabase/client';
import { syncLocalChats } from '~/lib/persistence/db';
export const userStore = atom<User | null>(null);
export const sessionStore = atom<Session | null>(null);
@ -68,6 +69,8 @@ export async function initializeAuth() {
sessionStore.set(null);
logStore.logSystem('User signed out');
}
await syncLocalChats();
});
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> {
const user = await getCurrentUser();
@ -137,8 +142,3 @@ export function getSupabase() {
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' });
}
// TODO: Common up with createMessagesForRepository.
export function createChatFromFolder(folderName: string, repositoryId: string): Message[] {
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
WHERE p.id = chat_id;
END;
$$ LANGUAGE plpgsql;
$$ LANGUAGE plpgsql SECURITY DEFINER;