chore: format

This commit is contained in:
Timothy Jaeryang Baek
2026-04-01 04:36:02 -05:00
parent 53583f8d83
commit c8ef5a4f38
92 changed files with 3573 additions and 692 deletions
+2 -10
View File
@@ -155,17 +155,9 @@
<!-- Bottom toolbar -->
<div class="flex items-center justify-between px-4 pb-3.5 pt-1 gap-2">
<div class="flex items-center gap-0.5 flex-wrap flex-1 min-w-0">
<ScheduleDropdown
bind:this={scheduleDropdown}
side="top"
align="start"
/>
<ScheduleDropdown bind:this={scheduleDropdown} side="top" align="start" />
<ModelDropdown
bind:model_id
side="top"
align="start"
/>
<ModelDropdown bind:model_id side="top" align="start" />
<TerminalDropdown
{terminalServers}
@@ -161,7 +161,7 @@
if (runsLoading || (!hasMoreRuns && loadMore)) return;
runsLoading = true;
if (!loadMore) {
runsPage = 0;
hasMoreRuns = true;
@@ -175,7 +175,7 @@
} else {
runs = fetchedRuns;
}
if (fetchedRuns.length < 50) {
hasMoreRuns = false;
}
@@ -414,10 +414,7 @@
<div class="text-gray-500 text-xs mb-2 shrink-0">
{$i18n.t('Execution Logs')}
</div>
<div
class="flex-1 overflow-y-auto scrollbar-hidden w-full"
on:scroll={onScroll}
>
<div class="flex-1 overflow-y-auto scrollbar-hidden w-full" on:scroll={onScroll}>
{#if runsLoading && runs.length === 0}
<div class="flex justify-center py-4">
<Spinner className="size-4" />
@@ -30,9 +30,7 @@
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl text-xs transition
{terminalServerId
? 'text-black dark:text-gray-100'
: 'text-gray-600 dark:text-gray-400'}
{terminalServerId ? 'text-black dark:text-gray-100' : 'text-gray-600 dark:text-gray-400'}
hover:bg-black/5 dark:hover:bg-white/5"
>
<Cloud className="size-3.5 shrink-0" strokeWidth="2" />
+408 -393
View File
@@ -94,7 +94,10 @@
const deltaY = touch.clientY - swipeStartY;
// Determine swipe direction from dead zone
if (!swipeLocked && (Math.abs(deltaX) > SWIPE_DEAD_ZONE || Math.abs(deltaY) > SWIPE_DEAD_ZONE)) {
if (
!swipeLocked &&
(Math.abs(deltaX) > SWIPE_DEAD_ZONE || Math.abs(deltaY) > SWIPE_DEAD_ZONE)
) {
if (Math.abs(deltaY) > Math.abs(deltaX)) {
// Vertical scroll — abort swipe tracking
isSwiping = false;
@@ -111,9 +114,8 @@
// Only allow right swipe
const clampedX = Math.max(0, deltaX);
// Dampen the motion beyond threshold for a rubber-band feel
swipeOffsetX = clampedX <= SWIPE_THRESHOLD
? clampedX
: SWIPE_THRESHOLD + (clampedX - SWIPE_THRESHOLD) * 0.3;
swipeOffsetX =
clampedX <= SWIPE_THRESHOLD ? clampedX : SWIPE_THRESHOLD + (clampedX - SWIPE_THRESHOLD) * 0.3;
swipeOffsetX = Math.min(swipeOffsetX, SWIPE_MAX);
};
@@ -162,9 +164,13 @@
{#if swipeOffsetX > 0}
<div
class="swipe-reply-indicator"
style="opacity: {Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1)}; transform: scale({0.5 + Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1) * 0.5});"
style="opacity: {Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1)}; transform: scale({0.5 +
Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1) * 0.5});"
>
<div class="swipe-reply-icon" class:swipe-reply-icon--active={swipeOffsetX >= SWIPE_THRESHOLD}>
<div
class="swipe-reply-icon"
class:swipe-reply-icon--active={swipeOffsetX >= SWIPE_THRESHOLD}
>
<ArrowUpLeftAlt className="size-5" />
</div>
</div>
@@ -175,7 +181,9 @@
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 relative {className
? className
: `px-5 ${
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
replyToMessage
? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4'
: ''
} ${
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
@@ -184,448 +192,453 @@
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
? 'pt-1.5 pb-0.5'
: ''}"
style="transform: translateX({swipeOffsetX}px); {swipeOffsetX > 0 ? '' : 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
style="transform: translateX({swipeOffsetX}px); {swipeOffsetX > 0
? ''
: 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
>
{#if !edit && !disabled}
<div
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
>
{#if !edit && !disabled}
<div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100/30 dark:border-gray-850/30"
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
>
{#if onReaction}
<EmojiPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
showButtons = true;
}}
>
<FaceSmile />
</button>
</Tooltip>
</EmojiPicker>
{/if}
{#if onReply}
<Tooltip content={$i18n.t('Reply')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
on:click={() => {
onReply(message);
<div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100/30 dark:border-gray-850/30"
>
{#if onReaction}
<EmojiPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
onReaction(name);
}}
>
<ArrowUpLeftAlt className="size-5" />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Add Reaction')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
showButtons = true;
}}
>
<FaceSmile />
</button>
</Tooltip>
</EmojiPicker>
{/if}
<Tooltip content={message?.is_pinned ? $i18n.t('Unpin') : $i18n.t('Pin')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onPin(message);
}}
>
{#if message?.is_pinned}
<PinSlash className="size-4" />
{:else}
<Pin className="size-4" />
{/if}
</button>
</Tooltip>
{#if onReply}
<Tooltip content={$i18n.t('Reply')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
on:click={() => {
onReply(message);
}}
>
<ArrowUpLeftAlt className="size-5" />
</button>
</Tooltip>
{/if}
{#if !thread && onThread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<Tooltip content={message?.is_pinned ? $i18n.t('Unpin') : $i18n.t('Pin')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onThread(message.id);
onPin(message);
}}
>
<ChatBubbleOvalEllipsis />
{#if message?.is_pinned}
<PinSlash className="size-4" />
{:else}
<Pin className="size-4" />
{/if}
</button>
</Tooltip>
{/if}
{#if message.user_id === $user?.id || $user?.role === 'admin'}
{#if onEdit}
<Tooltip content={$i18n.t('Edit')}>
{#if !thread && onThread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
edit = true;
editedContent = message.content;
onThread(message.id);
}}
>
<Pencil />
<ChatBubbleOvalEllipsis />
</button>
</Tooltip>
{/if}
{#if onDelete}
<Tooltip content={$i18n.t('Delete')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
</Tooltip>
{#if message.user_id === $user?.id || $user?.role === 'admin'}
{#if onEdit}
<Tooltip content={$i18n.t('Edit')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
edit = true;
editedContent = message.content;
}}
>
<Pencil />
</button>
</Tooltip>
{/if}
{#if onDelete}
<Tooltip content={$i18n.t('Delete')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
</Tooltip>
{/if}
{/if}
{/if}
</div>
</div>
{/if}
{#if message?.is_pinned}
<div class="flex {showUserProfile ? 'mb-0.5' : 'mt-0.5'}">
<div class="ml-8.5 flex items-center gap-1 px-1 rounded-full text-xs">
<Pin className="size-3 text-yellow-500 dark:text-yellow-300" />
<span class="text-gray-500">{$i18n.t('Pinned')}</span>
</div>
</div>
{/if}
{#if message?.reply_to_message?.user}
<div class="relative text-xs mb-1">
<div
class="absolute h-3 w-7 left-[18px] top-2 rounded-tl-lg border-t-[1.5px] border-l-[1.5px] border-gray-200 dark:border-gray-700 z-0"
></div>
<button
class="ml-12 flex items-center space-x-2 relative z-0"
on:click={() => {
const messageElement = document.getElementById(
`message-${message.reply_to_message.id}`
);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.classList.add('highlight');
setTimeout(() => {
messageElement.classList.remove('highlight');
}, 2000);
return;
}
}}
>
{#if message?.reply_to_message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.reply_to_message.meta.model_id}`}
alt={message.reply_to_message.meta.model_name ??
message.reply_to_message.meta.model_id}
class="size-4 ml-0.5 rounded-full object-cover"
on:error={(e) => {
e.currentTarget.src = '/favicon.png';
}}
/>
{:else}
<img
src={message.reply_to_message.user?.role === 'webhook'
? `${WEBUI_API_BASE_URL}/channels/webhooks/${message.reply_to_message.user?.id}/profile/image`
: `${WEBUI_API_BASE_URL}/users/${message.reply_to_message.user?.id}/profile/image`}
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
class="size-4 ml-0.5 rounded-full object-cover"
/>
{/if}
<div class="shrink-0">
{message?.reply_to_message.meta?.model_name ??
message?.reply_to_message.user?.name ??
$i18n.t('Unknown User')}
</div>
</div>
{/if}
<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1">
<Markdown id={`${message.id}-reply-to`} content={message?.reply_to_message?.content} />
{#if message?.is_pinned}
<div class="flex {showUserProfile ? 'mb-0.5' : 'mt-0.5'}">
<div class="ml-8.5 flex items-center gap-1 px-1 rounded-full text-xs">
<Pin className="size-3 text-yellow-500 dark:text-yellow-300" />
<span class="text-gray-500">{$i18n.t('Pinned')}</span>
</div>
</button>
</div>
{/if}
</div>
{/if}
<div
class=" flex w-full message-{message.id} "
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div class={`shrink-0 mr-1 w-9`}>
{#if showUserProfile}
{#if message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
alt={message.meta.model_name ?? message.meta.model_id}
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
on:error={(e) => {
e.currentTarget.src = '/favicon.png';
}}
/>
{:else if message.user?.role === 'webhook'}
<ProfileImage
src={`${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}
className={'size-8 ml-0.5'}
/>
{:else}
<ProfilePreview user={message.user}>
{#if message?.reply_to_message?.user}
<div class="relative text-xs mb-1">
<div
class="absolute h-3 w-7 left-[18px] top-2 rounded-tl-lg border-t-[1.5px] border-l-[1.5px] border-gray-200 dark:border-gray-700 z-0"
></div>
<button
class="ml-12 flex items-center space-x-2 relative z-0"
on:click={() => {
const messageElement = document.getElementById(
`message-${message.reply_to_message.id}`
);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.classList.add('highlight');
setTimeout(() => {
messageElement.classList.remove('highlight');
}, 2000);
return;
}
}}
>
{#if message?.reply_to_message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.reply_to_message.meta.model_id}`}
alt={message.reply_to_message.meta.model_name ??
message.reply_to_message.meta.model_id}
class="size-4 ml-0.5 rounded-full object-cover"
on:error={(e) => {
e.currentTarget.src = '/favicon.png';
}}
/>
{:else}
<img
src={message.reply_to_message.user?.role === 'webhook'
? `${WEBUI_API_BASE_URL}/channels/webhooks/${message.reply_to_message.user?.id}/profile/image`
: `${WEBUI_API_BASE_URL}/users/${message.reply_to_message.user?.id}/profile/image`}
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
class="size-4 ml-0.5 rounded-full object-cover"
/>
{/if}
<div class="shrink-0">
{message?.reply_to_message.meta?.model_name ??
message?.reply_to_message.user?.name ??
$i18n.t('Unknown User')}
</div>
<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1">
<Markdown
id={`${message.id}-reply-to`}
content={message?.reply_to_message?.content}
/>
</div>
</button>
</div>
{/if}
<div
class=" flex w-full message-{message.id} "
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div class={`shrink-0 mr-1 w-9`}>
{#if showUserProfile}
{#if message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
alt={message.meta.model_name ?? message.meta.model_id}
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
on:error={(e) => {
e.currentTarget.src = '/favicon.png';
}}
/>
{:else if message.user?.role === 'webhook'}
<ProfileImage
src={`${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
src={`${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}
className={'size-8 ml-0.5'}
/>
</ProfilePreview>
{/if}
{:else}
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
{#if message.created_at}
<div
class="mt-1.5 flex shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-medium first-letter:capitalize"
>
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
{dayjs(message.created_at / 1000000).format('HH:mm')}
</Tooltip>
</div>
{/if}
{/if}
</div>
<div class="flex-auto w-0 pl-2">
{#if showUserProfile}
<Name>
<div class=" self-end text-base shrink-0 font-medium truncate">
{#if message?.meta?.model_id}
{message?.meta?.model_name ?? message?.meta?.model_id}
{:else}
{message?.user?.name}
{/if}
</div>
{:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={`${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
className={'size-8 ml-0.5'}
/>
</ProfilePreview>
{/if}
{:else}
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
{#if message.created_at}
<div
class=" self-center text-xs text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
class="mt-1.5 flex shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-medium first-letter:capitalize"
>
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
<span class="line-clamp-1">
{#if dayjs(message.created_at / 1000000).isToday()}
{dayjs(message.created_at / 1000000).format('LT')}
{:else}
{$i18n.t(formatDate(message.created_at / 1000000), {
LOCALIZED_TIME: dayjs(message.created_at / 1000000).format('LT'),
LOCALIZED_DATE: dayjs(message.created_at / 1000000).format('L')
})}
{/if}
</span>
{dayjs(message.created_at / 1000000).format('HH:mm')}
</Tooltip>
</div>
{/if}
</Name>
{/if}
{/if}
</div>
{#if message?.data === true}
<!-- loading indicator -->
<div class=" my-2">
<Skeleton />
</div>
{:else if (message?.data?.files ?? []).length > 0}
<div
class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap"
dir={$settings?.chatDirection ?? 'auto'}
>
{#each message?.data?.files as file}
{@const fileUrl =
file.url.startsWith('data') || file.url.startsWith('http')
? file.url
: `${WEBUI_API_BASE_URL}/files/${file.url}${file?.content_type ? '/content' : ''}`}
<div>
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
<Image src={fileUrl} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
{:else if file.type === 'video' || (file?.content_type ?? '').startsWith('video/')}
<video src={fileUrl} controls class=" max-h-96 rounded-lg"></video>
<div class="flex-auto w-0 pl-2">
{#if showUserProfile}
<Name>
<div class=" self-end text-base shrink-0 font-medium truncate">
{#if message?.meta?.model_id}
{message?.meta?.model_name ?? message?.meta?.model_id}
{:else}
<FileItem
item={file}
url={file.url}
name={file.name}
type={file.type}
size={file?.size}
small={true}
/>
{message?.user?.name}
{/if}
</div>
{/each}
</div>
{/if}
{#if edit}
<div class="py-2">
<Textarea
className=" bg-transparent outline-hidden w-full resize-none"
bind:value={editedContent}
onKeydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
<div class=" mt-2 mb-1 flex justify-end text-sm font-medium">
<div class="flex space-x-1.5">
<button
id="close-edit-message-button"
class="px-3.5 py-1.5 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
on:click={() => {
edit = false;
editedContent = null;
}}
{#if message.created_at}
<div
class=" self-center text-xs text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
>
{$i18n.t('Cancel')}
</button>
<button
id="confirm-edit-message-button"
class="px-3.5 py-1.5 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
on:click={async () => {
onEdit(editedContent);
edit = false;
editedContent = null;
}}
>
{$i18n.t('Save')}
</button>
</div>
</div>
</div>
{:else}
<div class=" min-w-full markdown-prose {pending ? 'opacity-50' : ''}">
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton />
{:else}
<Markdown
id={message.id}
content={message.content}
paragraphTag="span"
/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
class="text-gray-500 text-[10px] pl-1 self-center">({$i18n.t('edited')})</span
>{/if}
{/if}
</div>
{#if (message?.reactions ?? []).length > 0}
<div>
<div class="flex items-center flex-wrap gap-y-1.5 gap-1 mt-1 mb-2">
{#each message.reactions as reaction}
<Tooltip
content={$i18n.t('{{NAMES}} reacted with {{REACTION}}', {
NAMES: reaction.users
.reduce((acc, u, idx) => {
const name = u.id === $user?.id ? $i18n.t('You') : u.name;
const total = reaction.users.length;
// First three names always added normally
if (idx < 3) {
const separator =
idx === 0
? ''
: idx === Math.min(2, total - 1)
? ` ${$i18n.t('and')} `
: ', ';
return `${acc}${separator}${name}`;
}
// More than 4 → "and X others"
if (idx === 3 && total > 4) {
return (
acc +
` ${$i18n.t('and {{COUNT}} others', {
COUNT: total - 3
})}`
);
}
return acc;
}, '')
.trim(),
REACTION: `:${reaction.name}:`
})}
>
<button
class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.users
.map((u) => u.id)
.includes($user?.id)
? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
: 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
on:click={() => {
if (onReaction) {
onReaction(reaction.name);
}
}}
>
<Emoji shortCode={reaction.name} />
{#if reaction.users.length > 0}
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{reaction.users?.length}
</div>
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
<span class="line-clamp-1">
{#if dayjs(message.created_at / 1000000).isToday()}
{dayjs(message.created_at / 1000000).format('LT')}
{:else}
{$i18n.t(formatDate(message.created_at / 1000000), {
LOCALIZED_TIME: dayjs(message.created_at / 1000000).format('LT'),
LOCALIZED_DATE: dayjs(message.created_at / 1000000).format('L')
})}
{/if}
</button>
</span>
</Tooltip>
{/each}
</div>
{/if}
</Name>
{/if}
{#if onReaction}
<EmojiPicker
onSubmit={(name) => {
onReaction(name);
{#if message?.data === true}
<!-- loading indicator -->
<div class=" my-2">
<Skeleton />
</div>
{:else if (message?.data?.files ?? []).length > 0}
<div
class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap"
dir={$settings?.chatDirection ?? 'auto'}
>
{#each message?.data?.files as file}
{@const fileUrl =
file.url.startsWith('data') || file.url.startsWith('http')
? file.url
: `${WEBUI_API_BASE_URL}/files/${file.url}${file?.content_type ? '/content' : ''}`}
<div>
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
<Image src={fileUrl} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
{:else if file.type === 'video' || (file?.content_type ?? '').startsWith('video/')}
<video src={fileUrl} controls class=" max-h-96 rounded-lg"></video>
{:else}
<FileItem
item={file}
url={file.url}
name={file.name}
type={file.type}
size={file?.size}
small={true}
/>
{/if}
</div>
{/each}
</div>
{/if}
{#if edit}
<div class="py-2">
<Textarea
className=" bg-transparent outline-hidden w-full resize-none"
bind:value={editedContent}
onKeydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
<div class=" mt-2 mb-1 flex justify-end text-sm font-medium">
<div class="flex space-x-1.5">
<button
id="close-edit-message-button"
class="px-3.5 py-1.5 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
on:click={() => {
edit = false;
editedContent = null;
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<div
class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"
>
<FaceSmile />
</div>
</Tooltip>
</EmojiPicker>
{/if}
{$i18n.t('Cancel')}
</button>
<button
id="confirm-edit-message-button"
class="px-3.5 py-1.5 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
on:click={async () => {
onEdit(editedContent);
edit = false;
editedContent = null;
}}
>
{$i18n.t('Save')}
</button>
</div>
</div>
</div>
{/if}
{#if !thread && message.reply_count > 0}
<div class="flex items-center gap-1.5 -mt-0.5 mb-1.5">
<button
class="flex items-center text-xs py-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={() => {
onThread(message.id);
}}
>
<span class="font-medium mr-1">
{$i18n.t('{{COUNT}} Replies', { COUNT: message.reply_count })}</span
><span>
{' - '}{$i18n.t('Last reply')}
{dayjs.unix(message.latest_reply_at / 1000000000).fromNow()}</span
>
<span class="ml-1">
<ChevronRight className="size-2.5" strokeWidth="3" />
</span>
<!-- {$i18n.t('View Replies')} -->
</button>
{:else}
<div class=" min-w-full markdown-prose {pending ? 'opacity-50' : ''}">
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton />
{:else}
<Markdown
id={message.id}
content={message.content}
paragraphTag="span"
/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
class="text-gray-500 text-[10px] pl-1 self-center">({$i18n.t('edited')})</span
>{/if}
{/if}
</div>
{#if (message?.reactions ?? []).length > 0}
<div>
<div class="flex items-center flex-wrap gap-y-1.5 gap-1 mt-1 mb-2">
{#each message.reactions as reaction}
<Tooltip
content={$i18n.t('{{NAMES}} reacted with {{REACTION}}', {
NAMES: reaction.users
.reduce((acc, u, idx) => {
const name = u.id === $user?.id ? $i18n.t('You') : u.name;
const total = reaction.users.length;
// First three names always added normally
if (idx < 3) {
const separator =
idx === 0
? ''
: idx === Math.min(2, total - 1)
? ` ${$i18n.t('and')} `
: ', ';
return `${acc}${separator}${name}`;
}
// More than 4 → "and X others"
if (idx === 3 && total > 4) {
return (
acc +
` ${$i18n.t('and {{COUNT}} others', {
COUNT: total - 3
})}`
);
}
return acc;
}, '')
.trim(),
REACTION: `:${reaction.name}:`
})}
>
<button
class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.users
.map((u) => u.id)
.includes($user?.id)
? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
: 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
on:click={() => {
if (onReaction) {
onReaction(reaction.name);
}
}}
>
<Emoji shortCode={reaction.name} />
{#if reaction.users.length > 0}
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{reaction.users?.length}
</div>
{/if}
</button>
</Tooltip>
{/each}
{#if onReaction}
<EmojiPicker
onSubmit={(name) => {
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<div
class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"
>
<FaceSmile />
</div>
</Tooltip>
</EmojiPicker>
{/if}
</div>
</div>
{/if}
{#if !thread && message.reply_count > 0}
<div class="flex items-center gap-1.5 -mt-0.5 mb-1.5">
<button
class="flex items-center text-xs py-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={() => {
onThread(message.id);
}}
>
<span class="font-medium mr-1">
{$i18n.t('{{COUNT}} Replies', { COUNT: message.reply_count })}</span
><span>
{' - '}{$i18n.t('Last reply')}
{dayjs.unix(message.latest_reply_at / 1000000000).fromNow()}</span
>
<span class="ml-1">
<ChevronRight className="size-2.5" strokeWidth="3" />
</span>
<!-- {$i18n.t('View Replies')} -->
</button>
</div>
{/if}
{/if}
{/if}
</div>
</div>
</div>
</div>
</div>
{/if}
@@ -669,7 +682,9 @@
border-radius: 50%;
background-color: rgba(128, 128, 128, 0.15);
color: rgba(128, 128, 128, 0.8);
transition: background-color 0.15s, color 0.15s;
transition:
background-color 0.15s,
color 0.15s;
}
.swipe-reply-icon--active {
+2 -2
View File
@@ -200,7 +200,7 @@
window.setTimeout(() => scrollToBottom(), 0);
await tick();
// Mark chat read when initially loading it
if (chatIdProp && !$temporaryChatEnabled) {
updateLastReadAt(chatIdProp);
@@ -2886,7 +2886,7 @@
{createMessagePair}
{onUpload}
messageQueue={$chatRequestQueues[$chatId] ?? []}
{chatTasks}
{chatTasks}
onQueueSendNow={async (id) => {
const queue = $chatRequestQueues[$chatId] ?? [];
const item = queue.find((m) => m.id === id);
+56 -9
View File
@@ -365,22 +365,52 @@
clearFilePreview();
if (isImage(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileImageUrl = URL.createObjectURL(result.blob);
} else if (isVideo(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileVideoUrl = URL.createObjectURL(result.blob);
} else if (isAudio(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileAudioUrl = URL.createObjectURL(result.blob);
} else if (isPdf(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) filePdfData = await result.blob.arrayBuffer();
} else if (isSqlite(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) {
const ext = getFileExt(filePath);
const arrayBuffer = await result.blob.arrayBuffer();
@@ -493,7 +523,12 @@
const terminal = selectedTerminal;
if (!terminal) return;
const result = await createDirectory(terminal.url, terminal.key, `${currentPath}${name}`, chatId ?? undefined);
const result = await createDirectory(
terminal.url,
terminal.key,
`${currentPath}${name}`,
chatId ?? undefined
);
toast[result ? 'success' : 'error'](
$i18n.t(result ? 'Folder created' : 'Failed to create folder')
);
@@ -554,7 +589,13 @@
const sourceDir = source.endsWith('/') ? source : source + '/';
if (destFolder.startsWith(sourceDir)) return;
const result = await moveEntry(terminal.url, terminal.key, source, destination, chatId ?? undefined);
const result = await moveEntry(
terminal.url,
terminal.key,
source,
destination,
chatId ?? undefined
);
if ('error' in result) {
toast.error(result.error);
} else {
@@ -573,7 +614,13 @@
if (oldPath === destination) return;
const result = await moveEntry(terminal.url, terminal.key, oldPath, destination, chatId ?? undefined);
const result = await moveEntry(
terminal.url,
terminal.key,
oldPath,
destination,
chatId ?? undefined
);
if ('error' in result) {
toast.error(result.error);
} else {
+4 -1
View File
@@ -122,7 +122,10 @@
export let history;
export let taskIds = null;
$: isActive = (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating;
$: isActive =
(taskIds && taskIds.length > 0) ||
(history.currentId && history.messages[history.currentId]?.done != true) ||
generating;
export let prompt = '';
export let files = [];
@@ -43,7 +43,6 @@
export let readOnly = false;
export let editCodeBlock = true;
export let topPadding = false;
</script>
<div
@@ -393,7 +393,9 @@
});
if (res) {
toast.success($i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id }));
toast.success(
$i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id })
);
// If the deleted model was selected, clear the selection
if (value === model.id) {
@@ -427,7 +429,9 @@
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete Model')}
message={$i18n.t('Are you sure you want to delete **{{modelName}}**?', { modelName: deleteModelTarget?.name ?? deleteModelTarget?.id ?? '' })}
message={$i18n.t('Are you sure you want to delete **{{modelName}}**?', {
modelName: deleteModelTarget?.name ?? deleteModelTarget?.id ?? ''
})}
on:confirm={() => {
confirmDeleteModel();
}}
@@ -28,7 +28,6 @@
import PanzoomContainer from './PanzoomContainer.svelte';
import Reset from '../icons/Reset.svelte';
export let item;
export let show = false;
export let edit = false;
@@ -161,12 +161,7 @@
</div>
</div>
<PanzoomContainer className="flex h-full max-h-full justify-center items-center z-0">
<img
{src}
{alt}
class=" mx-auto h-full object-scale-down select-none"
draggable="false"
/>
<img {src} {alt} class=" mx-auto h-full object-scale-down select-none" draggable="false" />
</PanzoomContainer>
</div>
{/if}
@@ -24,7 +24,7 @@
instance = localInstance;
return () => {
localInstance.dispose();
}
};
});
</script>
+28 -4
View File
@@ -10,8 +10,32 @@
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M20 20L15 15M15 15V19M15 15H19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 20L9 15M9 15V19M9 15H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M20 4L15 9M15 9V5M15 9H19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 4L9 9M9 9V5M9 9H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M20 20L15 15M15 15V19M15 15H19"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 20L9 15M9 15V19M9 15H5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M20 4L15 9M15 9V5M15 9H19"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 4L9 9M9 9V5M9 9H5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
+28 -4
View File
@@ -10,8 +10,32 @@
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M9 9L4 4M4 4V8M4 4H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M15 9L20 4M20 4V8M20 4H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 15L4 20M4 20V16M4 20H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M15 15L20 20M20 20V16M20 20H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M9 9L4 4M4 4V8M4 4H8"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15 9L20 4M20 4V8M20 4H16"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 15L4 20M4 20V16M4 20H8"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15 15L20 20M20 20V16M20 20H16"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
+42 -6
View File
@@ -9,10 +9,46 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 6L20 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M3.80002 5.79999L4.60002 6.59998L6.60001 4.59999" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M3.80002 11.8L4.60002 12.6L6.60001 10.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M3.80002 17.8L4.60002 18.6L6.60001 16.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 12L20 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 18L20 18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M9 6L20 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.80002 5.79999L4.60002 6.59998L6.60001 4.59999"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.80002 11.8L4.60002 12.6L6.60001 10.6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.80002 17.8L4.60002 18.6L6.60001 16.6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 12L20 12"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 18L20 18"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
@@ -9,7 +9,6 @@
import { showSettings, mobile, showSidebar, showShortcuts, user, config } from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -216,39 +215,39 @@
</button>
{#if $user?.role === 'admin' || $user?.permissions?.features?.automations}
<a
href="/automations"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/automations');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Automations')}</div>
</a>
<a
href="/automations"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/automations');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Automations')}</div>
</a>
{/if}
<button