Merge branch 'dev' into amberyufangchiu-dev

This commit is contained in:
Tim Jaeryang Baek
2025-07-10 10:33:12 +04:00
committed by GitHub
286 changed files with 11342 additions and 5304 deletions
+143 -3
View File
@@ -65,19 +65,23 @@ textarea::placeholder {
}
.input-prose {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
}
.input-prose-sm {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line text-sm;
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
}
.markdown-prose {
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown-prose-sm {
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown a {
@@ -326,6 +330,138 @@ input[type='number'] {
@apply line-clamp-1 absolute;
}
.tiptap ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
padding: 0;
li {
align-items: start;
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
margin-top: 0.2rem;
user-select: none;
display: flex;
}
> div {
flex: 1 1 auto;
align-items: center;
}
}
/* checked data-checked="true" */
li[data-checked='true'] {
> div {
opacity: 0.5;
text-decoration: line-through;
}
}
input[type='checkbox'] {
cursor: pointer;
}
ul[data-type='taskList'] {
margin: 0;
}
/* Reset nested regular ul elements to default styling */
ul:not([data-type='taskList']) {
list-style: disc;
padding-left: 1rem;
li {
align-items: initial;
display: list-item;
label {
flex: initial;
margin-right: initial;
margin-top: initial;
user-select: initial;
display: initial;
}
div {
flex: initial;
align-items: initial;
}
}
}
}
.input-prose .tiptap ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
padding: 0;
li {
align-items: start;
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
margin-top: 0.4rem;
user-select: none;
display: flex;
}
> div {
flex: 1 1 auto;
align-items: center;
}
}
/* checked data-checked="true" */
li[data-checked='true'] {
> div {
opacity: 0.5;
text-decoration: line-through;
}
}
input[type='checkbox'] {
cursor: pointer;
}
ul[data-type='taskList'] {
margin: 0;
}
/* Reset nested regular ul elements to default styling */
ul:not([data-type='taskList']) {
list-style: disc;
padding-left: 1rem;
li {
align-items: initial;
display: list-item;
label {
flex: initial;
margin-right: initial;
margin-top: initial;
user-select: initial;
display: initial;
}
div {
flex: initial;
align-items: initial;
}
}
}
}
@media (prefers-color-scheme: dark) {
.ProseMirror p.is-editor-empty:first-child::before {
color: #757575;
@@ -442,3 +578,7 @@ input[type='number'] {
.tiptap tr {
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
}
.tippy-box[data-theme~='transparent'] {
@apply bg-transparent p-0 m-0;
}
+10 -33
View File
@@ -77,28 +77,18 @@
}
}
});
const isDarkMode = document.documentElement.classList.contains('dark');
function setSplashImage() {
const logo = document.getElementById('logo');
const isDarkMode = document.documentElement.classList.contains('dark');
const logo = document.createElement('img');
logo.id = 'logo';
logo.style =
'position: absolute; width: auto; height: 6rem; top: 44%; left: 50%; transform: translateX(-50%); display:block;';
logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
if (isDarkMode) {
const darkImage = new Image();
darkImage.src = '/static/splash-dark.png';
darkImage.onload = () => {
logo.src = '/static/splash-dark.png';
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
};
darkImage.onerror = () => {
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
};
}
}
// Runs after classes are assigned
window.onload = setSplashImage;
document.addEventListener('DOMContentLoaded', function () {
const splash = document.getElementById('splash-screen');
if (splash) splash.prepend(logo);
});
})();
</script>
@@ -120,19 +110,6 @@
}
</style>
<img
id="logo"
style="
position: absolute;
width: auto;
height: 6rem;
top: 44%;
left: 50%;
transform: translateX(-50%);
"
src="/static/splash.png"
/>
<div
style="
position: absolute;
+2
View File
@@ -347,6 +347,8 @@ export const userSignOut = async () => {
if (error) {
throw error;
}
sessionStorage.clear();
return res;
};
+6 -2
View File
@@ -37,7 +37,9 @@ export const importChat = async (
chat: object,
meta: object | null,
pinned?: boolean,
folderId?: string | null
folderId?: string | null,
createdAt: number | null = null,
updatedAt: number | null = null
) => {
let error = null;
@@ -52,7 +54,9 @@ export const importChat = async (
chat: chat,
meta: meta ?? {},
pinned: pinned,
folder_id: folderId
folder_id: folderId,
created_at: createdAt ?? null,
updated_at: updatedAt ?? null
})
})
.then(async (res) => {
+4 -4
View File
@@ -58,10 +58,10 @@ export const exportConfig = async (token: string) => {
return res;
};
export const getDirectConnectionsConfig = async (token: string) => {
export const getConnectionsConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -85,10 +85,10 @@ export const getDirectConnectionsConfig = async (token: string) => {
return res;
};
export const setDirectConnectionsConfig = async (token: string, config: object) => {
export const setConnectionsConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+18 -8
View File
@@ -8,17 +8,26 @@ import { toast } from 'svelte-sonner';
export const getModels = async (
token: string = '',
connections: object | null = null,
base: boolean = false
base: boolean = false,
refresh: boolean = false
) => {
const searchParams = new URLSearchParams();
if (refresh) {
searchParams.append('refresh', 'true');
}
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
const res = await fetch(
`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}?${searchParams.toString()}`,
{
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();
@@ -1587,6 +1596,7 @@ export interface ModelConfig {
}
export interface ModelMeta {
toolIds: never[];
description?: string;
capabilities?: object;
profile_image_url?: string;
+36 -1
View File
@@ -39,7 +39,7 @@ export const createNewNote = async (token: string, note: NoteItem) => {
return res;
};
export const getNotes = async (token: string = '') => {
export const getNotes = async (token: string = '', raw: boolean = false) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
@@ -67,6 +67,10 @@ export const getNotes = async (token: string = '') => {
throw error;
}
if (raw) {
return res; // Return raw response if requested
}
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
@@ -87,6 +91,37 @@ export const getNotes = async (token: string = '') => {
return grouped;
};
export const getNoteList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, {
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.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNoteById = async (token: string, id: string) => {
let error = null;
+2 -2
View File
@@ -366,7 +366,7 @@ export const unloadModel = async (token: string, tagName: string) => {
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: tagName
model: tagName
})
}).catch((err) => {
error = err;
@@ -419,7 +419,7 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: tagName
model: tagName
})
}
)
+1
View File
@@ -403,6 +403,7 @@ export const deleteUserById = async (token: string, userId: string) => {
};
type UserUpdateForm = {
role: string;
profile_image_url: string;
email: string;
name: string;
+4 -34
View File
@@ -15,6 +15,8 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
@@ -208,17 +210,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -524,29 +516,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
+49 -47
View File
@@ -3,6 +3,7 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
@@ -14,6 +15,8 @@
import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection } from '$lib/apis/configs';
import AccessControl from './workspace/common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
@@ -153,29 +156,21 @@
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
<h1 class=" text-lg font-medium self-center font-primary">
{#if edit}
{$i18n.t('Edit Connection')}
{:else}
{$i18n.t('Add Connection')}
{/if}
</div>
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close Configure Connection Modal')}
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -192,12 +187,17 @@
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div class=" text-xs text-gray-500">{$i18n.t('URL')}</div>
<label
for="api-base-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
class="w-full flex-1 text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
id="api-base-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
@@ -214,6 +214,7 @@
on:click={() => {
verifyHandler();
}}
aria-label={$i18n.t('Verify Connection')}
type="button"
>
<svg
@@ -221,6 +222,7 @@
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
aria-hidden="true"
>
<path
fill-rule="evenodd"
@@ -237,9 +239,13 @@
</div>
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
@@ -249,7 +255,9 @@
</div>
</div>
<div class="text-xs text-gray-500 mt-1">
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
@@ -257,12 +265,17 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class=" text-xs text-gray-500">{$i18n.t('Auth')}</div>
<label
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Auth')}</label
>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class="w-full text-sm bg-transparent dark:bg-gray-900 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden pr-5"
id="select-bearer-or-session"
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<option value="bearer">Bearer</option>
@@ -273,13 +286,14 @@
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'session'}
<div class="text-xs text-gray-500 self-center translate-y-[1px]">
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{/if}
@@ -293,11 +307,16 @@
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<label
for="enter-name"
class={`mb-0.5 text-xs" ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}</label
>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
id="enter-name"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
@@ -309,11 +328,16 @@
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<label
for="description"
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
>{$i18n.t('Description')}</label
>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
id="description"
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
@@ -357,29 +381,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
+4 -10
View File
@@ -9,6 +9,7 @@
import Modal from './common/Modal.svelte';
import { updateUserSettings } from '$lib/apis/users';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
@@ -36,18 +37,11 @@
localStorage.version = $config.version;
show = false;
}}
aria-label={$i18n.t('Close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<XMark className={'size-5'}>
<p class="sr-only">{$i18n.t('Close')}</p>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</XMark>
</button>
</div>
<div class="flex items-center mt-1">
+4 -33
View File
@@ -3,7 +3,9 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { extractFrontmatter } from '$lib/utils';
export let show = false;
@@ -69,16 +71,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -120,29 +113,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants';
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
import DOMPurify from 'dompurify';
@@ -38,7 +39,7 @@
}}
>
<div class="shrink-0 self-top -translate-y-0.5">
<img src={'/static/favicon.png'} alt="favicon" class="size-7 rounded-full" />
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-7 rounded-full" />
</div>
<div>
+2 -2
View File
@@ -19,10 +19,10 @@
if (isDarkMode) {
const darkImage = new Image();
darkImage.src = '/static/favicon-dark.png';
darkImage.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
darkImage.onload = () => {
logo.src = '/static/favicon-dark.png';
logo.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
};
@@ -2,16 +2,41 @@
import Modal from '$lib/components/common/Modal.svelte';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
import { getFeedbackById } from '$lib/apis/evaluations';
import { toast } from 'svelte-sonner';
import Spinner from '$lib/components/common/Spinner.svelte';
export let show = false;
export let selectedFeedback = null;
export let onClose: () => void = () => {};
let loaded = false;
let feedbackData = null;
const close = () => {
show = false;
onClose();
};
const init = async () => {
loaded = false;
feedbackData = null;
if (selectedFeedback) {
feedbackData = await getFeedbackById(localStorage.token, selectedFeedback.id).catch((err) => {
return null;
});
console.log('Feedback Data:', selectedFeedback, feedbackData);
}
loaded = true;
};
$: if (show) {
init();
}
</script>
<Modal size="sm" bind:show>
@@ -22,58 +47,89 @@
{$i18n.t('Feedback Details')}
</div>
<button class="self-center" on:click={close} aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col w-full">
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
{#if loaded}
<div class="flex flex-col w-full">
{#if feedbackData}
{@const messageId = feedbackData?.meta?.message_id}
{@const messages = feedbackData?.snapshot?.chat?.chat?.history.messages}
<div class="flex-1">
<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
</div>
</div>
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
{#if messages[messages[messageId]?.parentId]}
<div class="flex flex-col w-full mb-2">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Prompt')}</div>
<div class="flex-1">
<span>{selectedFeedback?.data?.reason || '-'}</span>
</div>
</div>
<div class="flex-1 text-xs whitespace-pre-line break-words">
<span>{messages[messages[messageId]?.parentId]?.content || '-'}</span>
</div>
</div>
{/if}
<div class="mb-2">
{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length}
<div class="flex flex-wrap gap-1 mt-1">
{#each selectedFeedback?.data?.tags as tag}
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">{tag}</span
{#if messages[messageId]}
<div class="flex flex-col w-full mb-2">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Response')}</div>
<div
class="flex-1 text-xs whitespace-pre-line break-words max-h-32 overflow-y-auto"
>
{/each}
</div>
{:else}
<span>-</span>
<span>{messages[messageId]?.content || '-'}</span>
</div>
</div>
{/if}
{/if}
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Rating')}</div>
<div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.details?.rating ?? '-'}</span>
</div>
</div>
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Reason')}</div>
<div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.reason || '-'}</span>
</div>
</div>
<div class="flex flex-col w-full mb-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Comment')}</div>
<div class="flex-1 text-xs">
<span>{selectedFeedback?.data?.comment || '-'}</span>
</div>
</div>
{#if selectedFeedback?.data?.tags && selectedFeedback?.data?.tags.length}
<div class="mb-2 -mx-1">
<div class="flex flex-wrap gap-1 mt-1">
{#each selectedFeedback?.data?.tags as tag}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-[9px]"
>{tag}</span
>
{/each}
</div>
</div>
{/if}
<div class="flex justify-end pt-2">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
on:click={close}
>
{$i18n.t('Close')}
</button>
</div>
</div>
<div class="flex justify-end pt-3">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
on:click={close}
>
{$i18n.t('Close')}
</button>
{:else}
<div class="flex items-center justify-center w-full h-32">
<Spinner className={'size-5'} />
</div>
</div>
{/if}
</div>
</div>
{/if}
@@ -23,6 +23,7 @@
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
export let feedbacks = [];
@@ -305,7 +306,7 @@
<tbody class="">
{#each paginatedFeedbacks as feedback (feedback.id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openFeedbackModal(feedback)}
>
<td class=" py-0.5 text-right font-semibold">
@@ -313,7 +314,7 @@
<Tooltip content={feedback?.user?.name}>
<div class="shrink-0">
<img
src={feedback?.user?.profile_image_url ?? '/user.png'}
src={feedback?.user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`}
alt={feedback?.user?.name}
class="size-5 rounded-full object-cover shrink-0"
/>
@@ -369,7 +370,7 @@
{dayjs(feedback.updated_at * 1000).fromNow()}
</td>
<td class=" px-3 py-1 text-right font-semibold">
<td class=" px-3 py-1 text-right font-semibold" on:click={(e) => e.stopPropagation()}>
<FeedbackMenu
on:delete={(e) => {
deleteFeedbackHandler(feedback.id);
@@ -11,10 +11,11 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte';
import Search from '$lib/components/icons/Search.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
@@ -77,7 +78,7 @@
let showLeaderboardModal = false;
let selectedModel = null;
const openFeedbackModal = (model) => {
const openLeaderboardModelModal = (model) => {
showLeaderboardModal = true;
selectedModel = model;
};
@@ -350,7 +351,7 @@
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<MagnifyingGlass className="size-3" />
<Search className="size-3" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
@@ -371,7 +372,7 @@
{#if loadingLeaderboard}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
<div class="m-auto">
<Spinner />
<Spinner className="size-5" />
</div>
</div>
{/if}
@@ -504,8 +505,8 @@
<tbody class="">
{#each sortedModels as model, modelIdx (model.id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition"
on:click={() => openFeedbackModal(model)}
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openLeaderboardModelModal(model)}
>
<td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
<div class=" line-clamp-1">
@@ -516,7 +517,7 @@
<div class="flex items-center gap-2">
<div class="shrink-0">
<img
src={model?.info?.meta?.profile_image_url ?? '/favicon.png'}
src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/favicon.png`}
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
/>
@@ -6,6 +6,7 @@
export let feedbacks = [];
export let onClose: () => void = () => {};
const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
const close = () => {
show = false;
@@ -37,25 +38,16 @@
{model.name}
</div>
<button class="self-center" on:click={close} aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
<div class="px-5 pb-4 dark:text-gray-200">
<div class="mb-2">
{#if topTags.length}
<div class="flex flex-wrap gap-1 mt-1">
<div class="flex flex-wrap gap-1 mt-1 -mx-1">
{#each topTags as tagInfo}
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-xs">
{tagInfo.tag} <span class="text-gray-500">({tagInfo.count})</span>
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
</span>
{/each}
</div>
@@ -63,7 +55,7 @@
<span>-</span>
{/if}
</div>
<div class="flex justify-end pt-3">
<div class="flex justify-end pt-2">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
+66 -11
View File
@@ -49,6 +49,8 @@
let showConfirm = false;
let query = '';
let selectedType = 'all';
let showManifestModal = false;
let showValvesModal = false;
let selectedFunction = null;
@@ -59,9 +61,10 @@
$: filteredItems = $functions
.filter(
(f) =>
query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase())
(selectedType !== 'all' ? f.type === selectedType : true) &&
(query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase()))
)
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
@@ -135,7 +138,9 @@
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
}
@@ -161,7 +166,9 @@
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
}
@@ -215,8 +222,8 @@
}}
/>
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex flex-col mt-1.5 mb-0.5">
<div class="flex justify-between items-center mb-1">
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
{$i18n.t('Functions')}
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
@@ -266,12 +273,54 @@
</AddFunctionMenu>
</div>
</div>
<div class=" flex w-full">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent"
>
<button
class="min-w-fit p-1.5 {selectedType === 'all'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'all';
}}>{$i18n.t('All')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'pipe'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'pipe';
}}>{$i18n.t('Pipe')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'filter'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'filter';
}}>{$i18n.t('Filter')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'action'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'action';
}}>{$i18n.t('Action')}</button
>
</div>
</div>
</div>
<div class="mb-5">
{#each filteredItems as func (func.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
@@ -413,7 +462,9 @@
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
($settings?.directConnections ?? null),
false,
true
)
);
}}
@@ -559,7 +610,9 @@
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
}}
@@ -585,7 +638,9 @@
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
};
+2 -27
View File
@@ -12,6 +12,7 @@
} from '$lib/apis/audio';
import { config, settings } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import { TTS_RESPONSE_SPLIT } from '$lib/types';
@@ -373,33 +374,7 @@
>
{#if STT_WHISPER_MODEL_LOADING}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
<Spinner />
</div>
{:else}
<svg
@@ -7,7 +7,7 @@
import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama';
import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai';
import { getModels as _getModels } from '$lib/apis';
import { getDirectConnectionsConfig, setDirectConnectionsConfig } from '$lib/apis/configs';
import { getConnectionsConfig, setConnectionsConfig } from '$lib/apis/configs';
import { config, models, settings, user } from '$lib/stores';
@@ -25,7 +25,9 @@
const getModels = async () => {
const models = await _getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
);
return models;
};
@@ -41,7 +43,7 @@
let ENABLE_OPENAI_API: null | boolean = null;
let ENABLE_OLLAMA_API: null | boolean = null;
let directConnectionsConfig = null;
let connectionsConfig = null;
let pipelineUrls = {};
let showAddOpenAIConnectionModal = false;
@@ -104,15 +106,13 @@
}
};
const updateDirectConnectionsHandler = async () => {
const res = await setDirectConnectionsConfig(localStorage.token, directConnectionsConfig).catch(
(error) => {
toast.error(`${error}`);
}
);
const updateConnectionsHandler = async () => {
const res = await setConnectionsConfig(localStorage.token, connectionsConfig).catch((error) => {
toast.error(`${error}`);
});
if (res) {
toast.success($i18n.t('Direct Connections settings updated'));
toast.success($i18n.t('Connections settings updated'));
await models.set(await getModels());
}
};
@@ -148,7 +148,7 @@
openaiConfig = await getOpenAIConfig(localStorage.token);
})(),
(async () => {
directConnectionsConfig = await getDirectConnectionsConfig(localStorage.token);
connectionsConfig = await getConnectionsConfig(localStorage.token);
})()
]);
@@ -215,36 +215,103 @@
<form class="flex flex-col h-full justify-between text-sm" on:submit|preventDefault={submitHandler}>
<div class=" overflow-y-scroll scrollbar-hidden h-full">
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && directConnectionsConfig !== null}
<div class="my-2">
<div class="mt-2 space-y-2 pr-1.5">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
{#if ENABLE_OPENAI_API !== null && ENABLE_OLLAMA_API !== null && connectionsConfig !== null}
<div class="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<div class="flex items-center">
<div class="">
<Switch
bind:state={ENABLE_OPENAI_API}
on:change={async () => {
updateOpenAIHandler();
}}
/>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="my-2">
<div class="mt-2 space-y-2">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('OpenAI API')}</div>
<div class="flex items-center">
<div class="">
<Switch
bind:state={ENABLE_OPENAI_API}
on:change={async () => {
updateOpenAIHandler();
}}
/>
</div>
</div>
</div>
{#if ENABLE_OPENAI_API}
<div class="">
<div class="flex justify-between items-center">
<div class="font-medium text-xs">{$i18n.t('Manage OpenAI API Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showAddOpenAIConnectionModal = true;
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5 mt-1.5">
{#each OPENAI_API_BASE_URLS as url, idx}
<OpenAIConnection
pipeline={pipelineUrls[url] ? true : false}
bind:url
bind:key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[idx]}
onSubmit={() => {
updateOpenAIHandler();
}}
onDelete={() => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
let newConfig = {};
OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] =
OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OPENAI_API_CONFIGS = newConfig;
updateOpenAIHandler();
}}
/>
{/each}
</div>
</div>
{/if}
</div>
</div>
<div class=" my-2">
<div class="flex justify-between items-center text-sm mb-2">
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
<div class="mt-1">
<Switch
bind:state={ENABLE_OLLAMA_API}
on:change={async () => {
updateOllamaHandler();
}}
/>
</div>
</div>
{#if ENABLE_OPENAI_API}
<hr class=" border-gray-100 dark:border-gray-850" />
{#if ENABLE_OLLAMA_API}
<div class="">
<div class="flex justify-between items-center">
<div class="font-medium">{$i18n.t('Manage OpenAI API Connections')}</div>
<div class="font-medium text-xs">{$i18n.t('Manage Ollama API Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showAddOpenAIConnectionModal = true;
showAddOllamaConnectionModal = true;
}}
type="button"
>
@@ -253,133 +320,89 @@
</Tooltip>
</div>
<div class="flex flex-col gap-1.5 mt-1.5">
{#each OPENAI_API_BASE_URLS as url, idx}
<OpenAIConnection
pipeline={pipelineUrls[url] ? true : false}
bind:url
bind:key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[idx]}
onSubmit={() => {
updateOpenAIHandler();
}}
onDelete={() => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
<div class="flex w-full gap-1.5">
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
{#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection
bind:url
bind:config={OLLAMA_API_CONFIGS[idx]}
{idx}
onSubmit={() => {
updateOllamaHandler();
}}
onDelete={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
let newConfig = {};
OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OPENAI_API_CONFIGS = newConfig;
updateOpenAIHandler();
}}
/>
{/each}
let newConfig = {};
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] =
OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OLLAMA_API_CONFIGS = newConfig;
}}
/>
{/each}
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Trouble accessing Ollama?')}
<a
class=" text-gray-300 font-medium underline"
href="https://github.com/open-webui/open-webui#troubleshooting"
target="_blank"
>
{$i18n.t('Click here for help.')}
</a>
</div>
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="my-2">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('Direct Connections')}</div>
<div class="pr-1.5 my-2">
<div class="flex justify-between items-center text-sm mb-2">
<div class=" font-medium">{$i18n.t('Ollama API')}</div>
<div class="mt-1">
<Switch
bind:state={ENABLE_OLLAMA_API}
on:change={async () => {
updateOllamaHandler();
}}
/>
</div>
</div>
{#if ENABLE_OLLAMA_API}
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="">
<div class="flex justify-between items-center">
<div class="font-medium">{$i18n.t('Manage Ollama API Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showAddOllamaConnectionModal = true;
<div class="flex items-center">
<div class="">
<Switch
bind:state={connectionsConfig.ENABLE_DIRECT_CONNECTIONS}
on:change={async () => {
updateConnectionsHandler();
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex w-full gap-1.5">
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
{#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection
bind:url
bind:config={OLLAMA_API_CONFIGS[idx]}
{idx}
onSubmit={() => {
updateOllamaHandler();
}}
onDelete={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
let newConfig = {};
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OLLAMA_API_CONFIGS = newConfig;
}}
/>
{/each}
/>
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Trouble accessing Ollama?')}
<a
class=" text-gray-300 font-medium underline"
href="https://github.com/open-webui/open-webui#troubleshooting"
target="_blank"
>
{$i18n.t('Click here for help.')}
</a>
</div>
</div>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="pr-1.5 my-2">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('Direct Connections')}</div>
<div class="flex items-center">
<div class="">
<Switch
bind:state={directConnectionsConfig.ENABLE_DIRECT_CONNECTIONS}
on:change={async () => {
updateDirectConnectionsHandler();
}}
/>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
)}
</div>
</div>
<div class="mt-1.5">
<div class="text-xs text-gray-500">
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="my-2">
<div class="flex justify-between items-center text-sm">
<div class=" text-xs font-medium">{$i18n.t('Cache Base Model List')}</div>
<div class="flex items-center">
<div class="">
<Switch
bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
on:change={async () => {
updateConnectionsHandler();
}}
/>
</div>
</div>
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
'Base Model List Cache speeds up access by fetching base models only at startup or on settings save—faster, but may not show recent base model changes.'
)}
</div>
</div>
@@ -5,6 +5,7 @@
import Modal from '$lib/components/common/Modal.svelte';
import ManageOllama from '../Models/Manage/ManageOllama.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
export let urlIdx: number | null = null;
@@ -26,16 +27,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -90,10 +90,6 @@
return;
}
if (embeddingEngine === 'openai' && (OpenAIKey === '' || OpenAIUrl === '')) {
toast.error($i18n.t('OpenAI URL/Key required.'));
return;
}
if (
embeddingEngine === 'azure_openai' &&
(AzureOpenAIKey === '' || AzureOpenAIUrl === '' || AzureOpenAIVersion === '')
@@ -643,6 +639,7 @@
>
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
<option value="markdown_header">{$i18n.t('Markdown (Header)')}</option>
</select>
</div>
</div>
@@ -731,7 +728,11 @@
required
/>
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={OpenAIKey} />
<SensitiveInput
placeholder={$i18n.t('API Key')}
bind:value={OpenAIKey}
required={false}
/>
</div>
{:else if embeddingEngine === 'ollama'}
<div class="my-0.5 flex gap-2 pr-2">
@@ -808,33 +809,7 @@
>
{#if updateEmbeddingModelLoading}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
<Spinner />
</div>
{:else}
<svg
@@ -1272,7 +1247,7 @@
</div>
{:else}
<div class="flex items-center justify-center h-full">
<Spinner />
<Spinner className="size-5" />
</div>
{/if}
</form>
@@ -3,6 +3,7 @@
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import { models } from '$lib/stores';
import Plus from '$lib/components/icons/Plus.svelte';
@@ -11,6 +12,8 @@
import { toast } from 'svelte-sonner';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
export let show = false;
export let edit = false;
@@ -34,7 +37,7 @@
}
};
let profileImageUrl = '/favicon.png';
let profileImageUrl = `${WEBUI_BASE_URL}/favicon.png`;
let description = '';
let selectedModelId = '';
@@ -90,7 +93,7 @@
name = '';
id = '';
profileImageUrl = '/favicon.png';
profileImageUrl = `${WEBUI_BASE_URL}/favicon.png`;
description = '';
modelIds = [];
selectedModelId = '';
@@ -141,16 +144,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -406,29 +400,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
@@ -90,7 +90,9 @@
};
onMount(async () => {
checkForVersionUpdates();
if ($config?.features?.enable_version_update_check) {
checkForVersionUpdates();
}
await Promise.all([
(async () => {
@@ -137,16 +139,18 @@
v{WEBUI_VERSION}
</Tooltip>
<a
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
target="_blank"
>
{updateAvailable === null
? $i18n.t('Checking for updates...')
: updateAvailable
? `(v${version.latest} ${$i18n.t('available!')})`
: $i18n.t('(latest)')}
</a>
{#if $config?.features?.enable_version_update_check}
<a
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
target="_blank"
>
{updateAvailable === null
? $i18n.t('Checking for updates...')
: updateAvailable
? `(v${version.latest} ${$i18n.t('available!')})`
: $i18n.t('(latest)')}
</a>
{/if}
</div>
<button
@@ -160,15 +164,17 @@
</button>
</div>
<button
class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
type="button"
on:click={() => {
checkForVersionUpdates();
}}
>
{$i18n.t('Check for updates')}
</button>
{#if $config?.features?.enable_version_update_check}
<button
class=" text-xs px-3 py-1.5 bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
type="button"
on:click={() => {
checkForVersionUpdates();
}}
>
{$i18n.t('Check for updates')}
</button>
{/if}
</div>
</div>
@@ -13,9 +13,11 @@
updateConfig,
verifyConfigUrl
} from '$lib/apis/images';
import Spinner from '$lib/components/common/Spinner.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@@ -504,7 +506,7 @@
<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
{#if config.comfyui.COMFYUI_WORKFLOW}
<textarea
<Textarea
class="w-full rounded-lg mb-1 py-2 px-4 text-xs bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden disabled:text-gray-600 resize-none"
rows="10"
bind:value={config.comfyui.COMFYUI_WORKFLOW}
@@ -533,7 +535,7 @@
/>
<button
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-50 border border-dashed border-gray-50 dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
type="button"
on:click={() => {
document.getElementById('upload-comfyui-workflow-input')?.click();
@@ -555,10 +557,10 @@
<div class="text-xs flex flex-col gap-1.5">
{#each requiredWorkflowNodes as node}
<div class="flex w-full items-center border dark:border-gray-850 rounded-lg">
<div class="flex w-full items-center">
<div class="shrink-0">
<div
class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center rounded-l-lg bg-green-500/10 text-green-700 dark:text-green-200"
class=" capitalize line-clamp-1 font-medium px-3 py-1 w-20 text-center bg-green-500/10 text-green-700 dark:text-green-200"
>
{node.type}{node.type === 'prompt' ? '*' : ''}
</div>
@@ -566,7 +568,7 @@
<div class="">
<Tooltip content="Input Key (e.g. text, unet_name, steps)">
<input
class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r dark:border-gray-850"
class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r border-gray-50 dark:border-gray-850"
placeholder="Key"
bind:value={node.key}
required
@@ -580,7 +582,7 @@
placement="top-start"
>
<input
class="w-full py-1 px-4 rounded-r-lg text-xs bg-transparent outline-hidden"
class="w-full py-1 px-4 text-xs bg-transparent outline-hidden"
placeholder="Node Ids"
bind:value={node.node_ids}
/>
@@ -711,29 +713,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
@@ -390,7 +390,7 @@
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-sm">
<div class=" self-center text-xs">
{$i18n.t('Banners')}
</div>
@@ -432,7 +432,7 @@
{#if $user?.role === 'admin'}
<div class=" space-y-3">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm">
<div class=" self-center text-xs">
{$i18n.t('Default Prompt Suggestions')}
</div>
@@ -636,6 +636,6 @@
</form>
{:else}
<div class=" h-full w-full flex justify-center items-center">
<Spinner />
<Spinner className="size-5" />
</div>
{/if}
@@ -1,7 +1,9 @@
<script lang="ts">
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Sortable from 'sortablejs';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
@@ -23,6 +25,13 @@
});
};
const classNames: Record<string, string> = {
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
};
$: if (banners) {
init();
}
@@ -44,14 +53,14 @@
};
</script>
<div class=" flex flex-col space-y-0.5" bind:this={bannerListElement}>
<div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)}
<div class=" flex justify-between items-center -ml-1" id="banner-item-{banner.id}">
<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
<EllipsisVertical className="size-4 cursor-move item-handle" />
<div class="flex flex-row flex-1 gap-2 items-center">
<div class="flex flex-row flex-1 gap-2 items-start">
<select
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden text-left pl-1 pr-2"
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
bind:value={banner.type}
required
>
@@ -64,14 +73,15 @@
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
<Textarea
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
maxSize={100}
/>
<div class="relative -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Tooltip content={$i18n.t('Remember Dismissal')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
@@ -85,16 +95,7 @@
banners = banners;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-4'} />
</button>
</div>
{/each}
@@ -14,6 +14,8 @@
toggleModelById,
updateModelById
} from '$lib/apis/models';
import { copyToClipboard } from '$lib/utils';
import { page } from '$app/stores';
import { getModels } from '$lib/apis';
import Search from '$lib/components/icons/Search.svelte';
@@ -34,7 +36,7 @@
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
import { copyToClipboard } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
let shiftKey = false;
@@ -205,6 +207,11 @@
onMount(async () => {
await init();
const id = $page.url.searchParams.get('id');
if (id) {
selectedModelId = id;
}
const onKeyDown = (event) => {
if (event.key === 'Shift') {
@@ -326,7 +333,7 @@
: 'opacity-50 dark:opacity-50'} "
>
<img
src={model?.meta?.profile_image_url ?? '/static/favicon.png'}
src={model?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
alt="modelfile profile"
class=" rounded-full w-full h-auto object-cover"
/>
@@ -563,6 +570,6 @@
{/if}
{:else}
<div class=" h-full w-full flex justify-center items-center">
<Spinner />
<Spinner className="size-5" />
</div>
{/if}
@@ -18,6 +18,7 @@
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
export let initHandler = () => {};
@@ -129,16 +130,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -278,29 +270,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
@@ -308,7 +278,7 @@
</form>
{:else}
<div>
<Spinner />
<Spinner className="size-5" />
</div>
{/if}
</div>
@@ -1057,6 +1057,6 @@
</div>
{:else}
<div class="flex justify-center items-center w-full h-full py-3">
<Spinner />
<Spinner className="size-5" />
</div>
{/if}
@@ -7,6 +7,7 @@
import { user } from '$lib/stores';
import XMark from '$lib/components/icons/XMark.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import ManageOllama from './Manage/ManageOllama.svelte';
import { getOllamaConfig } from '$lib/apis/ollama';
@@ -48,16 +49,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -75,7 +67,7 @@
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
>
<button
class="min-w-fit rounded-full p-1.5 {selected === 'ollama'
class="min-w-fit p-1.5 {selected === 'ollama'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
@@ -84,7 +76,7 @@
>
<!-- <button
class="min-w-fit rounded-full p-1.5 {selected === 'llamacpp'
class="min-w-fit p-1.5 {selected === 'llamacpp'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
@@ -141,6 +141,7 @@
placeholder={$i18n.t('Enter Searxng Query URL')}
bind:value={webConfig.SEARXNG_QUERY_URL}
autocomplete="off"
required
/>
</div>
</div>
@@ -248,7 +249,6 @@
bind:value={webConfig.KAGI_SEARCH_API_KEY}
/>
</div>
.
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'mojeek'}
<div class="mb-2.5 flex w-full flex-col">
+2 -12
View File
@@ -16,6 +16,7 @@
import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Search from '$lib/components/icons/Search.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './Groups/EditGroupModal.svelte';
@@ -159,18 +160,7 @@
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
<Search />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
@@ -3,8 +3,10 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let show = false;
@@ -45,16 +47,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -111,29 +104,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
@@ -3,6 +3,7 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Display from './Display.svelte';
import Permissions from './Permissions.svelte';
@@ -10,6 +11,7 @@
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
@@ -124,16 +126,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -305,29 +298,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
@@ -7,6 +7,7 @@
import { WEBUI_BASE_URL } from '$lib/constants';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Search from '$lib/components/icons/Search.svelte';
export let users = [];
export let userIds = [];
@@ -50,18 +51,7 @@
<div class="flex w-full">
<div class="flex flex-1">
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
<Search />
</div>
<input
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
@@ -98,7 +88,7 @@
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
: `${WEBUI_BASE_URL}/user.png`}
alt="user"
/>
@@ -151,7 +151,7 @@
{#if users === null || total === null}
<div class="my-10">
<Spinner />
<Spinner className="size-5" />
</div>
{:else}
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
@@ -396,7 +396,7 @@
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `/user.png`}
: `${WEBUI_BASE_URL}/user.png`}
alt="user"
/>
@@ -6,8 +6,10 @@
import { WEBUI_BASE_URL } from '$lib/constants';
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import { generateInitialsImage } from '$lib/utils';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -132,16 +134,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -157,7 +150,7 @@
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
>
<button
class="min-w-fit rounded-full p-1.5 {tab === ''
class="min-w-fit p-1.5 {tab === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
type="button"
@@ -167,7 +160,7 @@
>
<button
class="min-w-fit rounded-full p-1.5 {tab === 'import'
class="min-w-fit p-1.5 {tab === 'import'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
type="button"
@@ -293,29 +286,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
@@ -8,6 +8,7 @@
import Modal from '$lib/components/common/Modal.svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -54,16 +55,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
let selected = '';
</script>
@@ -25,7 +26,7 @@
}}
>
<img
src="/static/splash.png"
src="{WEBUI_BASE_URL}/static/splash.png"
class="size-11 dark:invert p-0.5"
alt="logo"
draggable="false"
@@ -49,7 +50,7 @@
}}
>
<img
src="/static/favicon.png"
src="{WEBUI_BASE_URL}/static/favicon.png"
class="size-10 {selected === '' ? 'rounded-2xl' : 'rounded-full'}"
alt="logo"
draggable="false"
+1 -1
View File
@@ -246,7 +246,7 @@
{/if}
</div>
<div class=" pb-[1rem]">
<div class=" pb-[1rem] px-2.5">
<MessageInput
id="root"
{typingUsers}
+439 -132
View File
@@ -1,13 +1,24 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import heic2any from 'heic2any';
import { tick, getContext, onMount, onDestroy } from 'svelte';
const i18n = getContext('i18n');
import { config, mobile, settings, socket } from '$lib/stores';
import { blobToFile, compressImage } from '$lib/utils';
import { config, mobile, settings, socket, user } from '$lib/stores';
import {
blobToFile,
compressImage,
extractInputVariables,
getCurrentDateTime,
getFormattedDate,
getFormattedTime,
getUserPosition,
getUserTimezone,
getWeekday
} from '$lib/utils';
import Tooltip from '../common/Tooltip.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
@@ -18,6 +29,8 @@
import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import Commands from '../chat/MessageInput/Commands.svelte';
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
export let placeholder = $i18n.t('Send a Message');
export let transparentBackground = false;
@@ -30,16 +43,185 @@
let content = '';
let files = [];
export let chatInputElement;
let commandsElement;
let filesInputElement;
let inputFiles;
export let typingUsers = [];
export let inputLoading = false;
export let onSubmit: Function = (e) => {};
export let onChange: Function = (e) => {};
export let onStop: Function = (e) => {};
export let onSubmit: Function;
export let onChange: Function;
export let scrollEnd = true;
export let scrollToBottom: Function = () => {};
export let acceptFiles = true;
export let showFormattingButtons = true;
let showInputVariablesModal = false;
let inputVariables: Record<string, any> = {};
let inputVariableValues = {};
const inputVariableHandler = async (text: string) => {
inputVariables = extractInputVariables(text);
if (Object.keys(inputVariables).length > 0) {
showInputVariablesModal = true;
}
};
const textVariableHandler = async (text: string) => {
if (text.includes('{{CLIPBOARD}}')) {
const clipboardText = await navigator.clipboard.readText().catch((err) => {
toast.error($i18n.t('Failed to read clipboard contents'));
return '{{CLIPBOARD}}';
});
const clipboardItems = await navigator.clipboard.read();
let imageUrl = null;
for (const item of clipboardItems) {
// Check for known image types
for (const type of item.types) {
if (type.startsWith('image/')) {
const blob = await item.getType(type);
imageUrl = URL.createObjectURL(blob);
}
}
}
if (imageUrl) {
files = [
...files,
{
type: 'image',
url: imageUrl
}
];
}
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
}
if (text.includes('{{USER_LOCATION}}')) {
let location;
try {
location = await getUserPosition();
} catch (error) {
toast.error($i18n.t('Location access not allowed'));
location = 'LOCATION_UNKNOWN';
}
text = text.replaceAll('{{USER_LOCATION}}', String(location));
}
if (text.includes('{{USER_NAME}}')) {
const name = $user?.name || 'User';
text = text.replaceAll('{{USER_NAME}}', name);
}
if (text.includes('{{USER_LANGUAGE}}')) {
const language = localStorage.getItem('locale') || 'en-US';
text = text.replaceAll('{{USER_LANGUAGE}}', language);
}
if (text.includes('{{CURRENT_DATE}}')) {
const date = getFormattedDate();
text = text.replaceAll('{{CURRENT_DATE}}', date);
}
if (text.includes('{{CURRENT_TIME}}')) {
const time = getFormattedTime();
text = text.replaceAll('{{CURRENT_TIME}}', time);
}
if (text.includes('{{CURRENT_DATETIME}}')) {
const dateTime = getCurrentDateTime();
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
}
if (text.includes('{{CURRENT_TIMEZONE}}')) {
const timezone = getUserTimezone();
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
}
if (text.includes('{{CURRENT_WEEKDAY}}')) {
const weekday = getWeekday();
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
}
inputVariableHandler(text);
return text;
};
const replaceVariables = (variables: Record<string, any>) => {
if (!chatInputElement) return;
console.log('Replacing variables:', variables);
chatInputElement.replaceVariables(variables);
chatInputElement.focus();
};
export const setText = async (text?: string) => {
if (!chatInputElement) return;
text = await textVariableHandler(text || '');
chatInputElement?.setText(text);
chatInputElement?.focus();
};
const getCommand = () => {
if (!chatInputElement) return;
let word = '';
word = chatInputElement?.getWordAtDocPos();
return word;
};
const replaceCommandWithText = (text) => {
if (!chatInputElement) return;
chatInputElement?.replaceCommandWithText(text);
};
const insertTextAtCursor = async (text: string) => {
text = await textVariableHandler(text);
if (command) {
replaceCommandWithText(text);
} else {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
await tick();
const chatInputContainer = document.getElementById('chat-input-container');
if (chatInputContainer) {
chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
}
await tick();
if (chatInputElement) {
chatInputElement.focus();
}
};
let command = '';
export let showCommands = false;
$: showCommands = ['/'].includes(command?.charAt(0));
const screenCaptureHandler = async () => {
try {
// Request screen media
@@ -78,7 +260,7 @@
};
const inputFilesHandler = async (inputFiles) => {
inputFiles.forEach((file) => {
inputFiles.forEach(async (file) => {
console.info('Processing file:', {
name: file.name,
type: file.type,
@@ -102,43 +284,50 @@
return;
}
if (
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
if (file['type'].startsWith('image/')) {
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
// Quick shortcut so we don’t do unnecessary work.
const settingsCompression = settings?.imageCompression ?? false;
const configWidth = config?.file?.image_compression?.width ?? null;
const configHeight = config?.file?.image_compression?.height ?? null;
// If neither settings nor config wants compression, return original URL.
if (!settingsCompression && !configWidth && !configHeight) {
return imageUrl;
}
// Default to null (no compression unless set)
let width = null;
let height = null;
// If user/settings want compression, pick their preferred size.
if (settingsCompression) {
width = settings?.imageCompressionSize?.width ?? null;
height = settings?.imageCompressionSize?.height ?? null;
}
// Apply config limits as an upper bound if any
if (configWidth && (width === null || width > configWidth)) {
width = configWidth;
}
if (configHeight && (height === null || height > configHeight)) {
height = configHeight;
}
// Do the compression if required
if (width || height) {
return await compressImage(imageUrl, width, height);
}
return imageUrl;
};
let reader = new FileReader();
reader.onload = async (event) => {
let imageUrl = event.target.result;
if (
($settings?.imageCompression ?? false) ||
($config?.file?.image_compression?.width ?? null) ||
($config?.file?.image_compression?.height ?? null)
) {
let width = null;
let height = null;
if ($settings?.imageCompression ?? false) {
width = $settings?.imageCompressionSize?.width ?? null;
height = $settings?.imageCompressionSize?.height ?? null;
}
if (
($config?.file?.image_compression?.width ?? null) ||
($config?.file?.image_compression?.height ?? null)
) {
if (width > ($config?.file?.image_compression?.width ?? null)) {
width = $config?.file?.image_compression?.width ?? null;
}
if (height > ($config?.file?.image_compression?.height ?? null)) {
height = $config?.file?.image_compression?.height ?? null;
}
}
if (width || height) {
imageUrl = await compressImage(imageUrl, width, height);
}
}
// Compress the image if settings or config require it
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
files = [
...files,
@@ -149,7 +338,11 @@
];
};
reader.readAsDataURL(file);
reader.readAsDataURL(
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
} else {
uploadFileHandler(file);
}
@@ -247,7 +440,7 @@
const onDrop = async (e) => {
e.preventDefault();
if (e.dataTransfer?.files) {
if (e.dataTransfer?.files && acceptFiles) {
const inputFiles = Array.from(e.dataTransfer?.files);
if (inputFiles && inputFiles.length > 0) {
console.log(inputFiles);
@@ -273,10 +466,13 @@
content = '';
files = [];
await tick();
if (chatInputElement) {
chatInputElement?.setText('');
const chatInputElement = document.getElementById(`chat-input-${id}`);
chatInputElement?.focus();
await tick();
chatInputElement.focus();
}
};
$: if (content) {
@@ -285,9 +481,10 @@
onMount(async () => {
window.setTimeout(() => {
const chatInput = document.getElementById(`chat-input-${id}`);
chatInput?.focus();
}, 0);
if (chatInputElement) {
chatInputElement.focus();
}
}, 100);
window.addEventListener('keydown', handleKeyDown);
await tick();
@@ -314,27 +511,39 @@
<FilesOverlay show={draggedOver} />
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
multiple
on:change={async () => {
if (inputFiles && inputFiles.length > 0) {
inputFilesHandler(Array.from(inputFiles));
} else {
toast.error($i18n.t(`File not found.`));
}
{#if acceptFiles}
<input
bind:this={filesInputElement}
bind:files={inputFiles}
type="file"
hidden
multiple
on:change={async () => {
if (inputFiles && inputFiles.length > 0) {
inputFilesHandler(Array.from(inputFiles));
} else {
toast.error($i18n.t(`File not found.`));
}
filesInputElement.value = '';
filesInputElement.value = '';
}}
/>
{/if}
<InputVariablesModal
bind:show={showInputVariablesModal}
variables={inputVariables}
onSave={(variableValues) => {
inputVariableValues = { ...inputVariableValues, ...variableValues };
replaceVariables(inputVariableValues);
}}
/>
<div class="bg-transparent">
<div
class="{($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
: 'max-w-6xl'} mx-auto inset-x-0 relative"
>
<div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col px-3 w-full">
@@ -378,6 +587,13 @@
</div>
{/if}
</div>
<Commands
bind:this={commandsElement}
show={showCommands}
{command}
insertTextHandler={insertTextAtCursor}
/>
</div>
</div>
</div>
@@ -390,7 +606,10 @@
recording = false;
await tick();
document.getElementById(`chat-input-${id}`)?.focus();
if (chatInputElement) {
chatInputElement.focus();
}
}}
onConfirm={async (data) => {
const { text, filename } = data;
@@ -398,7 +617,10 @@
recording = false;
await tick();
document.getElementById(`chat-input-${id}`)?.focus();
if (chatInputElement) {
chatInputElement.focus();
}
}}
/>
{:else}
@@ -470,40 +692,95 @@
<div class="px-2.5">
<div
class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 resize-none h-fit max-h-80 overflow-auto"
>
<RichTextInput
bind:value={content}
id={`chat-input-${id}`}
bind:this={chatInputElement}
json={true}
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 ||
{showFormattingButtons}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$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) {
))}
largeTextAsFile={$settings?.largeTextAsFile ?? false}
onChange={(e) => {
const { md } = e;
content = md;
command = getCommand();
}}
on:keydown={async (e) => {
e = e.detail.event;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const commandsContainerElement = document.getElementById('commands-container');
if (commandsContainerElement) {
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
// Submit the content when Enter key is pressed
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
submitHandler();
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton?.click();
}
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (commandOptionButton) {
commandOptionButton?.click();
} else {
document.getElementById('send-message-button')?.click();
}
}
} else {
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();
}
}
}
@@ -520,30 +797,34 @@
</div>
<div class=" flex justify-between mb-2.5 mt-1.5 mx-0.5">
<div class="ml-1 self-end flex space-x-1">
<InputMenu
{screenCaptureHandler}
uploadFilesHandler={() => {
filesInputElement.click();
}}
>
<button
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
type="button"
aria-label="More"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
<div class="ml-1 self-end flex space-x-1 flex-1">
<slot name="menu">
{#if acceptFiles}
<InputMenu
{screenCaptureHandler}
uploadFilesHandler={() => {
filesInputElement.click();
}}
>
<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>
</InputMenu>
<button
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
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>
</InputMenu>
{/if}
</slot>
</div>
<div class="self-end flex space-x-1 mr-1">
@@ -594,31 +875,57 @@
{/if}
<div class=" flex items-center">
<div class=" flex items-center">
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{content !== '' || files.length !== 0
? '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 === '' && files.length === 0}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-5"
{#if inputLoading && onStop}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Stop')}>
<button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
on:click={() => {
onStop();
}}
>
<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>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
{:else}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{content !== '' || files.length !== 0
? '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 === '' && files.length === 0}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-5"
>
<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>
{/if}
</div>
</div>
</div>
@@ -144,7 +144,9 @@
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
className={'size-8 translate-y-1 ml-0.5'}
/>
</ProfilePreview>
@@ -275,7 +277,7 @@
>
{#if $shortCodesToEmojis[reaction.name]}
<img
src="/assets/emojis/{$shortCodesToEmojis[
src="{WEBUI_BASE_URL}/assets/emojis/{$shortCodesToEmojis[
reaction.name
].toLowerCase()}.svg"
alt={reaction.name}
@@ -5,6 +5,7 @@
import emojiShortCodes from '$lib/emoji-shortcodes.json';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import VirtualList from '@sveltejs/svelte-virtual-list';
import { WEBUI_BASE_URL } from '$lib/constants';
export let onClose = () => {};
export let onSubmit = (name) => {};
@@ -147,7 +148,7 @@
on:click={() => selectEmoji(emojiItem)}
>
<img
src="/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
src="{WEBUI_BASE_URL}/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
alt={emojiItem.name}
class="size-5"
loading="lazy"
+2 -2
View File
@@ -119,7 +119,7 @@
};
const submitHandler = async ({ content, data }) => {
if (!content) {
if (!content && (data?.files ?? []).length === 0) {
return;
}
@@ -196,7 +196,7 @@
}}
/>
<div class=" pb-[1rem]">
<div class=" pb-[1rem] px-2.5">
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
</div>
</div>
+87 -51
View File
@@ -55,10 +55,7 @@
import { generateChatCompletion } from '$lib/apis/ollama';
import {
addTagById,
createNewChat,
deleteTagById,
deleteTagsById,
getAllTags,
getChatById,
getChatList,
@@ -99,6 +96,8 @@
let controlPane;
let controlPaneComponent;
let messageInput;
let autoScroll = true;
let processing = '';
let messagesContainerElement: HTMLDivElement;
@@ -126,6 +125,8 @@
let webSearchEnabled = false;
let codeInterpreterEnabled = false;
let showCommands = false;
let chat = null;
let tags = [];
@@ -143,24 +144,38 @@
let params = {};
$: if (chatIdProp) {
(async () => {
loading = true;
navigateHandler();
}
prompt = '';
files = [];
selectedToolIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
const navigateHandler = async () => {
loading = true;
if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
prompt = '';
messageInput?.setText('');
files = [];
selectedToolIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
const storageChatInput = sessionStorage.getItem(
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
);
if (chatIdProp && (await loadChat())) {
await tick();
loading = false;
window.setTimeout(() => scrollToBottom(), 0);
await tick();
if (storageChatInput) {
try {
const input = JSON.parse(
localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
);
const input = JSON.parse(storageChatInput);
if (!$temporaryChatEnabled) {
prompt = input.prompt;
messageInput?.setText(input.prompt);
files = input.files;
selectedToolIds = input.selectedToolIds;
selectedFilterIds = input.selectedFilterIds;
@@ -171,17 +186,21 @@
} catch (e) {}
}
if (chatIdProp && (await loadChat())) {
await tick();
loading = false;
window.setTimeout(() => scrollToBottom(), 0);
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
} else {
await goto('/');
}
})();
}
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
} else {
await goto('/');
}
};
const onSelect = async (e) => {
const { type, data } = e;
if (type === 'prompt') {
// Handle prompt selection
messageInput?.setText(data);
}
};
$: if (selectedModels && chatIdProp !== '') {
saveSessionSelectedModels();
@@ -408,7 +427,7 @@
const inputElement = document.getElementById('chat-input');
if (inputElement) {
prompt = event.data.text;
messageInput?.setText(event.data.text);
inputElement.focus();
}
}
@@ -446,8 +465,19 @@
}
});
if (localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
const storageChatInput = sessionStorage.getItem(
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
);
if (!chatIdProp) {
loading = false;
await tick();
}
if (storageChatInput) {
prompt = '';
messageInput?.setText('');
files = [];
selectedToolIds = [];
selectedFilterIds = [];
@@ -456,12 +486,10 @@
codeInterpreterEnabled = false;
try {
const input = JSON.parse(
localStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
);
const input = JSON.parse(storageChatInput);
if (!$temporaryChatEnabled) {
prompt = input.prompt;
messageInput?.setText(input.prompt);
files = input.files;
selectedToolIds = input.selectedToolIds;
selectedFilterIds = input.selectedFilterIds;
@@ -472,11 +500,6 @@
} catch (e) {}
}
if (!chatIdProp) {
loading = false;
await tick();
}
showControls.subscribe(async (value) => {
if (controlPane && !$mobile) {
try {
@@ -708,6 +731,10 @@
//////////////////////////
const initNewChat = async () => {
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
await temporaryChatEnabled.set(true);
}
const availableModels = $models
.filter((m) => !(m?.info?.meta?.hidden ?? false))
.map((m) => m.id);
@@ -832,11 +859,14 @@
}
if ($page.url.searchParams.get('q')) {
prompt = $page.url.searchParams.get('q') ?? '';
const q = $page.url.searchParams.get('q') ?? '';
messageInput?.setText(q);
if (prompt) {
await tick();
submitPrompt(prompt);
if (q) {
if (($page.url.searchParams.get('submit') ?? 'true') === 'true') {
await tick();
submitPrompt(q);
}
}
}
@@ -1068,7 +1098,7 @@
};
const createMessagePair = async (userPrompt) => {
prompt = '';
messageInput?.setText('');
if (selectedModels.length === 0) {
toast.error($i18n.t('Model not selected'));
} else {
@@ -1389,7 +1419,7 @@
return;
}
prompt = '';
messageInput?.setText('');
// Reset chat input textarea
if (!($settings?.richTextInput ?? true)) {
@@ -1410,7 +1440,7 @@
);
files = [];
prompt = '';
messageInput?.setText('');
// Create user message
let userMessageId = uuidv4();
@@ -1567,9 +1597,8 @@
let files = JSON.parse(JSON.stringify(chatFiles));
files.push(
...(userMessage?.files ?? []).filter((item) =>
['doc', 'file', 'collection'].includes(item.type)
),
...(responseMessage?.files ?? []).filter((item) => ['web_search_results'].includes(item.type))
['doc', 'text', 'file', 'note', 'collection'].includes(item.type)
)
);
// Remove duplicates
files = files.filter(
@@ -2064,6 +2093,7 @@
bind:selectedModels
shareEnabled={!!history.currentId}
{initNewChat}
showBanners={!showCommands}
/>
<div class="flex flex-col flex-auto z-10 w-full @container">
@@ -2095,12 +2125,14 @@
{chatActionHandler}
{addMessages}
bottomPadding={files.length > 0}
{onSelect}
/>
</div>
</div>
<div class=" pb-2">
<MessageInput
bind:this={messageInput}
{history}
{taskIds}
{selectedModels}
@@ -2113,6 +2145,7 @@
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
bind:showCommands
toolServers={$toolServers}
transparentBackground={$settings?.backgroundImageUrl ?? false}
{stopResponse}
@@ -2120,12 +2153,12 @@
onChange={(input) => {
if (!$temporaryChatEnabled) {
if (input.prompt !== null) {
localStorage.setItem(
sessionStorage.setItem(
`chat-input${$chatId ? `-${$chatId}` : ''}`,
JSON.stringify(input)
);
} else {
localStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
sessionStorage.removeItem(`chat-input${$chatId ? `-${$chatId}` : ''}`);
}
}
}}
@@ -2163,6 +2196,7 @@
<Placeholder
{history}
{selectedModels}
bind:messageInput
bind:files
bind:prompt
bind:autoScroll
@@ -2172,10 +2206,12 @@
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
bind:showCommands
transparentBackground={$settings?.backgroundImageUrl ?? false}
toolServers={$toolServers}
{stopResponse}
{createMessagePair}
{onSelect}
on:upload={async (e) => {
const { type, data } = e.detail;
@@ -2227,7 +2263,7 @@
{:else if loading}
<div class=" flex items-center justify-center h-full w-full">
<div class="m-auto">
<Spinner />
<Spinner className="size-5" />
</div>
</div>
{/if}
@@ -13,7 +13,6 @@
import Overview from './Overview.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Artifacts from './Artifacts.svelte';
import { min } from '@floating-ui/utils';
export let history;
export let models = [];
+10 -8
View File
@@ -18,7 +18,7 @@
export let models = [];
export let atSelectedModel;
export let submitPrompt;
export let onSelect = (e) => {};
let mounted = false;
let selectedModelIdx = 0;
@@ -46,7 +46,9 @@
>
<Tooltip
content={marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
placement="right"
>
@@ -54,7 +56,7 @@
crossorigin="anonymous"
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `/doge.png`
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-[2.7rem] rounded-full border-[1px] border-gray-100 dark:border-none"
alt="logo"
@@ -68,7 +70,7 @@
{#if $temporaryChatEnabled}
<Tooltip
content={$i18n.t('This chat won’t appear in history and your messages will not be saved.')}
content={$i18n.t("This chat won't appear in history and your messages will not be saved.")}
className="w-full flex justify-start mb-0.5"
placement="top"
>
@@ -96,7 +98,9 @@
class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3 markdown"
>
{@html marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description
).replaceAll('\n', '<br>')
)}
</div>
{#if models[selectedModelIdx]?.info?.meta?.user}
@@ -131,9 +135,7 @@
models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
$config?.default_prompt_suggestions ??
[]}
on:select={(e) => {
submitPrompt(e.detail);
}}
{onSelect}
/>
</div>
</div>
@@ -9,7 +9,7 @@
import FileItem from '$lib/components/common/FileItem.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import { user } from '$lib/stores';
import { user, settings } from '$lib/stores';
export let models = [];
export let chatFiles = [];
export let params = {};
@@ -74,7 +74,9 @@
<div class="" slot="content">
<textarea
bind:value={params.system}
class="w-full text-xs py-1.5 bg-transparent outline-hidden resize-none"
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"
placeholder={$i18n.t('Enter system prompt')}
/>
+470 -114
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import * as pdfjs from 'pdfjs-dist';
import * as pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs';
pdfjs.GlobalWorkerOptions.workerSrc = import.meta.url + 'pdfjs-dist/build/pdf.worker.mjs';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import heic2any from 'heic2any';
import { toast } from 'svelte-sonner';
@@ -22,14 +27,23 @@
tools,
user as _user,
showControls,
TTSWorker
TTSWorker,
temporaryChatEnabled
} from '$lib/stores';
import {
blobToFile,
compressImage,
createMessagesList,
extractCurlyBraceWords
extractContentFromFile,
extractCurlyBraceWords,
extractInputVariables,
getCurrentDateTime,
getFormattedDate,
getFormattedTime,
getUserPosition,
getUserTimezone,
getWeekday
} from '$lib/utils';
import { uploadFile } from '$lib/apis/files';
import { generateAutoCompletion } from '$lib/apis';
@@ -57,7 +71,7 @@
import Sparkles from '../icons/Sparkles.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
const i18n = getContext('i18n');
export let transparentBackground = false;
@@ -89,6 +103,10 @@
export let webSearchEnabled = false;
export let codeInterpreterEnabled = false;
let showInputVariablesModal = false;
let inputVariables = {};
let inputVariableValues = {};
$: onChange({
prompt,
files: files
@@ -107,6 +125,262 @@
codeInterpreterEnabled
});
const inputVariableHandler = async (text: string) => {
inputVariables = extractInputVariables(text);
if (Object.keys(inputVariables).length > 0) {
showInputVariablesModal = true;
}
};
const textVariableHandler = async (text: string) => {
if (text.includes('{{CLIPBOARD}}')) {
const clipboardText = await navigator.clipboard.readText().catch((err) => {
toast.error($i18n.t('Failed to read clipboard contents'));
return '{{CLIPBOARD}}';
});
const clipboardItems = await navigator.clipboard.read();
let imageUrl = null;
for (const item of clipboardItems) {
// Check for known image types
for (const type of item.types) {
if (type.startsWith('image/')) {
const blob = await item.getType(type);
imageUrl = URL.createObjectURL(blob);
}
}
}
if (imageUrl) {
files = [
...files,
{
type: 'image',
url: imageUrl
}
];
}
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
}
if (text.includes('{{USER_LOCATION}}')) {
let location;
try {
location = await getUserPosition();
} catch (error) {
toast.error($i18n.t('Location access not allowed'));
location = 'LOCATION_UNKNOWN';
}
text = text.replaceAll('{{USER_LOCATION}}', String(location));
}
if (text.includes('{{USER_NAME}}')) {
const name = $_user?.name || 'User';
text = text.replaceAll('{{USER_NAME}}', name);
}
if (text.includes('{{USER_LANGUAGE}}')) {
const language = localStorage.getItem('locale') || 'en-US';
text = text.replaceAll('{{USER_LANGUAGE}}', language);
}
if (text.includes('{{CURRENT_DATE}}')) {
const date = getFormattedDate();
text = text.replaceAll('{{CURRENT_DATE}}', date);
}
if (text.includes('{{CURRENT_TIME}}')) {
const time = getFormattedTime();
text = text.replaceAll('{{CURRENT_TIME}}', time);
}
if (text.includes('{{CURRENT_DATETIME}}')) {
const dateTime = getCurrentDateTime();
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
}
if (text.includes('{{CURRENT_TIMEZONE}}')) {
const timezone = getUserTimezone();
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
}
if (text.includes('{{CURRENT_WEEKDAY}}')) {
const weekday = getWeekday();
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
}
inputVariableHandler(text);
return text;
};
const replaceVariables = (variables: Record<string, any>) => {
console.log('Replacing variables:', variables);
const chatInput = document.getElementById('chat-input');
if (chatInput) {
if ($settings?.richTextInput ?? true) {
chatInputElement.replaceVariables(variables);
chatInputElement.focus();
} else {
// Get current value from the input element
let currentValue = chatInput.value || '';
// Replace template variables using regex
const updatedValue = currentValue.replace(
/{{\s*([^|}]+)(?:\|[^}]*)?\s*}}/g,
(match, varName) => {
const trimmedVarName = varName.trim();
return variables.hasOwnProperty(trimmedVarName)
? String(variables[trimmedVarName])
: match;
}
);
// Update the input value
chatInput.value = updatedValue;
chatInput.focus();
chatInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
};
export const setText = async (text?: string) => {
const chatInput = document.getElementById('chat-input');
if (chatInput) {
text = await textVariableHandler(text || '');
if ($settings?.richTextInput ?? true) {
chatInputElement?.setText(text);
chatInputElement?.focus();
} else {
chatInput.value = text;
prompt = text;
chatInput.focus();
chatInput.dispatchEvent(new Event('input'));
}
}
};
const getCommand = () => {
const getWordAtCursor = (text, cursor) => {
if (typeof text !== 'string' || cursor == null) return '';
const left = text.slice(0, cursor);
const right = text.slice(cursor);
const leftWord = left.match(/(?:^|\s)([^\s]*)$/)?.[1] || '';
const rightWord = right.match(/^([^\s]*)/)?.[1] || '';
return leftWord + rightWord;
};
const chatInput = document.getElementById('chat-input');
let word = '';
if (chatInput) {
if ($settings?.richTextInput ?? true) {
word = chatInputElement?.getWordAtDocPos();
} else {
const cursor = chatInput ? chatInput.selectionStart : prompt.length;
word = getWordAtCursor(prompt, cursor);
}
}
return word;
};
const replaceCommandWithText = (text) => {
const getWordBoundsAtCursor = (text, cursor) => {
let start = cursor,
end = cursor;
while (start > 0 && !/\s/.test(text[start - 1])) --start;
while (end < text.length && !/\s/.test(text[end])) ++end;
return { start, end };
};
const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
if ($settings?.richTextInput ?? true) {
chatInputElement?.replaceCommandWithText(text);
} else {
const cursor = chatInput.selectionStart;
const { start, end } = getWordBoundsAtCursor(prompt, cursor);
prompt = prompt.slice(0, start) + text + prompt.slice(end);
chatInput.focus();
chatInput.setSelectionRange(start + text.length, start + text.length);
}
};
const insertTextAtCursor = async (text: string) => {
const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
text = await textVariableHandler(text);
if (command) {
replaceCommandWithText(text);
} else {
if ($settings?.richTextInput ?? true) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
} else {
const cursor = chatInput.selectionStart;
prompt = prompt.slice(0, cursor) + text + prompt.slice(cursor);
chatInput.focus();
chatInput.setSelectionRange(cursor + text.length, cursor + text.length);
}
}
await tick();
const chatInputContainer = document.getElementById('chat-input-container');
if (chatInputContainer) {
chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
}
await tick();
if (chatInput) {
chatInput.focus();
chatInput.dispatchEvent(new Event('input'));
const words = extractCurlyBraceWords(prompt);
if (words.length > 0) {
const word = words.at(0);
await tick();
if (!($settings?.richTextInput ?? true)) {
// Move scroll to the first word
chatInput.setSelectionRange(word.startIndex, word.endIndex + 1);
chatInput.focus();
const selectionRow =
(word?.startIndex - (word?.startIndex % chatInput.cols)) / chatInput.cols;
const lineHeight = chatInput.clientHeight / chatInput.rows;
chatInput.scrollTop = lineHeight * selectionRow;
}
} else {
chatInput.scrollTop = chatInput.scrollHeight;
}
}
};
let command = '';
export let showCommands = false;
$: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
let showTools = false;
let loaded = false;
@@ -261,47 +535,77 @@
files = [...files, fileItem];
try {
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
// During the file upload, file content is automatically extracted.
const uploadedFile = await uploadFile(localStorage.token, file, metadata);
if (uploadedFile) {
console.log('File upload completed:', {
id: uploadedFile.id,
name: fileItem.name,
collection: uploadedFile?.meta?.collection_name
});
if (uploadedFile.error) {
console.warn('File upload warning:', uploadedFile.error);
toast.warning(uploadedFile.error);
if (!$temporaryChatEnabled) {
try {
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
fileItem.status = 'uploaded';
fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
fileItem.collection_name =
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
// During the file upload, file content is automatically extracted.
const uploadedFile = await uploadFile(localStorage.token, file, metadata);
files = files;
} else {
if (uploadedFile) {
console.log('File upload completed:', {
id: uploadedFile.id,
name: fileItem.name,
collection: uploadedFile?.meta?.collection_name
});
if (uploadedFile.error) {
console.warn('File upload warning:', uploadedFile.error);
toast.warning(uploadedFile.error);
}
fileItem.status = 'uploaded';
fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
fileItem.collection_name =
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
files = files;
} else {
files = files.filter((item) => item?.itemId !== tempItemId);
}
} catch (e) {
toast.error(`${e}`);
files = files.filter((item) => item?.itemId !== tempItemId);
}
} catch (e) {
toast.error(`${e}`);
files = files.filter((item) => item?.itemId !== tempItemId);
} else {
// If temporary chat is enabled, we just add the file to the list without uploading it.
const content = await extractContentFromFile(file, pdfjsLib).catch((error) => {
toast.error(
$i18n.t('Failed to extract content from the file: {{error}}', { error: error })
);
return null;
});
if (content === null) {
toast.error($i18n.t('Failed to extract content from the file.'));
files = files.filter((item) => item?.itemId !== tempItemId);
return null;
} else {
console.log('Extracted content from file:', {
name: file.name,
size: file.size,
content: content
});
fileItem.status = 'uploaded';
fileItem.type = 'text';
fileItem.content = content;
fileItem.id = uuidv4(); // Temporary ID for the file
files = files;
}
}
};
@@ -320,7 +624,7 @@
return;
}
inputFiles.forEach((file) => {
inputFiles.forEach(async (file) => {
console.log('Processing file:', {
name: file.name,
type: file.type,
@@ -344,46 +648,53 @@
return;
}
if (
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
if (file['type'].startsWith('image/')) {
if (visionCapableModels.length === 0) {
toast.error($i18n.t('Selected model(s) do not support image inputs'));
return;
}
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
// Quick shortcut so we don’t do unnecessary work.
const settingsCompression = settings?.imageCompression ?? false;
const configWidth = config?.file?.image_compression?.width ?? null;
const configHeight = config?.file?.image_compression?.height ?? null;
// If neither settings nor config wants compression, return original URL.
if (!settingsCompression && !configWidth && !configHeight) {
return imageUrl;
}
// Default to null (no compression unless set)
let width = null;
let height = null;
// If user/settings want compression, pick their preferred size.
if (settingsCompression) {
width = settings?.imageCompressionSize?.width ?? null;
height = settings?.imageCompressionSize?.height ?? null;
}
// Apply config limits as an upper bound if any
if (configWidth && (width === null || width > configWidth)) {
width = configWidth;
}
if (configHeight && (height === null || height > configHeight)) {
height = configHeight;
}
// Do the compression if required
if (width || height) {
return await compressImage(imageUrl, width, height);
}
return imageUrl;
};
let reader = new FileReader();
reader.onload = async (event) => {
let imageUrl = event.target.result;
if (
($settings?.imageCompression ?? false) ||
($config?.file?.image_compression?.width ?? null) ||
($config?.file?.image_compression?.height ?? null)
) {
let width = null;
let height = null;
if ($settings?.imageCompression ?? false) {
width = $settings?.imageCompressionSize?.width ?? null;
height = $settings?.imageCompressionSize?.height ?? null;
}
if (
($config?.file?.image_compression?.width ?? null) ||
($config?.file?.image_compression?.height ?? null)
) {
if (width > ($config?.file?.image_compression?.width ?? null)) {
width = $config?.file?.image_compression?.width ?? null;
}
if (height > ($config?.file?.image_compression?.height ?? null)) {
height = $config?.file?.image_compression?.height ?? null;
}
}
if (width || height) {
imageUrl = await compressImage(imageUrl, width, height);
}
}
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
files = [
...files,
@@ -393,7 +704,11 @@
}
];
};
reader.readAsDataURL(file);
reader.readAsDataURL(
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
} else {
uploadFileHandler(file);
}
@@ -496,6 +811,14 @@
<FilesOverlay show={dragged} />
<ToolServersModal bind:show={showTools} {selectedToolIds} />
<InputVariablesModal
bind:show={showInputVariablesModal}
variables={inputVariables}
onSave={(variableValues) => {
inputVariableValues = { ...inputVariableValues, ...variableValues };
replaceVariables(inputVariableValues);
}}
/>
{#if loaded}
<div class="w-full font-primary">
@@ -548,7 +871,7 @@
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
?.profile_image_url ??
($i18n.language === 'dg-DG'
? `/doge.png`
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
/>
<div class="translate-y-[0.5px]">
@@ -571,20 +894,36 @@
<Commands
bind:this={commandsElement}
bind:prompt
bind:files
on:upload={(e) => {
dispatch('upload', e.detail);
}}
on:select={(e) => {
const data = e.detail;
show={showCommands}
{command}
insertTextHandler={insertTextAtCursor}
onUpload={(e) => {
const { type, data } = e;
if (data?.type === 'model') {
atSelectedModel = data.data;
if (type === 'file') {
if (files.find((f) => f.id === data.id)) {
return;
}
files = [
...files,
{
...data,
status: 'processed'
}
];
} else {
dispatch('upload', e);
}
}}
onSelect={(e) => {
const { type, data } = e;
if (type === 'model') {
atSelectedModel = data;
}
const chatInputElement = document.getElementById('chat-input');
chatInputElement?.focus();
document.getElementById('chat-input')?.focus();
}}
/>
</div>
@@ -659,7 +998,7 @@
<div class="relative flex items-center">
<Image
src={file.url}
alt="input"
alt=""
imageClassName=" size-14 rounded-xl object-cover"
/>
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
@@ -677,6 +1016,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
class="size-4 fill-yellow-300"
>
<path
@@ -690,8 +1030,12 @@
</div>
<div class=" absolute -top-1 -right-1">
<button
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
false)
? ''
: 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
type="button"
aria-label={$i18n.t('Remove file')}
on:click={() => {
files.splice(fileIdx, 1);
files = files;
@@ -701,6 +1045,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="size-4"
>
<path
@@ -719,18 +1064,8 @@
loading={file.status === 'uploading'}
dismissible={true}
edit={true}
modal={['file', 'collection'].includes(file?.type)}
on:dismiss={async () => {
try {
if (file.type !== 'collection' && !file?.collection) {
if (file.id) {
// This will handle both file deletion and Chroma cleanup
await deleteFileById(localStorage.token, file.id);
}
}
} catch (error) {
console.error('Error deleting file:', error);
}
// Remove from UI state
files.splice(fileIdx, 1);
files = files;
@@ -752,9 +1087,15 @@
>
<RichTextInput
bind:this={chatInputElement}
bind:value={prompt}
id="chat-input"
onChange={(e) => {
prompt = e.md;
command = getCommand();
}}
json={true}
messageInput={true}
showFormattingButtons={false}
insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$mobile ||
!(
@@ -972,6 +1313,12 @@
class="scrollbar-hidden bg-transparent dark:text-gray-200 outline-hidden w-full pt-3 px-1 resize-none"
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt}
on:input={() => {
command = getCommand();
}}
on:click={() => {
command = getCommand();
}}
on:compositionstart={() => (isComposing = true)}
on:compositionend={() => (isComposing = false)}
on:keydown={async (e) => {
@@ -1119,17 +1466,20 @@
if (words.length > 0) {
const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1);
await tick();
if (word && e.target instanceof HTMLTextAreaElement) {
// Prevent default tab behavior
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
e.target.focus();
e.target.scrollTop = e.target.scrollHeight;
prompt = fullPrompt;
await tick();
const selectionRow =
(word?.startIndex - (word?.startIndex % e.target.cols)) /
e.target.cols;
const lineHeight = e.target.clientHeight / e.target.rows;
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
e.target.scrollTop = lineHeight * selectionRow;
}
}
e.target.style.height = '';
@@ -1250,14 +1600,13 @@
chatInput?.focus();
}}
>
<button
class="bg-transparent hover:bg-gray-100 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
type="button"
aria-label="More"
<div
class="bg-transparent hover:bg-gray-100 text-gray-800 dark:text-white dark:hover:bg-gray-800 rounded-full p-1.5 outline-hidden focus:outline-hidden"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
aria-hidden="true"
fill="currentColor"
class="size-5"
>
@@ -1265,7 +1614,7 @@
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>
</InputMenu>
{#if $_user && (showToolsButton || (toggleFilters && toggleFilters.length > 0) || showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton)}
@@ -1379,12 +1728,19 @@
{#if showCodeInterpreterButton}
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
<button
aria-label={codeInterpreterEnabled
? $i18n.t('Disable Code Interpreter')
: $i18n.t('Enable Code Interpreter')}
aria-pressed={codeInterpreterEnabled}
on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button"
class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
: 'bg-transparent text-gray-600 dark:text-gray-300 '}"
: 'bg-transparent text-gray-600 dark:text-gray-300 '} {($settings?.highContrastMode ??
false)
? 'm-1'
: 'focus:outline-hidden rounded-full'}"
>
<CommandLine className="size-4" strokeWidth="1.75" />
<span
@@ -1530,7 +1886,7 @@
);
}
}}
aria-label="Call"
aria-label={$i18n.t('Voice mode')}
>
<Headphone className="size-5" />
</button>
@@ -854,6 +854,7 @@
</button>
{:else}
<div class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full">
<!-- svelte-ignore a11y-media-has-caption -->
<video
id="camera-feed"
autoplay
@@ -1,9 +1,4 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
const dispatch = createEventDispatcher();
import { knowledge, prompts } from '$lib/stores';
import { removeLastWordFromString } from '$lib/utils';
@@ -15,8 +10,15 @@
import Models from './Commands/Models.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let prompt = '';
export let show = false;
export let files = [];
export let command = '';
export let onSelect = (e) => {};
export let onUpload = (e) => {};
export let insertTextHandler = (text) => {};
let loading = false;
let commandElement = null;
@@ -29,12 +31,6 @@
commandElement?.selectDown();
};
let command = '';
$: command = prompt?.split('\n').pop()?.split(' ')?.pop() ?? '';
let show = false;
$: show = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command.slice(0, 2);
$: if (show) {
init();
}
@@ -56,54 +52,63 @@
{#if show}
{#if !loading}
{#if command?.charAt(0) === '/'}
<Prompts bind:this={commandElement} bind:prompt bind:files {command} />
<Prompts
bind:this={commandElement}
{command}
onSelect={(e) => {
const { type, data } = e;
if (type === 'prompt') {
insertTextHandler(data.content);
}
}}
/>
{:else if (command?.charAt(0) === '#' && command.startsWith('#') && !command.includes('# ')) || ('\\#' === command.slice(0, 2) && command.startsWith('#') && !command.includes('# '))}
<Knowledge
bind:this={commandElement}
bind:prompt
command={command.includes('\\#') ? command.slice(2) : command}
on:youtube={(e) => {
console.log(e);
dispatch('upload', {
type: 'youtube',
data: e.detail
});
}}
on:url={(e) => {
console.log(e);
dispatch('upload', {
type: 'web',
data: e.detail
});
}}
on:select={(e) => {
console.log(e);
if (files.find((f) => f.id === e.detail.id)) {
return;
onSelect={(e) => {
const { type, data } = e;
if (type === 'knowledge') {
insertTextHandler('');
onUpload({
type: 'file',
data: data
});
} else if (type === 'youtube') {
insertTextHandler('');
onUpload({
type: 'youtube',
data: data
});
} else if (type === 'web') {
insertTextHandler('');
onUpload({
type: 'web',
data: data
});
}
files = [
...files,
{
...e.detail,
status: 'processed'
}
];
dispatch('select');
}}
/>
{:else if command?.charAt(0) === '@'}
<Models
bind:this={commandElement}
{command}
on:select={(e) => {
prompt = removeLastWordFromString(prompt, command);
onSelect={(e) => {
const { type, data } = e;
dispatch('select', {
type: 'model',
data: e.detail
});
if (type === 'model') {
insertTextHandler('');
onSelect({
type: 'model',
data: data
});
}
}}
/>
{/if}
@@ -6,16 +6,16 @@
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { createEventDispatcher, tick, getContext, onMount } from 'svelte';
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
import { knowledge } from '$lib/stores';
import { getNoteList, getNotes } from '$lib/apis/notes';
const i18n = getContext('i18n');
export let prompt = '';
export let command = '';
export let onSelect = (e) => {};
const dispatch = createEventDispatcher();
let selectedIdx = 0;
let items = [];
@@ -42,40 +42,57 @@
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
};
const confirmSelect = async (item) => {
dispatch('select', item);
let container;
let adjustHeightDebounce;
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
const adjustHeight = () => {
if (container) {
if (adjustHeightDebounce) {
clearTimeout(adjustHeightDebounce);
}
await tick();
chatInputElement?.focus();
await tick();
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
}, 100);
}
};
const confirmSelectWeb = async (url) => {
dispatch('url', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
const confirmSelect = async (type, data) => {
onSelect({
type: type,
data: data
});
};
const confirmSelectYoutube = async (url) => {
dispatch('youtube', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
onMount(() => {
onMount(async () => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
let notes = await getNoteList(localStorage.token).catch(() => {
return [];
});
notes = notes.map((note) => {
return {
...note,
type: 'note',
name: note.title,
description: dayjs(note.updated_at / 1000000).fromNow()
};
});
let legacy_documents = $knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
@@ -141,30 +158,30 @@
]
: [];
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
(item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
}
);
items = [
...notes,
...collections,
...collection_files,
...legacy_collections,
...legacy_documents
].map((item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, {
keys: ['name', 'description']
});
});
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script>
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
{#if filteredItems.length > 0 || command?.substring(1).startsWith('http')}
<div
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
@@ -174,6 +191,7 @@
<div
class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container"
bind:this={container}
>
{#each filteredItems as item, idx}
<button
@@ -184,7 +202,7 @@
type="button"
on:click={() => {
console.log(item);
confirmSelect(item);
confirmSelect('knowledge', item);
}}
on:mousemove={() => {
selectedIdx = idx;
@@ -210,6 +228,12 @@
>
File
</div>
{:else if item?.type === 'note'}
<div
class="bg-blue-500/20 text-blue-700 dark:text-blue-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
Note
</div>
{:else}
<div
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
@@ -272,18 +296,15 @@
</div> -->
{/each}
{#if prompt
.split(' ')
.some((s) => s.substring(1).startsWith('https://www.youtube.com') || s
.substring(1)
.startsWith('https://youtu.be'))}
{#if command.substring(1).startsWith('https://www.youtube.com') || command
.substring(1)
.startsWith('https://youtu.be')}
<button
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button"
type="button"
on:click={() => {
const url = prompt.split(' ')?.at(0)?.substring(1);
if (isValidHttpUrl(url)) {
confirmSelectYoutube(url);
if (isValidHttpUrl(command.substring(1))) {
confirmSelect('youtube', command.substring(1));
} else {
toast.error(
$i18n.t(
@@ -294,19 +315,18 @@
}}
>
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{prompt.split(' ')?.at(0)?.substring(1)}
{command.substring(1)}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Youtube')}</div>
</button>
{:else if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
{:else if command.substring(1).startsWith('http')}
<button
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button"
type="button"
on:click={() => {
const url = prompt.split(' ')?.at(0)?.substring(1);
if (isValidHttpUrl(url)) {
confirmSelectWeb(url);
if (isValidHttpUrl(command.substring(1))) {
confirmSelect('web', command.substring(1));
} else {
toast.error(
$i18n.t(
@@ -317,7 +337,7 @@
}}
>
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{prompt.split(' ')?.at(0)?.substring(1)}
{command}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Web')}</div>
@@ -1,16 +1,16 @@
<script lang="ts">
import Fuse from 'fuse.js';
import { createEventDispatcher, onMount } from 'svelte';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { tick, getContext } from 'svelte';
import { models } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let command = '';
export let onSelect = (e) => {};
let selectedIdx = 0;
let filteredItems = [];
@@ -51,18 +51,43 @@
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
};
let container;
let adjustHeightDebounce;
const adjustHeight = () => {
if (container) {
if (adjustHeightDebounce) {
clearTimeout(adjustHeightDebounce);
}
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
}, 100);
}
};
const confirmSelect = async (model) => {
command = '';
dispatch('select', model);
onSelect({ type: 'model', data: model });
};
onMount(async () => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
await tick();
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script>
{#if filteredItems.length > 0}
@@ -75,6 +100,7 @@
<div
class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container"
bind:this={container}
>
{#each filteredItems as model, modelIdx}
<button
@@ -92,7 +118,8 @@
>
<div class="flex font-medium text-black dark:text-gray-100 line-clamp-1">
<img
src={model?.info?.meta?.profile_image_url ?? '/static/favicon.png'}
src={model?.info?.meta?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
alt={model?.name ?? model.id}
class="rounded-full size-6 items-center mr-2"
/>
@@ -9,15 +9,13 @@
getUserTimezone,
getWeekday
} from '$lib/utils';
import { tick, getContext } from 'svelte';
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
export let files;
export let prompt = '';
export let command = '';
export let onSelect = (e) => {};
let selectedPromptIdx = 0;
let filteredPrompts = [];
@@ -38,140 +36,37 @@
selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1);
};
const confirmPrompt = async (command) => {
let text = command.content;
let container;
let adjustHeightDebounce;
if (command.content.includes('{{CLIPBOARD}}')) {
const clipboardText = await navigator.clipboard.readText().catch((err) => {
toast.error($i18n.t('Failed to read clipboard contents'));
return '{{CLIPBOARD}}';
});
const clipboardItems = await navigator.clipboard.read();
let imageUrl = null;
for (const item of clipboardItems) {
// Check for known image types
for (const type of item.types) {
if (type.startsWith('image/')) {
const blob = await item.getType(type);
imageUrl = URL.createObjectURL(blob);
}
}
const adjustHeight = () => {
if (container) {
if (adjustHeightDebounce) {
clearTimeout(adjustHeightDebounce);
}
if (imageUrl) {
files = [
...files,
{
type: 'image',
url: imageUrl
}
];
}
adjustHeightDebounce = setTimeout(() => {
if (!container) return;
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
}
if (command.content.includes('{{USER_LOCATION}}')) {
let location;
try {
location = await getUserPosition();
} catch (error) {
toast.error($i18n.t('Location access not allowed'));
location = 'LOCATION_UNKNOWN';
}
text = text.replaceAll('{{USER_LOCATION}}', String(location));
}
if (command.content.includes('{{USER_NAME}}')) {
console.log($user);
const name = $user?.name || 'User';
text = text.replaceAll('{{USER_NAME}}', name);
}
if (command.content.includes('{{USER_LANGUAGE}}')) {
const language = localStorage.getItem('locale') || 'en-US';
text = text.replaceAll('{{USER_LANGUAGE}}', language);
}
if (command.content.includes('{{CURRENT_DATE}}')) {
const date = getFormattedDate();
text = text.replaceAll('{{CURRENT_DATE}}', date);
}
if (command.content.includes('{{CURRENT_TIME}}')) {
const time = getFormattedTime();
text = text.replaceAll('{{CURRENT_TIME}}', time);
}
if (command.content.includes('{{CURRENT_DATETIME}}')) {
const dateTime = getCurrentDateTime();
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
}
if (command.content.includes('{{CURRENT_TIMEZONE}}')) {
const timezone = getUserTimezone();
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
}
if (command.content.includes('{{CURRENT_WEEKDAY}}')) {
const weekday = getWeekday();
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
}
const lines = prompt.split('\n');
const lastLine = lines.pop();
const lastLineWords = lastLine.split(' ');
const lastWord = lastLineWords.pop();
if ($settings?.richTextInput ?? true) {
lastLineWords.push(
`${text.replace(/</g, '&lt;').replace(/>/g, '&gt;').replaceAll('\n', '<br/>')}`
);
lines.push(lastLineWords.join(' '));
prompt = lines.join('<br/>');
} else {
lastLineWords.push(text);
lines.push(lastLineWords.join(' '));
prompt = lines.join('\n');
}
const chatInputContainerElement = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input');
await tick();
if (chatInputContainerElement) {
chatInputContainerElement.scrollTop = chatInputContainerElement.scrollHeight;
}
await tick();
if (chatInputElement) {
chatInputElement.focus();
chatInputElement.dispatchEvent(new Event('input'));
const words = extractCurlyBraceWords(prompt);
if (words.length > 0) {
const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1);
await tick();
chatInputElement.scrollTop = chatInputElement.scrollHeight;
prompt = fullPrompt;
await tick();
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
} else {
chatInputElement.scrollTop = chatInputElement.scrollHeight;
}
// Ensure the container is visible before adjusting height
const rect = container.getBoundingClientRect();
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 80), 100) + 'px';
}, 100);
}
};
const confirmPrompt = async (command) => {
onSelect({ type: 'prompt', data: command });
};
onMount(() => {
window.addEventListener('resize', adjustHeight);
adjustHeight();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script>
{#if filteredPrompts.length > 0}
@@ -184,15 +79,16 @@
<div
class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden max-h-60"
id="command-options-container"
bind:this={container}
>
{#each filteredPrompts as prompt, promptIdx}
{#each filteredPrompts as promptItem, promptIdx}
<button
class=" px-3 py-1.5 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
? ' bg-gray-50 dark:bg-gray-850 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
confirmPrompt(prompt);
confirmPrompt(promptItem);
}}
on:mousemove={() => {
selectedPromptIdx = promptIdx;
@@ -200,11 +96,11 @@
on:focus={() => {}}
>
<div class=" font-medium text-black dark:text-gray-100">
{prompt.command}
{promptItem.command}
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100">
{prompt.title}
{promptItem.title}
</div>
</button>
{/each}
@@ -0,0 +1,365 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import { models, config } from '$lib/stores';
import { toast } from 'svelte-sonner';
import { copyToClipboard } from '$lib/utils';
import XMark from '$lib/components/icons/XMark.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import MapSelector from '$lib/components/common/Valves/MapSelector.svelte';
const i18n = getContext('i18n');
export let show = false;
export let variables = {};
export let onSave = (e) => {};
let loading = false;
let variableValues = {};
const submitHandler = async () => {
onSave(variableValues);
show = false;
};
const init = async () => {
loading = true;
variableValues = {};
for (const variable of Object.keys(variables)) {
if (variables[variable]?.default !== undefined) {
variableValues[variable] = variables[variable].default;
} else {
variableValues[variable] = '';
}
}
loading = false;
await tick();
const firstInputElement = document.getElementById('input-variable-0');
if (firstInputElement) {
console.log('Focusing first input element:', firstInputElement);
firstInputElement.focus();
}
};
$: if (show) {
init();
}
</script>
<Modal bind:show size="md">
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center">{$i18n.t('Input Variables')}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="px-1">
{#if !loading}
<div class="flex flex-col gap-1">
{#each Object.keys(variables) as variable, idx}
{@const { type, ...variableAttributes } = variables[variable] ?? {}}
<div class=" py-0.5 w-full justify-between">
<div class="flex w-full justify-between mb-1.5">
<div class=" self-center text-xs font-medium">
{variable}
{#if variables[variable]?.required ?? true}
<span class=" text-gray-500">*required</span>
{/if}
</div>
</div>
<div class="flex mt-0.5 mb-0.5 space-x-2">
<div class=" flex-1">
{#if variables[variable]?.type === 'select'}
{@const options = variableAttributes?.options ?? []}
{@const placeholder = variableAttributes?.placeholder ?? ''}
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
bind:value={variableValues[variable]}
id="input-variable-{idx}"
>
{#if placeholder}
<option value="" disabled selected>
{placeholder}
</option>
{/if}
{#each options as option}
<option value={option} selected={option === variableValues[variable]}>
{option}
</option>
{/each}
</select>
{:else if variables[variable]?.type === 'checkbox'}
<div class="flex items-center space-x-2">
<div class="relative flex justify-center items-center gap-2">
<input
type="checkbox"
bind:checked={variableValues[variable]}
class="size-3.5 rounded cursor-pointer border border-gray-200 dark:border-gray-700"
id="input-variable-{idx}"
{...variableAttributes}
/>
<label for="input-variable-{idx}" class="text-sm"
>{variables[variable]?.label ?? variable}</label
>
</div>
<input
type="text"
class="flex-1 py-1 text-sm dark:text-gray-300 bg-transparent outline-hidden"
placeholder="Enter value (true/false)"
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
</div>
{:else if variables[variable]?.type === 'color'}
<div class="flex items-center space-x-2">
<div class="relative size-6">
<input
type="color"
class="size-6 rounded cursor-pointer border border-gray-200 dark:border-gray-700"
value={variableValues[variable]}
id="input-variable-{idx}"
on:input={(e) => {
// Convert the color value to uppercase immediately
variableValues[variable] = e.target.value.toUpperCase();
}}
{...variableAttributes}
/>
</div>
<input
type="text"
class="flex-1 py-2 text-sm dark:text-gray-300 bg-transparent outline-hidden"
placeholder="Enter hex color (e.g. #FF0000)"
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
</div>
{:else if variables[variable]?.type === 'date'}
<input
type="date"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'datetime-local'}
<input
type="datetime-local"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'email'}
<input
type="email"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'month'}
<input
type="month"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'number'}
<input
type="number"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'range'}
<div class="flex items-center space-x-2">
<div class="relative flex justify-center items-center gap-2 flex-1">
<input
type="range"
bind:value={variableValues[variable]}
class="w-full rounded-lg py-1 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
id="input-variable-{idx}"
{...variableAttributes}
/>
</div>
<input
type="text"
class=" py-1 text-sm dark:text-gray-300 bg-transparent outline-hidden text-right"
placeholder="Enter value"
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
</div>
<!-- <input
type="range"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
/> -->
{:else if variables[variable]?.type === 'tel'}
<input
type="tel"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'text'}
<input
type="text"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'time'}
<input
type="time"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'url'}
<input
type="url"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
{...variableAttributes}
/>
{:else if variables[variable]?.type === 'map'}
<!-- EXPERIMENTAL INPUT TYPE, DO NOT USE IN PRODUCTION -->
<div class="flex flex-col items-center gap-1">
<MapSelector
setViewLocation={((variableValues[variable] ?? '').includes(',') ??
false)
? variableValues[variable].split(',')
: null}
onClick={(value) => {
variableValues[variable] = value;
}}
/>
<input
type="text"
class=" w-full py-1 text-left text-sm dark:text-gray-300 bg-transparent outline-hidden"
placeholder="Enter coordinates (e.g. 51.505, -0.09)"
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
</div>
{:else}
<textarea
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
id="input-variable-{idx}"
required
/>
{/if}
</div>
</div>
<!-- {#if (valvesSpec.properties[property]?.description ?? null) !== null}
<div class="text-xs text-gray-500">
{valvesSpec.properties[property].description}
</div>
{/if} -->
</div>
{/each}
</div>
{:else}
<Spinner className="size-5" />
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-white hover:bg-gray-100 text-black dark:bg-black dark:text-white dark:hover:bg-gray-900 transition rounded-full"
type="button"
on:click={() => {
show = false;
}}
>
{$i18n.t('Cancel')}
</button>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
@@ -5,6 +5,7 @@
import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
import { transcribeAudio } from '$lib/apis/audio';
import XMark from '$lib/components/icons/XMark.svelte';
import dayjs from 'dayjs';
import LocalizedFormat from 'dayjs/plugin/localizedFormat';
@@ -406,16 +407,7 @@
onCancel();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="3"
stroke="currentColor"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
<XMark className={'size-4'} />
</button>
</div>
+4 -19
View File
@@ -51,6 +51,8 @@
export let bottomPadding = false;
export let autoScroll;
export let onSelect = (e) => {};
let messagesCount = 20;
let messagesLoading = false;
@@ -395,25 +397,7 @@
<div class={className}>
{#if Object.keys(history?.messages ?? {}).length == 0}
<ChatPlaceholder
modelIds={selectedModels}
{atSelectedModel}
submitPrompt={async (p) => {
let text = p;
if (p.includes('{{CLIPBOARD}}')) {
const clipboardText = await navigator.clipboard.readText().catch((err) => {
toast.error($i18n.t('Failed to read clipboard contents'));
return '{{CLIPBOARD}}';
});
text = p.replaceAll('{{CLIPBOARD}}', clipboardText);
}
prompt = text;
await tick();
}}
/>
<ChatPlaceholder modelIds={selectedModels} {atSelectedModel} {onSelect} />
{:else}
<div class="w-full pt-2">
{#key chatId}
@@ -438,6 +422,7 @@
<Message
{chatId}
bind:history
{selectedModels}
messageId={message.id}
idx={messageIdx}
{user}
@@ -43,7 +43,6 @@
}
$: {
console.log('sources', sources);
citations = sources.reduce((acc, source) => {
if (Object.keys(source).length === 0) {
return acc;
@@ -4,6 +4,8 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
export let show = false;
@@ -67,16 +69,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -224,7 +224,8 @@
code.includes('sympy') ? 'sympy' : null,
code.includes('tiktoken') ? 'tiktoken' : null,
code.includes('matplotlib') ? 'matplotlib' : null,
code.includes('pytz') ? 'pytz' : null
code.includes('pytz') ? 'pytz' : null,
code.includes('openai') ? 'openai' : null
].filter(Boolean);
console.log(packages);
@@ -385,7 +386,6 @@
};
onMount(async () => {
console.log('codeblock', lang, code);
if (token) {
onUpdate(token);
}
@@ -4,6 +4,7 @@
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
export let show = false;
@@ -49,16 +50,7 @@
codeExecution = null;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -18,6 +18,8 @@
export let id;
export let content;
export let history;
export let selectedModels = [];
export let model = null;
export let sources = null;
@@ -25,11 +27,10 @@
export let preview = false;
export let floatingButtons = true;
export let onSave = () => {};
export let onSourceClick = () => {};
export let onTaskClick = () => {};
export let onAddMessages = () => {};
export let onSave = (e) => {};
export let onSourceClick = (e) => {};
export let onTaskClick = (e) => {};
export let onAddMessages = (e) => {};
let contentContainerElement;
@@ -192,7 +193,11 @@
<FloatingButtons
bind:this={floatingButtonsElement}
{id}
model={model?.id}
model={(selectedModels ?? []).includes(model?.id)
? model?.id
: (selectedModels ?? []).length > 0
? selectedModels.at(0)
: model?.id}
messages={createMessagesList(history, id)}
onAdd={({ modelId, parentId, messages }) => {
console.log(modelId, parentId, messages);
@@ -27,7 +27,8 @@
let tokens = [];
const options = {
throwOnError: false
throwOnError: false,
breaks: true
};
marked.use(markedKatexExtension(options));
@@ -84,6 +84,23 @@
{:else}
{token.text}
{/if}
{:else if token.text && token.text.includes('<status')}
{@const match = token.text.match(/<status title="([^"]+)" done="(true|false)" ?\/?>/)}
{@const statusTitle = match && match[1]}
{@const statusDone = match && match[2] === 'true'}
{#if statusTitle}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{statusDone === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 line-clamp-1 text-wrap"
>
{statusTitle}
</div>
</div>
{:else}
{token.text}
{/if}
{:else if token.text.includes(`<file type="html"`)}
{@const match = token.text.match(/<file type="html" id="([^"]+)"/)}
{@const fileId = match && match[1]}
@@ -146,7 +146,7 @@
{#each row ?? [] as cell, cellIdx}
<td
class="px-3! py-1.5! text-gray-900 dark:text-white w-max border border-gray-100 dark:border-gray-850"
style={token.align[cellIdx] ? '' : `text-align: ${token.align[cellIdx]}`}
style={token.align[cellIdx] ? `text-align: ${token.align[cellIdx]}` : ''}
>
<div class="break-normal">
<MarkdownInlineTokens
@@ -220,12 +220,12 @@
{/each}
</ol>
{:else}
<ul dir="auto">
<ul dir="auto" class="">
{#each token.items as item, itemIdx}
<li class="text-start">
<li class="text-start {item?.task ? 'flex -translate-x-6.5 gap-3 ' : ''}">
{#if item?.task}
<input
class=" translate-y-[1px] -translate-x-1"
class=""
type="checkbox"
checked={item.checked}
on:change={(e) => {
@@ -239,15 +239,25 @@
});
}}
/>
{/if}
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
tokens={item.tokens}
top={token.loose}
{onTaskClick}
{onSourceClick}
/>
<div>
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
tokens={item.tokens}
top={token.loose}
{onTaskClick}
{onSourceClick}
/>
</div>
{:else}
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
tokens={item.tokens}
top={token.loose}
{onTaskClick}
{onSourceClick}
/>
{/if}
</li>
{/each}
</ul>
@@ -13,6 +13,7 @@
import UserMessage from './UserMessage.svelte';
export let chatId;
export let selectedModels = [];
export let idx = 0;
export let history;
@@ -70,6 +71,7 @@
{chatId}
{history}
{messageId}
{selectedModels}
isLastMessage={messageId === history.currentId}
siblings={history.messages[history.messages[messageId].parentId]?.childrenIds ?? []}
{gotoMessage}
@@ -92,6 +94,7 @@
bind:history
{chatId}
{messageId}
{selectedModels}
isLastMessage={messageId === history?.currentId}
{updateChat}
{editMessage}
@@ -23,6 +23,7 @@
export let chatId;
export let history;
export let messageId;
export let selectedModels = [];
export let isLastMessage;
export let readOnly = false;
@@ -252,6 +253,7 @@
{chatId}
{history}
messageId={_messageId}
{selectedModels}
isLastMessage={true}
siblings={groupedMessageIds[modelIdx].messageIds}
gotoMessage={(message, messageIdx) => gotoMessage(modelIdx, messageIdx)}
@@ -14,7 +14,7 @@
src.startsWith('data:') ||
src.startsWith('/')
? src
: `/user.png`}
: `${WEBUI_BASE_URL}/user.png`}
class=" {className} object-cover rounded-full"
alt="profile"
draggable="false"
@@ -4,6 +4,7 @@
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { config, models } from '$lib/stores';
import Tags from '$lib/components/common/Tags.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n');
@@ -123,16 +124,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
<XMark className={'size-4'} />
</button>
</div>
@@ -106,6 +106,7 @@
export let chatId = '';
export let history;
export let messageId;
export let selectedModels = [];
let message: MessageType = JSON.parse(JSON.stringify(history.messages[messageId]));
$: if (history.messages) {
@@ -601,15 +602,17 @@
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex `}>
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex mt-1 `}>
<ProfileImage
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/favicon.png`)}
className={'size-8 assistant-message-profile-image'}
/>
</div>
<div class="flex-auto w-0 pl-1 relative -translate-y-0.5">
<div class="flex-auto w-0 pl-1 relative">
<Name>
<Tooltip content={model?.name ?? message.model} placement="top-start">
<span class="line-clamp-1 text-black dark:text-white">
@@ -795,6 +798,7 @@
<ContentRenderer
id={message.id}
{history}
{selectedModels}
content={message.content}
sources={message.sources}
floatingButtons={message?.done && !readOnly}
@@ -1,7 +1,7 @@
<script lang="ts">
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import MagnifyingGlass from '$lib/components/icons/MagnifyingGlass.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
export let status = { urls: [], query: '' };
@@ -31,7 +31,7 @@
class="flex w-full items-center p-3 px-4 border-b border-gray-300/30 dark:border-gray-700/50 group/item justify-between font-normal text-gray-800 dark:text-gray-300 no-underline"
>
<div class="flex gap-2 items-center">
<MagnifyingGlass />
<Search />
<div class=" line-clamp-1">
{status.query}
@@ -2,44 +2,29 @@
export let size = 'md';
</script>
<div class="w-full mt-2 mb-2">
<div class="animate-pulse flex w-full">
<div class="{size === 'md' ? 'space-y-2' : 'space-y-1.5'} w-full">
<div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm mr-14"
/>
<span class="relative flex {size === 'md' ? 'size-3 my-2' : 'size-2 my-1'} mx-1">
<span
class="absolute inline-flex h-full w-full animate-pulse rounded-full bg-gray-700 dark:bg-gray-200 opacity-75"
></span>
<span
class="relative inline-flex {size === 'md'
? 'size-3'
: 'size-2'} rounded-full bg-black dark:bg-white animate-size"
></span>
</span>
<div class="grid grid-cols-3 gap-4">
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2"
/>
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1"
/>
</div>
<div class="grid grid-cols-4 gap-4">
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1"
/>
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-2"
/>
<div
class="{size === 'md'
? 'h-2'
: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm col-span-1 mr-4"
/>
</div>
<style>
@keyframes size {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.25);
}
}
<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm" />
</div>
</div>
</div>
.animate-size {
animation: size 1.5s ease-in-out infinite;
}
</style>
@@ -6,6 +6,7 @@
import { models, settings } from '$lib/stores';
import { user as _user } from '$lib/stores';
import { copyToClipboard as _copyToClipboard, formatDate } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
import Name from './Name.svelte';
import ProfileImage from './ProfileImage.svelte';
@@ -113,12 +114,12 @@
id="message-{message.id}"
>
{#if !($settings?.chatBubble ?? true)}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3`}>
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
<ProfileImage
src={message.user
? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ??
'/user.png')
: (user?.profile_image_url ?? '/user.png')}
`${WEBUI_BASE_URL}/user.png`)
: (user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`)}
className={'size-8 user-message-profile-image'}
/>
</div>
@@ -148,9 +149,9 @@
</Name>
</div>
{:else if message.timestamp}
<div class="flex justify-end pb-1 pr-2">
<div class="flex justify-end pr-2 text-xs">
<div
class="text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize translate-y-[1px]"
class="text-[0.65rem] invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize mb-0.5"
>
<Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
@@ -162,7 +163,7 @@
<div class="chat-{message.role} w-full min-w-full markdown-prose">
{#if edit !== true}
{#if message.files}
<div class="mt-2.5 mb-1 w-full flex flex-col justify-end overflow-x-auto gap-1 flex-wrap">
<div class="mb-1 w-full flex flex-col justify-end overflow-x-auto gap-1 flex-wrap">
{#each message.files as file}
<div class={($settings?.chatBubble ?? true) ? 'self-end' : ''}>
{#if file.type === 'image'}
+1 -1
View File
@@ -49,7 +49,7 @@
{#each selectedModels as selectedModel, selectedModelIdx}
<div class="flex w-full max-w-fit">
<div class="overflow-hidden w-full">
<div class="mr-1 max-w-full">
<div class="max-w-full {($settings?.highContrastMode ?? false) ? 'm-1' : 'mr-1'}">
<Selector
id={`${selectedModelIdx}`}
placeholder={$i18n.t('Select a model')}
@@ -5,6 +5,7 @@
import dayjs from '$lib/dayjs';
import { mobile, settings, user } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { copyToClipboard, sanitizeResponseContent } from '$lib/utils';
@@ -73,7 +74,8 @@
<div class="flex items-center min-w-fit">
<Tooltip content={$user?.role === 'admin' ? (item?.value ?? '') : ''} placement="top-start">
<img
src={item.model?.info?.meta?.profile_image_url ?? '/static/favicon.png'}
src={item.model?.info?.meta?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
alt="Model"
class="rounded-full size-5 flex items-center"
/>
@@ -7,6 +7,7 @@
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import Spinner from '$lib/components/common/Spinner.svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation';
@@ -345,12 +346,17 @@
closeFocus={false}
>
<DropdownMenu.Trigger
class="relative w-full font-primary"
class="relative w-full font-primary {($settings?.highContrastMode ?? false)
? ''
: 'outline-hidden focus:outline-hidden'}"
aria-label={placeholder}
id="model-selector-{id}-button"
>
<button
class="flex w-full text-left px-0.5 outline-hidden bg-transparent truncate {triggerClassName} justify-between font-medium placeholder-gray-400 focus:outline-hidden"
<div
class="flex w-full text-left px-0.5 bg-transparent truncate {triggerClassName} justify-between {($settings?.highContrastMode ??
false)
? 'dark:placeholder-gray-100 placeholder-gray-800'
: 'placeholder-gray-400'}"
on:mouseenter={async () => {
models.set(
await getModels(
@@ -359,7 +365,6 @@
)
);
}}
type="button"
>
{#if selectedModel}
{selectedModel.label}
@@ -367,7 +372,7 @@
{placeholder}
{/if}
<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
</button>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -550,29 +555,7 @@
>
<div class="flex">
<div class="-ml-2 mr-2.5 translate-y-0.5">
<svg
class="size-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
<div class="flex flex-col self-start">
+79 -50
View File
@@ -40,6 +40,9 @@
export let history;
export let selectedModels;
export let showModelSelector = true;
export let showBanners = true;
let closedBannerIds = [];
let showShareChatModal = false;
let showDownloadChatModal = false;
@@ -162,6 +165,24 @@
</button>
</Tooltip>
{#if $mobile}
<Tooltip content={$i18n.t('New Chat')}>
<button
class=" flex {$showSidebar
? 'md:hidden'
: ''} cursor-pointer px-2 py-2 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={() => {
initNewChat();
}}
aria-label="New Chat"
>
<div class=" m-auto self-center">
<PencilSquare className=" size-5" strokeWidth="2" />
</div>
</button>
</Tooltip>
{/if}
{#if $user !== undefined && $user !== null}
<UserMenu
className="max-w-[240px]"
@@ -173,19 +194,19 @@
}
}}
>
<button
<div
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
>
<div class=" self-center">
<span class="sr-only">{$i18n.t('User menu')}</span>
<img
src={$user?.profile_image_url}
class="size-6 object-cover rounded-full"
alt="User profile"
alt=""
draggable="false"
/>
</div>
</button>
</div>
</UserMenu>
{/if}
</div>
@@ -199,52 +220,60 @@
</div>
{/if}
{#if !history.currentId && !$chatId && ($banners.length > 0 || ($config?.license_metadata?.type ?? null) === 'trial' || (($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats))}
<div class=" w-full z-30 mt-5">
<div class=" flex flex-col gap-1 w-full">
{#if ($config?.license_metadata?.type ?? null) === 'trial'}
<Banner
banner={{
type: 'info',
title: 'Trial License',
content: $i18n.t(
'You are currently using a trial license. Please contact support to upgrade your license.'
)
}}
/>
{/if}
{#if ($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats}
<Banner
banner={{
type: 'error',
title: 'License Error',
content: $i18n.t(
'Exceeded the number of seats in your license. Please contact support to increase the number of seats.'
)
}}
/>
{/if}
{#each $banners.filter( (b) => (b.dismissible ? !JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]').includes(b.id) : true) ) as banner}
<Banner
{banner}
on:dismiss={(e) => {
const bannerId = e.detail;
localStorage.setItem(
'dismissedBannerIds',
JSON.stringify(
[
bannerId,
...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]')
].filter((id) => $banners.find((b) => b.id === id))
<div class="absolute top-[100%] left-0 right-0 h-fit">
{#if !history.currentId && !$chatId && ($banners.length > 0 || ($config?.license_metadata?.type ?? null) === 'trial' || (($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats))}
<div class=" w-full z-30 mt-5">
<div class=" flex flex-col gap-1 w-full">
{#if ($config?.license_metadata?.type ?? null) === 'trial'}
<Banner
banner={{
type: 'info',
title: 'Trial License',
content: $i18n.t(
'You are currently using a trial license. Please contact support to upgrade your license.'
)
);
}}
/>
{/each}
}}
/>
{/if}
{#if ($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats}
<Banner
banner={{
type: 'error',
title: 'License Error',
content: $i18n.t(
'Exceeded the number of seats in your license. Please contact support to increase the number of seats.'
)
}}
/>
{/if}
{#if showBanners}
{#each $banners.filter((b) => ![...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]'), ...closedBannerIds].includes(b.id)) as banner}
<Banner
{banner}
on:dismiss={(e) => {
const bannerId = e.detail;
if (banner.dismissible) {
localStorage.setItem(
'dismissedBannerIds',
JSON.stringify(
[
bannerId,
...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]')
].filter((id) => $banners.find((b) => b.id === id))
)
);
} else {
closedBannerIds = [...closedBannerIds, bannerId];
}
}}
/>
{/each}
{/if}
</div>
</div>
</div>
{/if}
{/if}
</div>
</nav>
+1 -1
View File
@@ -21,7 +21,7 @@
{#if data.message.role === 'user'}
<div class="flex w-full">
<ProfileImage
src={data.user?.profile_image_url ?? '/user.png'}
src={data.user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`}
className={'size-5 -translate-y-[1px]'}
/>
<div class="ml-2">
+22 -44
View File
@@ -32,53 +32,23 @@
export let prompt = '';
export let files = [];
export let messageInput = null;
export let selectedToolIds = [];
export let selectedFilterIds = [];
export let showCommands = false;
export let imageGenerationEnabled = false;
export let codeInterpreterEnabled = false;
export let webSearchEnabled = false;
export let onSelect = (e) => {};
export let toolServers = [];
let models = [];
const selectSuggestionPrompt = async (p) => {
let text = p;
if (p.includes('{{CLIPBOARD}}')) {
const clipboardText = await navigator.clipboard.readText().catch((err) => {
toast.error($i18n.t('Failed to read clipboard contents'));
return '{{CLIPBOARD}}';
});
text = p.replaceAll('{{CLIPBOARD}}', clipboardText);
console.log('Clipboard text:', clipboardText, text);
}
prompt = text;
console.log(prompt);
await tick();
const chatInputContainerElement = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input');
if (chatInputContainerElement) {
chatInputContainerElement.scrollTop = chatInputContainerElement.scrollHeight;
}
await tick();
if (chatInputElement) {
chatInputElement.focus();
chatInputElement.dispatchEvent(new Event('input'));
}
await tick();
};
let selectedModelIdx = 0;
$: if (selectedModels.length > 0) {
@@ -118,6 +88,10 @@
placement="top"
>
<button
aria-hidden={models.length <= 1}
aria-label={$i18n.t('Get information on {{name}} in the UI', {
name: models[modelIdx]?.name
})}
on:click={() => {
selectedModelIdx = modelIdx;
}}
@@ -126,10 +100,10 @@
crossorigin="anonymous"
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG'
? `/doge.png`
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
alt="logo"
aria-hidden="true"
draggable="false"
/>
</button>
@@ -164,7 +138,9 @@
<Tooltip
className=" w-fit"
content={marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description ?? '')
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
placement="top"
>
@@ -172,7 +148,9 @@
class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
>
{@html marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
</div>
</Tooltip>
@@ -199,6 +177,7 @@
<div class="text-base font-normal @md:max-w-3xl w-full py-3 {atSelectedModel ? 'mt-2' : ''}">
<MessageInput
bind:this={messageInput}
{history}
{selectedModels}
bind:files
@@ -210,6 +189,7 @@
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
bind:showCommands
{toolServers}
{transparentBackground}
{stopResponse}
@@ -218,9 +198,9 @@
onChange={(input) => {
if (!$temporaryChatEnabled) {
if (input.prompt !== null) {
localStorage.setItem(`chat-input`, JSON.stringify(input));
sessionStorage.setItem(`chat-input`, JSON.stringify(input));
} else {
localStorage.removeItem(`chat-input`);
sessionStorage.removeItem(`chat-input`);
}
}
}}
@@ -242,9 +222,7 @@
$config?.default_prompt_suggestions ??
[]}
inputValue={prompt}
on:select={(e) => {
selectSuggestionPrompt(e.detail);
}}
{onSelect}
/>
</div>
</div>
+25 -19
View File
@@ -38,7 +38,9 @@
return '';
});
checkForVersionUpdates();
if ($config?.features?.enable_version_update_check) {
checkForVersionUpdates();
}
});
</script>
@@ -58,16 +60,18 @@
v{WEBUI_VERSION}
</Tooltip>
<a
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
target="_blank"
>
{updateAvailable === null
? $i18n.t('Checking for updates...')
: updateAvailable
? `(v${version.latest} ${$i18n.t('available!')})`
: $i18n.t('(latest)')}
</a>
{#if $config?.features?.enable_version_update_check}
<a
href="https://github.com/open-webui/open-webui/releases/tag/v{version.latest}"
target="_blank"
>
{updateAvailable === null
? $i18n.t('Checking for updates...')
: updateAvailable
? `(v${version.latest} ${$i18n.t('available!')})`
: $i18n.t('(latest)')}
</a>
{/if}
</div>
<button
@@ -80,14 +84,16 @@
</button>
</div>
<button
class=" text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
on:click={() => {
checkForVersionUpdates();
}}
>
{$i18n.t('Check for updates')}
</button>
{#if $config?.features?.enable_version_update_check}
<button
class=" text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-medium"
on:click={() => {
checkForVersionUpdates();
}}
>
{$i18n.t('Check for updates')}
</button>
{/if}
</div>
</div>
@@ -4,6 +4,7 @@
import { user, config, settings } from '$lib/stores';
import { updateUserProfile, createAPIKey, getAPIKey, getSessionUser } from '$lib/apis/auths';
import { WEBUI_BASE_URL } from '$lib/constants';
import UpdatePassword from './Account/UpdatePassword.svelte';
import { getGravatarUrl } from '$lib/apis/utils';
@@ -223,7 +224,7 @@
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg px-2 py-1"
on:click={async () => {
profileImageUrl = '/user.png';
profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
}}>{$i18n.t('Remove')}</button
>
</div>
+14 -5
View File
@@ -6,11 +6,10 @@
import {
archiveAllChats,
createNewChat,
deleteAllChats,
getAllChats,
getAllUserChats,
getChatList
getChatList,
importChat
} from '$lib/apis/chats';
import { getImportOrigin, convertOpenAIChats } from '$lib/utils';
import { onMount, getContext } from 'svelte';
@@ -58,9 +57,18 @@
console.log(chat);
if (chat.chat) {
await createNewChat(localStorage.token, chat.chat);
await importChat(
localStorage.token,
chat.chat,
chat.meta ?? {},
false,
null,
chat?.created_at ?? null,
chat?.updated_at ?? null
);
} else {
await createNewChat(localStorage.token, chat);
// Legacy format
await importChat(localStorage.token, chat, {}, false, null);
}
}
@@ -101,6 +109,7 @@
const handleArchivedChatsChange = async () => {
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
scrollPaginationEnabled.set(true);
};
</script>
@@ -274,7 +274,10 @@
<div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div>
<Textarea
bind:value={system}
className="w-full text-sm bg-white dark:text-gray-300 dark:bg-gray-900 outline-hidden resize-none"
className={'w-full text-sm outline-hidden resize-vertical' +
($settings.highContrastMode
? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-850 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden'
: ' bg-white dark:text-gray-300 dark:bg-gray-900')}
rows="4"
placeholder={$i18n.t('Enter system prompt here')}
/>
@@ -38,6 +38,7 @@
let detectArtifacts = true;
let richTextInput = true;
let insertPromptAsRichText = false;
let promptAutocomplete = false;
let largeTextAsFile = false;
@@ -218,6 +219,11 @@
saveSettings({ richTextInput });
};
const toggleInsertPromptAsRichText = async () => {
insertPromptAsRichText = !insertPromptAsRichText;
saveSettings({ insertPromptAsRichText });
};
const toggleLargeTextAsFile = async () => {
largeTextAsFile = !largeTextAsFile;
saveSettings({ largeTextAsFile });
@@ -308,7 +314,9 @@
voiceInterruption = $settings?.voiceInterruption ?? false;
richTextInput = $settings?.richTextInput ?? true;
insertPromptAsRichText = $settings?.insertPromptAsRichText ?? false;
promptAutocomplete = $settings?.promptAutocomplete ?? false;
largeTextAsFile = $settings?.largeTextAsFile ?? false;
copyFormatted = $settings?.copyFormatted ?? false;
@@ -761,22 +769,22 @@
</div>
</div>
{#if $config?.features?.enable_autocomplete_generation && richTextInput}
{#if richTextInput}
<div>
<div class=" py-0.5 flex w-full justify-between">
<div id="prompt-autocompletion-label" class=" self-center text-xs">
{$i18n.t('Prompt Autocompletion')}
<div id="rich-input-label" class=" self-center text-xs">
{$i18n.t('Insert Prompt as Rich Text')}
</div>
<button
aria-labelledby="prompt-autocompletion-label"
aria-labelledby="rich-input-label"
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
togglePromptAutocomplete();
toggleInsertPromptAsRichText();
}}
type="button"
>
{#if promptAutocomplete === true}
{#if insertPromptAsRichText === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
@@ -784,6 +792,31 @@
</button>
</div>
</div>
{#if $config?.features?.enable_autocomplete_generation}
<div>
<div class=" py-0.5 flex w-full justify-between">
<div id="prompt-autocompletion-label" class=" self-center text-xs">
{$i18n.t('Prompt Autocompletion')}
</div>
<button
aria-labelledby="prompt-autocompletion-label"
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
togglePromptAutocomplete();
}}
type="button"
>
{#if promptAutocomplete === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
{/if}
{/if}
<div>
@@ -4,6 +4,8 @@
import Modal from '$lib/components/common/Modal.svelte';
import { addNewMemory, updateMemoryById } from '$lib/apis/memories';
import { toast } from 'svelte-sonner';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const dispatch = createEventDispatcher();
@@ -46,16 +48,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -93,29 +86,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
@@ -4,7 +4,9 @@
import { updateMemoryById } from '$lib/apis/memories';
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const dispatch = createEventDispatcher();
@@ -56,16 +58,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
@@ -103,29 +96,7 @@
{#if loading}
<div class="ml-2 self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
<Spinner />
</div>
{/if}
</button>
+2 -4
View File
@@ -211,9 +211,7 @@
: []),
...($user?.role === 'admin' ||
($user?.role === 'user' &&
$user?.permissions?.features?.direct_tool_servers &&
$config?.features?.direct_tool_servers)
($user?.role === 'user' && $user?.permissions?.features?.direct_tool_servers)
? [
{
id: 'tools',
@@ -688,7 +686,7 @@
</button>
{/if}
{:else if tabId === 'tools'}
{#if $user?.role === 'admin' || ($user?.role === 'user' && $user?.permissions?.features?.direct_tool_servers && $config?.features?.direct_tool_servers)}
{#if $user?.role === 'admin' || ($user?.role === 'user' && $user?.permissions?.features?.direct_tool_servers)}
<button
role="tab"
aria-controls="tab-tools"
+2 -10
View File
@@ -8,6 +8,7 @@
import Modal from '../common/Modal.svelte';
import Link from '../icons/Link.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let chatId;
@@ -90,16 +91,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
+2 -10
View File
@@ -4,6 +4,7 @@
import Tooltip from '../common/Tooltip.svelte';
const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
</script>
@@ -18,16 +19,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
+6 -4
View File
@@ -1,16 +1,16 @@
<script lang="ts">
import Fuse from 'fuse.js';
import Bolt from '$lib/components/icons/Bolt.svelte';
import { onMount, getContext, createEventDispatcher } from 'svelte';
import { onMount, getContext } from 'svelte';
import { settings, WEBUI_NAME } from '$lib/stores';
import { WEBUI_VERSION } from '$lib/constants';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let suggestionPrompts = [];
export let className = '';
export let inputValue = '';
export let onSelect = (e) => {};
let sortedPrompts = [];
@@ -83,14 +83,16 @@
<div class="h-40 w-full">
{#if filteredPrompts.length > 0}
<div class="max-h-40 overflow-auto scrollbar-none items-start {className}">
<div role="list" class="max-h-40 overflow-auto scrollbar-none items-start {className}">
{#each filteredPrompts as prompt, idx (prompt.id || prompt.content)}
<!-- svelte-ignore a11y-no-interactive-element-to-noninteractive-role -->
<button
role="listitem"
class="waterfall flex flex-col flex-1 shrink-0 w-full justify-between
px-3 py-2 rounded-xl bg-transparent hover:bg-black/5
dark:hover:bg-white/5 transition group"
style="animation-delay: {idx * 60}ms"
on:click={() => dispatch('select', prompt.content)}
on:click={() => onSelect({ type: 'prompt', data: prompt.content })}
>
<div class="flex flex-col text-left">
{#if prompt.title && prompt.title[0] !== ''}
@@ -9,6 +9,7 @@
import Modal from '../common/Modal.svelte';
import Link from '../icons/Link.svelte';
import Collapsible from '../common/Collapsible.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
export let selectedToolIds = [];
@@ -30,16 +31,7 @@
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-5'} />
</button>
</div>
+13 -13
View File
@@ -4,6 +4,7 @@
import { fade } from 'svelte/transition';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { WEBUI_BASE_URL } from '$lib/constants';
const dispatch = createEventDispatcher();
@@ -36,6 +37,8 @@
onMount(() => {
mounted = true;
console.log('Banner mounted:', banner);
});
</script>
@@ -58,7 +61,7 @@
<div class="flex md:hidden group w-fit md:items-center">
<a
class="text-gray-700 dark:text-white text-xs font-semibold underline"
href="/assets/files/whitepaper.pdf"
href="{WEBUI_BASE_URL}/assets/files/whitepaper.pdf"
target="_blank">Learn More</a
>
@@ -82,9 +85,8 @@
</div>
{/if}
</div>
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-20 overflow-y-auto">
{@html marked.parse(DOMPurify.sanitize(banner.content))}
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
{@html marked.parse(DOMPurify.sanitize((banner?.content ?? '').replace(/\n/g, '<br>')))}
</div>
</div>
@@ -114,15 +116,13 @@
</div>
{/if}
<div class="flex self-start">
{#if banner.dismissible}
<button
on:click={() => {
dismiss(banner.id);
}}
class=" -mt-1 -mb-2 -translate-y-[1px] ml-1.5 mr-1 text-gray-400 dark:hover:text-white"
>&times;</button
>
{/if}
<button
on:click={() => {
dismiss(banner.id);
}}
class=" -mt-1 -mb-2 -translate-y-[1px] ml-1.5 mr-1 text-gray-400 dark:hover:text-white"
>&times;</button
>
</div>
</div>
{/if}
+21 -28
View File
@@ -48,35 +48,28 @@
/**
* Finds multiple diffs in two strings and generates minimal change edits.
*/
function findChanges(oldStr, newStr) {
let changes = [];
let oldIndex = 0,
newIndex = 0;
while (oldIndex < oldStr.length || newIndex < newStr.length) {
if (oldStr[oldIndex] !== newStr[newIndex]) {
let start = oldIndex;
// Identify the changed portion
while (oldIndex < oldStr.length && oldStr[oldIndex] !== newStr[newIndex]) {
oldIndex++;
}
while (newIndex < newStr.length && newStr[newIndex] !== oldStr[start]) {
newIndex++;
}
changes.push({
from: start,
to: oldIndex, // Replace the differing part
insert: newStr.substring(start, newIndex)
});
} else {
oldIndex++;
newIndex++;
}
function findChanges(oldStr: string, newStr: string) {
// Find the start of the difference
let start = 0;
while (start < oldStr.length && start < newStr.length && oldStr[start] === newStr[start]) {
start++;
}
return changes;
// If equal, nothing to change
if (oldStr === newStr) return [];
// Find the end of the difference by comparing backwards
let endOld = oldStr.length,
endNew = newStr.length;
while (endOld > start && endNew > start && oldStr[endOld - 1] === newStr[endNew - 1]) {
endOld--;
endNew--;
}
return [
{
from: start,
to: endOld,
insert: newStr.slice(start, endNew)
}
];
}
export let id = '';
+10 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { onMount, getContext, createEventDispatcher, onDestroy } from 'svelte';
import { onMount, getContext, createEventDispatcher, onDestroy, tick } from 'svelte';
import * as FocusTrap from 'focus-trap';
const i18n = getContext('i18n');
@@ -25,11 +25,19 @@
export let show = false;
$: if (show) {
init();
}
let modalElement = null;
let mounted = false;
let focusTrap: FocusTrap.FocusTrap | null = null;
const init = () => {
inputValue = '';
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
console.log('Escape');
@@ -44,6 +52,7 @@
const confirmHandler = async () => {
show = false;
await tick();
await onConfirm();
dispatch('confirm', inputValue);
};
+16 -13
View File
@@ -6,6 +6,8 @@
import GarbageBin from '../icons/GarbageBin.svelte';
import Spinner from './Spinner.svelte';
import Tooltip from './Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { settings } from '$lib/stores';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -15,6 +17,7 @@
export let url: string | null = null;
export let dismissible = false;
export let modal = false;
export let loading = false;
export let item = null;
@@ -48,7 +51,7 @@
: 'rounded-2xl'} text-left"
type="button"
on:click={async () => {
if (item?.file?.data?.content) {
if (item?.file?.data?.content || modal) {
showModal = !showModal;
} else {
if (url) {
@@ -70,6 +73,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
class=" size-5"
>
<path
@@ -93,7 +97,11 @@
{decodeString(name)}
</div>
<div class=" flex justify-between text-gray-500 text-xs line-clamp-1">
<div
class=" flex justify-between text-xs line-clamp-1 {($settings?.highContrastMode ?? false)
? 'text-gray-800 dark:text-gray-100'
: 'text-gray-500'}"
>
{#if type === 'file'}
{$i18n.t('File')}
{:else if type === 'doc'}
@@ -127,22 +135,17 @@
{#if dismissible}
<div class=" absolute -top-1 -right-1">
<button
class=" bg-white text-black border border-gray-50 rounded-full group-hover:visible invisible transition"
aria-label={$i18n.t('Remove File')}
class=" bg-white text-black border border-gray-50 rounded-full {($settings?.highContrastMode ??
false)
? ''
: 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
type="button"
on:click|stopPropagation={() => {
dispatch('dismiss');
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
<XMark className={'size-4'} />
</button>
<!-- <button
+30 -1
View File
@@ -10,6 +10,7 @@
import Info from '../icons/Info.svelte';
import Switch from './Switch.svelte';
import Tooltip from './Tooltip.svelte';
import dayjs from 'dayjs';
export let item;
export let show = false;
@@ -77,6 +78,24 @@
<div>
<div class="flex flex-col items-center md:flex-row gap-1 justify-between w-full">
<div class=" flex flex-wrap text-sm gap-1 text-gray-500">
{#if item?.type === 'collection'}
{#if item?.type}
<div class="capitalize shrink-0">{item.type}</div>
•
{/if}
{#if item?.description}
<div class="line-clamp-1">{item.description}</div>
•
{/if}
{#if item?.created_at}
<div class="capitalize shrink-0">
{dayjs(item.created_at * 1000).format('LL')}
</div>
{/if}
{/if}
{#if item.size}
<div class="capitalize shrink-0">{formatFileSize(item.size)}</div>
•
@@ -127,7 +146,17 @@
</div>
<div class="max-h-[75vh] overflow-auto">
{#if isPDF}
{#if item?.type === 'collection'}
<div>
{#each item?.files as file}
<div class="flex items-center gap-2 mb-2">
<div class="flex-shrink-0 text-xs">
{file?.meta?.name}
</div>
</div>
{/each}
</div>
{:else if isPDF}
<iframe
title={item?.name}
src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`}
+17 -7
View File
@@ -69,12 +69,22 @@
}
} else {
open = true;
const dataTransfer = e.dataTransfer.getData('text/plain');
const data = JSON.parse(dataTransfer);
console.log(data);
dispatch('drop', data);
try {
const dataTransfer = e.dataTransfer.getData('text/plain');
if (dataTransfer) {
const data = JSON.parse(dataTransfer);
console.log(data);
dispatch('drop', data);
} else {
console.log('Dropped text data is empty or not text/plain.');
}
} catch (error) {
console.log(
'Dropped data is not valid JSON text or is empty. Ignoring drop event for this type of data.'
);
} finally {
draggedOver = false;
}
}
}
}
@@ -103,7 +113,7 @@
if (!dragAndDrop) {
return;
}
folderElement.addEventListener('dragover', onDragOver);
folderElement.removeEventListener('dragover', onDragOver);
folderElement.removeEventListener('drop', onDrop);
folderElement.removeEventListener('dragleave', onDragLeave);
});

Some files were not shown because too many files have changed in this diff Show More