This commit is contained in:
Timothy Jaeryang Baek 2026-07-14 02:55:01 -04:00
parent d8131d1091
commit d364101761
12 changed files with 161 additions and 128 deletions

View file

@ -695,12 +695,12 @@
} else if (type === 'chat:message:favorite') {
// Update message favorite status
message.favorite = data.favorite;
} else if (type === 'chat:title') {
chatTitle.set(data);
await refreshChatList(localStorage.token);
} else if (type === 'chat:tags') {
chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token));
} else if (type === 'chat:title') {
chatTitle.set(data);
await refreshChatList(localStorage.token);
} else if (type === 'chat:tags') {
chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token));
} else if (type === 'source' || type === 'citation') {
if (data?.type === 'code_execution') {
// Code execution; update existing code execution by ID, or add new one.
@ -1759,11 +1759,11 @@
const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => {
// Backend handles outlet filters and persistence inline.
// Just refresh the sidebar chat list.
if ($chatId == _chatId && !$temporaryChatEnabled) {
await refreshChatList(localStorage.token);
}
};
// Just refresh the sidebar chat list.
if ($chatId == _chatId && !$temporaryChatEnabled) {
await refreshChatList(localStorage.token);
}
};
const chatActionHandler = async (_chatId, actionId, modelId, responseMessageId, event = null) => {
const messages = createMessagesList(history, responseMessageId);
@ -1810,12 +1810,12 @@
history: history,
params: params,
files: chatFiles
});
});
await refreshChatList(localStorage.token);
}
await refreshChatList(localStorage.token);
}
};
}
};
const getChatEventEmitter = async (modelId: string, chatId: string = '') => {
return setInterval(() => {
@ -2653,11 +2653,11 @@
// and causing spurious toast notifications / state duplication).
if (res.chat_id && $chatId !== res.chat_id && $chatId === _chatId) {
await chatId.set(res.chat_id);
if (!$temporaryChatEnabled) {
window.history.replaceState(history.state, '', `/c/${res.chat_id}`);
await refreshChatList(localStorage.token);
if (!$temporaryChatEnabled) {
window.history.replaceState(history.state, '', `/c/${res.chat_id}`);
await refreshChatList(localStorage.token);
// Persist chat-level params (system prompt, advanced
// Persist chat-level params (system prompt, advanced
// params) that the backend doesn't receive in the
// chat completion request. Files are now persisted
// by the backend at chat creation time.
@ -2930,14 +2930,14 @@
_chatId = chat.id;
await chatId.set(_chatId);
window.history.replaceState(history.state, '', `/c/${_chatId}`);
window.history.replaceState(history.state, '', `/c/${_chatId}`);
await tick();
await tick();
await refreshChatList(localStorage.token);
await refreshChatList(localStorage.token);
selectedFolder.set(null);
} else {
selectedFolder.set(null);
} else {
_chatId = `local:${$socket?.id}`; // Use socket id for temporary chat
await chatId.set(_chatId);
}
@ -3012,24 +3012,24 @@
}
);
if (res) {
await refreshChatList(localStorage.token, { refreshPinned: true });
if (res) {
await refreshChatList(localStorage.token, { refreshPinned: true });
toast.success($i18n.t('Chat moved successfully'));
}
toast.success($i18n.t('Chat moved successfully'));
}
} else {
toast.error($i18n.t('Failed to move chat'));
}
};
const archiveChatHandler = async (id: string) => {
try {
await archiveChatById(localStorage.token, id);
initNewChat();
await goto('/');
await refreshChatList(localStorage.token, { refreshPinned: true });
toast.success($i18n.t('Chat archived.'));
} catch (error) {
const archiveChatHandler = async (id: string) => {
try {
await archiveChatById(localStorage.token, id);
initNewChat();
await goto('/');
await refreshChatList(localStorage.token, { refreshPinned: true });
toast.success($i18n.t('Chat archived.'));
} catch (error) {
console.error('Error archiving chat:', error);
toast.error($i18n.t('Failed to archive chat.'));
}
@ -3058,13 +3058,13 @@
if (!id) return;
try {
const res = await deleteChatById(localStorage.token, id);
if (res) {
initNewChat();
await goto('/');
await refreshChatList(localStorage.token, { refreshPinned: true });
allTags.set(await getAllTags(localStorage.token));
toast.success($i18n.t('Chat deleted.'));
const res = await deleteChatById(localStorage.token, id);
if (res) {
initNewChat();
await goto('/');
await refreshChatList(localStorage.token, { refreshPinned: true });
allTags.set(await getAllTags(localStorage.token));
toast.success($i18n.t('Chat deleted.'));
}
} catch (error) {
console.error('Error deleting chat:', error);
@ -3183,7 +3183,6 @@
}}
{history}
title={$chatTitle}
bind:selectedModels
shareEnabled={!!history.currentId}
{initNewChat}
scrollToTop={!isNearTop ? scrollToTop : null}
@ -3214,12 +3213,12 @@
null
);
if (savedChat) {
temporaryChatEnabled.set(false);
chatId.set(savedChat.id);
await refreshChatList(localStorage.token);
if (savedChat) {
temporaryChatEnabled.set(false);
chatId.set(savedChat.id);
await refreshChatList(localStorage.token);
await goto(`/c/${savedChat.id}`);
await goto(`/c/${savedChat.id}`);
toast.success($i18n.t('Conversation saved successfully'));
}
} catch (error) {
@ -3253,7 +3252,7 @@
setInputText={(text) => {
messageInput?.setText(text);
}}
{selectedModels}
bind:selectedModels
{atSelectedModel}
{sendMessage}
{showMessage}
@ -3282,7 +3281,7 @@
bind:this={messageInput}
{history}
{taskIds}
{selectedModels}
bind:selectedModels
bind:files
bind:prompt
bind:autoScroll
@ -3365,7 +3364,7 @@
<div class="flex items-center h-full">
<Placeholder
{history}
{selectedModels}
bind:selectedModels
bind:messageInput
bind:files
bind:prompt

View file

@ -57,7 +57,7 @@
>
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model?.id}&lang=${$i18n.language}`}
class=" size-[2.7rem] rounded-full border-[1px] border-gray-100 dark:border-none"
class=" size-[2.7rem] rounded-full"
alt="logo"
draggable="false"
on:error={(e) => {

View file

@ -28,6 +28,9 @@
let showValves = getOpen('valves', false);
let showSystemPrompt = getOpen('systemPrompt');
let showAdvancedParams = getOpen('advancedParams');
const compactSectionButtonClass =
'w-full h-8 px-2.5 rounded-xl text-[13px] font-normal text-gray-600 hover:bg-gray-50/40 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 transition cursor-pointer select-none';
</script>
<div class=" dark:text-white">
@ -80,8 +83,6 @@
{/each}
</div>
</Collapsible>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
{#if $user?.role === 'admin' || ($user?.permissions.chat?.valves ?? true)}
@ -89,14 +90,12 @@
bind:open={showValves}
onChange={setOpen('valves')}
title={$i18n.t('Valves')}
buttonClassName="w-full"
buttonClassName={compactSectionButtonClass}
>
<div class="text-sm" slot="content">
<div class="px-2 pt-1 text-sm" slot="content">
<Valves show={showValves} />
</div>
</Collapsible>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
{#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)}
@ -104,21 +103,19 @@
title={$i18n.t('System Prompt')}
bind:open={showSystemPrompt}
onChange={setOpen('systemPrompt')}
buttonClassName="w-full"
buttonClassName={compactSectionButtonClass}
>
<div class="" slot="content">
<div class="px-2 pt-1" slot="content">
<textarea
bind:value={params.system}
class="w-full text-xs outline-hidden resize-vertical {$settings.highContrastMode
? 'border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 p-2.5'
: 'py-1.5 bg-transparent'}"
rows="4"
class="w-full text-sm outline-hidden resize-vertical {$settings.highContrastMode
? 'border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 px-2 py-1.5'
: 'py-1 bg-transparent'}"
rows="3"
placeholder={$i18n.t('Enter system prompt')}
/>
</div>
</Collapsible>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
{#if $user?.role === 'admin' || ($user?.permissions.chat?.params ?? true)}
@ -126,9 +123,9 @@
title={$i18n.t('Advanced Params')}
bind:open={showAdvancedParams}
onChange={setOpen('advancedParams')}
buttonClassName="w-full"
buttonClassName={compactSectionButtonClass}
>
<div class="text-sm mt-1.5" slot="content">
<div class="px-2 pt-1 text-sm" slot="content">
<div>
<AdvancedParams admin={$user?.role === 'admin'} custom={true} bind:params />
</div>

View file

@ -137,7 +137,7 @@
{#if show && !loading}
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
class="flex flex-col h-full justify-between space-y-2 text-xs"
on:submit|preventDefault={() => {
submitHandler();
dispatch('save');
@ -148,7 +148,7 @@
<div class="flex gap-2">
<div class="flex-1">
<select
class=" w-full rounded-sm text-xs py-2 px-1 bg-transparent outline-hidden"
class="w-full h-[1.6875rem] rounded-xl text-xs px-2 bg-transparent outline-hidden"
bind:value={tab}
placeholder={$i18n.t('Select')}
>
@ -161,7 +161,7 @@
<div class="flex-1">
<select
class="w-full rounded-sm py-2 px-1 text-xs bg-transparent outline-hidden"
class="w-full h-[1.6875rem] rounded-xl px-2 text-xs bg-transparent outline-hidden"
bind:value={selectedId}
on:change={async () => {
await tick();
@ -192,17 +192,17 @@
</div>
{#if selectedId}
<hr class="border-gray-50/30 dark:border-gray-800/30 my-1 w-full" />
<div class="my-2 text-xs">
<div class="my-1 text-xs">
{#if !loading}
<Valves
{valvesSpec}
bind:valves
on:change={() => {
debounceSubmitHandler();
}}
/>
<div class="chat-control-valves">
<Valves
{valvesSpec}
bind:valves
on:change={() => {
debounceSubmitHandler();
}}
/>
</div>
{:else}
<Spinner className="size-5" />
{/if}
@ -213,3 +213,12 @@
{:else}
<Spinner className="size-4" />
{/if}
<style>
.chat-control-valves :global(input),
.chat-control-valves :global(select),
.chat-control-valves :global(textarea) {
font-size: 0.75rem;
line-height: 1rem;
}
</style>

View file

@ -69,6 +69,7 @@
import InputMenu from './MessageInput/InputMenu.svelte';
import VoiceRecording from './MessageInput/VoiceRecording.svelte';
import ModelSelector from './ModelSelector.svelte';
import ToolServersModal from './ToolServersModal.svelte';
import SkillsModal from './SkillsModal.svelte';
@ -1997,11 +1998,31 @@
</button>
</Tooltip>
{/each}
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
<!-- Terminal Server Selector -->
{@const hasDirectToolServerAccess =
$_user?.role === 'admin' ||
($_user?.permissions?.features?.direct_tool_servers ?? true)}
{#if terminalCapableModels.length > 0 && (($terminalServers ?? []).some((t) => t.id) || (hasDirectToolServerAccess && (($terminalServers ?? []).some((t) => !t.id) || ($settings?.terminalServers ?? []).some((s) => s.url))))}
<TerminalMenu bind:show={showTerminalMenu} />
{/if}
{/if}
</div>
</div>
</div>
<div class="self-end flex space-x-1 mr-1 shrink-0 gap-[0.5px]">
<div class="-mr-1 flex min-w-0 max-w-[10rem] items-center sm:max-w-[13rem]">
<ModelSelector
bind:selectedModels
showSetDefault={!history?.currentId}
placement="auto"
align="end"
triggerClassName="items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] font-normal text-gray-600 transition-colors duration-100 hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
/>
</div>
{#if isActive && prompt === '' && files.length === 0}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Stop')}>
@ -2045,14 +2066,6 @@
{/if}
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
<!-- Terminal Server Selector -->
{@const hasDirectToolServerAccess =
$_user?.role === 'admin' ||
($_user?.permissions?.features?.direct_tool_servers ?? true)}
{#if terminalCapableModels.length > 0 && (($terminalServers ?? []).some((t) => t.id) || (hasDirectToolServerAccess && (($terminalServers ?? []).some((t) => !t.id) || ($settings?.terminalServers ?? []).some((s) => s.url))))}
<TerminalMenu bind:show={showTerminalMenu} />
{/if}
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true)}
<!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>

View file

@ -91,14 +91,14 @@
</script>
<div class="flex items-center translate-x-0.5">
<Dropdown bind:show align="end">
<Dropdown bind:show align="start">
<Tooltip content={$i18n.t('Terminal')} placement="top">
<button
type="button"
class="flex items-center gap-1.5 translate-y-[1px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 text-[13px] transition rounded-lg cursor-pointer {$selectedTerminalId &&
class="flex items-center gap-1.5 translate-y-[1px] text-[13px] text-gray-600 hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800/40 dark:hover:text-gray-200 transition rounded-lg cursor-pointer {$selectedTerminalId &&
selectedLabel
? ' px-2.5 py-1 '
: ' p-2 opacity-50'}"
? ' p-1 '
: ' p-1 opacity-50'}"
>
<Cloud className="size-3.5" strokeWidth="2" />

View file

@ -14,6 +14,8 @@
export let showSetDefault = true;
export let triggerClassName = 'text-lg';
export let className = undefined;
export let placement: 'top' | 'bottom' | 'auto' = 'bottom';
export let align: 'start' | 'end' = 'start';
let compareModels = selectedModels.length > 1;
@ -57,10 +59,10 @@
}
</script>
<div class="flex flex-col w-full items-start">
<div class="flex w-full max-w-fit">
<div class="overflow-hidden w-full">
<div class="max-w-full {($settings?.highContrastMode ?? false) ? 'm-1' : 'mr-1'}">
<div class="flex min-w-0 max-w-full flex-col items-start">
<div class="flex min-w-0 max-w-full">
<div class="min-w-0 max-w-full overflow-hidden">
<div class="min-w-0 max-w-full">
<Selector
id="model"
placeholder={$i18n.t('Select a model')}
@ -72,6 +74,8 @@
{pinModelHandler}
{className}
{triggerClassName}
{placement}
{align}
{showSetDefault}
onSetDefault={saveDefaultModel}
multipleEnabled={$user?.role === 'admin' ||

View file

@ -65,6 +65,8 @@
export let className = 'w-[22rem]';
export let triggerClassName = 'text-lg';
export let placement: 'top' | 'bottom' | 'auto' = 'bottom';
export let align: 'start' | 'end' = 'start';
export let showSetDefault = false;
export let onSetDefault: () => Promise<void> | void = () => {};
@ -87,20 +89,36 @@
const updatePosition = () => {
if (!show || !triggerElement) return;
const rect = triggerElement.getBoundingClientRect();
const contentRect = contentElement?.getBoundingClientRect();
const contentWidth = contentRect?.width ?? 0;
const contentHeight = contentRect?.height ?? 0;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
const resolvedPlacement =
placement === 'auto'
? contentHeight && spaceBelow < contentHeight && spaceAbove > spaceBelow
? 'top'
: 'bottom'
: placement;
dropdownPosition = {
top: rect.bottom + 2,
left: $mobile ? 8 : rect.left,
top:
resolvedPlacement === 'top' && contentHeight
? rect.top - contentHeight - 2
: rect.bottom + 2,
left: $mobile ? 8 : align === 'end' && contentWidth ? rect.right - contentWidth : rect.left,
width: $mobile ? window.innerWidth - 16 : 0
};
};
const toggleOpen = () => {
const toggleOpen = async () => {
show = !show;
if (show) {
searchValue = '';
listScrollTop = 0;
resetView();
updatePosition();
await tick();
updatePosition();
window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
} else {
document.getElementById(`model-selector-${id}-button`)?.blur();
@ -609,7 +627,7 @@
on:click={toggleOpen}
>
<div
class="flex w-full text-left px-0.5 bg-transparent truncate {triggerClassName} justify-between {($settings?.highContrastMode ??
class="flex w-full min-w-0 text-left px-0.5 bg-transparent {triggerClassName} justify-between {($settings?.highContrastMode ??
false)
? 'dark:placeholder-gray-100 placeholder-gray-800'
: 'placeholder-gray-400'}"
@ -622,8 +640,8 @@
);
}}
>
{triggerLabel}
<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
<span class="min-w-0 flex-1 truncate">{triggerLabel}</span>
<ChevronDown className="ml-1.5 size-2.5 shrink-0 self-center" strokeWidth="2.5" />
</div>
</button>

View file

@ -21,7 +21,6 @@
import { goto } from '$app/navigation';
import ShareChatModal from '../chat/ShareChatModal.svelte';
import ModelSelector from '../chat/ModelSelector.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Menu from '$lib/components/layout/Navbar/Menu.svelte';
import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
@ -50,9 +49,7 @@
export let chat;
export let history;
export let selectedModels;
export let showModelSelector = true;
export let title = '';
export let onSaveTempChat: () => {};
export let archiveChatHandler: (id: string) => void;
export let deleteChatHandler: (id: string) => void;
@ -117,18 +114,15 @@
</div>
{/if}
<div
class="flex-1 overflow-hidden max-w-full mt-0.5 py-0.5
{$showSidebar ? 'ml-1' : ''}
"
>
{#if showModelSelector}
<ModelSelector
bind:selectedModels
showSetDefault={!shareEnabled && !readOnly}
disabled={readOnly}
triggerClassName="text-[17px]"
/>
<div class="flex-1 overflow-hidden max-w-full mt-0.5 py-0.5 {$showSidebar ? 'ml-1' : ''}">
{#if chat?.id}
<button
type="button"
class="max-w-full truncate rounded-lg px-2 py-1 text-left text-[13px] font-medium text-gray-700 transition-colors duration-100 hover:bg-gray-50/40 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800/40 dark:hover:text-gray-100"
on:click={() => scrollToTop?.()}
>
{title || chat?.chat?.title || $i18n.t('New Chat')}
</button>
{/if}
</div>

View file

@ -78,7 +78,6 @@
$selectedFolder != null &&
$selectedFolder.user_id !== $user?.id &&
$selectedFolder.permission !== 'write';
</script>
<div class="m-auto w-full max-w-6xl px-2 @2xl:px-20 translate-y-6 py-24 text-center">
@ -133,7 +132,7 @@
>
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model?.id}&lang=${$i18n.language}`}
class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
class=" size-9 @sm:size-10 rounded-full"
aria-hidden="true"
draggable="false"
on:error={(e) => {
@ -219,7 +218,7 @@
<MessageInput
bind:this={messageInput}
{history}
{selectedModels}
bind:selectedModels
bind:files
bind:prompt
bind:autoScroll

View file

@ -119,9 +119,9 @@
{#if !disabled}
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
<ChevronUp strokeWidth="2.75" className="size-3" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
<ChevronDown strokeWidth="2.75" className="size-3" />
{/if}
</div>
{/if}
@ -144,9 +144,9 @@
{#if chevron}
<div class="flex self-start translate-y-1">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
<ChevronUp strokeWidth="2.75" className="size-3" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
<ChevronDown strokeWidth="2.75" className="size-3" />
{/if}
</div>
{/if}

View file

@ -54,7 +54,7 @@
<XMark className="size-3.5" />
</button>
{:else}
<ChevronDown className=" size-3.5" strokeWidth="2.5" />
<ChevronDown className="size-2.5" strokeWidth="2.5" />
{/if}
</svelte:fragment>