From 2987f9b3892afac12e51d406a43c180a53ca0ec0 Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 25 Sep 2024 11:29:15 -0600 Subject: [PATCH 1/5] init --- .../src/lib/components/AssistantForm.svelte | 2 +- .../src/routes/api/threads/+server.ts | 67 +++++++++++ .../(dashboard)/[[thread_id]]/+page.svelte | 110 +++++++++++------- .../chat/(dashboard)/[[thread_id]]/+page.ts | 29 ----- .../src/routes/chat/+layout.server.ts | 64 +--------- src/leapfrogai_ui/src/routes/chat/+layout.ts | 29 ++--- 6 files changed, 149 insertions(+), 152 deletions(-) create mode 100644 src/leapfrogai_ui/src/routes/api/threads/+server.ts delete mode 100644 src/leapfrogai_ui/src/routes/chat/(dashboard)/[[thread_id]]/+page.ts diff --git a/src/leapfrogai_ui/src/lib/components/AssistantForm.svelte b/src/leapfrogai_ui/src/lib/components/AssistantForm.svelte index 815e009b2..609b8360b 100644 --- a/src/leapfrogai_ui/src/lib/components/AssistantForm.svelte +++ b/src/leapfrogai_ui/src/lib/components/AssistantForm.svelte @@ -56,7 +56,7 @@ bypassCancelWarning = true; await invalidate('lf:assistants'); - goto(result.data.redirectUrl); + await goto(result.data.redirectUrl); } else if (result.type === 'failure') { // 400 errors will show errors for the respective fields, do not show toast if (result.status !== 400) { diff --git a/src/leapfrogai_ui/src/routes/api/threads/+server.ts b/src/leapfrogai_ui/src/routes/api/threads/+server.ts new file mode 100644 index 000000000..d6c767257 --- /dev/null +++ b/src/leapfrogai_ui/src/routes/api/threads/+server.ts @@ -0,0 +1,67 @@ +import type { RequestHandler } from './$types'; +import { error, json, redirect } from '@sveltejs/kit'; +import { getOpenAiClient } from '$lib/server/constants'; +import type { Profile } from '$lib/types/profile'; +import type { LFThread } from '$lib/types/threads'; +import type { LFMessage } from '$lib/types/messages'; + +const getThreadWithMessages = async ( + thread_id: string, + access_token: string +): Promise => { + try { + const openai = getOpenAiClient(access_token); + const thread = (await openai.beta.threads.retrieve(thread_id)) as LFThread; + if (!thread) { + return null; + } + const messagesPage = await openai.beta.threads.messages.list(thread.id); + const messages = messagesPage.data as LFMessage[]; + messages.sort((a, b) => a.created_at - b.created_at); + return { ...thread, messages: messages }; + } catch (e) { + console.error(`Error fetching thread or messages: ${e}`); + return null; + } +}; + +export const GET: RequestHandler = async ({ locals: { session, supabase, user } }) => { + if (!session) { + error(401, 'Unauthorized'); + } + + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select(`*`) + .eq('id', user?.id) + .returns() + .single(); + + if (profileError) { + console.error( + `error getting user profile for user_id: ${user?.id}. ${JSON.stringify(profileError)}` + ); + throw redirect(303, '/'); + } + + const threads: LFThread[] = []; + if (profile?.thread_ids && profile?.thread_ids.length > 0) { + try { + const threadPromises = profile.thread_ids.map((thread_id) => + getThreadWithMessages(thread_id, session.access_token) + ); + const results = await Promise.allSettled(threadPromises); + + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value) { + threads.push(result.value); + } + }); + } catch (e) { + console.error(`Error fetching threads: ${e}`); + error(500, 'Internal Error'); + } + } + + return json(threads); +}; diff --git a/src/leapfrogai_ui/src/routes/chat/(dashboard)/[[thread_id]]/+page.svelte b/src/leapfrogai_ui/src/routes/chat/(dashboard)/[[thread_id]]/+page.svelte index f082615c5..9c6c3cca7 100644 --- a/src/leapfrogai_ui/src/routes/chat/(dashboard)/[[thread_id]]/+page.svelte +++ b/src/leapfrogai_ui/src/routes/chat/(dashboard)/[[thread_id]]/+page.svelte @@ -3,7 +3,7 @@ import { LFTextArea, PoweredByDU } from '$components'; import { Hr, ToolbarButton } from 'flowbite-svelte'; import { onMount, tick } from 'svelte'; - import { threadsStore, toastStore } from '$stores'; + import { filesStore, threadsStore, toastStore } from '$stores'; import { type Message as VercelAIMessage, useAssistant, useChat } from '@ai-sdk/svelte'; import { page } from '$app/stores'; import Message from '$components/Message.svelte'; @@ -24,11 +24,13 @@ } from '$constants/toastMessages'; import SelectAssistantDropdown from '$components/SelectAssistantDropdown.svelte'; import { PaperPlaneOutline, StopOutline } from 'flowbite-svelte-icons'; - import type { FileMetadata, LFFile } from '$lib/types/files'; + import type { FileMetadata, FileRow, LFFile } from '$lib/types/files'; import UploadedFileCards from '$components/UploadedFileCards.svelte'; import ChatFileUploadForm from '$components/ChatFileUpload.svelte'; import FileChatActions from '$components/FileChatActions.svelte'; import LFCarousel from '$components/LFCarousel.svelte'; + import { convertFileObjectToFileRows } from '$helpers/fileHelpers'; + import type { LFThread } from '$lib/types/threads'; export let data; @@ -38,20 +40,15 @@ let uploadingFiles = false; let attachedFiles: LFFile[] = []; // the actual files uploaded let attachedFileMetadata: FileMetadata[] = []; // metadata about the files uploaded, e.g. upload status, extracted text, etc... + let activeThread: LFThread | undefined = undefined; /** END LOCAL VARS **/ /** REACTIVE STATE **/ $: componentHasMounted = false; - $: $page.params.thread_id, threadsStore.setLastVisitedThreadId($page.params.thread_id); - $: $page.params.thread_id, - resetMessages({ - activeThread: data.thread, - setChatMessages, - setAssistantMessages - }); - - $: activeThreadMessages = - $threadsStore.threads.find((thread) => thread.id === $page.params.thread_id)?.messages || []; + $: activeThread = $threadsStore.threads.find( + (thread: LFThread) => thread.id === $page.params.thread_id + ); + $: $page.params.thread_id, handleThreadChange(); $: messageStreaming = $isLoading || $status === 'in_progress'; $: latestChatMessage = $chatMessages[$chatMessages.length - 1]; $: latestAssistantMessage = $assistantMessages[$assistantMessages.length - 1]; @@ -78,6 +75,26 @@ /** END REACTIVE STATE **/ + const handleThreadChange = () => { + if ($page.params.thread_id) { + if (activeThread) { + threadsStore.setLastVisitedThreadId(activeThread.id); + resetMessages({ + activeThread, + setChatMessages, + setAssistantMessages + }); + } + } else { + threadsStore.setLastVisitedThreadId(''); + resetMessages({ + activeThread, + setChatMessages, + setAssistantMessages + }); + } + }; + const resetFiles = () => { uploadingFiles = false; attachedFileMetadata = []; @@ -144,10 +161,10 @@ // Handle completed AI Responses onFinish: async (message: VercelAIMessage) => { try { - if (data.thread?.id) { + if (activeThread?.id) { // Save with API to db const newMessage = await saveMessage({ - thread_id: data.thread.id, + thread_id: activeThread.id, content: getMessageText(message), role: 'assistant' }); @@ -183,7 +200,7 @@ append: assistantAppend } = useAssistant({ api: '/api/chat/assistants', - threadId: data.thread?.id, + threadId: activeThread?.id, onError: async (e) => { // ignore this error b/c it is expected on cancel if (e.message !== 'BodyStreamBuffer was aborted') { @@ -197,7 +214,7 @@ const sendAssistantMessage = async (e: SubmitEvent | KeyboardEvent) => { await threadsStore.setSendingBlocked(true); - if (data.thread?.id) { + if (activeThread?.id) { // assistant mode $assistantInput = $chatInput; $chatInput = ''; // clear chat input @@ -207,7 +224,7 @@ data: { message: $chatInput, assistantId: $threadsStore.selectedAssistantId, - threadId: data.thread.id + threadId: activeThread.id } }); $assistantInput = ''; @@ -218,13 +235,13 @@ const sendChatMessage = async (e: SubmitEvent | KeyboardEvent) => { try { await threadsStore.setSendingBlocked(true); - if (data.thread?.id) { + if (activeThread?.id) { let extractedFilesTextString = JSON.stringify(attachedFileMetadata); if (attachedFileMetadata.length > 0) { // Save the text of the document as its own message before sending actual question const contextMsg = await saveMessage({ - thread_id: data.thread.id, + thread_id: activeThread.id, content: `${FILE_UPLOAD_PROMPT}: ${extractedFilesTextString}`, role: 'user', metadata: { @@ -237,7 +254,7 @@ // Save with API const newMessage = await saveMessage({ - thread_id: data.thread.id, + thread_id: activeThread.id, content: $chatInput, role: 'user', ...(attachedFileMetadata.length > 0 @@ -270,11 +287,11 @@ // setSendingBlocked (when called with the value 'false') automatically handles this delay const onSubmit = async (e: SubmitEvent | KeyboardEvent) => { e.preventDefault(); - if (($isLoading || $status === 'in_progress') && data.thread?.id) { + if (($isLoading || $status === 'in_progress') && activeThread?.id) { const isAssistantChat = $status === 'in_progress'; // message still sending await stopThenSave({ - activeThreadId: data.thread.id, + activeThreadId: activeThread.id, messages: isAssistantChat ? $assistantMessages : $chatMessages, status: $status, isLoading: $isLoading || false, @@ -285,7 +302,7 @@ return; } else { if (sendDisabled) return; - if (!data.thread?.id) { + if (!activeThread?.id) { // create new thread await threadsStore.newThread($chatInput); await tick(); // allow store to update @@ -304,20 +321,31 @@ }; onMount(async () => { - componentHasMounted = true; + // Convert files to fileRows and set in store + let fileRows: FileRow[] = []; + if (data.files && data.files.length > 0) { + fileRows = convertFileObjectToFileRows(data.files); + } + filesStore.setFiles(fileRows); + // Set threads in store + threadsStore.setThreads(data?.threads || []); + + // Setup assistants list assistantsList = [...(data.assistants || [])].map((assistant) => ({ id: assistant.id, text: assistant.name || 'unknown' })); assistantsList.unshift({ id: NO_SELECTED_ASSISTANT_ID, text: 'Select assistant...' }); // add dropdown item for no assistant selected assistantsList.unshift({ id: `manage-assistants`, text: 'Manage assistants' }); // add dropdown item for manage assistants button + + componentHasMounted = true; }); beforeNavigate(async () => { - if (($isLoading || $status === 'in_progress') && data.thread?.id) { + if (($isLoading || $status === 'in_progress') && activeThread?.id) { const isAssistantChat = $status === 'in_progress'; await stopThenSave({ - activeThreadId: data.thread.id, + activeThreadId: activeThread.id, messages: isAssistantChat ? $assistantMessages : $chatMessages, status: $status, isLoading: $isLoading || false, @@ -331,19 +359,21 @@
- {#each activeThreadMessages as message, index (message.id)} - {#if message.metadata?.hideMessage !== 'true'} - - {/if} - {/each} + {#if activeThread} + {#each activeThread.messages as message, index (message.id)} + {#if message.metadata?.hideMessage !== 'true'} + + {/if} + {/each} + {/if} {#if $threadsStore.streamingMessage} @@ -414,7 +444,7 @@
{ - const promises = [fetch('/api/assistants'), fetch('/api/files')]; - - if (params.thread_id) promises.push(fetch(`/api/threads/${params.thread_id}`)); - - const promiseResponses = await Promise.all(promises); - - const assistants = await promiseResponses[0].json(); - const files = await promiseResponses[1].json(); - - let thread: LFThread | undefined = undefined; - if (params.thread_id) { - thread = await promiseResponses[2].json(); - } - - if (browser) { - if (thread) { - // update store with latest thread fetched by page data - threadsStore.updateThread(thread); - } - } - - return { thread, assistants, files }; -}; diff --git a/src/leapfrogai_ui/src/routes/chat/+layout.server.ts b/src/leapfrogai_ui/src/routes/chat/+layout.server.ts index 8d92f474b..8e2cddf7f 100644 --- a/src/leapfrogai_ui/src/routes/chat/+layout.server.ts +++ b/src/leapfrogai_ui/src/routes/chat/+layout.server.ts @@ -1,9 +1,5 @@ import type { LayoutServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; -import type { Profile } from '$lib/types/profile'; -import type { LFThread } from '$lib/types/threads'; -import type { LFMessage } from '$lib/types/messages'; -import { getOpenAiClient } from '$lib/server/constants'; /** * This file is necessary to ensure protection of all routes in the `chat` @@ -12,66 +8,10 @@ import { getOpenAiClient } from '$lib/server/constants'; * Keep it even if there is no code in it. **/ -const getThreadWithMessages = async ( - thread_id: string, - access_token: string -): Promise => { - try { - const openai = getOpenAiClient(access_token); - const thread = (await openai.beta.threads.retrieve(thread_id)) as LFThread; - if (!thread) { - return null; - } - const messagesPage = await openai.beta.threads.messages.list(thread.id); - const messages = messagesPage.data as LFMessage[]; - messages.sort((a, b) => a.created_at - b.created_at); - return { ...thread, messages: messages }; - } catch (e) { - console.error(`Error fetching thread or messages: ${e}`); - return null; - } -}; - -export const load: LayoutServerLoad = async ({ - locals: { supabase, session, user, isUsingOpenAI } -}) => { +export const load: LayoutServerLoad = async ({ locals: { session, isUsingOpenAI } }) => { if (!session) { throw redirect(303, '/'); } - const { data: profile, error: profileError } = await supabase - .from('profiles') - .select(`*`) - .eq('id', user?.id) - .returns() - .single(); - - if (profileError) { - console.error( - `error getting user profile for user_id: ${user?.id}. ${JSON.stringify(profileError)}` - ); - throw redirect(303, '/'); - } - - const threads: LFThread[] = []; - if (profile?.thread_ids && profile?.thread_ids.length > 0) { - try { - const threadPromises = profile.thread_ids.map((thread_id) => - getThreadWithMessages(thread_id, session.access_token) - ); - const results = await Promise.allSettled(threadPromises); - - results.forEach((result) => { - if (result.status === 'fulfilled' && result.value) { - threads.push(result.value); - } - }); - } catch (e) { - console.error(`Error fetching threads: ${e}`); - // fail silently - return null; - } - } - - return { threads, isUsingOpenAI }; + return { isUsingOpenAI }; }; diff --git a/src/leapfrogai_ui/src/routes/chat/+layout.ts b/src/leapfrogai_ui/src/routes/chat/+layout.ts index d5fb482bb..15c3180c8 100644 --- a/src/leapfrogai_ui/src/routes/chat/+layout.ts +++ b/src/leapfrogai_ui/src/routes/chat/+layout.ts @@ -1,37 +1,26 @@ import type { LayoutLoad } from './$types'; import { browser } from '$app/environment'; -import { filesStore, threadsStore, uiStore } from '$stores'; +import { uiStore } from '$stores'; import type { LFAssistant } from '$lib/types/assistants'; import type { FileObject } from 'openai/resources/files'; -import { convertFileObjectToFileRows } from '$helpers/fileHelpers'; -import type { FileRow } from '$lib/types/files'; +import type { LFThread } from '$lib/types/threads'; -// Load the store with the threads fetched by the +layout.server.ts (set store on the client side only) -// This only runs when the app is first loaded (because it's a higher level layout) -// After this load, the app keeps the store in sync with data changes and we don't -// re-fetch all that data from the server -// The same applies to files, we keep track of them in a store export const load: LayoutLoad = async ({ fetch, data, depends }) => { depends('lf:assistants'); depends('lf:files'); + depends('lf:threads'); - const promises: [Promise, Promise] = [ + const promises: Array> = [ fetch('/api/assistants'), - fetch('/api/files') + fetch('/api/files'), + fetch('/api/threads') ]; - const [assistantRes, filesRes] = await Promise.all(promises); + const [assistantRes, filesRes, threadsRes] = await Promise.all(promises); const assistants = (await assistantRes.json()) as LFAssistant[]; const files = (await filesRes.json()) as FileObject[]; - + const threads = (await threadsRes.json()) as LFThread[]; if (browser) { - let fileRows: FileRow[] = []; - if (files && files.length > 0) { - fileRows = convertFileObjectToFileRows(files); - } - - filesStore.setFiles(fileRows); - threadsStore.setThreads(data?.threads || []); uiStore.setIsUsingOpenAI(data?.isUsingOpenAI); } - return { assistants }; + return { assistants, files, threads }; }; From ea9eab74f22dc7174bf64b63088528ef7ea21e2a Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 25 Sep 2024 12:48:07 -0600 Subject: [PATCH 2/5] finish refactor to fetch client side --- src/leapfrogai_ui/src/app.d.ts | 1 - .../src/lib/components/AssistantCard.svelte | 10 ++-- .../lib/components/AssistantFileSelect.svelte | 4 +- .../components/AssistantFileSelect.test.ts | 4 +- .../src/lib/components/AssistantForm.svelte | 16 +++-- .../components/AssistantProgressToast.test.ts | 4 +- .../src/lib/components/ChatFileUpload.svelte | 4 +- .../src/lib/components/Message.svelte | 10 ++-- .../components/SelectAssistantDropdown.svelte | 23 ++++---- .../modals/ConfirmFilesDeleteModal.svelte | 6 +- src/leapfrogai_ui/src/lib/constants/index.ts | 9 ++- .../src/lib/helpers/fileHelpers.ts | 7 +-- .../src/lib/stores/assistantsStore.ts | 59 +++++++++++++++++++ .../src/lib/stores/filesStore.ts | 55 ++++++++++------- src/leapfrogai_ui/src/lib/stores/index.ts | 1 + src/leapfrogai_ui/src/lib/stores/threads.ts | 12 +--- src/leapfrogai_ui/src/lib/types/files.d.ts | 8 +-- .../(dashboard)/[[thread_id]]/+page.svelte | 36 +++-------- .../assistants-management/+page.svelte | 7 +-- .../assistant_form.test.ts | 4 +- .../edit/[assistantId]/+page.server.ts | 2 +- .../file-management/+page.server.ts | 10 ++-- .../(settings)/file-management/+page.svelte | 31 ++++++---- .../file-management/file-management.test.ts | 8 +-- src/leapfrogai_ui/src/routes/chat/+layout.ts | 18 ++++-- 25 files changed, 211 insertions(+), 138 deletions(-) create mode 100644 src/leapfrogai_ui/src/lib/stores/assistantsStore.ts diff --git a/src/leapfrogai_ui/src/app.d.ts b/src/leapfrogai_ui/src/app.d.ts index d493910cc..f19b0b155 100644 --- a/src/leapfrogai_ui/src/app.d.ts +++ b/src/leapfrogai_ui/src/app.d.ts @@ -23,7 +23,6 @@ declare global { profile?: Profile; threads?: LFThread[]; assistants?: LFAssistant[]; - assistant?: LFAssistant; files?: FileObject[]; keys?: APIKeyRow[]; } diff --git a/src/leapfrogai_ui/src/lib/components/AssistantCard.svelte b/src/leapfrogai_ui/src/lib/components/AssistantCard.svelte index ceabb4098..0a0f29db5 100644 --- a/src/leapfrogai_ui/src/lib/components/AssistantCard.svelte +++ b/src/leapfrogai_ui/src/lib/components/AssistantCard.svelte @@ -1,10 +1,10 @@ - +
{#each filteredStoreFiles as file} diff --git a/src/leapfrogai_ui/src/lib/components/AssistantFileSelect.test.ts b/src/leapfrogai_ui/src/lib/components/AssistantFileSelect.test.ts index 6bb15f2ae..61c3efed9 100644 --- a/src/leapfrogai_ui/src/lib/components/AssistantFileSelect.test.ts +++ b/src/leapfrogai_ui/src/lib/components/AssistantFileSelect.test.ts @@ -4,14 +4,14 @@ import AssistantFileSelect from '$components/AssistantFileSelect.svelte'; import { superValidate } from 'sveltekit-superforms'; import { yup } from 'sveltekit-superforms/adapters'; import { filesSchema } from '$schemas/files'; -import type { FileRow } from '$lib/types/files'; +import type { LFFileObject } from '$lib/types/files'; import { getUnixSeconds } from '$helpers/dates'; import userEvent from '@testing-library/user-event'; const filesForm = await superValidate({}, yup(filesSchema), { errors: false }); describe('AssistantFileSelect', () => { - const mockFiles: FileRow[] = [ + const mockFiles: LFFileObject[] = [ { id: '1', filename: 'file1.pdf', status: 'complete', created_at: getUnixSeconds(new Date()) }, { id: '2', filename: 'file2.pdf', status: 'error', created_at: getUnixSeconds(new Date()) }, { id: '3', filename: 'file3.txt', status: 'uploading', created_at: getUnixSeconds(new Date()) } diff --git a/src/leapfrogai_ui/src/lib/components/AssistantForm.svelte b/src/leapfrogai_ui/src/lib/components/AssistantForm.svelte index 609b8360b..8e7c97a5a 100644 --- a/src/leapfrogai_ui/src/lib/components/AssistantForm.svelte +++ b/src/leapfrogai_ui/src/lib/components/AssistantForm.svelte @@ -6,11 +6,11 @@ } from '$lib/constants'; import { superForm } from 'sveltekit-superforms'; import { page } from '$app/stores'; - import { beforeNavigate, goto, invalidate } from '$app/navigation'; + import { beforeNavigate, goto } from '$app/navigation'; import { Button, Modal, P } from 'flowbite-svelte'; import Slider from '$components/Slider.svelte'; import { yup } from 'sveltekit-superforms/adapters'; - import { filesStore, toastStore, uiStore } from '$stores'; + import { assistantsStore, filesStore, toastStore, uiStore } from '$stores'; import { assistantInputSchema, editAssistantInputSchema } from '$lib/schemas/assistants'; import type { NavigationTarget } from '@sveltejs/kit'; import { onMount } from 'svelte'; @@ -25,6 +25,10 @@ let bypassCancelWarning = false; + $: assistant = $assistantsStore.assistants.find( + (assistant) => assistant.id === $page.params.assistantId + ); + const { form, errors, enhance, submitting, isTainted, delayed } = superForm(data.form, { invalidateAll: false, validators: yup(isEditMode ? editAssistantInputSchema : assistantInputSchema), @@ -55,7 +59,11 @@ } bypassCancelWarning = true; - await invalidate('lf:assistants'); + if (isEditMode) { + assistantsStore.updateAssistant(result.data.assistant); + } else { + assistantsStore.addAssistant(result.data.assistant); + } await goto(result.data.redirectUrl); } else if (result.type === 'failure') { // 400 errors will show errors for the respective fields, do not show toast @@ -174,7 +182,7 @@
diff --git a/src/leapfrogai_ui/src/lib/components/AssistantProgressToast.test.ts b/src/leapfrogai_ui/src/lib/components/AssistantProgressToast.test.ts index fb21bd849..fc1d5c5e4 100644 --- a/src/leapfrogai_ui/src/lib/components/AssistantProgressToast.test.ts +++ b/src/leapfrogai_ui/src/lib/components/AssistantProgressToast.test.ts @@ -10,7 +10,7 @@ import AssistantProgressToast from '$components/AssistantProgressToast.svelte'; import { render, screen } from '@testing-library/svelte'; import filesStore from '$stores/filesStore'; import { getFakeFiles } from '$testUtils/fakeData'; -import { convertFileObjectToFileRows } from '$helpers/fileHelpers'; +import { convertFileObjectToLFFileObject } from '$helpers/fileHelpers'; import { delay } from 'msw'; import { vi } from 'vitest'; import { toastStore } from '$stores'; @@ -27,7 +27,7 @@ describe('AssistantProgressToast', () => { fileIds: files.map((file) => file.id), vectorStoreId: '123' }; - filesStore.setFiles(convertFileObjectToFileRows(files)); + filesStore.setFiles(convertFileObjectToLFFileObject(files)); const timeout = 10; //10ms render(AssistantProgressToast, { timeout, toast }); //10ms timeout diff --git a/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte b/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte index e01575ce2..12a0f9bff 100644 --- a/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte +++ b/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte @@ -1,7 +1,7 @@ @@ -284,7 +291,7 @@ { const fileList = e.detail; handleUpload(fileList); diff --git a/src/leapfrogai_ui/src/routes/chat/(settings)/file-management/file-management.test.ts b/src/leapfrogai_ui/src/routes/chat/(settings)/file-management/file-management.test.ts index 3a5764748..7385da044 100644 --- a/src/leapfrogai_ui/src/routes/chat/(settings)/file-management/file-management.test.ts +++ b/src/leapfrogai_ui/src/routes/chat/(settings)/file-management/file-management.test.ts @@ -13,7 +13,7 @@ import { } from '$lib/mocks/file-mocks'; import { beforeEach, vi } from 'vitest'; import { filesStore, toastStore } from '$stores'; -import { convertFileObjectToFileRows } from '$helpers/fileHelpers'; +import { convertFileObjectToLFFileObject } from '$helpers/fileHelpers'; import { superValidate } from 'sveltekit-superforms'; import { yup } from 'sveltekit-superforms/adapters'; import { filesSchema } from '$schemas/files'; @@ -32,7 +32,7 @@ describe('file management', () => { const data = await load(); form = await superValidate(yup(filesSchema)); - filesStore.setFiles(convertFileObjectToFileRows(files)); + filesStore.setFiles(convertFileObjectToLFFileObject(files)); filesStore.setSelectedFileManagementFileIds([]); render(FileManagementPage, { @@ -75,7 +75,7 @@ describe('file management', () => { const file2 = getFakeFiles({ numFiles: 1, created_at: yesterday })[0]; // Set different files for this test, await tick so component reflect store update - filesStore.setFiles(convertFileObjectToFileRows([file1, file2])); + filesStore.setFiles(convertFileObjectToLFFileObject([file1, file2])); await tick(); mockGetFiles([file1, file2]); @@ -219,7 +219,7 @@ describe('table pagination', () => { const data = await load(); form = await superValidate(yup(filesSchema)); - filesStore.setFiles(convertFileObjectToFileRows(files)); + filesStore.setFiles(convertFileObjectToLFFileObject(files)); filesStore.setSelectedFileManagementFileIds([]); render(FileManagementPage, { diff --git a/src/leapfrogai_ui/src/routes/chat/+layout.ts b/src/leapfrogai_ui/src/routes/chat/+layout.ts index 15c3180c8..f418e0d74 100644 --- a/src/leapfrogai_ui/src/routes/chat/+layout.ts +++ b/src/leapfrogai_ui/src/routes/chat/+layout.ts @@ -1,15 +1,13 @@ import type { LayoutLoad } from './$types'; import { browser } from '$app/environment'; -import { uiStore } from '$stores'; +import { assistantsStore, filesStore, threadsStore, uiStore } from '$stores'; import type { LFAssistant } from '$lib/types/assistants'; import type { FileObject } from 'openai/resources/files'; import type { LFThread } from '$lib/types/threads'; +import type { LFFileObject } from '$lib/types/files'; +import { convertFileObjectToLFFileObject } from '$helpers/fileHelpers'; -export const load: LayoutLoad = async ({ fetch, data, depends }) => { - depends('lf:assistants'); - depends('lf:files'); - depends('lf:threads'); - +export const load: LayoutLoad = async ({ fetch, data }) => { const promises: Array> = [ fetch('/api/assistants'), fetch('/api/files'), @@ -21,6 +19,14 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => { const threads = (await threadsRes.json()) as LFThread[]; if (browser) { uiStore.setIsUsingOpenAI(data?.isUsingOpenAI); + // Convert files to LFFileObjects and set in store + let lfFileObjects: LFFileObject[] = []; + if (files && files.length > 0) { + lfFileObjects = convertFileObjectToLFFileObject(files); + } + filesStore.setFiles(lfFileObjects); + threadsStore.setThreads(threads || []); + assistantsStore.setAssistants(assistants || []); } return { assistants, files, threads }; }; From b80c45075be9ac2a45361bbf1354323b3332e533 Mon Sep 17 00:00:00 2001 From: Andrew Risse Date: Wed, 25 Sep 2024 13:06:28 -0600 Subject: [PATCH 3/5] fix unit tests and lint --- .../lib/components/AssistantFileSelect.svelte | 2 +- .../lib/components/FileChatActions.test.ts | 3 +-- .../src/lib/components/LFHeader.test.ts | 1 - .../src/lib/components/Message.svelte | 3 ++- .../src/lib/components/Message.test.ts | 21 ++++++++++--------- .../components/SelectAssistantDropdown.svelte | 3 +-- .../src/lib/components/Sidebar.test.ts | 15 ------------- .../src/lib/stores/assistantsStore.ts | 4 +--- .../src/lib/stores/filesStore.ts | 2 +- .../(dashboard)/[[thread_id]]/+page.svelte | 7 ++----- .../[[thread_id]]/chatpage.test.ts | 10 --------- .../[[thread_id]]/chatpage_no_thread.test.ts | 9 +------- .../assistants-management-page.test.ts | 14 ++++--------- .../file-management/file-management.test.ts | 2 -- src/leapfrogai_ui/tests/file-chat.test.ts | 3 ++- 15 files changed, 27 insertions(+), 72 deletions(-) diff --git a/src/leapfrogai_ui/src/lib/components/AssistantFileSelect.svelte b/src/leapfrogai_ui/src/lib/components/AssistantFileSelect.svelte index 86f61e8dd..6cf1ab3e5 100644 --- a/src/leapfrogai_ui/src/lib/components/AssistantFileSelect.svelte +++ b/src/leapfrogai_ui/src/lib/components/AssistantFileSelect.svelte @@ -2,7 +2,7 @@ import { fade } from 'svelte/transition'; import { filesStore } from '$stores'; import type { FilesForm } from '$lib/types/files'; - import { ACCEPTED_DOC_TYPES, ACCEPTED_DOC_AND_AUDIO_FILE_TYPES, STANDARD_FADE_DURATION } from '$constants'; + import { ACCEPTED_DOC_TYPES, STANDARD_FADE_DURATION } from '$constants'; import AssistantFileDropdown from '$components/AssistantFileDropdown.svelte'; import FileUploaderItem from '$components/FileUploaderItem.svelte'; diff --git a/src/leapfrogai_ui/src/lib/components/FileChatActions.test.ts b/src/leapfrogai_ui/src/lib/components/FileChatActions.test.ts index 316f54233..d920f325b 100644 --- a/src/leapfrogai_ui/src/lib/components/FileChatActions.test.ts +++ b/src/leapfrogai_ui/src/lib/components/FileChatActions.test.ts @@ -22,7 +22,7 @@ import { FILE_TRANSLATION_ERROR } from '$constants/toastMessages'; import { getFakeThread } from '$testUtils/fakeData'; -import { AUDIO_FILE_SIZE_ERROR_TEXT, NO_SELECTED_ASSISTANT_ID } from '$constants'; +import { AUDIO_FILE_SIZE_ERROR_TEXT } from '$constants'; const thread = getFakeThread(); @@ -74,7 +74,6 @@ describe('FileChatActions', () => { threadsStore.set({ threads: [thread], // uses date override starting in March sendingBlocked: false, - selectedAssistantId: NO_SELECTED_ASSISTANT_ID, lastVisitedThreadId: '', streamingMessage: null }); diff --git a/src/leapfrogai_ui/src/lib/components/LFHeader.test.ts b/src/leapfrogai_ui/src/lib/components/LFHeader.test.ts index 105caad11..340efb603 100644 --- a/src/leapfrogai_ui/src/lib/components/LFHeader.test.ts +++ b/src/leapfrogai_ui/src/lib/components/LFHeader.test.ts @@ -10,7 +10,6 @@ describe('LFHeader', () => { threadsStore.set({ threads: [thread], lastVisitedThreadId: thread.id, - selectedAssistantId: '', sendingBlocked: false, streamingMessage: null }); diff --git a/src/leapfrogai_ui/src/lib/components/Message.svelte b/src/leapfrogai_ui/src/lib/components/Message.svelte index 02ff57d13..7a98d8c08 100644 --- a/src/leapfrogai_ui/src/lib/components/Message.svelte +++ b/src/leapfrogai_ui/src/lib/components/Message.svelte @@ -69,7 +69,8 @@ const getAssistantName = (id?: string) => { if (!id) return 'LeapfrogAI Bot'; return ( - $assistantsStore.assistants?.find((assistant) => assistant.id === id)?.name || 'LeapfrogAI Bot' + $assistantsStore.assistants?.find((assistant) => assistant.id === id)?.name || + 'LeapfrogAI Bot' ); }; diff --git a/src/leapfrogai_ui/src/lib/components/Message.test.ts b/src/leapfrogai_ui/src/lib/components/Message.test.ts index 79584fa80..c7e296fa0 100644 --- a/src/leapfrogai_ui/src/lib/components/Message.test.ts +++ b/src/leapfrogai_ui/src/lib/components/Message.test.ts @@ -2,13 +2,12 @@ import { render, screen } from '@testing-library/svelte'; import { afterAll, afterEach, type MockInstance, vi } from 'vitest'; import { Message } from '$components/index'; import userEvent from '@testing-library/user-event'; -import { fakeAssistants, fakeThreads, getFakeMessage } from '$testUtils/fakeData'; +import { fakeThreads, getFakeAssistant, getFakeMessage } from '$testUtils/fakeData'; import MessageWithToast from '$components/MessageWithToast.test.svelte'; import { convertMessageToVercelAiMessage, getMessageText } from '$helpers/threads'; import { type Message as VercelAIMessage } from '@ai-sdk/svelte'; import { chatHelpers } from '$helpers'; -import { threadsStore } from '$stores'; -import { NO_SELECTED_ASSISTANT_ID } from '$constants'; +import { assistantsStore, threadsStore } from '$stores'; const fakeAppend = vi.fn(); @@ -27,6 +26,8 @@ const getDefaultMessageProps = () => { }; }; +const assistant = getFakeAssistant(); + describe('Message component', () => { afterEach(() => { fakeAppend.mockReset(); @@ -36,6 +37,10 @@ describe('Message component', () => { fakeAppend.mockRestore(); }); + beforeEach(() => { + assistantsStore.setAssistants([assistant]); + }); + it('displays edit text area when edit btn is clicked', async () => { render(Message, { ...getDefaultMessageProps() }); expect(screen.queryByTestId('edit-message-input')).not.toBeInTheDocument(); @@ -129,7 +134,6 @@ describe('Message component', () => { it('disables edit submit button when message is loading', async () => { threadsStore.set({ threads: fakeThreads, - selectedAssistantId: NO_SELECTED_ASSISTANT_ID, sendingBlocked: true, lastVisitedThreadId: '', streamingMessage: null @@ -147,7 +151,6 @@ describe('Message component', () => { it('has copy and regenerate buttons for the last AI response', () => { threadsStore.set({ threads: fakeThreads, - selectedAssistantId: NO_SELECTED_ASSISTANT_ID, sendingBlocked: false, lastVisitedThreadId: '', streamingMessage: null @@ -190,7 +193,6 @@ describe('Message component', () => { it('removes the regenerate buttons when a response is loading', () => { threadsStore.set({ threads: fakeThreads, - selectedAssistantId: NO_SELECTED_ASSISTANT_ID, sendingBlocked: true, lastVisitedThreadId: '', streamingMessage: null @@ -206,7 +208,6 @@ describe('Message component', () => { it('leaves the copy button for messages when it is loading', () => { threadsStore.set({ threads: fakeThreads, - selectedAssistantId: NO_SELECTED_ASSISTANT_ID, sendingBlocked: true, lastVisitedThreadId: '', streamingMessage: null @@ -221,7 +222,6 @@ describe('Message component', () => { it('leaves the edit button for messages when it is loading', () => { threadsStore.set({ threads: fakeThreads, - selectedAssistantId: NO_SELECTED_ASSISTANT_ID, sendingBlocked: true, lastVisitedThreadId: '', streamingMessage: null @@ -248,11 +248,12 @@ describe('Message component', () => { screen.getByText('LeapfrogAI Bot'); }); it('Has the title of the assistant name for regular AI responses', () => { + assistantsStore.setSelectedAssistantId(assistant.id); render(Message, { ...getDefaultMessageProps(), - message: getFakeMessage({ role: 'assistant', assistant_id: fakeAssistants[0].id }) + message: getFakeMessage({ role: 'assistant', assistant_id: assistant.id }) }); - screen.getByText(fakeAssistants[0].name!); + screen.getByText(assistant.name!); }); }); }); diff --git a/src/leapfrogai_ui/src/lib/components/SelectAssistantDropdown.svelte b/src/leapfrogai_ui/src/lib/components/SelectAssistantDropdown.svelte index 9ed1a5fef..c700c4fe6 100644 --- a/src/leapfrogai_ui/src/lib/components/SelectAssistantDropdown.svelte +++ b/src/leapfrogai_ui/src/lib/components/SelectAssistantDropdown.svelte @@ -1,10 +1,9 @@ diff --git a/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte b/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte index 12a0f9bff..73356ee1a 100644 --- a/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte +++ b/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte @@ -61,24 +61,23 @@ body: formData }) .then(async (response) => { - if (!response.ok) { + if (response.ok) { + const result = await response.json(); return { id: file.id, name: shortenFileName(file.name), type: file.type, - text: ERROR_UPLOADING_FILE_MSG, - status: 'error', - errorText: ERROR_UPLOADING_FILE_MSG + text: result.text, + status: 'complete' }; } - - const result = await response.json(); return { id: file.id, name: shortenFileName(file.name), type: file.type, - text: result.text, - status: 'complete' + text: ERROR_UPLOADING_FILE_MSG, + status: 'error', + errorText: ERROR_UPLOADING_FILE_MSG }; }) .catch(() => { diff --git a/src/leapfrogai_ui/src/lib/components/UploadedFileCard.svelte b/src/leapfrogai_ui/src/lib/components/UploadedFileCard.svelte index 64b98fa1d..3c3e8e30b 100644 --- a/src/leapfrogai_ui/src/lib/components/UploadedFileCard.svelte +++ b/src/leapfrogai_ui/src/lib/components/UploadedFileCard.svelte @@ -12,7 +12,7 @@ const dispatch = createEventDispatcher(); - $: hovered = false; + let hovered = false;
diff --git a/src/leapfrogai_ui/src/lib/components/modals/ConfirmFilesDeleteModal.svelte b/src/leapfrogai_ui/src/lib/components/modals/ConfirmFilesDeleteModal.svelte index 54e9f1162..d581f83cd 100644 --- a/src/leapfrogai_ui/src/lib/components/modals/ConfirmFilesDeleteModal.svelte +++ b/src/leapfrogai_ui/src/lib/components/modals/ConfirmFilesDeleteModal.svelte @@ -11,6 +11,8 @@ export let deleting: boolean; export let affectedAssistants: Assistant[]; + $: isMultipleFiles = $filesStore.selectedFileManagementFileIds.length > 1; + const dispatch = createEventDispatcher(); const handleCancel = () => { @@ -19,37 +21,43 @@ affectedAssistantsLoading = false; }; - const handleConfirmedDelete = async () => { - const isMultipleFiles = $filesStore.selectedFileManagementFileIds.length > 1; - deleting = true; - const res = await fetch('/api/files/delete', { - method: 'DELETE', - body: JSON.stringify({ ids: $filesStore.selectedFileManagementFileIds }), - headers: { - 'Content-Type': 'application/json' - } + const handleDeleteError = () => { + toastStore.addToast({ + kind: 'error', + title: `Error Deleting ${isMultipleFiles ? 'Files' : 'File'}` }); - open = false; - for (const id of $filesStore.selectedFileManagementFileIds) { - filesStore.removeFile(id); - } + }; - if (res.ok) { - toastStore.addToast({ - kind: 'success', - title: `${isMultipleFiles ? 'Files' : 'File'} Deleted` - }); - } else { - toastStore.addToast({ - kind: 'error', - title: `Error Deleting ${isMultipleFiles ? 'Files' : 'File'}` + const handleConfirmedDelete = async () => { + deleting = true; + try { + const res = await fetch('/api/files/delete', { + method: 'DELETE', + body: JSON.stringify({ ids: $filesStore.selectedFileManagementFileIds }), + headers: { + 'Content-Type': 'application/json' + } }); - } - vectorStatusStore.removeFiles($filesStore.selectedFileManagementFileIds); - filesStore.setSelectedFileManagementFileIds([]); + if (res.ok) { + open = false; + for (const id of $filesStore.selectedFileManagementFileIds) { + filesStore.removeFile(id); + } + vectorStatusStore.removeFiles($filesStore.selectedFileManagementFileIds); + filesStore.setSelectedFileManagementFileIds([]); + toastStore.addToast({ + kind: 'success', + title: `${isMultipleFiles ? 'Files' : 'File'} Deleted` + }); + dispatch('delete'); + } else { + handleDeleteError(); + } + } catch { + handleDeleteError(); + } deleting = false; - dispatch('delete'); }; $: fileNames = $filesStore.files diff --git a/src/leapfrogai_ui/src/lib/components/modals/DeleteApiKeyModal.svelte b/src/leapfrogai_ui/src/lib/components/modals/DeleteApiKeyModal.svelte index 58b0d9d58..c0c7083a8 100644 --- a/src/leapfrogai_ui/src/lib/components/modals/DeleteApiKeyModal.svelte +++ b/src/leapfrogai_ui/src/lib/components/modals/DeleteApiKeyModal.svelte @@ -10,10 +10,12 @@ export let selectedRowIds: string[]; export let deleting: boolean; + $: isMultiple = selectedRowIds.length > 1; + const dispatch = createEventDispatcher(); - $: keyNames = $page.data.keys - ? $page.data.keys + $: keyNames = $page.data.apiKeys + ? $page.data.apiKeys .map((key) => { if (selectedRowIds.includes(key.id)) return key.name; }) @@ -25,27 +27,35 @@ confirmDeleteModalOpen = false; }; + const handleDeleteError = () => { + toastStore.addToast({ + kind: 'error', + title: `Error Deleting ${isMultiple ? 'Keys' : 'Key'}` + }); + }; + const handleDelete = async () => { deleting = true; - const isMultiple = selectedRowIds.length > 1; - const res = await fetch('/api/api-keys/delete', { - body: JSON.stringify({ ids: selectedRowIds }), - method: 'DELETE' - }); - dispatch('delete', selectedRowIds); - deleting = false; - if (res.ok) { - toastStore.addToast({ - kind: 'success', - title: `${isMultiple ? 'Keys' : 'Key'} Deleted` - }); - } else { - toastStore.addToast({ - kind: 'error', - title: `Error Deleting ${isMultiple ? 'Keys' : 'Key'}` + try { + const res = await fetch('/api/api-keys/delete', { + body: JSON.stringify({ ids: selectedRowIds }), + method: 'DELETE' }); + if (res.ok) { + dispatch('delete', selectedRowIds); + toastStore.addToast({ + kind: 'success', + title: `${isMultiple ? 'Keys' : 'Key'} Deleted` + }); + await invalidate('lf:api-keys'); + } else { + handleDeleteError(); + } + } catch { + handleDeleteError(); } - await invalidate('lf:api-keys'); + + deleting = false; }; diff --git a/src/leapfrogai_ui/src/lib/mocks/file-mocks.ts b/src/leapfrogai_ui/src/lib/mocks/file-mocks.ts index f4ff4460f..88fa6d566 100644 --- a/src/leapfrogai_ui/src/lib/mocks/file-mocks.ts +++ b/src/leapfrogai_ui/src/lib/mocks/file-mocks.ts @@ -78,7 +78,7 @@ export const mockConvertFileErrorNoId = () => { export const mockDeleteCheck = (assistantsToReturn: LFAssistant[]) => { server.use( - http.post('/api/files/delete-check', async () => { + http.post('/api/files/delete/check', async () => { await delay(100); return HttpResponse.json(assistantsToReturn); }) diff --git a/src/leapfrogai_ui/src/routes/api/api-keys/delete/+server.ts b/src/leapfrogai_ui/src/routes/api/api-keys/delete/+server.ts index 785c289ac..eacdd3b2d 100644 --- a/src/leapfrogai_ui/src/routes/api/api-keys/delete/+server.ts +++ b/src/leapfrogai_ui/src/routes/api/api-keys/delete/+server.ts @@ -10,7 +10,6 @@ export const DELETE: RequestHandler = async ({ request, locals: { session } }) = if (!session) { error(401, 'Unauthorized'); } - let requestData: { ids: string }; // Validate request body diff --git a/src/leapfrogai_ui/src/routes/api/files/delete/+server.ts b/src/leapfrogai_ui/src/routes/api/files/delete/+server.ts index 935195842..e8942d8da 100644 --- a/src/leapfrogai_ui/src/routes/api/files/delete/+server.ts +++ b/src/leapfrogai_ui/src/routes/api/files/delete/+server.ts @@ -8,7 +8,6 @@ export const DELETE: RequestHandler = async ({ request, locals: { session } }) = error(401, 'Unauthorized'); } let requestData: { ids: string[] }; - // Validate request body try { requestData = await request.json(); diff --git a/src/leapfrogai_ui/src/routes/api/files/delete-check/+server.ts b/src/leapfrogai_ui/src/routes/api/files/delete/check/+server.ts similarity index 100% rename from src/leapfrogai_ui/src/routes/api/files/delete-check/+server.ts rename to src/leapfrogai_ui/src/routes/api/files/delete/check/+server.ts diff --git a/src/leapfrogai_ui/src/routes/api/files/delete-check/server.test.ts b/src/leapfrogai_ui/src/routes/api/files/delete/check/server.test.ts similarity index 86% rename from src/leapfrogai_ui/src/routes/api/files/delete-check/server.test.ts rename to src/leapfrogai_ui/src/routes/api/files/delete/check/server.test.ts index 1f6bb19bc..f78b142e9 100644 --- a/src/leapfrogai_ui/src/routes/api/files/delete-check/server.test.ts +++ b/src/leapfrogai_ui/src/routes/api/files/delete/check/server.test.ts @@ -1,5 +1,5 @@ import { POST } from './+server'; -import { mockOpenAI } from '../../../../../vitest-setup'; +import { mockOpenAI } from '../../../../../../vitest-setup'; import { getFakeAssistant, getFakeFiles, @@ -7,11 +7,11 @@ import { getFakeVectorStoreFile } from '$testUtils/fakeData'; import type { RequestEvent } from '@sveltejs/kit'; -import type { RouteParams } from '../../../../../.svelte-kit/types/src/routes/api/messages/new/$types'; +import type { RouteParams } from './$types'; import { getLocalsMock } from '$lib/mocks/misc'; const validMessageBody = { fileIds: ['file1', 'file2'] }; -describe('/api/files/delete-check', () => { +describe('/api/files/delete/check', () => { it('returns a 401 when there is no session', async () => { const request = new Request('http://thisurlhasnoeffect', { method: 'POST', @@ -22,7 +22,7 @@ describe('/api/files/delete-check', () => { POST({ request, locals: getLocalsMock({ nullSession: true }) - } as RequestEvent) + } as RequestEvent) ).rejects.toMatchObject({ status: 401 }); @@ -39,7 +39,7 @@ describe('/api/files/delete-check', () => { POST({ request, locals: getLocalsMock() - } as RequestEvent) + } as RequestEvent) ).rejects.toMatchObject({ status: 400 }); @@ -54,7 +54,7 @@ describe('/api/files/delete-check', () => { POST({ request, locals: getLocalsMock() - } as RequestEvent) + } as RequestEvent) ).rejects.toMatchObject({ status: 400 }); @@ -69,7 +69,7 @@ describe('/api/files/delete-check', () => { POST({ request, locals: getLocalsMock() - } as RequestEvent) + } as RequestEvent) ).rejects.toMatchObject({ status: 400 }); @@ -84,7 +84,7 @@ describe('/api/files/delete-check', () => { POST({ request, locals: getLocalsMock() - } as RequestEvent) + } as RequestEvent) ).rejects.toMatchObject({ status: 400 }); @@ -137,7 +137,7 @@ describe('/api/files/delete-check', () => { const res = await POST({ request, locals: getLocalsMock() - } as RequestEvent); + } as RequestEvent); const resData = await res.json(); expect(res.status).toEqual(200); @@ -153,7 +153,7 @@ describe('/api/files/delete-check', () => { const res2 = await POST({ request: request2, locals: getLocalsMock() - } as RequestEvent); + } as RequestEvent); const resData2 = await res2.json(); expect(res2.status).toEqual(200); @@ -173,7 +173,7 @@ describe('/api/files/delete-check', () => { POST({ request, locals: getLocalsMock() - } as RequestEvent) + } as RequestEvent) ).rejects.toMatchObject({ status: 500 }); diff --git a/src/leapfrogai_ui/src/routes/api/helpers.ts b/src/leapfrogai_ui/src/routes/api/helpers.ts new file mode 100644 index 000000000..c64bfe611 --- /dev/null +++ b/src/leapfrogai_ui/src/routes/api/helpers.ts @@ -0,0 +1,18 @@ +import type { LFThread } from '$lib/types/threads'; +import { getOpenAiClient } from '$lib/server/constants'; +import type { LFMessage } from '$lib/types/messages'; + +export const getThreadWithMessages = async ( + thread_id: string, + access_token: string +): Promise => { + const openai = getOpenAiClient(access_token); + const thread = (await openai.beta.threads.retrieve(thread_id)) as LFThread; + if (!thread) { + return null; + } + const messagesPage = await openai.beta.threads.messages.list(thread.id); + const messages = messagesPage.data as LFMessage[]; + messages.sort((a, b) => a.created_at - b.created_at); + return { ...thread, messages: messages }; +}; diff --git a/src/leapfrogai_ui/src/routes/api/threads/+server.ts b/src/leapfrogai_ui/src/routes/api/threads/+server.ts index d6c767257..8158bab7a 100644 --- a/src/leapfrogai_ui/src/routes/api/threads/+server.ts +++ b/src/leapfrogai_ui/src/routes/api/threads/+server.ts @@ -1,29 +1,8 @@ import type { RequestHandler } from './$types'; -import { error, json, redirect } from '@sveltejs/kit'; -import { getOpenAiClient } from '$lib/server/constants'; +import { error, json } from '@sveltejs/kit'; import type { Profile } from '$lib/types/profile'; import type { LFThread } from '$lib/types/threads'; -import type { LFMessage } from '$lib/types/messages'; - -const getThreadWithMessages = async ( - thread_id: string, - access_token: string -): Promise => { - try { - const openai = getOpenAiClient(access_token); - const thread = (await openai.beta.threads.retrieve(thread_id)) as LFThread; - if (!thread) { - return null; - } - const messagesPage = await openai.beta.threads.messages.list(thread.id); - const messages = messagesPage.data as LFMessage[]; - messages.sort((a, b) => a.created_at - b.created_at); - return { ...thread, messages: messages }; - } catch (e) { - console.error(`Error fetching thread or messages: ${e}`); - return null; - } -}; +import { getThreadWithMessages } from '../helpers'; export const GET: RequestHandler = async ({ locals: { session, supabase, user } }) => { if (!session) { @@ -41,7 +20,7 @@ export const GET: RequestHandler = async ({ locals: { session, supabase, user } console.error( `error getting user profile for user_id: ${user?.id}. ${JSON.stringify(profileError)}` ); - throw redirect(303, '/'); + error(500, 'Internal Error'); } const threads: LFThread[] = []; @@ -51,7 +30,6 @@ export const GET: RequestHandler = async ({ locals: { session, supabase, user } getThreadWithMessages(thread_id, session.access_token) ); const results = await Promise.allSettled(threadPromises); - results.forEach((result) => { if (result.status === 'fulfilled' && result.value) { threads.push(result.value); @@ -59,7 +37,7 @@ export const GET: RequestHandler = async ({ locals: { session, supabase, user } }); } catch (e) { console.error(`Error fetching threads: ${e}`); - error(500, 'Internal Error'); + return json([]); } } diff --git a/src/leapfrogai_ui/src/routes/api/threads/[thread_id]/+server.ts b/src/leapfrogai_ui/src/routes/api/threads/[thread_id]/+server.ts index 0a4a29f76..5c0c9f769 100644 --- a/src/leapfrogai_ui/src/routes/api/threads/[thread_id]/+server.ts +++ b/src/leapfrogai_ui/src/routes/api/threads/[thread_id]/+server.ts @@ -1,23 +1,6 @@ import type { RequestHandler } from './$types'; import { error, json } from '@sveltejs/kit'; -import { getOpenAiClient } from '$lib/server/constants'; -import type { LFThread } from '$lib/types/threads'; -import type { LFMessage } from '$lib/types/messages'; - -const getThreadWithMessages = async ( - thread_id: string, - access_token: string -): Promise => { - const openai = getOpenAiClient(access_token); - const thread = (await openai.beta.threads.retrieve(thread_id)) as LFThread; - if (!thread) { - return null; - } - const messagesPage = await openai.beta.threads.messages.list(thread.id); - const messages = messagesPage.data as LFMessage[]; - messages.sort((a, b) => a.created_at - b.created_at); - return { ...thread, messages: messages }; -}; +import { getThreadWithMessages } from '../../helpers'; export const GET: RequestHandler = async ({ params, locals: { session } }) => { if (!session) { diff --git a/src/leapfrogai_ui/src/routes/api/threads/server.test.ts b/src/leapfrogai_ui/src/routes/api/threads/server.test.ts new file mode 100644 index 000000000..34c7dade9 --- /dev/null +++ b/src/leapfrogai_ui/src/routes/api/threads/server.test.ts @@ -0,0 +1,125 @@ +import { GET } from './+server'; +import { getLocalsMock } from '$lib/mocks/misc'; +import type { RequestEvent } from '@sveltejs/kit'; +import type { RouteParams } from './$types'; +import { + selectSingleReturnsMockError, + supabaseFromMockWrapper, + supabaseSelectSingleByIdMock +} from '$lib/mocks/supabase-mocks'; +import { getFakeThread } from '$testUtils/fakeData'; +import { mockOpenAI } from '../../../../vitest-setup'; +import * as apiHelpers from '../helpers'; + +const request = new Request('http://thisurlhasnoeffect', { + method: 'GET' +}); + +const thread1 = getFakeThread({ numMessages: 1 }); +const thread2 = getFakeThread({ numMessages: 2 }); +const fakeProfile = { thread_ids: [thread1.id, thread2.id] }; + +describe('/api/threads', () => { + it('returns a 401 when there is no session', async () => { + await expect( + GET({ + request, + locals: getLocalsMock({ nullSession: true }) + } as RequestEvent) + ).rejects.toMatchObject({ + status: 401 + }); + }); + it("returns a user's threads", async () => { + const thread1WithoutMessages = { ...thread1, messages: undefined }; + const thread2WithoutMessages = { ...thread2, messages: undefined }; + + mockOpenAI.setThreads([thread1WithoutMessages, thread2WithoutMessages]); + mockOpenAI.setMessages([...(thread1.messages || []), ...(thread2.messages || [])]); + + const res = await GET({ + request, + locals: getLocalsMock({ + supabase: supabaseFromMockWrapper({ + ...supabaseSelectSingleByIdMock(fakeProfile) + }) + }) + } as RequestEvent); + + expect(res.status).toEqual(200); + const resJson = await res.json(); + // Note - our fake threads already have messages attached, we are checking here that the + // API fetched the messages and added them to the threads since real threads don't have messages + expect(resJson[0].id).toEqual(thread1.id); + expect(resJson[0].messages).toEqual(thread1.messages); + expect(resJson[1].id).toEqual(thread2.id); + expect(resJson[1].messages).toEqual(thread2.messages); + }); + it('still returns threads that were successfully retrieved when there is an error getting a thread', async () => { + mockOpenAI.setThreads([thread2]); + mockOpenAI.setError('retrieveThread'); // fail the first thread fetching + const res = await GET({ + request, + locals: getLocalsMock({ + supabase: supabaseFromMockWrapper({ + ...supabaseSelectSingleByIdMock(fakeProfile) + }) + }) + } as RequestEvent); + + expect(res.status).toEqual(200); + const resJson = await res.json(); + expect(resJson[0].id).toEqual(thread2.id); + }); + it('still returns threads that were successfully retrieved when there is an error getting messages for a thread', async () => { + mockOpenAI.setThreads([thread1, thread2]); + mockOpenAI.setError('listMessages'); // fail the first thread's message fetching + const res = await GET({ + request, + locals: getLocalsMock({ + supabase: supabaseFromMockWrapper({ + ...supabaseSelectSingleByIdMock(fakeProfile) + }) + }) + } as RequestEvent); + + expect(res.status).toEqual(200); + const resJson = await res.json(); + expect(resJson[0].id).toEqual(thread2.id); + }); + it('returns an empty array if there is an unhandled error fetching threads', async () => { + vi.spyOn(apiHelpers, 'getThreadWithMessages').mockImplementationOnce(() => { + throw new Error('fake error'); + }); + const consoleSpy = vi.spyOn(console, 'error'); + + const res = await GET({ + request, + locals: getLocalsMock({ + supabase: supabaseFromMockWrapper({ + ...supabaseSelectSingleByIdMock(fakeProfile) + }) + }) + } as RequestEvent); + + expect(res.status).toEqual(200); + const resJson = await res.json(); + expect(resJson).toEqual([]); + // ensure we hit the correct catch block/error case with this test + expect(consoleSpy).toHaveBeenCalledWith('Error fetching threads: Error: fake error'); + }); + it("returns a 500 is an error getting the user's profile", async () => { + await expect( + GET({ + request, + locals: getLocalsMock({ + supabase: supabaseFromMockWrapper({ + ...selectSingleReturnsMockError() + }) + }) + } as RequestEvent) + ).rejects.toMatchObject({ + status: 500 + }); + }); +}); diff --git a/src/leapfrogai_ui/src/routes/chat/(settings)/api-keys/+page.server.ts b/src/leapfrogai_ui/src/routes/chat/(settings)/api-keys/+page.server.ts index 1cc33e4e8..ae0ec066c 100644 --- a/src/leapfrogai_ui/src/routes/chat/(settings)/api-keys/+page.server.ts +++ b/src/leapfrogai_ui/src/routes/chat/(settings)/api-keys/+page.server.ts @@ -30,7 +30,6 @@ export const load: PageServerLoad = async ({ depends, locals: { session } }) => if (!res.ok) { return error(500, { message: 'Error fetching API keys' }); } - keys = (await res.json()) as APIKeyRow[]; // convert from seconds to milliseconds keys.forEach((key) => { diff --git a/src/leapfrogai_ui/src/routes/chat/(settings)/api-keys/+page.svelte b/src/leapfrogai_ui/src/routes/chat/(settings)/api-keys/+page.svelte index e854a8e6f..413cf8e23 100644 --- a/src/leapfrogai_ui/src/routes/chat/(settings)/api-keys/+page.svelte +++ b/src/leapfrogai_ui/src/routes/chat/(settings)/api-keys/+page.svelte @@ -137,7 +137,11 @@
{#if editMode} -
+
{#if deleting} {#if deleting} - -
{#if hideUploader} @@ -222,7 +217,9 @@
- + { @@ -236,5 +233,6 @@ name="avatarFile" class="sr-only" /> - + +
diff --git a/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/edit/[assistantId]/+page.server.ts b/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/edit/[assistantId]/+page.server.ts index d1915be9b..697f07e03 100644 --- a/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/edit/[assistantId]/+page.server.ts +++ b/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/edit/[assistantId]/+page.server.ts @@ -152,7 +152,6 @@ export const actions: Actions = { } } } - // Create assistant object const updatedAssistantParams: AssistantCreateParams = { name: form.data.name, diff --git a/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/new/+page.server.ts b/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/new/+page.server.ts index 5ba7ef818..d1fcb2ce2 100644 --- a/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/new/+page.server.ts +++ b/src/leapfrogai_ui/src/routes/chat/(settings)/assistants-management/new/+page.server.ts @@ -102,7 +102,6 @@ export const actions: Actions = { console.error('Error saving assistant avatar:', error); return fail(500, { message: 'Error saving assistant avatar.' }); } - // update assistant with saved avatar path try { createdAssistant = (await openai.beta.assistants.update(createdAssistant.id, { diff --git a/src/leapfrogai_ui/tests/assistant-avatars.test.ts b/src/leapfrogai_ui/tests/assistant-avatars.test.ts index 47232cea0..e6f73864f 100644 --- a/src/leapfrogai_ui/tests/assistant-avatars.test.ts +++ b/src/leapfrogai_ui/tests/assistant-avatars.test.ts @@ -5,6 +5,7 @@ import { createAssistantWithApi, deleteAllAssistants, deleteAssistantWithApi, + editAssistantCard, fillOutRequiredAssistantFields, getRandomPictogramName, saveAssistant, @@ -71,6 +72,35 @@ test('it can upload an image as an avatar', async ({ page }) => { expect(avatarSrc).toBeDefined(); }); +test('it keeps the avatar when an assistant has been edited', async ({ page }) => { + const assistantInput = getFakeAssistantInput(); + + await loadNewAssistantPage(page); + + await fillOutRequiredAssistantFields(assistantInput, page); + + await page.getByTestId('mini-avatar-container').click(); + await uploadAvatar(page); + + await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click(); + + await saveAssistant(assistantInput.name, page); + const card = page.getByTestId(`assistant-card-${assistantInput.name}`); + const avatar = card.getByTestId('assistant-card-avatar'); + const originalAvatarSrc = await avatar.getAttribute('src'); + + expect(originalAvatarSrc).toBeDefined(); + + await page.waitForURL('/chat/assistants-management'); + await expect(page.getByTestId(`assistant-card-${assistantInput.name}`)).toBeVisible(); + await editAssistantCard(assistantInput.name, page); + await page.getByLabel('tagline').fill('new description'); + await saveAssistant(assistantInput.name, page); + + const avatarSrcAfterUpdate = await avatar.getAttribute('src'); + expect(avatarSrcAfterUpdate!.split('?v=')[0]).toEqual(originalAvatarSrc?.split('?v=')[0]); +}); + test('it can change an image uploaded as an avatar', async ({ page }) => { await loadNewAssistantPage(page);