enh: channel file upload
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Python CI / Format Backend (3.11) (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
Integration Test / Run Cypress Integration Tests (push) Waiting to run
Integration Test / Run Migration Tests (push) Waiting to run

This commit is contained in:
Timothy Jaeryang Baek 2024-12-23 14:43:58 -07:00
parent b4d7268bed
commit ecd3b4ebd4
7 changed files with 477 additions and 25 deletions

View File

@ -0,0 +1,26 @@
"""Update file table
Revision ID: 7826ab40b532
Revises: 57c599a3cb57
Create Date: 2024-12-23 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "7826ab40b532"
down_revision = "57c599a3cb57"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"file",
sa.Column("access_control", sa.JSON(), nullable=True),
)
def downgrade():
op.drop_column("file", "access_control")

View File

@ -27,6 +27,8 @@ class File(Base):
data = Column(JSON, nullable=True) data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True) meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger) created_at = Column(BigInteger)
updated_at = Column(BigInteger) updated_at = Column(BigInteger)
@ -44,6 +46,8 @@ class FileModel(BaseModel):
data: Optional[dict] = None data: Optional[dict] = None
meta: Optional[dict] = None meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: Optional[int] # timestamp in epoch created_at: Optional[int] # timestamp in epoch
updated_at: Optional[int] # timestamp in epoch updated_at: Optional[int] # timestamp in epoch
@ -90,6 +94,7 @@ class FileForm(BaseModel):
path: str path: str
data: dict = {} data: dict = {}
meta: dict = {} meta: dict = {}
access_control: Optional[dict] = None
class FilesTable: class FilesTable:

View File

@ -80,15 +80,17 @@
} }
}; };
const submitHandler = async ({ content }) => { const submitHandler = async ({ content, data }) => {
if (!content) { if (!content) {
return; return;
} }
const res = await sendMessage(localStorage.token, id, { content: content }).catch((error) => { const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
toast.error(error); (error) => {
return null; toast.error(error);
}); return null;
}
);
if (res) { if (res) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight; messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
@ -108,6 +110,7 @@
class="h-screen max-h-[100dvh] {$showSidebar class="h-screen max-h-[100dvh] {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col" : ''} w-full max-w-full flex flex-col"
id="channel-container"
> >
<Navbar {channel} /> <Navbar {channel} />

View File

@ -1,43 +1,282 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { tick, getContext } from 'svelte'; import { v4 as uuidv4 } from 'uuid';
import { tick, getContext, onMount, onDestroy } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import { mobile, settings } from '$lib/stores'; import { config, mobile, settings } from '$lib/stores';
import { blobToFile } from '$lib/utils';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import RichTextInput from '../common/RichTextInput.svelte'; import RichTextInput from '../common/RichTextInput.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte'; import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import { uploadFile } from '$lib/apis/files';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte';
import { transcribeAudio } from '$lib/apis/audio';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
export let placeholder = $i18n.t('Send a Message'); export let placeholder = $i18n.t('Send a Message');
export let transparentBackground = false; export let transparentBackground = false;
let recording = false; let draggedOver = false;
let recording = false;
let content = ''; let content = '';
let files = [];
let filesInputElement;
let inputFiles;
export let onSubmit: Function; export let onSubmit: Function;
export let scrollEnd = true; export let scrollEnd = true;
export let scrollToBottom: Function; export let scrollToBottom: Function;
let submitHandler = async () => { const screenCaptureHandler = async () => {
try {
// Request screen media
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'never' },
audio: false
});
// Once the user selects a screen, temporarily create a video element
const video = document.createElement('video');
video.srcObject = mediaStream;
// Ensure the video loads without affecting user experience or tab switching
await video.play();
// Set up the canvas to match the video dimensions
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Grab a single frame from the video stream using the canvas
const context = canvas.getContext('2d');
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Stop all video tracks (stop screen sharing) after capturing the image
mediaStream.getTracks().forEach((track) => track.stop());
// bring back focus to this current tab, so that the user can see the screen capture
window.focus();
// Convert the canvas to a Base64 image URL
const imageUrl = canvas.toDataURL('image/png');
// Add the captured image to the files array to render it
files = [...files, { type: 'image', url: imageUrl }];
// Clean memory: Clear video srcObject
video.srcObject = null;
} catch (error) {
// Handle any errors (e.g., user cancels screen sharing)
console.error('Error capturing screen:', error);
}
};
const inputFilesHandler = async (inputFiles) => {
inputFiles.forEach((file) => {
console.log('Processing file:', {
name: file.name,
type: file.type,
size: file.size,
extension: file.name.split('.').at(-1)
});
if (
($config?.file?.max_size ?? null) !== null &&
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
) {
console.log('File exceeds max size limit:', {
fileSize: file.size,
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
});
toast.error(
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
maxSize: $config?.file?.max_size
})
);
return;
}
if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
let reader = new FileReader();
reader.onload = (event) => {
files = [
...files,
{
type: 'image',
url: `${event.target.result}`
}
];
};
reader.readAsDataURL(file);
} else {
uploadFileHandler(file);
}
});
};
const uploadFileHandler = async (file) => {
const tempItemId = uuidv4();
const fileItem = {
type: 'file',
file: '',
id: null,
url: '',
name: file.name,
collection_name: '',
status: 'uploading',
size: file.size,
error: '',
itemId: tempItemId
};
if (fileItem.size == 0) {
toast.error($i18n.t('You cannot upload an empty file.'));
return null;
}
files = [...files, fileItem];
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error);
return null;
});
if (res) {
console.log(res);
const blob = new Blob([res.text], { type: 'text/plain' });
file = blobToFile(blob, `${file.name}.txt`);
fileItem.name = file.name;
fileItem.size = file.size;
}
}
try {
// During the file upload, file content is automatically extracted.
const uploadedFile = await uploadFile(localStorage.token, file);
if (uploadedFile) {
console.log('File upload completed:', {
id: uploadedFile.id,
name: fileItem.name,
collection: uploadedFile?.meta?.collection_name
});
if (uploadedFile.error) {
console.warn('File upload warning:', uploadedFile.error);
toast.warning(uploadedFile.error);
}
fileItem.status = 'uploaded';
fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
fileItem.collection_name =
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
files = files;
} else {
files = files.filter((item) => item?.itemId !== tempItemId);
}
} catch (e) {
toast.error(e);
files = files.filter((item) => item?.itemId !== tempItemId);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
console.log('Escape');
draggedOver = false;
}
};
const onDragOver = (e) => {
e.preventDefault();
// Check if a file is being draggedOver.
if (e.dataTransfer?.types?.includes('Files')) {
draggedOver = true;
} else {
draggedOver = false;
}
};
const onDragLeave = () => {
draggedOver = false;
};
const onDrop = async (e) => {
e.preventDefault();
console.log(e);
if (e.dataTransfer?.files) {
const inputFiles = Array.from(e.dataTransfer?.files);
if (inputFiles && inputFiles.length > 0) {
console.log(inputFiles);
inputFilesHandler(inputFiles);
}
}
draggedOver = false;
};
const submitHandler = async () => {
if (content === '') { if (content === '') {
return; return;
} }
onSubmit({ onSubmit({
content content,
data: {
files: files
}
}); });
content = ''; content = '';
files = [];
await tick(); await tick();
const chatInputElement = document.getElementById('chat-input'); const chatInputElement = document.getElementById('chat-input');
chatInputElement?.focus(); chatInputElement?.focus();
}; };
onMount(async () => {
window.setTimeout(() => {
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
}, 0);
window.addEventListener('keydown', handleKeyDown);
await tick();
const dropzoneElement = document.getElementById('channel-container');
dropzoneElement?.addEventListener('dragover', onDragOver);
dropzoneElement?.addEventListener('drop', onDrop);
dropzoneElement?.addEventListener('dragleave', onDragLeave);
});
onDestroy(() => {
console.log('destroy');
window.removeEventListener('keydown', handleKeyDown);
const dropzoneElement = document.getElementById('channel-container');
if (dropzoneElement) {
dropzoneElement?.removeEventListener('dragover', onDragOver);
dropzoneElement?.removeEventListener('drop', onDrop);
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
}
});
</script> </script>
<FilesOverlay show={draggedOver} />
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center"> <div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col px-3 max-w-6xl w-full"> <div class="flex flex-col px-3 max-w-6xl w-full">
<div class="relative"> <div class="relative">
@ -69,6 +308,22 @@
</div> </div>
</div> </div>
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
multiple
on:change={async () => {
if (inputFiles && inputFiles.length > 0) {
inputFilesHandler(Array.from(inputFiles));
} else {
toast.error($i18n.t(`File not found.`));
}
filesInputElement.value = '';
}}
/>
<div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} "> <div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
<div class="max-w-6xl px-2.5 mx-auto inset-x-0"> <div class="max-w-6xl px-2.5 mx-auto inset-x-0">
<div class=""> <div class="">
@ -101,24 +356,87 @@
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-50 dark:bg-gray-400/5 dark:text-gray-100" class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-50 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'LTR'} dir={$settings?.chatDirection ?? 'LTR'}
> >
{#if files.length > 0}
<div class="mx-1 mt-2.5 mb-1 flex flex-wrap gap-2">
{#each files as file, fileIdx}
{#if file.type === 'image'}
<div class=" relative group">
<div class="relative">
<Image
src={file.url}
alt="input"
imageClassName=" h-16 w-16 rounded-xl object-cover"
/>
</div>
<div class=" absolute -top-1 -right-1">
<button
class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
type="button"
on:click={() => {
files.splice(fileIdx, 1);
files = files;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
</div>
{:else}
<FileItem
item={file}
name={file.name}
type={file.type}
size={file?.size}
loading={file.status === 'uploading'}
dismissible={true}
edit={true}
on:dismiss={() => {
files.splice(fileIdx, 1);
files = files;
}}
on:click={() => {
console.log(file);
}}
/>
{/if}
{/each}
</div>
{/if}
<div class=" flex"> <div class=" flex">
<div class="ml-1 self-end mb-1.5 flex space-x-1"> <div class="ml-1 self-end mb-1.5 flex space-x-1">
<button <InputMenu
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-2 outline-none focus:outline-none" {screenCaptureHandler}
type="button" uploadFilesHandler={() => {
aria-label="More" filesInputElement.click();
}}
> >
<svg <button
xmlns="http://www.w3.org/2000/svg" class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-2 outline-none focus:outline-none"
viewBox="0 0 20 20" type="button"
fill="currentColor" aria-label="More"
class="size-5"
> >
<path <svg
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" xmlns="http://www.w3.org/2000/svg"
/> viewBox="0 0 20 20"
</svg> fill="currentColor"
</button> class="size-5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</InputMenu>
</div> </div>
{#if $settings?.richTextInput ?? true} {#if $settings?.richTextInput ?? true}

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext, onMount, tick } from 'svelte';
import { config, user, tools as _tools, mobile } from '$lib/stores';
import { getTools } from '$lib/apis/tools';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentArrowUpSolid from '$lib/components/icons/DocumentArrowUpSolid.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
const i18n = getContext('i18n');
export let screenCaptureHandler: Function;
export let uploadFilesHandler: Function;
export let onClose: Function = () => {};
let show = false;
$: if (show) {
init();
}
const init = async () => {};
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
sideOffset={15}
alignOffset={-8}
side="top"
align="start"
transition={flyAndScale}
>
{#if !$mobile}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
>
<CameraSolid />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadFilesHandler();
}}
>
<DocumentArrowUpSolid />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>

View File

@ -23,6 +23,8 @@
import Pencil from '$lib/components/icons/Pencil.svelte'; import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte'; import Textarea from '$lib/components/common/Textarea.svelte';
import Image from '$lib/components/common/Image.svelte';
import FileItem from '$lib/components/common/FileItem.svelte';
export let message; export let message;
export let showUserProfile = true; export let showUserProfile = true;
@ -142,6 +144,27 @@
</Name> </Name>
{/if} {/if}
{#if (message?.data?.files ?? []).length > 0}
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message?.data?.files as file}
<div>
{#if file.type === 'image'}
<Image src={file.url} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
{:else}
<FileItem
item={file}
url={file.url}
name={file.name}
type={file.type}
size={file?.size}
colorClassName="bg-white dark:bg-gray-850 "
/>
{/if}
</div>
{/each}
</div>
{/if}
{#if edit} {#if edit}
<div class="py-2"> <div class="py-2">
<Textarea <Textarea

View File

@ -5,7 +5,7 @@
export let src = ''; export let src = '';
export let alt = ''; export let alt = '';
export let className = ' w-full'; export let className = ' w-full outline-none focus:outline-none';
export let imageClassName = 'rounded-lg'; export let imageClassName = 'rounded-lg';
let _src = ''; let _src = '';