From 7978adbf457be13290548b28e0169e67c227d08e Mon Sep 17 00:00:00 2001 From: Jun Siang Cheah <git@jscheah.me> Date: Sun, 31 Mar 2024 22:03:28 +0100 Subject: [PATCH] feat: add frontend support for locally sharing chats --- src/lib/apis/chats/index.ts | 96 +++++++++ src/lib/components/chat/Messages.svelte | 3 + .../chat/Messages/ResponseMessage.svelte | 158 ++++++++------- .../chat/Messages/UserMessage.svelte | 49 ++--- src/lib/components/chat/ShareChatModal.svelte | 12 ++ src/lib/components/layout/Navbar.svelte | 30 ++- src/routes/(app)/+page.svelte | 2 +- src/routes/(app)/c/[id]/+page.svelte | 2 +- src/routes/(app)/s/[id]/+page.svelte | 187 ++++++++++++++++++ 9 files changed, 433 insertions(+), 106 deletions(-) create mode 100644 src/routes/(app)/s/[id]/+page.svelte diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 35b259d56..4fcd1b639 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -218,6 +218,102 @@ export const getChatById = async (token: string, id: string) => { return res; }; +export const getChatByShareId = async (token: string, share_id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/share/${share_id}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const shareChatById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/share`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteSharedChatById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateChatById = async (token: string, id: string, chat: object) => { let error = null; diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index 7afb5c376..4cd97ca80 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -16,6 +16,7 @@ const i18n = getContext('i18n'); export let chatId = ''; + export let readOnly = false; export let sendPrompt: Function; export let continueGeneration: Function; export let regenerateResponse: Function; @@ -317,6 +318,7 @@ <UserMessage on:delete={() => messageDeleteHandler(message.id)} user={$user} + {readOnly} {message} isFirstMessage={messageIdx === 0} siblings={message.parentId !== null @@ -335,6 +337,7 @@ modelfiles={selectedModelfiles} siblings={history.messages[message.parentId]?.childrenIds ?? []} isLastMessage={messageIdx + 1 === messages.length} + {readOnly} {confirmEditResponseMessage} {showPreviousMessage} {showNextMessage} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 50e6e0c03..3888d764e 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -33,6 +33,8 @@ export let isLastMessage = true; + export let readOnly = false; + export let confirmEditResponseMessage: Function; export let showPreviousMessage: Function; export let showNextMessage: Function; @@ -469,31 +471,33 @@ </div> {/if} - <Tooltip content="Edit" placement="bottom"> - <button - class="{isLastMessage - ? 'visible' - : 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition" - on:click={() => { - editMessageHandler(); - }} - > - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - stroke-width="2" - stroke="currentColor" - class="w-4 h-4" + {#if !readOnly} + <Tooltip content="Edit" placement="bottom"> + <button + class="{isLastMessage + ? 'visible' + : 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition" + on:click={() => { + editMessageHandler(); + }} > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" - /> - </svg> - </button> - </Tooltip> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="w-4 h-4" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" + /> + </svg> + </button> + </Tooltip> + {/if} <Tooltip content="Copy" placement="bottom"> <button @@ -521,59 +525,61 @@ </button> </Tooltip> - <Tooltip content="Good Response" placement="bottom"> - <button - class="{isLastMessage - ? 'visible' - : 'invisible group-hover:visible'} p-1 rounded {message.rating === 1 - ? 'bg-gray-100 dark:bg-gray-800' - : ''} dark:hover:text-white hover:text-black transition" - on:click={() => { - rateMessage(message.id, 1); - }} - > - <svg - stroke="currentColor" - fill="none" - stroke-width="2" - viewBox="0 0 24 24" - stroke-linecap="round" - stroke-linejoin="round" - class="w-4 h-4" - xmlns="http://www.w3.org/2000/svg" - ><path - d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" - /></svg + {#if !readOnly} + <Tooltip content="Good Response" placement="bottom"> + <button + class="{isLastMessage + ? 'visible' + : 'invisible group-hover:visible'} p-1 rounded {message.rating === 1 + ? 'bg-gray-100 dark:bg-gray-800' + : ''} dark:hover:text-white hover:text-black transition" + on:click={() => { + rateMessage(message.id, 1); + }} > - </button> - </Tooltip> + <svg + stroke="currentColor" + fill="none" + stroke-width="2" + viewBox="0 0 24 24" + stroke-linecap="round" + stroke-linejoin="round" + class="w-4 h-4" + xmlns="http://www.w3.org/2000/svg" + ><path + d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" + /></svg + > + </button> + </Tooltip> - <Tooltip content="Bad Response" placement="bottom"> - <button - class="{isLastMessage - ? 'visible' - : 'invisible group-hover:visible'} p-1 rounded {message.rating === -1 - ? 'bg-gray-100 dark:bg-gray-800' - : ''} dark:hover:text-white hover:text-black transition" - on:click={() => { - rateMessage(message.id, -1); - }} - > - <svg - stroke="currentColor" - fill="none" - stroke-width="2" - viewBox="0 0 24 24" - stroke-linecap="round" - stroke-linejoin="round" - class="w-4 h-4" - xmlns="http://www.w3.org/2000/svg" - ><path - d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" - /></svg + <Tooltip content="Bad Response" placement="bottom"> + <button + class="{isLastMessage + ? 'visible' + : 'invisible group-hover:visible'} p-1 rounded {message.rating === -1 + ? 'bg-gray-100 dark:bg-gray-800' + : ''} dark:hover:text-white hover:text-black transition" + on:click={() => { + rateMessage(message.id, -1); + }} > - </button> - </Tooltip> + <svg + stroke="currentColor" + fill="none" + stroke-width="2" + viewBox="0 0 24 24" + stroke-linecap="round" + stroke-linejoin="round" + class="w-4 h-4" + xmlns="http://www.w3.org/2000/svg" + ><path + d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" + /></svg + > + </button> + </Tooltip> + {/if} <Tooltip content="Read Aloud" placement="bottom"> <button @@ -656,7 +662,7 @@ </button> </Tooltip> - {#if $config.images} + {#if $config.images && !readOnly} <Tooltip content="Generate Image" placement="bottom"> <button class="{isLastMessage @@ -752,7 +758,7 @@ </Tooltip> {/if} - {#if isLastMessage} + {#if isLastMessage && !readOnly} <Tooltip content="Continue Response" placement="bottom"> <button type="button" diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index 4e0088d78..99ce6b8f7 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -15,6 +15,7 @@ export let message; export let siblings; export let isFirstMessage: boolean; + export let readOnly: boolean; export let confirmEditMessage: Function; export let showPreviousMessage: Function; @@ -250,29 +251,31 @@ </div> {/if} - <Tooltip content="Edit" placement="bottom"> - <button - class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition edit-user-message-button" - on:click={() => { - editMessageHandler(); - }} - > - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - stroke-width="2" - stroke="currentColor" - class="w-4 h-4" + {#if !readOnly} + <Tooltip content="Edit" placement="bottom"> + <button + class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition edit-user-message-button" + on:click={() => { + editMessageHandler(); + }} > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" - /> - </svg> - </button> - </Tooltip> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="w-4 h-4" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" + /> + </svg> + </button> + </Tooltip> + {/if} <Tooltip content="Copy" placement="bottom"> <button @@ -298,7 +301,7 @@ </button> </Tooltip> - {#if !isFirstMessage} + {#if !isFirstMessage && !readOnly} <Tooltip content="Delete" placement="bottom"> <button class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition" diff --git a/src/lib/components/chat/ShareChatModal.svelte b/src/lib/components/chat/ShareChatModal.svelte index aef098f04..ac1bfef9f 100644 --- a/src/lib/components/chat/ShareChatModal.svelte +++ b/src/lib/components/chat/ShareChatModal.svelte @@ -6,6 +6,7 @@ export let downloadChat: Function; export let shareChat: Function; + export let shareLocalChat: Function; export let show = false; </script> @@ -23,6 +24,17 @@ {$i18n.t('Share to OpenWebUI Community')} </button> + <button + class=" self-center px-8 py-1.5 w-full rounded-full text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white mt-1.5" + type="button" + on:click={() => { + shareLocalChat(); + show = false; + }} + > + {$i18n.t('Create local share link')} + </button> + <div class="flex justify-center space-x-1 mt-1.5"> <div class=" self-center text-gray-400 text-xs font-medium">{$i18n.t('or')}</div> diff --git a/src/lib/components/layout/Navbar.svelte b/src/lib/components/layout/Navbar.svelte index 2680de8dd..7be5fbd8c 100644 --- a/src/lib/components/layout/Navbar.svelte +++ b/src/lib/components/layout/Navbar.svelte @@ -5,7 +5,7 @@ const { saveAs } = fileSaver; import { Separator } from 'bits-ui'; - import { getChatById } from '$lib/apis/chats'; + import { getChatById, shareChatById } from '$lib/apis/chats'; import { WEBUI_NAME, chatId, modelfiles, settings, showSettings } from '$lib/stores'; import { slide } from 'svelte/transition'; @@ -19,6 +19,7 @@ import ChevronUpDown from '../icons/ChevronUpDown.svelte'; import Menu from './Navbar/Menu.svelte'; import TagChatModal from '../chat/TagChatModal.svelte'; + import { copyToClipboard } from '$lib/utils'; const i18n = getContext('i18n'); @@ -32,7 +33,7 @@ export let addTag: Function; export let deleteTag: Function; - export let showModelSelector = false; + export let showModelSelector = true; let showShareChatModal = false; let showTagChatModal = false; @@ -64,6 +65,23 @@ ); }; + const shareLocalChat = async () => { + const chat = await getChatById(localStorage.token, $chatId); + console.log('shareLocal', chat); + if (chat.share_id) { + const shareUrl = `${window.location.origin}/s/${chat.share_id}`; + toast.info( + $i18n.t('Chat is already shared at {{shareUrl}}, copied to clipboard', { shareUrl }) + ); + copyToClipboard(shareUrl); + } else { + const sharedChat = await shareChatById(localStorage.token, $chatId); + const shareUrl = `${window.location.origin}/s/${sharedChat.id}`; + toast.info($i18n.t('Chat is now shared at {{shareUrl}}, copied to clipboard', { shareUrl })); + copyToClipboard(shareUrl); + } + }; + const downloadChat = async () => { const chat = (await getChatById(localStorage.token, $chatId)).chat; console.log('download', chat); @@ -80,7 +98,7 @@ }; </script> -<ShareChatModal bind:show={showShareChatModal} {downloadChat} {shareChat} /> +<ShareChatModal bind:show={showShareChatModal} {downloadChat} {shareChat} {shareLocalChat} /> <!-- <TagChatModal bind:show={showTagChatModal} {tags} {deleteTag} {addTag} /> --> <nav id="nav" class=" sticky py-2.5 top-0 flex flex-row justify-center z-30"> <div @@ -135,8 +153,10 @@ </div> --> <div class="flex items-center w-full max-w-full"> - <div class="w-full flex-1 overflow-hidden max-w-full"> - <ModelSelector bind:selectedModels /> + <div class="flex-1 overflow-hidden max-w-full"> + {#if showModelSelector} + <ModelSelector bind:selectedModels /> + {/if} </div> <div class="self-start flex flex-none items-center"> diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 1580e365e..4e7ebfa30 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -48,7 +48,7 @@ let messagesContainerElement: HTMLDivElement; let currentRequestId = null; - let showModelSelector = false; + let showModelSelector = true; let selectedModels = ['']; let selectedModelfile = null; diff --git a/src/routes/(app)/c/[id]/+page.svelte b/src/routes/(app)/c/[id]/+page.svelte index 139fcd401..74cf9df10 100644 --- a/src/routes/(app)/c/[id]/+page.svelte +++ b/src/routes/(app)/c/[id]/+page.svelte @@ -56,7 +56,7 @@ let currentRequestId = null; // let chatId = $page.params.id; - let showModelSelector = false; + let showModelSelector = true; let selectedModels = ['']; let selectedModelfile = null; diff --git a/src/routes/(app)/s/[id]/+page.svelte b/src/routes/(app)/s/[id]/+page.svelte new file mode 100644 index 000000000..2b5226205 --- /dev/null +++ b/src/routes/(app)/s/[id]/+page.svelte @@ -0,0 +1,187 @@ +<script lang="ts"> + import { onMount, tick, getContext } from 'svelte'; + import { goto } from '$app/navigation'; + import { page } from '$app/stores'; + + import { modelfiles, settings, chatId, WEBUI_NAME } from '$lib/stores'; + import { convertMessagesToHistory } from '$lib/utils'; + + import { getChatByShareId } from '$lib/apis/chats'; + + import Messages from '$lib/components/chat/Messages.svelte'; + import Navbar from '$lib/components/layout/Navbar.svelte'; + + const i18n = getContext('i18n'); + + let loaded = false; + + let autoScroll = true; + let processing = ''; + let messagesContainerElement: HTMLDivElement; + + // let chatId = $page.params.id; + let showModelSelector = false; + let selectedModels = ['']; + + let selectedModelfiles = {}; + $: selectedModelfiles = selectedModels.reduce((a, tagName, i, arr) => { + const modelfile = + $modelfiles.filter((modelfile) => modelfile.tagName === tagName)?.at(0) ?? undefined; + + return { + ...a, + ...(modelfile && { [tagName]: modelfile }) + }; + }, {}); + + let chat = null; + + let title = ''; + let files = []; + + let messages = []; + let history = { + messages: {}, + currentId: null + }; + + $: if (history.currentId !== null) { + let _messages = []; + + let currentMessage = history.messages[history.currentId]; + while (currentMessage !== null) { + _messages.unshift({ ...currentMessage }); + currentMessage = + currentMessage.parentId !== null ? history.messages[currentMessage.parentId] : null; + } + messages = _messages; + } else { + messages = []; + } + + $: if ($page.params.id) { + (async () => { + if (await loadSharedChat()) { + await tick(); + loaded = true; + + window.setTimeout(() => scrollToBottom(), 0); + const chatInput = document.getElementById('chat-textarea'); + chatInput?.focus(); + } else { + await goto('/'); + } + })(); + } + + ////////////////////////// + // Web functions + ////////////////////////// + + const loadSharedChat = async () => { + await chatId.set($page.params.id); + chat = await getChatByShareId(localStorage.token, $chatId).catch(async (error) => { + await goto('/'); + return null; + }); + + if (chat) { + const chatContent = chat.chat; + + if (chatContent) { + console.log(chatContent); + + selectedModels = + (chatContent?.models ?? undefined) !== undefined + ? chatContent.models + : [chatContent.models ?? '']; + history = + (chatContent?.history ?? undefined) !== undefined + ? chatContent.history + : convertMessagesToHistory(chatContent.messages); + title = chatContent.title; + + let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}'); + await settings.set({ + ..._settings, + system: chatContent.system ?? _settings.system, + options: chatContent.options ?? _settings.options + }); + autoScroll = true; + await tick(); + + if (messages.length > 0) { + history.messages[messages.at(-1).id].done = true; + } + await tick(); + + return true; + } else { + return null; + } + } + }; + + const scrollToBottom = () => { + if (messagesContainerElement) { + messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight; + } + }; + + onMount(async () => { + if (!($settings.saveChatHistory ?? true)) { + await goto('/'); + } + }); +</script> + +<svelte:head> + <title> + {title + ? `${title.length > 30 ? `${title.slice(0, 30)}...` : title} | ${$WEBUI_NAME}` + : `${$WEBUI_NAME}`} + </title> +</svelte:head> + +{#if loaded} + <div class="min-h-screen max-h-screen w-full flex flex-col"> + <Navbar + {title} + bind:selectedModels + bind:showModelSelector + shareEnabled={false} + initNewChat={async () => { + goto('/'); + }} + /> + <div class="flex flex-col flex-auto"> + <div + class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0" + id="messages-container" + bind:this={messagesContainerElement} + on:scroll={(e) => { + autoScroll = + messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <= + messagesContainerElement.clientHeight + 5; + }} + > + <div class=" h-full w-full flex flex-col py-4"> + <Messages + chatId={$chatId} + readOnly={true} + {selectedModels} + {selectedModelfiles} + {processing} + bind:history + bind:messages + bind:autoScroll + bottomPadding={files.length > 0} + sendPrompt={() => {}} + continueGeneration={() => {}} + regenerateResponse={() => {}} + /> + </div> + </div> + </div> + </div> +{/if}