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..73356ee1a 100644
--- a/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte
+++ b/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte
@@ -1,7 +1,7 @@
diff --git a/src/leapfrogai_ui/src/lib/components/modals/ConfirmFilesDeleteModal.svelte b/src/leapfrogai_ui/src/lib/components/modals/ConfirmFilesDeleteModal.svelte
index d80d93147..d581f83cd 100644
--- a/src/leapfrogai_ui/src/lib/components/modals/ConfirmFilesDeleteModal.svelte
+++ b/src/leapfrogai_ui/src/lib/components/modals/ConfirmFilesDeleteModal.svelte
@@ -3,7 +3,6 @@
import type { Assistant } from 'openai/resources/beta/assistants';
import { filesStore, toastStore } from '$stores';
import { ExclamationCircleOutline } from 'flowbite-svelte-icons';
- import { invalidate } from '$app/navigation';
import { createEventDispatcher } from 'svelte';
import vectorStatusStore from '$stores/vectorStatusStore';
@@ -12,6 +11,8 @@
export let deleting: boolean;
export let affectedAssistants: Assistant[];
+ $: isMultipleFiles = $filesStore.selectedFileManagementFileIds.length > 1;
+
const dispatch = createEventDispatcher();
const handleCancel = () => {
@@ -20,34 +21,43 @@
affectedAssistantsLoading = false;
};
+ const handleDeleteError = () => {
+ toastStore.addToast({
+ kind: 'error',
+ title: `Error Deleting ${isMultipleFiles ? 'Files' : 'File'}`
+ });
+ };
+
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'
- }
- });
- open = false;
- await invalidate('lf:files');
- if (res.ok) {
- toastStore.addToast({
- kind: 'success',
- title: `${isMultipleFiles ? 'Files' : 'File'} Deleted`
- });
- } else {
- toastStore.addToast({
- kind: 'error',
- title: `Error Deleting ${isMultipleFiles ? 'Files' : 'File'}`
+ 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/constants/index.ts b/src/leapfrogai_ui/src/lib/constants/index.ts
index 5ad6cac6d..08e813bf0 100644
--- a/src/leapfrogai_ui/src/lib/constants/index.ts
+++ b/src/leapfrogai_ui/src/lib/constants/index.ts
@@ -52,7 +52,7 @@ export const ACCEPTED_AUDIO_FILE_TYPES = [
'.webm'
];
-export const ACCEPTED_FILE_TYPES = [
+export const ACCEPTED_DOC_TYPES = [
'.pdf',
'.txt',
'.text',
@@ -62,7 +62,10 @@ export const ACCEPTED_FILE_TYPES = [
'.pptx',
'.doc',
'.docx',
- '.csv',
+ '.csv'
+];
+export const ACCEPTED_DOC_AND_AUDIO_FILE_TYPES = [
+ ...ACCEPTED_DOC_TYPES,
...ACCEPTED_AUDIO_FILE_TYPES
];
@@ -108,7 +111,7 @@ export const NO_FILE_ERROR_TEXT = 'Please upload an image or select a pictogram'
export const AVATAR_FILE_SIZE_ERROR_TEXT = `File must be less than ${MAX_AVATAR_SIZE / 1000000} MB`;
export const FILE_SIZE_ERROR_TEXT = `File must be less than ${MAX_FILE_SIZE / 1000000} MB`;
export const AUDIO_FILE_SIZE_ERROR_TEXT = `Audio file must be less than ${MAX_AUDIO_FILE_SIZE / 1000000} MB`;
-export const INVALID_FILE_TYPE_ERROR_TEXT = `Invalid file type, accepted types are: ${ACCEPTED_FILE_TYPES.join(', ')}`;
+export const INVALID_FILE_TYPE_ERROR_TEXT = `Invalid file type, accepted types are: ${ACCEPTED_DOC_AND_AUDIO_FILE_TYPES.join(', ')}`;
export const INVALID_AUDIO_FILE_TYPE_ERROR_TEXT = `Invalid file type, accepted types are: ${ACCEPTED_AUDIO_FILE_TYPES.join(', ')}`;
export const NO_SELECTED_ASSISTANT_ID = 'noSelectedAssistantId';
diff --git a/src/leapfrogai_ui/src/lib/helpers/fileHelpers.ts b/src/leapfrogai_ui/src/lib/helpers/fileHelpers.ts
index a0cd0fc5b..b6d229336 100644
--- a/src/leapfrogai_ui/src/lib/helpers/fileHelpers.ts
+++ b/src/leapfrogai_ui/src/lib/helpers/fileHelpers.ts
@@ -1,11 +1,10 @@
-import type { FileMetadata, FileRow } from '$lib/types/files';
+import type { FileMetadata, LFFileObject } from '$lib/types/files';
import type { FileObject } from 'openai/resources/files';
import { FILE_CONTEXT_TOO_LARGE_ERROR_MSG } from '$constants/errors';
-export const convertFileObjectToFileRows = (files: FileObject[]): FileRow[] =>
+export const convertFileObjectToLFFileObject = (files: FileObject[]): LFFileObject[] =>
files.map((file) => ({
- id: file.id,
- filename: file.filename,
+ ...file,
created_at: file.created_at * 1000,
status: 'hide'
}));
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/lib/stores/assistantsStore.ts b/src/leapfrogai_ui/src/lib/stores/assistantsStore.ts
new file mode 100644
index 000000000..b0356c576
--- /dev/null
+++ b/src/leapfrogai_ui/src/lib/stores/assistantsStore.ts
@@ -0,0 +1,57 @@
+import { writable } from 'svelte/store';
+import type { LFAssistant } from '$lib/types/assistants';
+import { NO_SELECTED_ASSISTANT_ID } from '$constants';
+
+type AssistantsStore = {
+ assistants: LFAssistant[];
+ selectedAssistantId?: string;
+};
+
+const defaultValues: AssistantsStore = {
+ assistants: [],
+ selectedAssistantId: NO_SELECTED_ASSISTANT_ID
+};
+const createAssistantsStore = () => {
+ const { subscribe, set, update } = writable
({ ...defaultValues });
+
+ return {
+ subscribe,
+ set,
+ update,
+ setAssistants: (newAssistants: LFAssistant[]) => {
+ update((old) => ({ ...old, assistants: newAssistants }));
+ },
+ setSelectedAssistantId: (selectedAssistantId: string) => {
+ update((old) => {
+ return { ...old, selectedAssistantId };
+ });
+ },
+ addAssistant: (newAssistant: LFAssistant) => {
+ update((old) => ({ ...old, assistants: [...old.assistants, newAssistant] }));
+ },
+ removeAssistant: (id: string) => {
+ update((old) => {
+ const updatedAssistants = [...old.assistants];
+ const assistantIndex = updatedAssistants.findIndex((assistant) => assistant.id === id);
+ if (assistantIndex > -1) {
+ updatedAssistants.splice(assistantIndex, 1);
+ }
+ return { ...old, assistants: updatedAssistants };
+ });
+ },
+ updateAssistant: (newAssistant: LFAssistant) => {
+ update((old) => {
+ const updatedAssistants = [...old.assistants];
+ const assistantIndex = updatedAssistants.findIndex(
+ (assistant) => assistant.id === newAssistant.id
+ );
+ if (assistantIndex > -1) {
+ updatedAssistants[assistantIndex] = newAssistant;
+ }
+ return { ...old, assistants: updatedAssistants };
+ });
+ }
+ };
+};
+const assistantsStore = createAssistantsStore();
+export default assistantsStore;
diff --git a/src/leapfrogai_ui/src/lib/stores/filesStore.ts b/src/leapfrogai_ui/src/lib/stores/filesStore.ts
index c6ba33db8..5e0eeea19 100644
--- a/src/leapfrogai_ui/src/lib/stores/filesStore.ts
+++ b/src/leapfrogai_ui/src/lib/stores/filesStore.ts
@@ -1,14 +1,16 @@
import { derived, writable } from 'svelte/store';
import type { FileObject } from 'openai/resources/files';
-import type { FileRow } from '$lib/types/files';
+import type { LFFileObject, PendingOrErrorFile } from '$lib/types/files';
import { toastStore } from '$stores/index';
+import { getUnixSeconds } from '$helpers/dates';
type FilesStore = {
- files: FileRow[];
+ files: LFFileObject[];
selectedFileManagementFileIds: string[];
selectedAssistantFileIds: string[];
uploading: boolean;
- pendingUploads: FileRow[];
+ pendingUploads: PendingOrErrorFile[];
+ needsUpdate?: boolean;
};
const defaultValues: FilesStore = {
@@ -16,7 +18,8 @@ const defaultValues: FilesStore = {
selectedFileManagementFileIds: [],
selectedAssistantFileIds: [],
uploading: false,
- pendingUploads: []
+ pendingUploads: [],
+ needsUpdate: false
};
const createFilesStore = () => {
@@ -27,16 +30,32 @@ const createFilesStore = () => {
set,
update,
setUploading: (status: boolean) => update((old) => ({ ...old, uploading: status })),
-
- setFiles: (newFiles: FileRow[]) => {
+ removeFile: (id: string) => {
+ update((old) => {
+ const updatedFiles = [...old.files];
+ const fileIndex = updatedFiles.findIndex((file) => file.id === id);
+ if (fileIndex > -1) {
+ updatedFiles.splice(fileIndex, 1);
+ }
+ return { ...old, files: updatedFiles };
+ });
+ },
+ setFiles: (newFiles: LFFileObject[]) => {
update((old) => ({ ...old, files: [...newFiles] }));
},
- setPendingUploads: (newFiles: FileRow[]) => {
+ setPendingUploads: (newFiles: LFFileObject[]) => {
update((old) => ({ ...old, pendingUploads: [...newFiles] }));
},
setSelectedFileManagementFileIds: (newIds: string[]) => {
update((old) => ({ ...old, selectedFileManagementFileIds: newIds }));
},
+ setNeedsUpdate: (status: boolean) => {
+ update((old) => ({ ...old, needsUpdate: status }));
+ },
+ fetchFiles: async () => {
+ const files = await fetch('/api/files').then((res) => res.json());
+ update((old) => ({ ...old, files, needsUpdate: false }));
+ },
addSelectedFileManagementFileId: (id: string) => {
update((old) => ({
...old,
@@ -66,7 +85,7 @@ const createFilesStore = () => {
},
addUploadingFiles: (files: File[], { autoSelectUploadedFiles = false } = {}) => {
update((old) => {
- const newFiles: FileRow[] = [];
+ const newFiles: Pick[] = [];
const newFileIds: string[] = [];
for (const file of files) {
const id = `${file.name}-${new Date()}`; // temp id
@@ -74,7 +93,7 @@ const createFilesStore = () => {
id,
filename: file.name,
status: 'uploading',
- created_at: null
+ created_at: getUnixSeconds(new Date())
});
newFileIds.push(id);
}
@@ -87,16 +106,14 @@ const createFilesStore = () => {
};
});
},
- updateWithUploadErrors: (newFiles: Array) => {
+ updateWithUploadErrors: (newFiles: Array) => {
update((old) => {
- const failedRows: FileRow[] = [];
+ const failedRows: LFFileObject[] = [];
for (const file of newFiles) {
if (file.status === 'error') {
- const row: FileRow = {
- id: file.id,
- filename: file.filename,
- created_at: file.created_at,
+ const row: LFFileObject = {
+ ...file,
status: 'error'
};
@@ -126,15 +143,13 @@ const createFilesStore = () => {
};
});
},
- updateWithUploadSuccess: (newFiles: Array) => {
+ updateWithUploadSuccess: (newFiles: Array) => {
update((old) => {
const successRows = [...old.files];
for (const file of newFiles) {
- const row: FileRow = {
- id: file.id,
- filename: file.filename,
- created_at: file.created_at,
+ const row: LFFileObject = {
+ ...file,
status: 'complete'
};
diff --git a/src/leapfrogai_ui/src/lib/stores/index.ts b/src/leapfrogai_ui/src/lib/stores/index.ts
index 90cac2ebd..66da975b0 100644
--- a/src/leapfrogai_ui/src/lib/stores/index.ts
+++ b/src/leapfrogai_ui/src/lib/stores/index.ts
@@ -2,3 +2,4 @@ export { default as threadsStore } from './threads';
export { default as toastStore } from './toast';
export { default as uiStore } from './ui';
export { default as filesStore } from './filesStore';
+export { default as assistantsStore } from './assistantsStore';
diff --git a/src/leapfrogai_ui/src/lib/stores/threads.ts b/src/leapfrogai_ui/src/lib/stores/threads.ts
index 0b9738fbb..a79c66f1a 100644
--- a/src/leapfrogai_ui/src/lib/stores/threads.ts
+++ b/src/leapfrogai_ui/src/lib/stores/threads.ts
@@ -1,6 +1,6 @@
import { writable } from 'svelte/store';
-import { MAX_LABEL_SIZE, NO_SELECTED_ASSISTANT_ID } from '$lib/constants';
-import { goto, invalidate } from '$app/navigation';
+import { MAX_LABEL_SIZE } from '$lib/constants';
+import { goto } from '$app/navigation';
import { error } from '@sveltejs/kit';
import { type Message as VercelAIMessage } from '@ai-sdk/svelte';
import { toastStore } from '$stores';
@@ -12,7 +12,6 @@ import type { Message } from 'ai';
type ThreadsStore = {
threads: LFThread[];
- selectedAssistantId: string;
sendingBlocked: boolean;
lastVisitedThreadId: string;
streamingMessage: VercelAIMessage | null;
@@ -20,7 +19,6 @@ type ThreadsStore = {
const defaultValues: ThreadsStore = {
threads: [],
- selectedAssistantId: NO_SELECTED_ASSISTANT_ID,
sendingBlocked: false,
lastVisitedThreadId: '',
streamingMessage: null
@@ -97,11 +95,6 @@ const createThreadsStore = () => {
setLastVisitedThreadId: (id: string) => {
update((old) => ({ ...old, lastVisitedThreadId: id }));
},
- setSelectedAssistantId: (selectedAssistantId: string) => {
- update((old) => {
- return { ...old, selectedAssistantId };
- });
- },
// Important - this method has a built in delay to ensure next user message has a different timestamp when setting to false (unblocking)
setSendingBlocked: async (status: boolean) => {
if (!status && process.env.NODE_ENV !== 'test') {
@@ -303,7 +296,6 @@ const createThreadsStore = () => {
title: 'Error',
subtitle: `Error deleting message.`
});
- await invalidate('lf:threads');
}
},
updateThreadLabel: async (id: string, newLabel: string) => {
diff --git a/src/leapfrogai_ui/src/lib/types/files.d.ts b/src/leapfrogai_ui/src/lib/types/files.d.ts
index 599260041..17355cd32 100644
--- a/src/leapfrogai_ui/src/lib/types/files.d.ts
+++ b/src/leapfrogai_ui/src/lib/types/files.d.ts
@@ -1,16 +1,16 @@
import type { SuperValidated } from 'sveltekit-superforms';
+import type { FileObject } from 'openai/resources/files';
export type FileUploadStatus = 'uploading' | 'complete' | 'error' | 'hide';
export type VectorStatus = 'in_progress' | 'completed' | 'cancelled' | 'failed';
-export type FileRow = {
- id: string;
- filename: string;
- created_at: number | null;
+export type LFFileObject = Omit & {
status: FileUploadStatus;
};
+export type PendingOrErrorFile = Pick;
+
// This type is taken from SuperValidated, leaving the any
export type FilesForm = SuperValidated<
{ files?: (File | null | undefined)[] | undefined },
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
new file mode 100644
index 000000000..8158bab7a
--- /dev/null
+++ b/src/leapfrogai_ui/src/routes/api/threads/+server.ts
@@ -0,0 +1,45 @@
+import type { RequestHandler } from './$types';
+import { error, json } from '@sveltejs/kit';
+import type { Profile } from '$lib/types/profile';
+import type { LFThread } from '$lib/types/threads';
+import { getThreadWithMessages } from '../helpers';
+
+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)}`
+ );
+ error(500, 'Internal Error');
+ }
+
+ 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}`);
+ return json([]);
+ }
+ }
+
+ return json(threads);
+};
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/(dashboard)/[[thread_id]]/+page.svelte b/src/leapfrogai_ui/src/routes/chat/(dashboard)/[[thread_id]]/+page.svelte
index f082615c5..a9c359274 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 { assistantsStore, 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';
@@ -29,35 +29,28 @@
import ChatFileUploadForm from '$components/ChatFileUpload.svelte';
import FileChatActions from '$components/FileChatActions.svelte';
import LFCarousel from '$components/LFCarousel.svelte';
-
- export let data;
+ import type { LFThread } from '$lib/types/threads';
/** LOCAL VARS **/
let lengthInvalid: boolean; // bound to child LFTextArea
- let assistantsList: Array<{ id: string; text: string }>;
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];
$: assistantMode =
- $threadsStore.selectedAssistantId !== NO_SELECTED_ASSISTANT_ID &&
- $threadsStore.selectedAssistantId !== 'manage-assistants';
+ $assistantsStore.selectedAssistantId !== NO_SELECTED_ASSISTANT_ID &&
+ $assistantsStore.selectedAssistantId !== 'manage-assistants';
$: if (messageStreaming) threadsStore.setSendingBlocked(true);
@@ -78,6 +71,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 = [];
@@ -100,13 +113,13 @@
);
const message = await messageRes.json();
// store the assistant id on the user msg to know it's associated with an assistant
- message.metadata.assistant_id = $threadsStore.selectedAssistantId;
+ message.metadata.assistant_id = $assistantsStore.selectedAssistantId;
await threadsStore.addMessageToStore(message);
} else if (latestAssistantMessage?.role !== 'user') {
// Streamed assistant responses don't contain an assistant_id, so we add it here
// and also add a createdAt date if not present
if (!latestAssistantMessage.assistant_id) {
- latestAssistantMessage.assistant_id = $threadsStore.selectedAssistantId;
+ latestAssistantMessage.assistant_id = $assistantsStore.selectedAssistantId;
}
if (!latestAssistantMessage.createdAt)
@@ -144,10 +157,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 +196,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 +210,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
@@ -206,8 +219,8 @@
// submit to AI (/api/chat/assistants)
data: {
message: $chatInput,
- assistantId: $threadsStore.selectedAssistantId,
- threadId: data.thread.id
+ assistantId: $assistantsStore.selectedAssistantId,
+ threadId: activeThread.id
}
});
$assistantInput = '';
@@ -218,13 +231,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 +250,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 +283,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 +298,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
@@ -305,19 +318,13 @@
onMount(async () => {
componentHasMounted = true;
- 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
});
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 +338,21 @@