mirror of
https://github.com/open-webui/open-webui
synced 2025-05-19 12:51:35 +00:00
348 lines
11 KiB
Svelte
348 lines
11 KiB
Svelte
<script lang="ts">
|
|
import dayjs from 'dayjs';
|
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
import isToday from 'dayjs/plugin/isToday';
|
|
import isYesterday from 'dayjs/plugin/isYesterday';
|
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
|
|
|
dayjs.extend(relativeTime);
|
|
dayjs.extend(isToday);
|
|
dayjs.extend(isYesterday);
|
|
dayjs.extend(localizedFormat);
|
|
|
|
import { getContext, onMount } from 'svelte';
|
|
const i18n = getContext<Writable<i18nType>>('i18n');
|
|
|
|
import { settings, user, shortCodesToEmojis } from '$lib/stores';
|
|
|
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
|
|
|
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
|
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
|
|
import Name from '$lib/components/chat/Messages/Name.svelte';
|
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
|
import Pencil from '$lib/components/icons/Pencil.svelte';
|
|
import Tooltip from '$lib/components/common/Tooltip.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';
|
|
import ProfilePreview from './Message/ProfilePreview.svelte';
|
|
import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubbleOvalEllipsis.svelte';
|
|
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
|
|
import ReactionPicker from './Message/ReactionPicker.svelte';
|
|
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
|
import { formatDate } from '$lib/utils';
|
|
|
|
export let message;
|
|
export let showUserProfile = true;
|
|
export let thread = false;
|
|
|
|
export let onDelete: Function = () => {};
|
|
export let onEdit: Function = () => {};
|
|
export let onThread: Function = () => {};
|
|
export let onReaction: Function = () => {};
|
|
|
|
let showButtons = false;
|
|
|
|
let edit = false;
|
|
let editedContent = null;
|
|
let showDeleteConfirmDialog = false;
|
|
</script>
|
|
|
|
<ConfirmDialog
|
|
bind:show={showDeleteConfirmDialog}
|
|
title={$i18n.t('Delete Message')}
|
|
message={$i18n.t('Are you sure you want to delete this message?')}
|
|
onConfirm={async () => {
|
|
await onDelete();
|
|
}}
|
|
/>
|
|
|
|
{#if message}
|
|
<div
|
|
class="flex flex-col justify-between px-5 {showUserProfile
|
|
? 'pt-1.5 pb-0.5'
|
|
: ''} w-full {($settings?.widescreenMode ?? null)
|
|
? 'max-w-full'
|
|
: 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
|
|
>
|
|
{#if !edit}
|
|
<div
|
|
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
|
|
>
|
|
<div
|
|
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-800"
|
|
>
|
|
<ReactionPicker
|
|
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>
|
|
</ReactionPicker>
|
|
|
|
{#if !thread}
|
|
<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={() => {
|
|
onThread(message.id);
|
|
}}
|
|
>
|
|
<ChatBubbleOvalEllipsis />
|
|
</button>
|
|
</Tooltip>
|
|
{/if}
|
|
|
|
{#if message.user_id === $user.id || $user.role === 'admin'}
|
|
<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>
|
|
|
|
<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}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class=" flex w-full message-{message.id}"
|
|
id="message-{message.id}"
|
|
dir={$settings.chatDirection}
|
|
>
|
|
<div
|
|
class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
|
|
>
|
|
{#if showUserProfile}
|
|
<ProfilePreview user={message.user}>
|
|
<ProfileImage
|
|
src={message.user?.profile_image_url ??
|
|
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
|
|
className={'size-8 translate-y-1 ml-0.5'}
|
|
/>
|
|
</ProfilePreview>
|
|
{:else}
|
|
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
|
|
|
|
{#if message.created_at}
|
|
<div
|
|
class="mt-1.5 flex 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('LT')}
|
|
</Tooltip>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex-auto w-0 pl-1">
|
|
{#if showUserProfile}
|
|
<Name>
|
|
<div class=" self-end text-base shrink-0 font-medium truncate">
|
|
{message?.user?.name}
|
|
</div>
|
|
|
|
{#if message.created_at}
|
|
<div
|
|
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
|
|
>
|
|
<Tooltip
|
|
content={dayjs(message.created_at / 1000000).format('LLLL')}
|
|
>
|
|
<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
|
|
</Tooltip>
|
|
</div>
|
|
{/if}
|
|
</Name>
|
|
{/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}
|
|
<div class="py-2">
|
|
<Textarea
|
|
className=" bg-transparent outline-none 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-4 py-2 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;
|
|
}}
|
|
>
|
|
{$i18n.t('Cancel')}
|
|
</button>
|
|
|
|
<button
|
|
id="confirm-edit-message-button"
|
|
class=" px-4 py-2 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">
|
|
<Markdown
|
|
id={message.id}
|
|
content={message.content}
|
|
/>{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]"
|
|
>(edited)</span
|
|
>{/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={`:${reaction.name}:`}>
|
|
<button
|
|
class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.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={() => {
|
|
onReaction(reaction.name);
|
|
}}
|
|
>
|
|
{#if $shortCodesToEmojis[reaction.name]}
|
|
<img
|
|
src="/assets/emojis/{$shortCodesToEmojis[
|
|
reaction.name
|
|
].toLowerCase()}.svg"
|
|
alt={reaction.name}
|
|
class=" size-4"
|
|
loading="lazy"
|
|
/>
|
|
{:else}
|
|
<div>
|
|
{reaction.name}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if reaction.user_ids.length > 0}
|
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
{reaction.user_ids?.length}
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
</Tooltip>
|
|
{/each}
|
|
|
|
<ReactionPicker
|
|
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>
|
|
</ReactionPicker>
|
|
</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}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|