Skip to content

Commit

Permalink
532 upload file with assistant (#608)
Browse files Browse the repository at this point in the history
* Feature - upload new files while creating or editing an assistant

* Also adds a "filesStore" which will make completing #559 much easier. We will just refactor the file management page to follow the pattern used in this feature.

* Due to the custom behavior of the MultiSelect necessary for this story, we copied the Carbon Components Svelte MultiSelect code into our own custom version of it called LFMultiSelect.

---------

Co-authored-by: John Alling <[email protected]>
Co-authored-by: Jon Perry <[email protected]>
  • Loading branch information
3 people authored Jun 13, 2024
1 parent 3b91e7f commit 74911a8
Show file tree
Hide file tree
Showing 17 changed files with 1,122 additions and 79 deletions.
58 changes: 42 additions & 16 deletions src/leapfrogai_ui/src/lib/components/AssistantFileSelect.svelte
Original file line number Diff line number Diff line change
@@ -1,41 +1,67 @@
<script lang="ts">
import { FileUploaderItem, MultiSelect } from 'carbon-components-svelte';
import { FileUploaderItem } from 'carbon-components-svelte';
import { fade } from 'svelte/transition';
import type { FileObject } from 'openai/resources/files';
import LFMultiSelect from '$components/LFMultiSelect.svelte';
import { filesStore } from '$stores';
import type { FilesForm } from '$lib/types/files';
export let files: FileObject[];
export let selectedFileIds: string[];
export let filesForm: FilesForm;
$: filteredStoreFiles = $filesStore.files
.filter((f) => $filesStore.selectedAssistantFileIds.includes(f.id))
.sort((a, b) => a.filename.localeCompare(b.filename));
// Files with errors remain selected for 1.5 seconds until the files are re-fetched do show the error state
// If the assistant is saved before they are re-fetched, we need to ensure any files with errors are removed
// from the list of ids being saved
$: fileIdsWithoutErrors = $filesStore.files
.filter((row) => row.status !== 'error')
.map((row) => row.id)
.filter((id) => $filesStore.selectedAssistantFileIds.includes(id));
</script>

<MultiSelect
label="Choose data sources"
items={files?.map((file) => ({ id: file.id, text: file.filename }))}
direction="top"
bind:selectedIds={selectedFileIds}
/>
<div id="multi-select-container">
<LFMultiSelect
label="Choose data sources"
items={$filesStore.files.map((file) => ({ id: file.id, text: file.filename }))}
direction="top"
accept={['.pdf', 'txt']}
bind:selectedIds={$filesStore.selectedAssistantFileIds}
{filesForm}
/>
</div>

<div class="file-item-list">
{#each [...(files || [])]
.filter((f) => selectedFileIds.includes(f.id))
.sort((a, b) => a.filename.localeCompare(b.filename)) as file}
{#each filteredStoreFiles as file}
<div transition:fade={{ duration: 70 }}>
<FileUploaderItem
data-testid={`${file.filename}-${file.status}-uploader-item`}
invalid={file.status === 'error'}
id={file.id}
name={file.filename}
size="small"
status="edit"
status={file.status === 'uploading' ? 'uploading' : 'edit'}
style="max-width: 100%"
on:delete={() => {
selectedFileIds = selectedFileIds.filter((id) => id !== file.id);
filesStore.setSelectedAssistantFileIds(
$filesStore.selectedAssistantFileIds.filter((id) => id !== file.id)
);
}}
/>
</div>
{/each}
</div>

<input type="hidden" name="data_sources" bind:value={selectedFileIds} />
<input type="hidden" name="data_sources" bind:value={fileIdsWithoutErrors} />

<style lang="scss">
#multi-select-container {
// remove border from first item so button outline shows instead
:global(.bx--list-box__menu-item__option:nth-of-type(1)) {
border-top: none;
}
}
.file-item-list {
display: flex;
flex-direction: column;
Expand Down
58 changes: 58 additions & 0 deletions src/leapfrogai_ui/src/lib/components/AssistantFileSelect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { filesStore } from '$stores';
import { render, screen } from '@testing-library/svelte';
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 { getUnixSeconds } from '$helpers/dates';
import userEvent from '@testing-library/user-event';

const filesForm = await superValidate({}, yup(filesSchema), { errors: false });

describe('AssistantFileSelect', () => {
const mockFiles: FileRow[] = [
{ 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()) }
];

beforeEach(() => {
filesStore.set({
files: mockFiles,
selectedAssistantFileIds: ['1', '2'],
uploading: false
});
});

it('renders each selected file', async () => {
render(AssistantFileSelect, {
filesForm
});

expect(screen.getByTestId(`${mockFiles[0].filename}-${mockFiles[0].status}-uploader-item`));
expect(screen.getByTestId(`${mockFiles[1].filename}-${mockFiles[1].status}-uploader-item`));
expect(
screen.queryByTestId(`${mockFiles[2].filename}-${mockFiles[2].status}-uploader-item`)
).not.toBeInTheDocument();
});

it('can select files', async () => {
filesStore.set({
files: mockFiles,
selectedAssistantFileIds: [],
uploading: false
});

render(AssistantFileSelect, {
filesForm
});

expect(
screen.queryByTestId(`${mockFiles[0].filename}-${mockFiles[0].status}-uploader-item`)
).not.toBeInTheDocument();

await userEvent.click(screen.getByText(mockFiles[0].filename));
screen.getByTestId(`${mockFiles[0].filename}-${mockFiles[0].status}-uploader-item`);
});
});
24 changes: 20 additions & 4 deletions src/leapfrogai_ui/src/lib/components/AssistantForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@
import { Button, Modal, Slider, TextArea, TextInput } from 'carbon-components-svelte';
import AssistantAvatar from '$components/AssistantAvatar.svelte';
import { yup } from 'sveltekit-superforms/adapters';
import { toastStore } from '$stores';
import { filesStore, toastStore } from '$stores';
import InputTooltip from '$components/InputTooltip.svelte';
import { assistantInputSchema, editAssistantInputSchema } from '$lib/schemas/assistants';
import type { NavigationTarget } from '@sveltejs/kit';
import { onMount } from 'svelte';
import AssistantFileSelect from '$components/AssistantFileSelect.svelte';
import type { FileRow } from '$lib/types/files';
export let data;
let isEditMode = $page.url.pathname.includes('edit');
let bypassCancelWarning = false;
let selectedFileIds: string[] = data.form.data.data_sources || [];
const { form, errors, enhance, submitting, isTainted } = superForm(data.form, {
invalidateAll: false,
Expand Down Expand Up @@ -77,6 +77,16 @@
}
});
$: if (data.files) {
const fileRows: FileRow[] = data.files.map((file) => ({
id: file.id,
filename: file.filename,
created_at: file.created_at,
status: 'complete'
}));
filesStore.setFiles(fileRows);
}
onMount(() => {
if (isEditMode && Object.keys($errors).length > 0) {
toastStore.addToast({
Expand All @@ -86,6 +96,7 @@
});
goto('/chat/assistants-management');
}
filesStore.setSelectedAssistantFileIds($form.data_sources || []);
});
</script>

