This commit is contained in:
Timothy Jaeryang Baek 2026-10-08 15:29:52 +04:00
parent b5a20423e4
commit 1e9dd6e330
6 changed files with 120 additions and 4 deletions

View file

@ -700,6 +700,32 @@ async def chat_events(sid, data):
event_data = data.get('data', {})
event_type = event_data.get('type')
if event_type == 'typing':
chat_id = data.get('chat_id')
typing_data = event_data.get('data')
typing = typing_data.get('typing') if isinstance(typing_data, dict) else None
if not isinstance(chat_id, str) or not is_saved_chat_id(chat_id) or not isinstance(typing, bool):
return False
room = f'chat:{chat_id}'
if sid not in (get_room_sid_map(sio.manager, '/', room) or {}):
return False
sender = await Users.get_user_by_id(user['id'])
if not sender or not await Chats.get_accessible_chat_by_id(chat_id, sender, permission='write'):
return False
await sio.emit(
'events',
{
'chat_id': chat_id,
'user_id': sender.id,
'user': {'id': sender.id, 'name': sender.name},
'shared': True,
'data': {'type': 'typing', 'data': {'typing': typing}},
},
room=room,
skip_sid=sid,
)
return True
if event_type in {'join', 'leave'}:
chat_id = data.get('chat_id')
if not isinstance(chat_id, str) or not is_saved_chat_id(chat_id):

View file

