Skip to content

Commit

Permalink
feat(ui): persist file upload status (#967)
Browse files Browse the repository at this point in the history
* Notify user of successful file upload even when leaving file-management page
* Display special toast when vectorizing files for an assistant with those files' progress
  • Loading branch information
andrewrisse authored Sep 6, 2024
1 parent 38e9705 commit cb0650d
Show file tree
Hide file tree
Showing 42 changed files with 940 additions and 295 deletions.
17 changes: 17 additions & 0 deletions src/leapfrogai_ui/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
height: calc(100vh - var(--header-height));
}

.max-height-sidebar-height {
max-height: calc(100vh - var(--header-height));
}

.no-scrollbar {
scrollbar-width: none;
}
Expand Down Expand Up @@ -69,6 +73,19 @@
.z-max {
z-index: 9999;
}

.z-max-1 {
z-index: 9000;
}

/*Note - tailwind has a truncate class that sets these properties, but for an unknown reason, it was
not working.
*/
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

@layer components {
Expand Down
3 changes: 2 additions & 1 deletion src/leapfrogai_ui/src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ const csp: Handle = async ({ event, resolve }) => {
"'self'",
process.env.LEAPFROGAI_API_BASE_URL,
process.env.PUBLIC_SUPABASE_URL,
process.env.SUPABASE_AUTH_EXTERNAL_KEYCLOAK_URL
process.env.SUPABASE_AUTH_EXTERNAL_KEYCLOAK_URL,
`wss://${process.env.PUBLIC_SUPABASE_URL!.replace('https://', '')}` // supabase realtime websocket
],
'child-src': ["'none'"],
'frame-src': [`blob: 'self'`],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
if (result.type === 'success') {
const idsToSelect: string[] = [];
const uploadedFiles = result.data?.uploadedFiles;
filesStore.updateWithUploadResults(result.data?.uploadedFiles);
filesStore.updateWithUploadErrors(result.data?.uploadedFiles);
for (const uploadedFile of uploadedFiles) {
idsToSelect.push(uploadedFile.id);
}
Expand Down Expand Up @@ -88,7 +88,7 @@
data-testid={`${file.id}-checkbox`}
on:click={() => handleClick(file.id)}
checked={$filesStore.selectedAssistantFileIds.includes(file.id)}
class="overflow-hidden text-ellipsis whitespace-nowrap">{file.text}</Checkbox
class="truncate">{file.text}</Checkbox
>
</li>
{/each}
Expand Down
42 changes: 32 additions & 10 deletions src/leapfrogai_ui/src/lib/components/AssistantForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,57 @@
} from '$lib/constants';
import { superForm } from 'sveltekit-superforms';
import { page } from '$app/stores';
import { beforeNavigate, goto } from '$app/navigation';
import { beforeNavigate, goto, invalidate } 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 } from '$stores';
import { filesStore, toastStore, uiStore } from '$stores';
import { assistantInputSchema, editAssistantInputSchema } from '$lib/schemas/assistants';
import type { NavigationTarget } from '@sveltejs/kit';
import { onMount } from 'svelte';
import AssistantFileSelect from '$components/AssistantFileSelect.svelte';
import LFInput from '$components/LFInput.svelte';
import LFLabel from '$components/LFLabel.svelte';
import AssistantAvatar from '$components/AssistantAvatar.svelte';
import vectorStatusStore from '$stores/vectorStatusStore.js';
export let data;
export let isEditMode = $page.url.pathname.includes('edit');
let isEditMode = $page.url.pathname.includes('edit');
let bypassCancelWarning = false;
const { form, errors, enhance, submitting, isTainted, delayed } = superForm(data.form, {
invalidateAll: false,
validators: yup(isEditMode ? editAssistantInputSchema : assistantInputSchema),
onResult({ result }) {
if (result.type === 'redirect') {
toastStore.addToast({
kind: 'success',
title: `Assistant ${isEditMode ? 'Updated' : 'Created'}.`
});
onResult: async ({ result }) => {
if (result.type === 'success') {
const vectorStoreId = result.data.assistant?.tool_resources?.file_search
?.vector_store_ids?.[0] as string;
if (!uiStore.isUsingOpenAI && vectorStoreId) {
toastStore.addToast({
kind: 'info',
title: `Updating Assistant Files`,
subtitle: result.data.assistant.name,
fileIds: result.data.fileIds,
vectorStoreId: vectorStoreId,
variant: 'assistant-progress',
timeout: -1 // no expiration
});
// If the assistant is being edited and previously had files with "completed" vector status,
// we need to fetch those statuses. The realtime listener will not detect a change for those old files and
// will not update the vectorStatusStore.
await vectorStatusStore.updateAllStatusesForVector(vectorStoreId);
} else {
toastStore.addToast({
kind: 'success',
title: `Assistant ${isEditMode ? 'Updated' : 'Created'}.`
});
}
bypassCancelWarning = true;
goto(result.location);
await invalidate('lf:assistants');
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) {
Expand Down
77 changes: 77 additions & 0 deletions src/leapfrogai_ui/src/lib/components/AssistantProgressToast.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<!--
Note - fully testing the assistant progress toast has proven difficult with Playwright. Sometimes the websocket
connection for the Supabase realtime listeners works, and sometimes it does not. Due to the dynamic nature of
how this component updates in realtime, unit testing is limited.
There is an issue in the backlog to re-address at some point:
TODO - https://github.com/defenseunicorns/leapfrogai/issues/981
-->

<script lang="ts">
import { P } from 'flowbite-svelte';
import type { ToastKind, ToastNotificationProps } from '$lib/types/toast';
import AssistantProgressToastContent from '$components/AssistantProgressToastContent.svelte';
import ToastOverride from '$components/ToastOverride.svelte';
import { getColor, getIconComponent } from '$helpers/toastHelpers';
import { onMount } from 'svelte';
import { toastStore } from '$stores';
import { FILE_VECTOR_TIMEOUT_MSG_TOAST } from '$constants/toastMessages';
export let toast: ToastNotificationProps;
// Processing timeout
export let timeout: number = 5 * 60 * 1000;
let { id, subtitle, kind } = toast;
let timeoutId: number;
$: color = getColor(kind);
function getAssistantVariantTitle(toastKind: ToastKind) {
switch (toastKind) {
case 'success':
return 'Assistant Updated';
case 'info':
return 'Updating Assistant Files';
case 'warning':
return 'Updating Assistant Files';
case 'error':
return 'Error Updating Assistant';
default:
return 'Updating Assistant Files';
}
}
// If the files are still processing after x minutes, dismiss the toast and
// pop a new error toast
onMount(() => {
timeoutId = setTimeout(() => {
toastStore.addToast(FILE_VECTOR_TIMEOUT_MSG_TOAST());
toastStore.dismissToast(id);
}, timeout);
return () => {
clearTimeout(timeoutId);
};
});
</script>

<ToastOverride {color} align={false} data-testid="assistant-progress-toast">
<svelte:fragment slot="icon">
<svelte:component this={getIconComponent(kind)} class="h-5 w-5" />
<span class="sr-only">Toast icon</span>
</svelte:fragment>
<div class="flex flex-col">
{getAssistantVariantTitle(kind)}
{#if subtitle}
<P size="xs">{subtitle}</P>
{/if}

<AssistantProgressToastContent
toastId={id}
fileIds={toast.fileIds}
vectorStoreId={toast.vectorStoreId}
on:statusChange={(e) => {
kind = e.detail;
}}
/>

<P size="xs">{`Time stamp [${new Date().toLocaleTimeString()}]`}</P>
</div>
</ToastOverride>
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
Note - fully testing the assistant progress toast has proven difficult with Playwright. Sometimes the websocket
connection for the Supabase realtime listeners works, and sometimes it does not. Due to the dynamic nature of
how this component updates in realtime, unit testing is limited.
There is an issue in the backlog to re-address at some point:
TODO - https://github.com/defenseunicorns/leapfrogai/issues/981
*/

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 { delay } from 'msw';
import { vi } from 'vitest';
import { toastStore } from '$stores';

describe('AssistantProgressToast', () => {
it('is auto dismissed after a specified timeout', async () => {
const dismissToastSpy = vi.spyOn(toastStore, 'dismissToast');
const files = getFakeFiles({ numFiles: 2 });
const toastId = '1';
const toast: ToastNotificationProps = {
id: toastId,
kind: 'info',
title: '',
fileIds: files.map((file) => file.id),
vectorStoreId: '123'
};
filesStore.setFiles(convertFileObjectToFileRows(files));

const timeout = 10; //10ms
render(AssistantProgressToast, { timeout, toast }); //10ms timeout
await screen.findByText('Updating Assistant Files');
await delay(timeout + 1);
expect(dismissToastSpy).toHaveBeenCalledWith(toastId);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script lang="ts">
import { Spinner } from 'flowbite-svelte';
import { filesStore, toastStore } from '$stores';
import { CheckOutline, ClockOutline, CloseCircleOutline } from 'flowbite-svelte-icons';
import { createEventDispatcher, onMount } from 'svelte';
import vectorStatusStore from '$stores/vectorStatusStore';
export let toastId: string;
export let vectorStoreId: string;
export let fileIds: string[];
// Auto dismiss toast after success
export let successTimeout: number = 5000;
const dispatch = createEventDispatcher();
let completedTimeoutId: number;
$: filesToDisplay = $filesStore.files.filter((file) => fileIds.includes(file.id));
$: allCompleted =
filesToDisplay.length === 0
? true
: filesToDisplay.length > 0 &&
filesToDisplay.every(
(file) =>
$vectorStatusStore[file.id] &&
$vectorStatusStore[file.id][vectorStoreId] === 'completed'
);
$: errorStatus = filesToDisplay.some(
(file) => $vectorStatusStore[file.id] && $vectorStatusStore[file.id][vectorStoreId] === 'failed'
);
$: if (errorStatus) {
dispatch('statusChange', 'error');
}
// Auto dismiss success toast after x seconds
$: if (allCompleted) {
dispatch('statusChange', 'success');
completedTimeoutId = setTimeout(() => {
toastStore.dismissToast(toastId);
}, successTimeout);
}
onMount(() => {
return () => {
clearTimeout(completedTimeoutId);
};
});
</script>

<div class="flex max-h-36 flex-col overflow-y-auto">
{#if allCompleted}
<div class="text-green-500">File Processing Complete</div>
{:else}
{#each filesToDisplay as file}
<div class="flex items-center justify-between py-1">
<div class="max-w-32 truncate">{file?.filename}</div>

{#if !$vectorStatusStore[file.id] || !$vectorStatusStore[file.id][vectorStoreId]}
<ClockOutline
data-testid={`file-${file.id}-vector-pending`}
color="orange"
class="me-2"
/>
{:else if $vectorStatusStore[file.id][vectorStoreId] === 'in_progress'}
<Spinner
data-testid={`file-${file.id}-vector-in-progress`}
class="me-2.5"
size="4"
color="white"
/>
{:else if $vectorStatusStore[file.id][vectorStoreId] === 'completed'}
<CheckOutline
data-testid={`file-${file.id}-vector-completed`}
color="green"
class="me-2"
/>
{:else if $vectorStatusStore[file.id][vectorStoreId] === 'failed' || $vectorStatusStore[file.id][vectorStoreId] === 'cancelled'}
<CloseCircleOutline
data-testid={`file-${file.id}-vector-in-failed`}
color="red"
class="me-2"
/>
{/if}
</div>
{/each}
{/if}
</div>
4 changes: 1 addition & 3 deletions src/leapfrogai_ui/src/lib/components/FileUploaderItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@

<Card {id} class="w-full " padding="xs" {...$$restProps}>
<div class="flex items-center justify-between">
<p
class="overflow-hidden text-ellipsis whitespace-nowrap font-normal leading-tight text-gray-700 dark:text-gray-400"
>
<p class="truncate font-normal leading-tight text-gray-700 dark:text-gray-400">
{name}
</p>

Expand Down
4 changes: 2 additions & 2 deletions src/leapfrogai_ui/src/lib/components/LFHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
data-testid="header-settings-btn"
class="settings-menu cursor-pointer dark:text-white"
/>
<Dropdown triggeredBy=".settings-menu" data-testid="settings-dropdown">
<Dropdown triggeredBy=".settings-menu" data-testid="settings-dropdown" classContainer="z-max">
<DropdownItem href="/chat/assistants-management">Assistants Management</DropdownItem>
<DropdownItem href="/chat/file-management">File Management</DropdownItem>
{#if !$uiStore.isUsingOpenAI}
Expand All @@ -61,7 +61,7 @@
data-testid="header-profile-btn"
class="profile-menu cursor-pointer dark:text-white"
/>
<Dropdown triggeredBy=".profile-menu" data-testid="profile-dropdown">
<Dropdown triggeredBy=".profile-menu" data-testid="profile-dropdown" classContainer="z-max">
<DropdownItem on:click={handleLogOut}
>Log Out <form bind:this={signOutForm} method="post" action="/auth?/signout" />
</DropdownItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ It adds a "three-dot" menu button with Popover, and delete confirmation Modal
let editLabelText: string | undefined = label;
let editLabelInputDisabled = false;
let lengthOverride = 'overflow-hidden text-ellipsis whitespace-nowrap';
$: popperOpen = false;
$: editMode = false;
Expand Down Expand Up @@ -112,13 +110,13 @@ It adds a "three-dot" menu button with Popover, and delete confirmation Modal
}}
class={twMerge(
active ? activeClass : sClass,
lengthOverride,
'truncate',
'flex-grow',
'cursor-pointer',
$$props.class
)}
>
<P size="sm" class="overflow-hidden text-ellipsis whitespace-nowrap">
<P size="sm" class="truncate">
{label}
</P>
</button>
Expand Down
Loading

0 comments on commit cb0650d

Please sign in to comment.