mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Fix UX problems sharing chats and using Nut without being logged in (#87)
This commit is contained in:
parent
ac1858b84e
commit
8fb01c5586
@ -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;
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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', {
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -20,7 +20,6 @@ export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) {
|
||||
useEditChatTitle({
|
||||
initialTitle: item.title,
|
||||
customChatId: item.id,
|
||||
syncWithGlobalStore: isActiveChat,
|
||||
});
|
||||
|
||||
const renderDescriptionForm = (
|
||||
|
@ -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);
|
||||
|
@ -10,7 +10,6 @@ export function ChatDescription() {
|
||||
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentTitle, toggleEditMode } =
|
||||
useEditChatTitle({
|
||||
initialTitle,
|
||||
syncWithGlobalStore: true,
|
||||
});
|
||||
|
||||
if (!initialTitle) {
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 () => {
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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.`;
|
||||
|
||||
|
@ -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;
|
||||
}
|
@ -78,4 +78,4 @@ BEGIN
|
||||
FROM chats p
|
||||
WHERE p.id = chat_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
Loading…
Reference in New Issue
Block a user