@ -1264,6 +1264,36 @@
let joinedChatId = '';
let chatRefresh: { id: string; promise: Promise<void> } | null = null;
let typingUsers: { id: string; name: string }[] = [];
let typingUsersTimeout: Record<string, ReturnType<typeof setTimeout>> = {};
let lastTypingEmit = 0;
const emitTyping = (typing: boolean) => {
if (!$socket?.connected || !joinedChatId) return;
if (typing && (joinedChatId !== $chatId || (readOnly && chat?.chat?.share_mode !== 'continue')))
return;
if (typing ? Date.now() - lastTypingEmit < 2000 : !lastTypingEmit) return;
lastTypingEmit = typing ? Date.now() : 0;
$socket.emit('events:chat', {
chat_id: joinedChatId,
data: { type: 'typing', data: { typing } }
});
};
const removeTypingUser = (id: string) => {
clearTimeout(typingUsersTimeout[id]);
delete typingUsersTimeout[id];
typingUsers = typingUsers.filter((user) => user.id !== id);
};
const clearTyping = () => {
emitTyping(false);
Object.values(typingUsersTimeout).forEach(clearTimeout);
typingUsersTimeout = {};
typingUsers = [];
lastTypingEmit = 0;
};
$: if (!prompt.trim()) emitTyping(false);
const mergeChatMessages = (
incoming: typeof history,
@ -1333,6 +1363,7 @@
const syncChatRoom = (socket: typeof $socket, id: string, temporary: boolean) => {
const next = socket && id && !temporary && !isTemporaryChatId(id) ? id : '';
if (next === joinedChatId) return;
clearTyping();
if (joinedChatId)
socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } });
joinedChatId = next;
@ -1354,12 +1385,32 @@
) {
await tick();
const type = event?.data?.type ?? null;
if (type === 'typing') {
const sender = event.user;
if (
!sender?.id ||
sender.id === $user?.id ||
(readOnly && chat?.chat?.share_mode !== 'continue')
)
return;
if (event.data.data?.typing) {
if (!typingUsers.some((entry) => entry.id === sender.id))
typingUsers = [...typingUsers, { id: sender.id, name: sender.name }];
clearTimeout(typingUsersTimeout[sender.id]);
typingUsersTimeout[sender.id] = setTimeout(() => removeTypingUser(sender.id), 5000);
} else {
removeTypingUser(sender.id);
}
return;
}
if (event.shared) {
if (type === 'chat:messages') {
removeTypingUser(event.user_id);
mergeChatMessages(event.data.data);
return;
}
if (type === 'chat:access' || type === 'chat:active') {
if (type === 'chat:access') clearTyping();
if (type === 'chat:access' || event.data.data?.active === false) await refreshChat();
return;
}
@ -1684,6 +1735,7 @@
);
const handleSocketConnect = async () => {
clearTyping();
if (!$chatId || $temporaryChatEnabled) return;
joinedChatId = '';
syncChatRoom($socket, $chatId, $temporaryChatEnabled);
@ -1698,6 +1750,7 @@
window.addEventListener('message', onMessageHandler);
$socket?.on('events', chatEventHandler);
$socket?.on('connect', handleSocketConnect);
$socket?.on('disconnect', clearTyping);
$audioQueue?.destroy();
@ -1792,6 +1845,7 @@
// Clear the selected chat when leaving the chat surface (e.g. navigating
// to the admin panel), otherwise the previously-viewed chat stays selected
// in the sidebar and deleting/archiving it wrongly navigates away.
clearTyping();
if (joinedChatId)
$socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } });
joinedChatId = '';
@ -1801,6 +1855,7 @@
window.removeEventListener('message', onMessageHandler);
$socket?.off('events', chatEventHandler);
$socket?.off('connect', handleSocketConnect);
$socket?.off('disconnect', clearTyping);
dismissContextCompactionToast();
audioQueueInstance?.destroy();
audioQueue.set(null);
@ -3458,6 +3513,7 @@
bridge: bridgeRequest = null
}: { _raw?: boolean; bridge?: { userMessageId: string; modelId: string } | null } = {}
): Promise<BridgeSubmission> => {
emitTyping(false);
if (bridgeRequest && (selectedModelIds.length !== 1 || !bridgeRequest.modelId))
return { status: 'rejected' };
@ -4831,6 +4887,8 @@
class=" pb-2 {dragged ? 'z-0' : 'z-10'}"
>
<MessageInput
{typingUsers}
on:typing={(event) => emitTyping(event.detail)}
callActive={!!(bridge?.connected || bridge?.connecting)}
bind:this={messageInput}
{history}
@ -4924,6 +4982,8 @@
{/if}
<div id={embedded ? messageInputDropzoneId : undefined} class="pb-2 z-10">
<MessageInput
{typingUsers}
on:typing={(event) => emitTyping(event.detail)}
callActive={!!(bridge?.connected || bridge?.connecting)}
bind:this={messageInput}
{history}

View file

@ -117,6 +117,7 @@
import AskUserCard from './AskUserCard.svelte';
import { goto } from '$app/navigation';
import InputModal from '../common/InputModal.svelte';
import Skeleton from './Messages/Skeleton.svelte';
import Expand from '../icons/Expand.svelte';
import QueuedMessageItem from './MessageInput/QueuedMessageItem.svelte';
import TaskList from './Messages/ResponseMessage/TaskList.svelte';
@ -134,6 +135,12 @@
export let onUpload: Function = (e) => {};
export let onChange: Function = () => {};
export let typingUsers: { id: string; name: string }[] = [];
const handleTypingInput = (event: Event) => {
const input = event.target as HTMLElement;
if (input.isContentEditable) dispatch('typing', Boolean(input.textContent?.trim()));
};
export let onWebSearchToggle: Function = () => {};
export let createMessagePair: Function;
@ -1672,6 +1679,7 @@
<InputModal
bind:show={showInputModal}
oninput={handleTypingInput}
bind:value={prompt}
bind:inputContent
onChange={(content) => {
@ -1693,6 +1701,21 @@
: 'max-w-[58rem]'} w-full"
>
<div class="relative">
{#if typingUsers.length > 0}
<div
class="absolute -top-7 inset-x-0 pointer-events-none select-none bg-linear-to-t from-white dark:from-gray-900 to-transparent pb-2.5"
>
<div class="text-xs px-1 mt-1.5 flex items-center gap-1.5">
<Skeleton size="xs" />
<div class="min-w-0 truncate">
<span class="font-normal text-black dark:text-white">
{typingUsers.map((user) => user.name).join(', ')}
</span>
{$i18n.t(typingUsers.length === 1 ? 'is typing...' : 'are typing...')}
</div>
</div>
</div>
{/if}
{#if autoScroll === false && history?.currentId}
<div
class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
@ -2062,6 +2085,7 @@
: 'pt-2'
: ''}"
id="chat-input-container"
on:input={handleTypingInput}
>
{#if suggestions}
{#key $settings?.richTextInput ?? true}

View file

@ -142,12 +142,16 @@
/>
<div
class="flex w-full user-message group {!isOwn && !message?.meta?.internal ? 'mb-2' : ''}"
class="flex w-full user-message group {!isOwn && !message?.meta?.internal
? compactPreview
? 'my-2'
: 'mb-2'
: ''}"
dir={$settings.chatDirection}
id="message-{message.id}"
style="scroll-margin-top: 3rem;"
>
{#if (!($settings?.chatBubble ?? true) || !isOwn) && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
{#if !compactPreview && (!($settings?.chatBubble ?? true) || !isOwn) && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
<div
class="shrink-0 me-2 {($settings?.chatBubble ?? true) ? 'mt-6' : 'hidden @lg:flex mt-0.5'}"
>
@ -169,7 +173,7 @@
? ''
: 'pl-1'}"
>
{#if (!($settings?.chatBubble ?? true) || !isOwn) && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
{#if !compactPreview && (!($settings?.chatBubble ?? true) || !isOwn) && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
<div
class={($settings?.chatBubble ?? true)
? 'mb-1 ps-2 text-xs text-gray-400 dark:text-gray-500'

View file

@ -17,13 +17,14 @@
export let generateAutoCompletion = null;
export let onChange = () => {};
export let oninput: ((event: Event) => void) | undefined = undefined;
export let onClose = () => {};
let inputElement;
</script>
<Drawer bind:show>
<div class="flex h-full min-h-screen flex-col">
<div class="flex h-full min-h-screen flex-col" {oninput}>
<div
class=" sticky top-0 z-30 flex justify-between bg-white px-4.5 pt-3 pb-3 dark:bg-gray-900 dark:text-gray-100"
>

View file

@ -1256,6 +1256,7 @@
"Invalid JSON format in {{NAME}}": "",
"Invalid JSON format in Additional Config": "",
"Invalid JSON format in MinerU Parameters": "",
"are typing...": "",
"is typing...": "",
"Italic": "",
"January": "",