Expand Down Expand Up @@ -170,7 +181,7 @@
labelText="Data Sources"
tooltipText="Specific files your assistant can search and reference"
/>
<AssistantFileSelect files={data?.files} bind:selectedFileIds />
<AssistantFileSelect filesForm={data.filesForm} />
<input
type="hidden"
name="vectorStoreId"
Expand All @@ -186,7 +197,12 @@
goto('/chat/assistants-management');
}}>Cancel</Button
>
<Button kind="primary" size="small" type="submit" disabled={$submitting}>Save</Button>
<Button
kind="primary"
size="small"
type="submit"
disabled={$submitting || $filesStore.uploading}>Save</Button
>
</div>
</div>
</div>
Expand Down
176 changes: 176 additions & 0 deletions src/leapfrogai_ui/src/lib/components/FileUploadMenuItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<script lang="ts">
import { Upload } from 'carbon-icons-svelte';
import { ListBoxMenuItem } from 'carbon-components-svelte';
import { createEventDispatcher } from 'svelte';
import type { FilesForm } from '$lib/types/files';
import { filesStore, toastStore } from '$stores';
import { superForm } from 'sveltekit-superforms';
import { yup } from 'sveltekit-superforms/adapters';
import { filesSchema } from '$schemas/files';
import type { ActionResult } from '@sveltejs/kit';
export let multiple = false;
export let files: File[] = [];
export let labelText = 'Add file';
export let ref: HTMLInputElement | null = null;
export let id = 'ccs-' + Math.random().toString(36);
export let disabled = false;
export let tabindex = '0';
export let role = 'button';
export let accept: ReadonlyArray<string> = [];
export let disableLabelChanges = false;
export let filesForm: FilesForm; // for the form
export let open: boolean; //Parent LFMultiSelect open reactive variable
let initialLabelText = labelText;
const dispatch = createEventDispatcher();
$: if (ref && files.length === 0) {
labelText = initialLabelText;
ref.value = '';
}
$: if (files.length > 0) {
handleUpload();
}
const handleUpload = () => {
open = false; // close parent multi select
filesStore.setUploading(true);
filesStore.addUploadingFiles(files);
submit();
};
// The parent ListBox that uses this component has on:click|preventDefault for the other
// items in the list box to prevent it from closing. We get around that with this function
// to ensure you can still open a file upload dialog.
const handleClick = (e: MouseEvent) => {
e.stopPropagation();
if (ref) {
ref.click();
}
};
const handleResult = async (result: ActionResult) => {
if (result.type === 'success') {
const idsToSelect: string[] = [];
const uploadedFiles = result.data?.uploadedFiles;
filesStore.updateWithUploadResults(result.data?.uploadedFiles);
for (const uploadedFile of uploadedFiles) {
idsToSelect.push(uploadedFile.id);
if (uploadedFile.status === 'error') {
toastStore.addToast({
kind: 'error',
title: 'Upload Failed',
subtitle: `${uploadedFile.filename} upload failed.`
});
} else {
toastStore.addToast({
kind: 'success',
title: 'Uploaded Successfully',
subtitle: `${uploadedFile.filename} uploaded successfully.`
});
}
}
filesStore.addSelectedAssistantFileIds(idsToSelect);
}
filesStore.setUploading(false);
};
const { enhance, submit } = superForm(filesForm, {
validators: yup(filesSchema),
invalidateAll: false,
onError() {
// Backend failure, not just a single file failure
filesStore.setAllUploadingToError();
toastStore.addToast({
kind: 'error',
title: 'Upload Failed',
subtitle: `Please try again or contact support`
});
},
onResult: async ({ result }) => handleResult(result)
});
</script>

