feat: channel socket integration

This commit is contained in:
Timothy Jaeryang Baek
2024-12-22 19:40:01 -07:00
parent eaecd15e69
commit f1d21fc59a
13 changed files with 509 additions and 31 deletions

View File

@@ -69,3 +69,74 @@ export const getChannels = async (token: string = '') => {
return res;
};
export const getChannelMessages = async (token: string = '', channel_id: string, page: number = 1) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages?page=${page}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
}
type MessageForm = {
content: string;
data?: object;
meta?: object;
}
export const sendMessage = async (token: string = '', channel_id: string, message: MessageForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/post`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...message })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
}

View File

@@ -1,5 +1,96 @@
<script lang="ts">
import { getChannelMessages, sendMessage } from '$lib/apis/channels';
import { toast } from 'svelte-sonner';
import MessageInput from './MessageInput.svelte';
import Messages from './Messages.svelte';
import { socket } from '$lib/stores';
import { onDestroy, onMount, tick } from 'svelte';
export let id = '';
let scrollEnd = true;
let messagesContainerElement = null;
let top = false;
let page = 1;
let messages = null;
$: if (id) {
initHandler();
}
const initHandler = async () => {
top = false;
page = 1;
messages = null;
messages = await getChannelMessages(localStorage.token, id, page);
if (messages.length < 50) {
top = true;
}
};
const channelEventHandler = async (data) => {
console.log(data);
};
const submitHandler = async ({ content }) => {
if (!content) {
return;
}
const res = await sendMessage(localStorage.token, id, { content: content }).catch((error) => {
toast.error(error);
return null;
});
if (res) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
};
onMount(() => {
$socket?.on('channel-events', channelEventHandler);
});
onDestroy(() => {
$socket?.off('channel-events', channelEventHandler);
});
</script>
{id}
<div class="h-full md:max-w-[calc(100%-260px)] w-full max-w-full flex flex-col">
<div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
scrollEnd =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 5;
}}
>
{#key id}
<Messages
{messages}
onLoad={async () => {
page += 1;
const newMessages = await getChannelMessages(localStorage.token, id, page);
if (newMessages.length === 0) {
top = true;
return;
}
messages = [...newMessages, ...messages];
}}
/>
{/key}
</div>
<div class=" pb-[1rem]">
<MessageInput onSubmit={submitHandler} />
</div>
</div>

View File

@@ -0,0 +1,266 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { tick, getContext } from 'svelte';
const i18n = getContext('i18n');
import { mobile, settings } from '$lib/stores';
import Tooltip from '../common/Tooltip.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
export let placeholder = $i18n.t('Send a Message');
export let transparentBackground = false;
let recording = false;
let content = '';
export let onSubmit: Function;
let submitHandler = async () => {
if (content === '') {
return;
}
onSubmit({
content
});
content = '';
await tick();
const chatInputElement = document.getElementById('chat-input');
chatInputElement?.focus();
};
</script>
<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="">
{#if recording}
<VoiceRecording
bind:recording
on:cancel={async () => {
recording = false;
await tick();
document.getElementById('chat-input')?.focus();
}}
on:confirm={async (e) => {
const { text, filename } = e.detail;
content = `${content}${text} `;
recording = false;
await tick();
document.getElementById('chat-input')?.focus();
}}
/>
{:else}
<form
class="w-full flex gap-1.5"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div
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'}
>
<div class=" flex">
<div class="ml-1 self-end mb-1.5 flex space-x-1">
<button
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"
type="button"
aria-label="More"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
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>
</div>
{#if $settings?.richTextInput ?? true}
<div
class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
>
<RichTextInput
bind:value={content}
id="chat-input"
messageInput={true}
shiftEnter={!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)}
{placeholder}
largeTextAsFile={$settings?.largeTextAsFile ?? false}
on:keydown={async (e) => {
e = e.detail.event;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
if (e.keyCode === 13 && !e.shiftKey) {
e.preventDefault();
}
// Submit the content when Enter key is pressed
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
submitHandler();
}
}
if (e.key === 'Escape') {
console.log('Escape');
}
}}
on:paste={async (e) => {
e = e.detail.event;
console.log(e);
}}
/>
</div>
{:else}
<textarea
id="chat-input"
class="scrollbar-hidden bg-transparent dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
{placeholder}
bind:value={content}
on:keydown={async (e) => {
e = e.detail.event;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
if (e.keyCode === 13 && !e.shiftKey) {
e.preventDefault();
}
// Submit the content when Enter key is pressed
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
submitHandler();
}
}
if (e.key === 'Escape') {
console.log('Escape');
}
}}
rows="1"
on:input={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
}}
on:focus={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
}}
/>
{/if}
<div class="self-end mb-1.5 flex space-x-1 mr-1">
{#if content === ''}
<Tooltip content={$i18n.t('Record voice')}>
<button
id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
type="button"
on:click={async () => {
try {
let stream = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(function (err) {
toast.error(
$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
error: err
})
);
return null;
});
if (stream) {
recording = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
} catch {
toast.error($i18n.t('Permission denied when accessing microphone'));
}
}}
aria-label="Voice Input"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 translate-y-[0.5px]"
>
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
/>
</svg>
</button>
</Tooltip>
{/if}
<div class=" flex items-center">
<div class=" flex items-center">
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{content !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={content === ''}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</form>
{/if}
</div>
</div>
</div>

View File

@@ -9,14 +9,15 @@
import Message from './Messages/Message.svelte';
import Loader from '../common/Loader.svelte';
import Spinner from '../common/Spinner.svelte';
import { getChannelMessages } from '$lib/apis/channels';
const i18n = getContext('i18n');
export let channelId;
export let messages = [];
export let top = false;
let messages = null;
export let onLoad: Function = () => {};
let messagesCount = 50;
let messagesLoading = false;
const loadMoreMessages = async () => {
@@ -25,19 +26,19 @@
element.scrollTop = element.scrollTop + 100;
messagesLoading = true;
messagesCount += 50;
await onLoad();
await tick();
messagesLoading = false;
};
</script>
<div class="h-full flex pt-8">
<div class="w-full pt-2">
{#key channelId}
{#if messages}
<div class="h-full w-full flex-1 flex">
<div class="w-full pt-2">
<div class="w-full">
{#if messages.at(0)?.parentId !== null}
{#if !top}
<Loader
on:visible={(e) => {
console.log('visible');
@@ -54,10 +55,10 @@
{/if}
{#each messages as message, messageIdx (message.id)}
<Message {channelId} id={message.id} content={message.content} />
<Message {message} />
{/each}
</div>
<div class="pb-12" />
{/key}
<div class="pb-6" />
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
export let message;
</script>
{#if message}
<div>
<div>
<Markdown id={message.id} content={message.content} />
</div>
</div>
{/if}

View File

@@ -49,7 +49,7 @@
export let autoScroll = false;
export let atSelectedModel: Model | undefined;
export let atSelectedModel: Model | undefined = undefined;
export let selectedModels: [''];
let selectedModelIds = [];

View File

@@ -19,6 +19,7 @@
on:click={() => {
showImagePreview = true;
}}
type="button"
>
<img src={_src} {alt} class={imageClassName} draggable="false" data-cy="image" />
</button>

View File

@@ -17,7 +17,8 @@
scrollPaginationEnabled,
currentChatPage,
temporaryChatEnabled,
channels
channels,
socket
} from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte';
@@ -151,7 +152,7 @@
};
const initChannels = async () => {
channels.set(await getChannels(localStorage.token));
await channels.set(await getChannels(localStorage.token));
};
const initChatList = async () => {

View File

@@ -38,7 +38,7 @@
let loaded = false;
const BREAKPOINT = 768;
const setupSocket = (enableWebsocket) => {
const setupSocket = async (enableWebsocket) => {
const _socket = io(`${WEBUI_BASE_URL}` || undefined, {
reconnection: true,
reconnectionDelay: 1000,
@@ -49,7 +49,7 @@
auth: { token: localStorage.token }
});
socket.set(_socket);
await socket.set(_socket);
_socket.on('connect_error', (err) => {
console.log('connect_error', err);
@@ -127,7 +127,7 @@
await WEBUI_NAME.set(backendConfig.name);
if ($config) {
setupSocket($config.features?.enable_websocket ?? true);
await setupSocket($config.features?.enable_websocket ?? true);
if (localStorage.token) {
// Get Session User Info
@@ -138,6 +138,8 @@
if (sessionUser) {
// Save Session User to Store
$socket.emit('user-join', { auth: { token: sessionUser.token } });
await user.set(sessionUser);
await config.set(await getBackendConfig());
} else {