diff --git a/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte b/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte
new file mode 100644
index 000000000..e01575ce2
--- /dev/null
+++ b/src/leapfrogai_ui/src/lib/components/ChatFileUpload.svelte
@@ -0,0 +1,153 @@
+
+
+ {
+ if (e.detail.length > MAX_NUM_FILES_UPLOAD) {
+ e.detail.pop();
+ toastStore.addToast(MAX_NUM_FILES_UPLOAD_MSG_TOAST());
+ return;
+ }
+ uploadingFiles = true;
+ for (const file of e.detail) {
+ // Metadata is limited to 512 characters, we use a short id to save space
+ const id = uuidv4().substring(0, 8);
+ file.id = id;
+ attachedFileMetadata = [
+ ...attachedFileMetadata,
+ {
+ id,
+ name: file.name,
+ type: file.type,
+ status: file.type.startsWith('audio/') ? 'complete' : 'uploading'
+ }
+ ];
+ }
+
+ attachedFiles = [...attachedFiles, ...e.detail];
+ convertFiles(e.detail);
+ fileUploadBtnRef.value = '';
+ }}
+ accept={ACCEPTED_FILE_TYPES}
+ disabled={uploadingFiles}
+ class="remove-btn-style flex rounded-lg p-1.5 text-gray-500 hover:bg-inherit dark:hover:bg-inherit"
+>
+
+ Attach file
+
diff --git a/src/leapfrogai_ui/src/lib/components/ChatFileUploadForm.svelte b/src/leapfrogai_ui/src/lib/components/ChatFileUploadForm.svelte
deleted file mode 100644
index 894d97eaf..000000000
--- a/src/leapfrogai_ui/src/lib/components/ChatFileUploadForm.svelte
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
diff --git a/src/leapfrogai_ui/src/lib/components/FileChatActions.svelte b/src/leapfrogai_ui/src/lib/components/FileChatActions.svelte
new file mode 100644
index 000000000..f162407b6
--- /dev/null
+++ b/src/leapfrogai_ui/src/lib/components/FileChatActions.svelte
@@ -0,0 +1,164 @@
+
+
+ 0
+ ? 'ml-6 flex max-w-full gap-2 overflow-x-auto bg-gray-700'
+ : 'hidden'}
+>
+ {#each audioFiles as file}
+
+
+
+ {/each}
+
diff --git a/src/leapfrogai_ui/src/lib/components/FileChatActions.test.ts b/src/leapfrogai_ui/src/lib/components/FileChatActions.test.ts
new file mode 100644
index 000000000..03914e7a4
--- /dev/null
+++ b/src/leapfrogai_ui/src/lib/components/FileChatActions.test.ts
@@ -0,0 +1,139 @@
+import FileChatActions from '$components/FileChatActions.svelte';
+import { render, screen } from '@testing-library/svelte';
+import type { FileMetadata, LFFile } from '$lib/types/files';
+import userEvent from '@testing-library/user-event';
+import { mockConvertFileNoId } from '$lib/mocks/file-mocks';
+import {
+ mockNewMessage,
+ mockTranslation,
+ mockTranslationError,
+ mockTranslationFileSizeError
+} from '$lib/mocks/chat-mocks';
+import { vi } from 'vitest';
+import { threadsStore, toastStore } from '$stores';
+import { AUDIO_FILE_SIZE_ERROR_TOAST, FILE_TRANSLATION_ERROR } from '$constants/toastMessages';
+import { getFakeThread } from '$testUtils/fakeData';
+import { NO_SELECTED_ASSISTANT_ID } from '$constants';
+
+const thread = getFakeThread();
+
+const mockFile1: LFFile = new File([], 'test1.mpeg', { type: 'audio/mpeg' });
+const mockFile2: LFFile = new File([], 'test1.mp4', { type: 'audio/mp4' });
+
+mockFile1.id = '1';
+mockFile2.id = '2';
+
+const mockMetadata1: FileMetadata = {
+ id: mockFile1.id,
+ name: mockFile1.name,
+ type: 'audio/mpeg',
+ status: 'complete',
+ text: ''
+};
+
+const mockMetadata2: FileMetadata = {
+ id: mockFile2.id,
+ name: mockFile2.name,
+ type: 'audio/mp4',
+ status: 'complete',
+ text: ''
+};
+
+describe('FileChatActions', () => {
+ beforeEach(() => {
+ threadsStore.set({
+ threads: [thread], // uses date override starting in March
+ sendingBlocked: false,
+ selectedAssistantId: NO_SELECTED_ASSISTANT_ID,
+ lastVisitedThreadId: '',
+ streamingMessage: null
+ });
+ });
+
+ it('should render a translate button for each audio file', () => {
+ render(FileChatActions, {
+ attachedFiles: [mockFile1, mockFile2],
+ attachedFileMetadata: [mockMetadata1, mockMetadata2],
+ threadId: thread.id,
+ originalMessages: [],
+ setMessages: vi.fn()
+ });
+
+ expect(screen.getByText(`Translate ${mockMetadata1.name}`));
+ expect(screen.getByText(`Translate ${mockMetadata2.name}`));
+ });
+
+ // Tests that correct endpoints are called when clicked
+ // This is testing implementation rather than behavior, but is the best we can do for this component without
+ // going up a level for a complicated integration test (behavior is tested in e2e)
+ it('creates a message and requests a translation for the user requesting translation', async () => {
+ const fetchSpy = vi.spyOn(global, 'fetch');
+
+ mockConvertFileNoId('');
+ mockNewMessage();
+ mockTranslation();
+ mockNewMessage();
+ render(FileChatActions, {
+ attachedFiles: [mockFile1, mockFile2],
+ attachedFileMetadata: [mockMetadata1, mockMetadata2],
+ threadId: thread.id,
+ originalMessages: [],
+ setMessages: vi.fn()
+ });
+
+ await userEvent.click(screen.getByRole('button', { name: `Translate ${mockMetadata2.name}` }));
+ expect(fetchSpy).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('/api/messages/new'),
+ expect.any(Object)
+ );
+ expect(fetchSpy).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('/api/audio/translation'),
+ expect.any(Object)
+ );
+ expect(fetchSpy).toHaveBeenNthCalledWith(
+ 3,
+ expect.stringContaining('/api/messages/new'),
+ expect.any(Object)
+ );
+ });
+
+ it('dispatches a toast if there is an error translating a file', async () => {
+ mockConvertFileNoId('');
+ mockNewMessage();
+ mockTranslationError();
+
+ const toastSpy = vi.spyOn(toastStore, 'addToast');
+
+ render(FileChatActions, {
+ attachedFiles: [mockFile1, mockFile2],
+ attachedFileMetadata: [mockMetadata1, mockMetadata2],
+ threadId: thread.id,
+ originalMessages: [],
+ setMessages: vi.fn()
+ });
+
+ await userEvent.click(screen.getByRole('button', { name: `Translate ${mockMetadata2.name}` }));
+ expect(toastSpy).toHaveBeenCalledWith(FILE_TRANSLATION_ERROR());
+ });
+
+ it('dispatches a toast if the file is too big', async () => {
+ mockConvertFileNoId('');
+ mockNewMessage();
+ mockTranslationFileSizeError();
+
+ const toastSpy = vi.spyOn(toastStore, 'addToast');
+
+ render(FileChatActions, {
+ attachedFiles: [mockFile1, mockFile2],
+ attachedFileMetadata: [mockMetadata1, mockMetadata2],
+ threadId: thread.id,
+ originalMessages: [],
+ setMessages: vi.fn()
+ });
+
+ await userEvent.click(screen.getByRole('button', { name: `Translate ${mockMetadata2.name}` }));
+ expect(toastSpy).toHaveBeenCalledWith(AUDIO_FILE_SIZE_ERROR_TOAST());
+ });
+});
diff --git a/src/leapfrogai_ui/src/lib/components/LFSidebarDropdownItem.svelte b/src/leapfrogai_ui/src/lib/components/LFSidebarDropdownItem.svelte
index 9282826d0..7f0206739 100644
--- a/src/leapfrogai_ui/src/lib/components/LFSidebarDropdownItem.svelte
+++ b/src/leapfrogai_ui/src/lib/components/LFSidebarDropdownItem.svelte
@@ -116,7 +116,7 @@ It adds a "three-dot" menu button with Popover, and delete confirmation Modal
$$props.class
)}
>
-
+
{label}
diff --git a/src/leapfrogai_ui/src/lib/components/Message.svelte b/src/leapfrogai_ui/src/lib/components/Message.svelte
index 5496a2b0f..0af165b1f 100644
--- a/src/leapfrogai_ui/src/lib/components/Message.svelte
+++ b/src/leapfrogai_ui/src/lib/components/Message.svelte
@@ -197,7 +197,7 @@
{/if}
- {#if message.role === 'user' && !editMode}
+ {#if message.role === 'user' && !editMode && !message.metadata?.wasTranscriptionOrTranslation}
(editMode = true)}
@@ -219,7 +219,7 @@
{/if}
- {#if message.role !== 'user' && isLastMessage && !$threadsStore.sendingBlocked}
+ {#if message.role !== 'user' && isLastMessage && !$threadsStore.sendingBlocked && !message.metadata?.wasTranscriptionOrTranslation}
import { fade } from 'svelte/transition';
- import { CloseOutline, FileOutline } from 'flowbite-svelte-icons';
+ import { CloseOutline, FileMusicOutline, FileOutline } from 'flowbite-svelte-icons';
import { getFileType } from '$lib/utils/files.js';
import { Card, Spinner, ToolbarButton } from 'flowbite-svelte';
import { createEventDispatcher } from 'svelte';
@@ -20,11 +20,11 @@
data-testid={`${fileMetadata.name}-file-uploaded-card`}
horizontal
padding="xs"
- class="w-80 min-w-72"
+ class="w-80 min-w-72 border-none"
on:mouseenter={() => (hovered = true)}
on:mouseleave={() => (hovered = false)}
>
-
+
{#if fileMetadata.status === 'uploading'}
@@ -33,16 +33,20 @@
+ {:else if fileMetadata.type.startsWith('audio/')}
+
{:else}
{/if}
-
+
{fileMetadata.name}
diff --git a/src/leapfrogai_ui/src/lib/components/UploadedFileCards.svelte b/src/leapfrogai_ui/src/lib/components/UploadedFileCards.svelte
index 1c6fef92b..85ddfe7b3 100644
--- a/src/leapfrogai_ui/src/lib/components/UploadedFileCards.svelte
+++ b/src/leapfrogai_ui/src/lib/components/UploadedFileCards.svelte
@@ -1,18 +1,20 @@