This commit is contained in:
Timothy Jaeryang Baek 2026-10-01 08:13:45 +04:00
parent be35c8f658
commit 8a4547104c
7 changed files with 314 additions and 121 deletions

View file

@ -225,7 +225,17 @@
}
};
const submitHandler = async ({ content, data }) => {
const submitHandler = async ({
content,
data,
channel_id,
reply_to_message
}: {
content: string;
data: any;
channel_id: string;
reply_to_message: any;
}) => {
if (!content && (data?.files ?? []).length === 0) {
return;
}
@ -236,7 +246,7 @@
temp_id: tempId,
content: content,
data: data,
reply_to_id: replyToMessage?.id ?? null
reply_to_id: reply_to_message?.id ?? null
};
const ts = Date.now() * 1000000; // nanoseconds
@ -246,23 +256,21 @@
id: tempId,
user_id: $user?.id,
user: $user,
reply_to_message: replyToMessage ?? null,
reply_to_message: reply_to_message ?? null,
created_at: ts,
updated_at: ts
},
...messages
];
const res = await sendMessage(localStorage.token, id, message).catch((error) => {
const res = await sendMessage(localStorage.token, channel_id, message).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
if (res && id === channel_id && messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
replyToMessage = null;
};
const onChange = async () => {

View file

@ -3,10 +3,13 @@
import { v4 as uuidv4 } from 'uuid';
import { tick, getContext, onMount } from 'svelte';
import { get } from 'svelte/store';
const i18n: any = getContext('i18n');
import { config, mobile, settings, socket, user } from '$lib/stores';
import { channelRequestQueues, config, mobile, settings, socket, user } from '$lib/stores';
import { processingQueueChannels, processNextInQueue } from '$lib/utils/channelQueue';
import QueuedMessageItem from '../chat/MessageInput/QueuedMessageItem.svelte';
import {
convertHeicToJpeg,
isHeicImage,
@ -43,10 +46,10 @@
import XMark from '../icons/XMark.svelte';
export let placeholder = $i18n.t('Type here...');
export let chatInputElement;
export let chatInputElement: any;
export let id = null;
export let channel = null;
export let id: string | null = null;
export let channel: any = null;
export let typingUsers = [];
export let inputLoading = false;
@ -65,7 +68,7 @@
export let userSuggestions = false;
export let channelSuggestions = false;
export let replyToMessage = null;
export let replyToMessage: any = null;
export let typingUsersClassName = 'from-white dark:from-gray-900';
@ -74,7 +77,11 @@
let recording = false;
let content = '';
let files = [];
let files: any[] = [];
let isDestroyed = false;
$: parentId = id === 'root' ? null : id;
$: queueKey = JSON.stringify([$user?.id, channel?.id, parentId]);
$: messageQueue = $channelRequestQueues[queueKey] ?? [];
let filesInputElement;
let inputFiles;
@ -378,69 +385,66 @@
return;
}
if (file['type'].startsWith('image/') || isHeicImage(file)) {
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
// Quick shortcut so we don’t do unnecessary work.
const settingsCompression =
(settings?.imageCompression && settings?.imageCompressionInChannels) ?? false;
const configWidth = config?.file?.image_compression?.width ?? null;
const configHeight = config?.file?.image_compression?.height ?? null;
// If neither settings nor config wants compression, return original URL.
if (!settingsCompression && !configWidth && !configHeight) {
return imageUrl;
}
// Default to null (no compression unless set)
let width = null;
let height = null;
// If user/settings want compression, pick their preferred size.
if (settingsCompression) {
width = settings?.imageCompressionSize?.width ?? null;
height = settings?.imageCompressionSize?.height ?? null;
}
// Apply config limits as an upper bound if any
if (configWidth && (width === null || width > configWidth)) {
width = configWidth;
}
if (configHeight && (height === null || height > configHeight)) {
height = configHeight;
}
// Do the compression if required
if (width || height) {
return await compressImage(imageUrl, width, height);
}
return imageUrl;
};
const imageFile = isHeicImage(file) ? await convertHeicToJpeg(file) : file;
let reader = new FileReader();
reader.onload = async (event) => {
let imageUrl = event.target.result;
// Compress the image if settings or config require it
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
const blob = await (await fetch(imageUrl)).blob();
const compressedFile = new File([blob], imageFile.name, { type: imageFile.type });
uploadFileHandler(compressedFile, false);
};
reader.readAsDataURL(imageFile);
} else {
uploadFileHandler(file);
}
const image = file.type.startsWith('image/') || isHeicImage(file);
uploadFileHandler(file, !image, image);
});
};
const uploadFileHandler = async (file, process = true) => {
const prepareImageFileHandler = async (file: File) => {
const compressImageHandler = async (imageUrl: string, settings: any = {}, config: any = {}) => {
// Quick shortcut so we don’t do unnecessary work.
const settingsCompression =
(settings?.imageCompression && settings?.imageCompressionInChannels) ?? false;
const configWidth = config?.file?.image_compression?.width ?? null;
const configHeight = config?.file?.image_compression?.height ?? null;
// If neither settings nor config wants compression, return original URL.
if (!settingsCompression && !configWidth && !configHeight) {
return imageUrl;
}
// Default to null (no compression unless set)
let width = null;
let height = null;
// If user/settings want compression, pick their preferred size.
if (settingsCompression) {
width = settings?.imageCompressionSize?.width ?? null;
height = settings?.imageCompressionSize?.height ?? null;
}
// Apply config limits as an upper bound if any
if (configWidth && (width === null || width > configWidth)) {
width = configWidth;
}
if (configHeight && (height === null || height > configHeight)) {
height = configHeight;
}
// Do the compression if required
if (width || height) {
return await compressImage(imageUrl, width, height);
}
return imageUrl;
};
const imageFile = isHeicImage(file) ? await convertHeicToJpeg(file) : file;
const imageUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(imageFile);
});
const compressed = await compressImageHandler(imageUrl, $settings, $config);
const blob = await (await fetch(compressed as string)).blob();
return new File([blob], imageFile.name, { type: imageFile.type });
};
const uploadFileHandler = async (file: File, process = true, prepareImage = false) => {
const targetChannelId = channel.id;
const targetQueueKey = queueKey;
const senderId = $user?.id;
const tempItemId = uuidv4();
const fileItem = {
const fileItem: any = {
type: 'file',
file: '',
id: null,
@ -461,10 +465,12 @@
files = [...files, fileItem];
try {
if (prepareImage) file = await prepareImageFileHandler(file);
if (get(user)?.id !== senderId) return;
// During the file upload, file content is automatically extracted.
// If the file is an audio file, provide the language for STT.
let metadata = {
channel_id: channel.id,
channel_id: targetChannelId,
// If the file is an audio file, provide the language for STT.
...((file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
@ -498,11 +504,23 @@
files = files;
} else {
files = files.filter((item) => item?.itemId !== tempItemId);
throw new Error($i18n.t('Failed to upload file.'));
}
} catch (e) {
fileItem.status = 'error';
fileItem.error = String(e);
toast.error(`${e}`);
files = files.filter((item) => item?.itemId !== tempItemId);
} finally {
channelRequestQueues.update((q) => {
for (const item of q[targetQueueKey] ?? []) {
item.files = item.files.map((file) =>
file.itemId === fileItem.itemId ? fileItem : file
);
}
return q;
});
void processNextInQueue(targetQueueKey);
}
};
@ -541,28 +559,72 @@
draggedOver = false;
};
const editQueuedMessage = (messageId: string) => {
if (content || files.length || replyToMessage) return;
const entry = messageQueue.find((item) => item.id === messageId);
if (!entry || entry.sending) return;
deleteQueuedMessage(messageId);
content = entry.prompt;
files = entry.files;
replyToMessage = entry.reply_to_message;
chatInputElement?.setText(content);
chatInputElement?.focus();
};
const deleteQueuedMessage = (messageId: string) => {
channelRequestQueues.update((q) => ({
...q,
[queueKey]: (q[queueKey] ?? []).filter((item) => item.id !== messageId || item.sending)
}));
void processNextInQueue(queueKey);
};
const submitHandler = async () => {
if (content === '' && files.length === 0) {
return;
}
onSubmit({
content,
data: {
files: files
}
});
if (disabled || !$user?.id || (content === '' && files.length === 0)) return;
const targetQueueKey = queueKey;
const entry = {
id: uuidv4(),
prompt: content,
files,
user_id: $user.id,
channel_id: channel.id,
parent_id: parentId,
reply_to_message: replyToMessage
};
const queued =
processingQueueChannels.has(targetQueueKey) ||
messageQueue.length > 0 ||
files.some((file) => ['uploading', 'error'].includes(file.status));
content = '';
files = [];
replyToMessage = null;
chatInputElement?.setText('');
if (chatInputElement) {
chatInputElement?.setText('');
await tick();
chatInputElement.focus();
if (queued) {
channelRequestQueues.update((q) => ({
...q,
[targetQueueKey]: [...(q[targetQueueKey] ?? []), entry]
}));
void processNextInQueue(targetQueueKey);
} else {
processingQueueChannels.add(targetQueueKey);
try {
await onSubmit({
content: entry.prompt,
data: { files: entry.files },
channel_id: entry.channel_id,
parent_id: entry.parent_id,
reply_to_message: entry.reply_to_message
});
} finally {
processingQueueChannels.delete(targetQueueKey);
void processNextInQueue(targetQueueKey);
}
}
await tick();
if (!isDestroyed && targetQueueKey === queueKey) chatInputElement?.focus();
};
$: if (content) {
@ -570,6 +632,9 @@
}
onMount(() => {
const unsubscribe = channelRequestQueues.subscribe(() => {
files = [...files];
});
suggestions = [
{
char: '@',
@ -659,7 +724,6 @@
window.addEventListener('keydown', handleKeyDown);
let isDestroyed = false;
let dropzoneElement: HTMLElement | null = null;
const initialize = async () => {
await tick();
@ -675,6 +739,7 @@
initialize();
return () => {
unsubscribe();
isDestroyed = true;
window.removeEventListener('keydown', handleKeyDown);
@ -802,11 +867,31 @@
/>
{:else}
<form
class="w-full flex gap-1.5"
class="w-full flex flex-col gap-1.5"
on:submit|preventDefault={() => {
submitHandler();
}}
>
{#if messageQueue.length > 0}
<div
class="mb-1 mx-2 py-0.5 px-1.5 rounded-2xl bg-white dark:bg-gray-900/60 border border-gray-100 dark:border-gray-800/50 overflow-x-hidden overflow-y-auto max-h-[25vh]"
>
{#each messageQueue as entry (entry.id)}
<QueuedMessageItem
id={entry.id}
content={entry.prompt}
files={entry.files}
sending={entry.sending ?? false}
error={entry.error ?? ''}
onSendNow={(messageId) => processNextInQueue(queueKey, messageId)}
onEdit={content || files.length || replyToMessage
? undefined
: editQueuedMessage}
onDelete={deleteQueuedMessage}
/>
{/each}
</div>
{/if}
<div
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850/30 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-0.5 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"

View file

@ -71,7 +71,7 @@
if (type === 'message') {
if ((data?.parent_id ?? null) === threadId) {
if (messages) {
messages = [data, ...messages];
messages = [{ ...data, temp_id: null }, ...messages];
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
@ -141,22 +141,32 @@
}
};
const submitHandler = async ({ content, data }) => {
const submitHandler = async ({
content,
data,
channel_id,
parent_id,
reply_to_message
}: {
content: string;
data: any;
channel_id: string;
parent_id: string | null;
reply_to_message: any;
}) => {
if (!content && (data?.files ?? []).length === 0) {
return;
}
const res = await sendMessage(localStorage.token, channel.id, {
parent_id: threadId,
reply_to_id: replyToMessage?.id ?? null,
const res = await sendMessage(localStorage.token, channel_id, {
parent_id: parent_id ?? undefined,
reply_to_id: reply_to_message?.id ?? null,
content: content,
data: data
}).catch((error) => {
toast.error(`${error}`);
return null;
});
replyToMessage = null;
};
const onChange = async () => {

View file

@ -9,14 +9,25 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { isRasterImageContentType } from '$lib/utils';
const i18n = getContext('i18n');
const i18n = getContext<any>('i18n');
export let id: string;
export let content: string;
export let files: any[] = [];
export let onSendNow: (id: string) => void;
export let onEdit: (id: string) => void;
export let onEdit: ((id: string) => void) | undefined;
export let onDelete: (id: string) => void;
export let sending = false;
export let error = '';
$: uploadFailed = files.some((file) => file.status === 'error');
$: sendDisabled = sending || files.some((file) => ['uploading', 'error'].includes(file.status));
$: sendLabel = sending
? $i18n.t('Sending...')
: uploadFailed
? $i18n.t('Upload failed')
: sendDisabled
? $i18n.t('Waiting for upload')
: $i18n.t('Send now');
</script>
<div class="flex items-center gap-2 px-2 py-1.5">
@ -71,37 +82,33 @@
</p>
{/if}
{#if files.some((file) => file.status === 'error')}
{#if uploadFailed}
<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Upload failed')}
</span>
{:else if error}
<span class="shrink-0 text-xs text-red-500" title={error}
>{$i18n.t('Failed to send message')}</span
>
{:else if sending}
<Spinner className="size-3 shrink-0" />
{/if}
</div>
<!-- Actions -->
<div class="flex items-center gap-1 shrink-0">
<!-- Send immediately -->
<Tooltip
content={files.some((file) => ['uploading', 'error'].includes(file.status))
? $i18n.t('Waiting for upload')
: $i18n.t('Send now')}
>
<Tooltip content={sendLabel}>
<button
type="button"
class="p-1 text-gray-400 transition-colors {files.some((file) =>
['uploading', 'error'].includes(file.status)
)
class="p-1 text-gray-400 transition-colors {sendDisabled
? 'opacity-40 cursor-not-allowed'
: 'hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300'}"
disabled={files.some((file) => ['uploading', 'error'].includes(file.status))}
disabled={sendDisabled}
on:click={() => {
if (!files.some((file) => ['uploading', 'error'].includes(file.status))) {
onSendNow(id);
}
if (!sendDisabled) onSendNow(id);
}}
aria-label={files.some((file) => ['uploading', 'error'].includes(file.status))
? $i18n.t('Waiting for upload')
: $i18n.t('Send now')}
aria-label={sendLabel}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -124,8 +131,9 @@
<Tooltip content={$i18n.t('Edit')}>
<button
type="button"
class="p-1 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
on:click={() => onEdit(id)}
class="p-1 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
disabled={sending || !onEdit}
on:click={() => onEdit?.(id)}
aria-label={$i18n.t('Edit')}
>
<EditPencil className="size-3.5" />
@ -136,7 +144,8 @@
<Tooltip content={$i18n.t('Delete')}>
<button
type="button"
class="p-1 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
class="p-1 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
disabled={sending}
on:click={() => onDelete(id)}
aria-label={$i18n.t('Delete')}
>

View file

@ -124,6 +124,19 @@ export const chatRequestQueues: Writable<
Record<string, { id: string; prompt: string; files: any[] }[]>
> = writable({});
export type ChannelQueuedMessage = {
id: string;
prompt: string;
files: any[];
user_id: string;
channel_id: string;
parent_id: string | null;
reply_to_message: any;
sending?: boolean;
error?: string;
};
export const channelRequestQueues = writable<Record<string, ChannelQueuedMessage[]>>({});
export const sidebarWidth = writable(245);
export type SettingsModalRequest = {

View file

@ -0,0 +1,51 @@
import { get } from 'svelte/store';
import { sendMessage } from '$lib/apis/channels';
import { channelRequestQueues, user } from '$lib/stores';
export const processingQueueChannels = new Set<string>();
export const processNextInQueue = async (key: string, messageId?: string) => {
if (processingQueueChannels.has(key)) return;
processingQueueChannels.add(key);
try {
while (get(channelRequestQueues)[key]?.length) {
const queue = get(channelRequestQueues)[key] ?? [];
const item = messageId ? queue.find((m) => m.id === messageId) : queue[0];
if (
!item ||
item.user_id !== get(user)?.id ||
(item.error && !messageId) ||
item.files.some((file) => ['uploading', 'error'].includes(file.status))
)
return;
messageId = undefined;
item.sending = true;
item.error = undefined;
channelRequestQueues.update((q) => q);
try {
const result = await sendMessage(localStorage.token, item.channel_id, {
temp_id: item.id,
parent_id: item.parent_id ?? undefined,
reply_to_id: item.reply_to_message?.id ?? null,
content: item.prompt,
data: { files: item.files }
});
if (!result) throw new Error('Failed to send message');
channelRequestQueues.update((q) => {
if (q[key]) q[key] = q[key].filter((m) => m.id !== item.id);
return q;
});
} catch (error) {
// Logout can clear this entry while the request is pending.
if (!get(channelRequestQueues)[key]?.includes(item)) continue;
item.sending = false;
item.error = String(error);
channelRequestQueues.update((q) => q);
return;
}
}
} finally {
processingQueueChannels.delete(key);
}
};

View file

@ -30,6 +30,7 @@
playingNotificationSound,
channels,
channelId,
channelRequestQueues,
terminalServers,
connectedUserTerminals,
showControls,
@ -1245,7 +1246,12 @@
};
window.addEventListener('resize', onResize);
user.subscribe(async (value) => {
let queueUserId = $user?.id;
const unsubscribeQueueUser = user.subscribe(async (value) => {
if (queueUserId !== value?.id) {
channelRequestQueues.set({});
queueUserId = value?.id;
}
if (value) {
$socket?.off('events', chatEventHandler);
$socket?.off('events:channel', channelEventHandler);
@ -1264,6 +1270,15 @@
}
});
/** @param {BeforeUnloadEvent} event */
const beforeUnloadHandler = (event) => {
if (Object.values($channelRequestQueues).some((queue) => queue.length)) {
event.preventDefault();
event.returnValue = '';
}
};
window.addEventListener('beforeunload', beforeUnloadHandler);
let backendConfig = null;
try {
backendConfig = await getBackendConfig();
@ -1399,6 +1414,8 @@
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('pagehide', handlePageHidden);
window.removeEventListener('pageshow', handlePageVisible);
window.removeEventListener('beforeunload', beforeUnloadHandler);
unsubscribeQueueUser();
};
});