<form
class="file-upload-container"
method="POST"
enctype="multipart/form-data"
use:enhance
action="/chat/file-management"
>
<ListBoxMenuItem on:click={handleClick}>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<label
for={id}
aria-disabled={disabled}
tabindex={disabled ? '-1' : tabindex}
on:keydown
on:keydown={({ key }) => {
if (key === ' ' || key === 'Enter') {
ref?.click();
}
}}
>
<span {role}>
<slot name="labelText">
<div class="upload-item">
<div class="upload-icon">
<Upload />
</div>
<span class="bx--checkbox-label-text">{labelText}</span>
</div>
</slot>
</span>
</label>
<input
bind:this={ref}
{disabled}
type="file"
tabindex="-1"
{accept}
{multiple}
name="files"
class:bx--visually-hidden={true}
{...$$restProps}
on:change|stopPropagation={({ target }) => {
if (target) {
files = [...target.files];
if (files && !disableLabelChanges) {
labelText = files.length > 1 ? `${files.length} files` : files[0].name;
}
dispatch('change', files);
}
}}
on:click
on:click={({ target }) => {
if (target) {
target.value = null;
}
}}
/>
</ListBoxMenuItem>
</form>

<style lang="scss">
.file-upload-container {
outline: 1px solid themes.$border-subtle-03;
}
.upload-item {
display: flex;
align-items: center;
cursor: pointer;
}
.upload-icon {
display: flex;
width: 1.4rem;
justify-content: center;
margin-right: 0.3rem;
}
</style>
Loading

0 comments on commit 74911a8

Please sign in to comment.