feat: merge with main

This commit is contained in:
Fabio Polito
2025-03-05 22:04:34 +00:00
372 changed files with 26027 additions and 10944 deletions
+6 -3
View File
@@ -459,7 +459,7 @@ export const getChatById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
error = err;
error = err.detail;
console.log(err);
return null;
@@ -580,7 +580,7 @@ export const toggleChatPinnedStatusById = async (token: string, id: string) => {
return res;
};
export const cloneChatById = async (token: string, id: string) => {
export const cloneChatById = async (token: string, id: string, title?: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone`, {
@@ -589,7 +589,10 @@ export const cloneChatById = async (token: string, id: string) => {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
},
body: JSON.stringify({
...(title && { title: title })
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
+114
View File
@@ -58,6 +58,120 @@ export const exportConfig = async (token: string) => {
return res;
};
export const getDirectConnectionsConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const setDirectConnectionsConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...config
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getCodeExecutionConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_execution`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const setCodeExecutionConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_execution`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...config
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getModelsConfig = async (token: string) => {
let error = null;
+114 -3
View File
@@ -1,6 +1,11 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { getOpenAIModelsDirect } from './openai';
export const getModels = async (token: string = '', base: boolean = false) => {
export const getModels = async (
token: string = '',
connections: object | null = null,
base: boolean = false
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
method: 'GET',
@@ -25,6 +30,111 @@ export const getModels = async (token: string = '', base: boolean = false) => {
}
let models = res?.data ?? [];
if (connections && !base) {
let localModels = [];
if (connections) {
const OPENAI_API_BASE_URLS = connections.OPENAI_API_BASE_URLS;
const OPENAI_API_KEYS = connections.OPENAI_API_KEYS;
const OPENAI_API_CONFIGS = connections.OPENAI_API_CONFIGS;
const requests = [];
for (const idx in OPENAI_API_BASE_URLS) {
const url = OPENAI_API_BASE_URLS[idx];
if (idx.toString() in OPENAI_API_CONFIGS) {
const apiConfig = OPENAI_API_CONFIGS[idx.toString()] ?? {};
const enable = apiConfig?.enable ?? true;
const modelIds = apiConfig?.model_ids ?? [];
if (enable) {
if (modelIds.length > 0) {
const modelList = {
object: 'list',
data: modelIds.map((modelId) => ({
id: modelId,
name: modelId,
owned_by: 'openai',
openai: { id: modelId },
urlIdx: idx
}))
};
requests.push(
(async () => {
return modelList;
})()
);
} else {
requests.push(
(async () => {
return await getOpenAIModelsDirect(url, OPENAI_API_KEYS[idx])
.then((res) => {
return res;
})
.catch((err) => {
return {
object: 'list',
data: [],
urlIdx: idx
};
});
})()
);
}
} else {
requests.push(
(async () => {
return {
object: 'list',
data: [],
urlIdx: idx
};
})()
);
}
}
}
const responses = await Promise.all(requests);
for (const idx in responses) {
const response = responses[idx];
const apiConfig = OPENAI_API_CONFIGS[idx.toString()] ?? {};
let models = Array.isArray(response) ? response : (response?.data ?? []);
models = models.map((model) => ({ ...model, openai: { id: model.id }, urlIdx: idx }));
const prefixId = apiConfig.prefix_id;
if (prefixId) {
for (const model of models) {
model.id = `${prefixId}.${model.id}`;
}
}
localModels = localModels.concat(models);
}
}
models = models.concat(
localModels.map((model) => ({
...model,
name: model?.name ?? model?.id,
direct: true
}))
);
// Remove duplicates
const modelsMap = {};
for (const model of models) {
modelsMap[model.id] = model;
}
models = Object.values(modelsMap);
}
return models;
};
@@ -880,13 +990,14 @@ export const getChangelog = async () => {
return res;
};
export const getVersionUpdates = async () => {
export const getVersionUpdates = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/version/updates`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
+1 -1
View File
@@ -219,7 +219,7 @@ export const deleteModelById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
error = err;
error = err.detail;
console.log(err);
return null;
+79 -23
View File
@@ -208,6 +208,33 @@ export const updateOpenAIKeys = async (token: string = '', keys: string[]) => {
return res.OPENAI_API_KEYS;
};
export const getOpenAIModelsDirect = async (url: string, key: string) => {
let error = null;
const res = await fetch(`${url}/models`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(key && { authorization: `Bearer ${key}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
return res;
};
export const getOpenAIModels = async (token: string, urlIdx?: number) => {
let error = null;
@@ -241,33 +268,62 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => {
export const verifyOpenAIConnection = async (
token: string = '',
url: string = 'https://api.openai.com/v1',
key: string = ''
key: string = '',
direct: boolean = false
) => {
if (!url) {
throw 'OpenAI: URL is required';
}
let error = null;
let res = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/verify`, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
key
if (direct) {
res = await fetch(`${url}/models`, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json'
}
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
if (error) {
throw error;
}
} else {
res = await fetch(`${OPENAI_API_BASE_URL}/verify`, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
key
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
}
return res;
@@ -322,7 +378,7 @@ export const generateOpenAIChatCompletion = async (
return res.json();
})
.catch((err) => {
error = `${err?.detail ?? 'Network Problem'}`;
error = `${err?.detail ?? err}`;
return null;
});
+7
View File
@@ -32,9 +32,15 @@ type ChunkConfigForm = {
chunk_overlap: number;
};
type DocumentIntelligenceConfigForm = {
key: string;
endpoint: string;
};
type ContentExtractConfigForm = {
engine: string;
tika_server_url: string | null;
document_intelligence_config: DocumentIntelligenceConfigForm | null;
};
type YoutubeConfigForm = {
@@ -46,6 +52,7 @@ type YoutubeConfigForm = {
type RAGConfigForm = {
pdf_extract_images?: boolean;
enable_google_drive_integration?: boolean;
enable_onedrive_integration?: boolean;
chunk?: ChunkConfigForm;
content_extraction?: ContentExtractConfigForm;
web_loader_ssl_verification?: boolean;
+4 -2
View File
@@ -284,14 +284,16 @@ export const updateUserInfo = async (token: string, info: object) => {
export const getAndUpdateUserLocation = async (token: string) => {
const location = await getUserPosition().catch((err) => {
throw err;
console.log(err);
return null;
});
if (location) {
await updateUserInfo(token, { location: location });
return location;
} else {
throw new Error('Failed to get user location');
console.log('Failed to get user location');
return null;
}
};
+47 -9
View File
@@ -1,12 +1,13 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const getGravatarUrl = async (email: string) => {
export const getGravatarUrl = async (token: string, email: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/gravatar?email=${email}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
@@ -22,13 +23,14 @@ export const getGravatarUrl = async (email: string) => {
return res;
};
export const formatPythonCode = async (code: string) => {
export const executeCode = async (token: string, code: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
code: code
@@ -55,13 +57,48 @@ export const formatPythonCode = async (code: string) => {
return res;
};
export const downloadChatAsPDF = async (title: string, messages: object[]) => {
export const formatPythonCode = async (token: string, code: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
code: code
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
if (err.detail) {
error = err.detail;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const downloadChatAsPDF = async (token: string, title: string, messages: object[]) => {
let error = null;
const blob = await fetch(`${WEBUI_API_BASE_URL}/utils/pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
title: title,
@@ -81,13 +118,14 @@ export const downloadChatAsPDF = async (title: string, messages: object[]) => {
return blob;
};
export const getHTMLFromMarkdown = async (md: string) => {
export const getHTMLFromMarkdown = async (token: string, md: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/markdown`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
md: md
@@ -20,7 +20,9 @@
export let show = false;
export let edit = false;
export let ollama = false;
export let direct = false;
export let connection = null;
@@ -46,9 +48,11 @@
};
const verifyOpenAIHandler = async () => {
const res = await verifyOpenAIConnection(localStorage.token, url, key).catch((error) => {
toast.error(`${error}`);
});
const res = await verifyOpenAIConnection(localStorage.token, url, key, direct).catch(
(error) => {
toast.error(`${error}`);
}
);
if (res) {
toast.success($i18n.t('Server connection verified'));
@@ -165,7 +169,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
@@ -198,7 +202,7 @@
</button>
</Tooltip>
<div class="flex flex-col flex-shrink-0 self-end">
<div class="flex flex-col shrink-0 self-end">
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch bind:state={enable} />
</Tooltip>
@@ -211,7 +215,7 @@
<div class="flex-1">
<SensitiveInput
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
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={!ollama}
@@ -229,7 +233,7 @@
)}
>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
@@ -254,7 +258,7 @@
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="flex-shrink-0">
<div class="shrink-0">
<button
type="button"
on:click={() => {
@@ -288,7 +292,7 @@
<input
class="w-full py-1 text-sm rounded-lg bg-transparent {modelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={modelId}
placeholder={$i18n.t('Add a model ID')}
/>
+1 -1
View File
@@ -68,7 +68,7 @@
v{version} - {changelog[version].date}
</div>
<hr class=" dark:border-gray-800 my-2" />
<hr class="border-gray-100 dark:border-gray-850 my-2" />
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
<div class="">
+2 -2
View File
@@ -31,13 +31,13 @@
</script>
<button
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-50 dark:border-gray-800 rounded-xl px-3.5 py-3.5"
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-850 rounded-xl px-3.5 py-3.5"
on:click={() => {
onClick();
dispatch('closeToast');
}}
>
<div class="flex-shrink-0 self-top -translate-y-0.5">
<div class="shrink-0 self-top -translate-y-0.5">
<img src={'/static/favicon.png'} alt="favicon" class="size-7 rounded-full" />
</div>
+30 -3
View File
@@ -1,5 +1,5 @@
<script>
import { getContext } from 'svelte';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
@@ -10,6 +10,32 @@
export let show = true;
export let getStartedHandler = () => {};
function setLogoImage() {
const logo = document.getElementById('logo');
if (logo) {
const isDarkMode = document.documentElement.classList.contains('dark');
if (isDarkMode) {
const darkImage = new Image();
darkImage.src = '/static/favicon-dark.png';
darkImage.onload = () => {
logo.src = '/static/favicon-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
};
}
}
}
$: if (show) {
setLogoImage();
}
</script>
{#if show}
@@ -18,6 +44,7 @@
<div class="flex space-x-2">
<div class=" self-center">
<img
id="logo"
crossorigin="anonymous"
src="{WEBUI_BASE_URL}/static/favicon.png"
class=" w-6 rounded-full"
@@ -30,10 +57,10 @@
<SlideShow duration={5000} />
<div
class="w-full h-full absolute top-0 left-0 bg-gradient-to-t from-20% from-black to-transparent"
class="w-full h-full absolute top-0 left-0 bg-linear-to-t from-20% from-black to-transparent"
></div>
<div class="w-full h-full absolute top-0 left-0 backdrop-blur-sm bg-black/50"></div>
<div class="w-full h-full absolute top-0 left-0 backdrop-blur-xs bg-black/50"></div>
<div class="relative bg-transparent w-full min-h-screen flex z-10">
<div class="flex flex-col justify-end w-full items-center pb-10 text-center">
@@ -65,7 +65,7 @@
};
const shareHandler = async () => {
toast.success($i18n.t('Redirecting you to OpenWebUI Community'));
toast.success($i18n.t('Redirecting you to Open WebUI Community'));
// remove snapshot from feedbacks
const feedbacksToShare = feedbacks.map((f) => {
@@ -131,14 +131,16 @@
</div>
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5">
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
{#if (feedbacks ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedbacks found')}
</div>
{:else}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded"
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
@@ -169,7 +171,7 @@
<td class=" py-0.5 text-right font-semibold">
<div class="flex justify-center">
<Tooltip content={feedback?.user?.name}>
<div class="flex-shrink-0">
<div class="shrink-0">
<img
src={feedback?.user?.profile_image_url ?? '/user.png'}
alt={feedback?.user?.name}
@@ -266,7 +268,7 @@
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Share to OpenWebUI Community')}
{$i18n.t('Share to Open WebUI Community')}
</div>
<div class=" self-center">
@@ -1,6 +1,8 @@
<script lang="ts">
import * as ort from 'onnxruntime-web';
import { AutoModel, AutoTokenizer } from '@huggingface/transformers';
import { env, AutoModel, AutoTokenizer } from '@huggingface/transformers';
env.backends.onnx.wasm.wasmPaths = '/wasm/';
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
@@ -286,7 +288,7 @@
<MagnifyingGlass className="size-3" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search')}
on:focus={() => {
@@ -298,7 +300,9 @@
</div>
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5">
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
{#if loadingLeaderboard}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
<div class="m-auto">
@@ -347,7 +351,7 @@
</td>
<td class="px-3 py-1.5 flex flex-col justify-center">
<div class="flex items-center gap-2">
<div class="flex-shrink-0">
<div class="shrink-0">
<img
src={model?.info?.meta?.profile_image_url ?? '/favicon.png'}
alt={model.name}
+40 -14
View File
@@ -3,7 +3,7 @@
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { WEBUI_NAME, config, functions, models } from '$lib/stores';
import { WEBUI_NAME, config, functions, models, settings } from '$lib/stores';
import { onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation';
@@ -65,7 +65,7 @@
return null;
});
toast.success($i18n.t('Redirecting you to OpenWebUI Community'));
toast.success($i18n.t('Redirecting you to Open WebUI Community'));
const url = 'https://openwebui.com';
@@ -126,7 +126,12 @@
toast.success($i18n.t('Function deleted successfully'));
functions.set(await getFunctions(localStorage.token));
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
};
@@ -147,7 +152,12 @@
}
functions.set(await getFunctions(localStorage.token));
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
};
@@ -170,12 +180,12 @@
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
window.addEventListener('blur-sm', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
window.removeEventListener('blur-sm', onBlur);
};
});
</script>
@@ -201,7 +211,7 @@
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Functions')}
/>
@@ -231,14 +241,14 @@
<div class=" flex-1 self-center pl-1">
<div class=" font-semibold flex items-center gap-1.5">
<div
class=" text-xs font-bold px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class=" text-xs font-bold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
{func.type}
</div>
{#if func?.meta?.manifest?.version}
<div
class="text-xs font-bold px-1 rounded line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class="text-xs font-bold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
v{func?.meta?.manifest?.version ?? ''}
</div>
@@ -250,7 +260,7 @@
</div>
<div class="flex gap-1.5 px-1">
<div class=" text-gray-500 text-xs font-medium flex-shrink-0">{func.id}</div>
<div class=" text-gray-500 text-xs font-medium shrink-0">{func.id}</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{func.meta.description}
@@ -359,7 +369,13 @@
bind:state={func.is_active}
on:change={async (e) => {
toggleFunctionById(localStorage.token, func.id);
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
}}
/>
</Tooltip>
@@ -453,7 +469,7 @@
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" text-xl font-medium mb-1 line-clamp-1">
{$i18n.t('Made by OpenWebUI Community')}
{$i18n.t('Made by Open WebUI Community')}
</div>
<a
@@ -496,7 +512,12 @@
id={selectedFunction?.id ?? null}
on:save={async () => {
await tick();
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}}
/>
@@ -517,7 +538,12 @@
toast.success($i18n.t('Functions imported successfully'));
functions.set(await getFunctions(localStorage.token));
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
reader.readAsText(importFiles[0]);
@@ -1,8 +1,7 @@
<script>
import { getContext, createEventDispatcher, onMount, tick } from 'svelte';
import { getContext, onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
@@ -15,6 +14,8 @@
let loading = false;
let showConfirm = false;
export let onSave = () => {};
export let edit = false;
export let clone = false;
@@ -256,7 +257,7 @@ class Pipe:
const saveHandler = async () => {
loading = true;
dispatch('save', {
onSave({
id,
name,
meta,
@@ -300,7 +301,7 @@ class Pipe:
<div class="flex flex-col flex-1 overflow-auto h-0 rounded-lg">
<div class="w-full mb-2 flex flex-col gap-0.5">
<div class="flex w-full items-center">
<div class=" flex-shrink-0 mr-2">
<div class=" shrink-0 mr-2">
<Tooltip content={$i18n.t('Back')}>
<button
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
@@ -317,7 +318,7 @@ class Pipe:
<div class="flex-1">
<Tooltip content={$i18n.t('e.g. My Filter')} placement="top-start">
<input
class="w-full text-2xl font-medium bg-transparent outline-none font-primary"
class="w-full text-2xl font-medium bg-transparent outline-hidden font-primary"
type="text"
placeholder={$i18n.t('Function Name')}
bind:value={name}
@@ -333,13 +334,13 @@ class Pipe:
<div class=" flex gap-2 px-1 items-center">
{#if edit}
<div class="text-sm text-gray-500 flex-shrink-0">
<div class="text-sm text-gray-500 shrink-0">
{id}
</div>
{:else}
<Tooltip className="w-full" content={$i18n.t('e.g. my_filter')} placement="top-start">
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-none"
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Function ID')}
bind:value={id}
@@ -355,7 +356,7 @@ class Pipe:
placement="top-start"
>
<input
class="w-full text-sm bg-transparent outline-none"
class="w-full text-sm bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Function Description')}
bind:value={meta.description}
@@ -371,10 +372,10 @@ class Pipe:
value={content}
lang="python"
{boilerplate}
on:change={(e) => {
_content = e.detail.value;
onChange={(e) => {
_content = e;
}}
on:save={async () => {
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
@@ -42,7 +42,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={-2}
side="bottom"
align="start"
@@ -63,7 +63,7 @@
</div>
</div>
<hr class="border-gray-100 dark:border-gray-800 my-1" />
<hr class="border-gray-100 dark:border-gray-850 my-1" />
{/if}
<DropdownMenu.Item
@@ -122,7 +122,7 @@
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-800 my-1" />
<hr class="border-gray-100 dark:border-gray-850 my-1" />
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+36
View File
@@ -19,6 +19,7 @@
import ChartBar from '../icons/ChartBar.svelte';
import DocumentChartBar from '../icons/DocumentChartBar.svelte';
import Evaluations from './Settings/Evaluations.svelte';
import CodeExecution from './Settings/CodeExecution.svelte';
const i18n = getContext('i18n');
@@ -188,6 +189,32 @@
<div class=" self-center">{$i18n.t('Web Search')}</div>
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'code-execution'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'code-execution';
}}
>
<div class=" self-center mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4Zm2.22 1.97a.75.75 0 0 0 0 1.06l.97.97-.97.97a.75.75 0 1 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06l-1.5-1.5a.75.75 0 0 0-1.06 0ZM8.75 8.5a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Code Execution')}</div>
</button>
<button
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'interface'
@@ -364,6 +391,15 @@
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'code-execution'}
<CodeExecution
saveHandler={async () => {
toast.success($i18n.t('Settings saved successfully!'));
await tick();
await config.set(await getBackendConfig());
}}
/>
{:else if selectedTab === 'interface'}
<Interface
on:save={() => {
+65 -27
View File
@@ -10,7 +10,7 @@
getModels as _getModels,
getVoices as _getVoices
} from '$lib/apis/audio';
import { config } from '$lib/stores';
import { config, settings } from '$lib/stores';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
@@ -39,6 +39,7 @@
let STT_ENGINE = '';
let STT_MODEL = '';
let STT_WHISPER_MODEL = '';
let STT_DEEPGRAM_API_KEY = '';
let STT_WHISPER_MODEL_LOADING = false;
@@ -50,8 +51,11 @@
if (TTS_ENGINE === '') {
models = [];
} else {
const res = await _getModels(localStorage.token).catch((e) => {
toast.error(e);
const res = await _getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
).catch((e) => {
toast.error(`${e}`);
});
if (res) {
@@ -74,7 +78,7 @@
}, 100);
} else {
const res = await _getVoices(localStorage.token).catch((e) => {
toast.error(e);
toast.error(`${e}`);
});
if (res) {
@@ -103,7 +107,8 @@
OPENAI_API_KEY: STT_OPENAI_API_KEY,
ENGINE: STT_ENGINE,
MODEL: STT_MODEL,
WHISPER_MODEL: STT_WHISPER_MODEL
WHISPER_MODEL: STT_WHISPER_MODEL,
DEEPGRAM_API_KEY: STT_DEEPGRAM_API_KEY
}
});
@@ -143,6 +148,7 @@
STT_ENGINE = res.stt.ENGINE;
STT_MODEL = res.stt.MODEL;
STT_WHISPER_MODEL = res.stt.WHISPER_MODEL;
STT_DEEPGRAM_API_KEY = res.stt.DEEPGRAM_API_KEY;
}
await getVoices();
@@ -166,13 +172,14 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Speech-to-Text Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 cursor-pointer w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
class="dark:bg-gray-900 cursor-pointer w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={STT_ENGINE}
placeholder="Select an engine"
>
<option value="">{$i18n.t('Whisper (Local)')}</option>
<option value="openai">OpenAI</option>
<option value="web">{$i18n.t('Web API')}</option>
<option value="deepgram">Deepgram</option>
</select>
</div>
</div>
@@ -181,7 +188,7 @@
<div>
<div class="mt-1 flex gap-2 mb-1">
<input
class="flex-1 w-full bg-transparent outline-none"
class="flex-1 w-full bg-transparent outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={STT_OPENAI_API_BASE_URL}
required
@@ -191,7 +198,7 @@
</div>
</div>
<hr class=" dark:border-gray-850 my-2" />
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('STT Model')}</div>
@@ -199,7 +206,7 @@
<div class="flex-1">
<input
list="model-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={STT_MODEL}
placeholder="Select a model"
/>
@@ -210,6 +217,37 @@
</div>
</div>
</div>
{:else if STT_ENGINE === 'deepgram'}
<div>
<div class="mt-1 flex gap-2 mb-1">
<SensitiveInput placeholder={$i18n.t('API Key')} bind:value={STT_DEEPGRAM_API_KEY} />
</div>
</div>
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('STT Model')}</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={STT_MODEL}
placeholder="Select a model (optional)"
/>
</div>
</div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Leave model field empty to use the default model.')}
<a
class=" hover:underline dark:text-gray-200 text-gray-800"
href="https://developers.deepgram.com/docs/models"
target="_blank"
>
{$i18n.t('Click here to see available models.')}
</a>
</div>
</div>
{:else if STT_ENGINE === ''}
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('STT Model')}</div>
@@ -217,7 +255,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Set whisper model')}
bind:value={STT_WHISPER_MODEL}
/>
@@ -295,7 +333,7 @@
{/if}
</div>
<hr class=" dark:border-gray-800" />
<hr class="border-gray-100 dark:border-gray-850" />
<div>
<div class=" mb-1 text-sm font-medium">{$i18n.t('TTS Settings')}</div>
@@ -304,7 +342,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Text-to-Speech Engine')}</div>
<div class="flex items-center relative">
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={TTS_ENGINE}
placeholder="Select a mode"
on:change={async (e) => {
@@ -334,7 +372,7 @@
<div>
<div class="mt-1 flex gap-2 mb-1">
<input
class="flex-1 w-full bg-transparent outline-none"
class="flex-1 w-full bg-transparent outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={TTS_OPENAI_API_BASE_URL}
required
@@ -347,7 +385,7 @@
<div>
<div class="mt-1 flex gap-2 mb-1">
<input
class="flex-1 w-full rounded-lg py-2 pl-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full rounded-lg py-2 pl-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('API Key')}
bind:value={TTS_API_KEY}
required
@@ -358,13 +396,13 @@
<div>
<div class="mt-1 flex gap-2 mb-1">
<input
class="flex-1 w-full rounded-lg py-2 pl-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full rounded-lg py-2 pl-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('API Key')}
bind:value={TTS_API_KEY}
required
/>
<input
class="flex-1 w-full rounded-lg py-2 pl-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full rounded-lg py-2 pl-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Azure Region')}
bind:value={TTS_AZURE_SPEECH_REGION}
required
@@ -373,7 +411,7 @@
</div>
{/if}
<hr class=" dark:border-gray-850 my-2" />
<hr class="border-gray-100 dark:border-gray-850 my-2" />
{#if TTS_ENGINE === ''}
<div>
@@ -381,7 +419,7 @@
<div class="flex w-full">
<div class="flex-1">
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_VOICE}
>
<option value="" selected={TTS_VOICE !== ''}>{$i18n.t('Default')}</option>
@@ -404,7 +442,7 @@
<div class="flex-1">
<input
list="model-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_MODEL}
placeholder="CMU ARCTIC speaker embedding name"
/>
@@ -446,7 +484,7 @@
<div class="flex-1">
<input
list="voice-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_VOICE}
placeholder="Select a voice"
/>
@@ -465,7 +503,7 @@
<div class="flex-1">
<input
list="tts-model-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_MODEL}
placeholder="Select a model"
/>
@@ -487,7 +525,7 @@
<div class="flex-1">
<input
list="voice-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_VOICE}
placeholder="Select a voice"
/>
@@ -506,7 +544,7 @@
<div class="flex-1">
<input
list="tts-model-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_MODEL}
placeholder="Select a model"
/>
@@ -528,7 +566,7 @@
<div class="flex-1">
<input
list="voice-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_VOICE}
placeholder="Select a voice"
/>
@@ -555,7 +593,7 @@
<div class="flex-1">
<input
list="tts-model-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_AZURE_SPEECH_OUTPUT_FORMAT}
placeholder="Select a output format"
/>
@@ -565,13 +603,13 @@
</div>
{/if}
<hr class="dark:border-gray-850 my-2" />
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<div class="pt-0.5 flex w-full justify-between">
<div class="self-center text-xs font-medium">{$i18n.t('Response splitting')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
class="dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
aria-label="Select how to split message text for TTS requests"
bind:value={TTS_SPLIT_ON}
>
@@ -0,0 +1,317 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { getCodeExecutionConfig, setCodeExecutionConfig } from '$lib/apis/configs';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Switch from '$lib/components/common/Switch.svelte';
const i18n = getContext('i18n');
export let saveHandler: Function;
let config = null;
let engines = ['pyodide', 'jupyter'];
const submitHandler = async () => {
const res = await setCodeExecutionConfig(localStorage.token, config);
};
onMount(async () => {
const res = await getCodeExecutionConfig(localStorage.token);
if (res) {
config = res;
}
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={async () => {
await submitHandler();
saveHandler();
}}
>
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if config}
<div>
<div class="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Code Execution Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_EXECUTION_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}>{engine}</option>
{/each}
</select>
</div>
</div>
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
<div class="text-gray-500 text-xs">
{$i18n.t(
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.'
)}
</div>
{/if}
</div>
{#if config.CODE_EXECUTION_ENGINE === 'jupyter'}
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class="text-xs font-medium">
{$i18n.t('Jupyter URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter Jupyter URL')}
bind:value={config.CODE_EXECUTION_JUPYTER_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class=" flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Jupyter Auth')}
</div>
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
<option selected value="">{$i18n.t('None')}</option>
<option value="token">{$i18n.t('Token')}</option>
<option value="password">{$i18n.t('Password')}</option>
</select>
</div>
</div>
{#if config.CODE_EXECUTION_JUPYTER_AUTH}
<div class="flex w-full gap-2">
<div class="flex-1">
{#if config.CODE_EXECUTION_JUPYTER_AUTH === 'password'}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Password')}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD}
autocomplete="off"
/>
{:else}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Token')}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN}
autocomplete="off"
/>
{/if}
</div>
</div>
{/if}
</div>
<div class="flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Code Execution Timeout')}
</div>
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="dark:bg-gray-900 w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_EXECUTION_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
{/if}
</div>
<div class="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Code Interpreter')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5">
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enable Code Interpreter')}
</div>
<Switch bind:state={config.ENABLE_CODE_INTERPRETER} />
</div>
</div>
{#if config.ENABLE_CODE_INTERPRETER}
<div class="mb-2.5">
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Code Interpreter Engine')}
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_INTERPRETER_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each engines as engine}
<option value={engine}>{engine}</option>
{/each}
</select>
</div>
</div>
{#if config.CODE_INTERPRETER_ENGINE === 'jupyter'}
<div class="text-gray-500 text-xs">
{$i18n.t(
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.'
)}
</div>
{/if}
</div>
{#if config.CODE_INTERPRETER_ENGINE === 'jupyter'}
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class="text-xs font-medium">
{$i18n.t('Jupyter URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full text-sm py-0.5 placeholder:text-gray-300 dark:placeholder:text-gray-700 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter Jupyter URL')}
bind:value={config.CODE_INTERPRETER_JUPYTER_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mb-2.5 flex flex-col gap-1.5 w-full">
<div class="flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Jupyter Auth')}
</div>
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
<option selected value="">{$i18n.t('None')}</option>
<option value="token">{$i18n.t('Token')}</option>
<option value="password">{$i18n.t('Password')}</option>
</select>
</div>
</div>
{#if config.CODE_INTERPRETER_JUPYTER_AUTH}
<div class="flex w-full gap-2">
<div class="flex-1">
{#if config.CODE_INTERPRETER_JUPYTER_AUTH === 'password'}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Password')}
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD}
autocomplete="off"
/>
{:else}
<SensitiveInput
type="text"
placeholder={$i18n.t('Enter Jupyter Token')}
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN}
autocomplete="off"
/>
{/if}
</div>
</div>
{/if}
</div>
<div class="flex gap-2 w-full items-center justify-between">
<div class="text-xs font-medium">
{$i18n.t('Code Execution Timeout')}
</div>
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="dark:bg-gray-900 w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_INTERPRETER_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
{/if}
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class="py-0.5 w-full">
<div class=" mb-2.5 text-xs font-medium">
{$i18n.t('Code Interpreter Prompt Template')}
</div>
<Tooltip
content={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
placement="top-start"
>
<Textarea
bind:value={config.CODE_INTERPRETER_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
</div>
{/if}
</div>
</div>
{/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-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>
@@ -7,8 +7,9 @@
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 { models, user } from '$lib/stores';
import { config, models, settings, user } from '$lib/stores';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
@@ -16,13 +17,16 @@
import Plus from '$lib/components/icons/Plus.svelte';
import OpenAIConnection from './Connections/OpenAIConnection.svelte';
import AddConnectionModal from './Connections/AddConnectionModal.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import OllamaConnection from './Connections/OllamaConnection.svelte';
const i18n = getContext('i18n');
const getModels = async () => {
const models = await _getModels(localStorage.token);
const models = await _getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
);
return models;
};
@@ -37,6 +41,8 @@
let ENABLE_OPENAI_API: null | boolean = null;
let ENABLE_OLLAMA_API: null | boolean = null;
let directConnectionsConfig = null;
let pipelineUrls = {};
let showAddOpenAIConnectionModal = false;
let showAddOllamaConnectionModal = false;
@@ -98,17 +104,33 @@
}
};
const updateDirectConnectionsHandler = async () => {
const res = await setDirectConnectionsConfig(localStorage.token, directConnectionsConfig).catch(
(error) => {
toast.error(`${error}`);
}
);
if (res) {
toast.success($i18n.t('Direct Connections settings updated'));
await models.set(await getModels());
}
};
const addOpenAIConnectionHandler = async (connection) => {
OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url];
OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key];
OPENAI_API_CONFIGS[OPENAI_API_BASE_URLS.length] = connection.config;
OPENAI_API_CONFIGS[OPENAI_API_BASE_URLS.length - 1] = connection.config;
await updateOpenAIHandler();
};
const addOllamaConnectionHandler = async (connection) => {
OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, connection.url];
OLLAMA_API_CONFIGS[OLLAMA_BASE_URLS.length] = connection.config;
OLLAMA_API_CONFIGS[OLLAMA_BASE_URLS.length - 1] = {
...connection.config,
key: connection.key
};
await updateOllamaHandler();
};
@@ -124,6 +146,9 @@
})(),
(async () => {
openaiConfig = await getOpenAIConfig(localStorage.token);
})(),
(async () => {
directConnectionsConfig = await getDirectConnectionsConfig(localStorage.token);
})()
]);
@@ -167,6 +192,14 @@
}
}
});
const submitHandler = async () => {
updateOpenAIHandler();
updateOllamaHandler();
updateDirectConnectionsHandler();
dispatch('save');
};
</script>
<AddConnectionModal
@@ -180,17 +213,9 @@
onSubmit={addOllamaConnectionHandler}
/>
<form
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={() => {
updateOpenAIHandler();
updateOllamaHandler();
dispatch('save');
}}
>
<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}
{#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">
@@ -209,7 +234,7 @@
</div>
{#if ENABLE_OPENAI_API}
<hr class=" border-gray-50 dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="">
<div class="flex justify-between items-center">
@@ -244,7 +269,12 @@
);
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
delete OPENAI_API_CONFIGS[idx];
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}
@@ -254,7 +284,7 @@
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850" />
<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 mb-2">
@@ -271,7 +301,7 @@
</div>
{#if ENABLE_OLLAMA_API}
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="">
<div class="flex justify-between items-center">
@@ -302,7 +332,12 @@
}}
onDelete={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
delete OLLAMA_API_CONFIGS[idx];
let newConfig = {};
OLLAMA_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
OLLAMA_API_CONFIGS = newConfig;
}}
/>
{/each}
@@ -322,6 +357,33 @@
</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>
</div>
<div class="mt-1.5">
<div class="text-xs text-gray-500">
{$i18n.t(
'Direct Connections allow users to connect to their own OpenAI compatible API endpoints.'
)}
</div>
</div>
</div>
{:else}
<div class="flex h-full justify-center">
<div class="my-auto">
@@ -16,7 +16,7 @@
<div
class="flex w-full justify-between items-center text-lg font-medium self-center font-primary"
>
<div class=" flex-shrink-0">
<div class=" shrink-0">
{$i18n.t('Manage Ollama')}
</div>
</div>
@@ -4,7 +4,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import AddConnectionModal from './AddConnectionModal.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
@@ -56,7 +56,7 @@
{/if}
<input
class="w-full text-sm bg-transparent outline-none"
class="w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter URL (e.g. http://localhost:11434)')}
bind:value={url}
/>
@@ -5,7 +5,8 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from './AddConnectionModal.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import { connect } from 'socket.io-client';
export let onDelete = () => {};
@@ -53,7 +54,7 @@
<div class="flex w-full">
<div class="flex-1 relative">
<input
class=" outline-none w-full bg-transparent {pipeline ? 'pr-8' : ''}"
class=" outline-hidden w-full bg-transparent {pipeline ? 'pr-8' : ''}"
placeholder={$i18n.t('API Base URL')}
bind:value={url}
autocomplete="off"
@@ -84,7 +85,7 @@
</div>
<SensitiveInput
inputClassName=" outline-none bg-transparent w-full"
inputClassName=" outline-hidden bg-transparent w-full"
placeholder={$i18n.t('API Key')}
bind:value={key}
/>
@@ -119,7 +119,7 @@
</div>
</button>
<hr class=" dark:border-gray-850 my-1" />
<hr class="border-gray-100 dark:border-gray-850 my-1" />
{#if $config?.features.enable_admin_export ?? true}
<div class=" flex w-full justify-between">
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { models, user } from '$lib/stores';
import { models, settings, user, config } from '$lib/stores';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
const dispatch = createEventDispatcher();
@@ -16,49 +16,69 @@
const i18n = getContext('i18n');
let config = null;
let evaluationConfig = null;
let showAddModel = false;
const submitHandler = async () => {
config = await updateConfig(localStorage.token, config).catch((err) => {
evaluationConfig = await updateConfig(localStorage.token, evaluationConfig).catch((err) => {
toast.error(err);
return null;
});
if (config) {
if (evaluationConfig) {
toast.success('Settings saved successfully');
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
};
const addModelHandler = async (model) => {
config.EVALUATION_ARENA_MODELS.push(model);
config.EVALUATION_ARENA_MODELS = [...config.EVALUATION_ARENA_MODELS];
evaluationConfig.EVALUATION_ARENA_MODELS.push(model);
evaluationConfig.EVALUATION_ARENA_MODELS = [...evaluationConfig.EVALUATION_ARENA_MODELS];
await submitHandler();
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
const editModelHandler = async (model, modelIdx) => {
config.EVALUATION_ARENA_MODELS[modelIdx] = model;
config.EVALUATION_ARENA_MODELS = [...config.EVALUATION_ARENA_MODELS];
evaluationConfig.EVALUATION_ARENA_MODELS[modelIdx] = model;
evaluationConfig.EVALUATION_ARENA_MODELS = [...evaluationConfig.EVALUATION_ARENA_MODELS];
await submitHandler();
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
const deleteModelHandler = async (modelIdx) => {
config.EVALUATION_ARENA_MODELS = config.EVALUATION_ARENA_MODELS.filter(
evaluationConfig.EVALUATION_ARENA_MODELS = evaluationConfig.EVALUATION_ARENA_MODELS.filter(
(m, mIdx) => mIdx !== modelIdx
);
await submitHandler();
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
onMount(async () => {
if ($user.role === 'admin') {
config = await getConfig(localStorage.token).catch((err) => {
evaluationConfig = await getConfig(localStorage.token).catch((err) => {
toast.error(err);
return null;
});
@@ -81,61 +101,67 @@
}}
>
<div class="overflow-y-scroll scrollbar-hidden h-full">
{#if config !== null}
{#if evaluationConfig !== null}
<div class="">
<div class="text-sm font-medium mb-2">{$i18n.t('General Settings')}</div>
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<div class=" mb-2">
<div class="flex justify-between items-center text-xs">
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5 flex w-full justify-between">
<div class=" text-xs font-medium">{$i18n.t('Arena Models')}</div>
<Tooltip content={$i18n.t(`Message rating should be enabled to use this feature`)}>
<Switch bind:state={config.ENABLE_EVALUATION_ARENA_MODELS} />
<Switch bind:state={evaluationConfig.ENABLE_EVALUATION_ARENA_MODELS} />
</Tooltip>
</div>
</div>
{#if config.ENABLE_EVALUATION_ARENA_MODELS}
<hr class=" border-gray-50 dark:border-gray-700/10 my-2" />
<div class="flex justify-between items-center mb-2">
<div class="text-sm font-medium">{$i18n.t('Manage Arena Models')}</div>
<div>
<Tooltip content={$i18n.t('Add Arena Model')}>
<button
class="p-1"
type="button"
on:click={() => {
showAddModel = true;
}}
>
<Plus />
</button>
</Tooltip>
</div>
</div>
<div class="flex flex-col gap-2">
{#if (config?.EVALUATION_ARENA_MODELS ?? []).length > 0}
{#each config.EVALUATION_ARENA_MODELS as model, index}
<Model
{model}
on:edit={(e) => {
editModelHandler(e.detail, index);
}}
on:delete={(e) => {
deleteModelHandler(index);
}}
/>
{/each}
{:else}
<div class=" text-center text-xs text-gray-500">
{$i18n.t(
`Using the default arena model with all models. Click the plus button to add custom models.`
)}
{#if evaluationConfig.ENABLE_EVALUATION_ARENA_MODELS}
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium flex justify-between items-center">
<div>
{$i18n.t('Manage')}
</div>
{/if}
<div>
<Tooltip content={$i18n.t('Add Arena Model')}>
<button
class="p-1"
type="button"
on:click={() => {
showAddModel = true;
}}
>
<Plus />
</button>
</Tooltip>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="flex flex-col gap-2">
{#if (evaluationConfig?.EVALUATION_ARENA_MODELS ?? []).length > 0}
{#each evaluationConfig.EVALUATION_ARENA_MODELS as model, index}
<Model
{model}
on:edit={(e) => {
editModelHandler(e.detail, index);
}}
on:delete={(e) => {
deleteModelHandler(index);
}}
/>
{/each}
{:else}
<div class=" text-center text-xs text-gray-500">
{$i18n.t(
`Using the default arena model with all models. Click the plus button to add custom models.`
)}
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -245,7 +245,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Model Name')}
@@ -260,7 +260,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={id}
placeholder={$i18n.t('Model ID')}
@@ -277,7 +277,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
@@ -324,7 +324,7 @@
<div class=" text-sm flex-1 py-1 rounded-lg">
{$models.find((model) => model.id === modelId)?.name}
</div>
<div class="flex-shrink-0">
<div class="shrink-0">
<button
type="button"
on:click={() => {
@@ -350,7 +350,7 @@
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
@@ -34,7 +34,7 @@
<div class="w-full flex flex-col">
<div class="flex items-center gap-1">
<div class="flex-shrink-0 line-clamp-1">
<div class="shrink-0 line-clamp-1">
{model.name}
</div>
</div>
+532 -346
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { getBackendConfig, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import DOMPurify from 'dompurify';
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import {
getAdminConfig,
getLdapConfig,
@@ -11,7 +13,9 @@
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 { config } from '$lib/stores';
import { WEBUI_BUILD_HASH, WEBUI_VERSION } from '$lib/constants';
import { config, showChangelog } from '$lib/stores';
import { compareVersion } from '$lib/utils';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -19,6 +23,12 @@
export let saveHandler: Function;
let updateAvailable = null;
let version = {
current: '',
latest: ''
};
let adminConfig = null;
let webhookUrl = '';
@@ -39,6 +49,21 @@
ciphers: ''
};
const checkForVersionUpdates = async () => {
updateAvailable = null;
version = await getVersionUpdates(localStorage.token).catch((error) => {
return {
current: WEBUI_VERSION,
latest: WEBUI_VERSION
};
});
console.log(version);
updateAvailable = compareVersion(version.latest, version.current);
console.log(updateAvailable);
};
const updateLdapServerHandler = async () => {
if (!ENABLE_LDAP) return;
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
@@ -63,6 +88,8 @@
};
onMount(async () => {
checkForVersionUpdates();
await Promise.all([
(async () => {
adminConfig = await getAdminConfig(localStorage.token);
@@ -87,381 +114,540 @@
updateHandler();
}}
>
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
<div class="mt-0.5 space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if adminConfig !== null}
<div>
<div class=" mb-3 text-sm font-medium">{$i18n.t('General Settings')}</div>
<div class="">
<div class="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<div class=" flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<Switch bind:state={adminConfig.ENABLE_SIGNUP} />
</div>
<div class=" my-3 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded px-2 text-xs bg-transparent outline-none text-right"
bind:value={adminConfig.DEFAULT_USER_ROLE}
placeholder="Select a role"
>
<option value="pending">{$i18n.t('pending')}</option>
<option value="user">{$i18n.t('user')}</option>
<option value="admin">{$i18n.t('admin')}</option>
</select>
</div>
</div>
<div class=" flex w-full justify-between pr-2 my-3">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key')}</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY} />
</div>
{#if adminConfig?.ENABLE_API_KEY}
<div class=" flex w-full justify-between pr-2 my-3">
<div class=" self-center text-xs font-medium">
{$i18n.t('API Key Endpoint Restrictions')}
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium flex space-x-2 items-center">
<div>
{$i18n.t('Version')}
</div>
</div>
<div class="flex w-full justify-between items-center">
<div class="flex flex-col text-xs text-gray-700 dark:text-gray-200">
<div class="flex gap-1">
<Tooltip content={WEBUI_BUILD_HASH}>
v{WEBUI_VERSION}
</Tooltip>
<Switch bind:state={adminConfig.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS} />
</div>
<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>
</div>
{#if adminConfig?.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS}
<div class=" flex w-full flex-col pr-2">
<div class=" text-xs font-medium">
{$i18n.t('Allowed Endpoints')}
<button
class=" underline flex items-center space-x-1 text-xs text-gray-500 dark:text-gray-500"
type="button"
on:click={() => {
showChangelog.set(true);
}}
>
<div>{$i18n.t("See what's new")}</div>
</button>
</div>
<input
class="w-full mt-1 rounded-lg text-sm dark:text-gray-300 bg-transparent outline-none"
type="text"
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
bind:value={adminConfig.API_KEY_ALLOWED_ENDPOINTS}
/>
<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>
</div>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<!-- https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints -->
<a
href="https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints"
target="_blank"
class=" text-gray-300 font-medium underline"
>
{$i18n.t('To learn more about available endpoints, visit our documentation.')}
<div class="mb-2.5">
<div class="flex w-full justify-between items-center">
<div class="text-xs pr-2">
<div class="">
{$i18n.t('Help')}
</div>
<div class=" text-xs text-gray-500">
{$i18n.t('Discover how to use Open WebUI and seek support from the community.')}
</div>
</div>
<a
class="flex-shrink-0 text-xs font-medium underline"
href="https://docs.openwebui.com/"
target="_blank"
>
{$i18n.t('Documentation')}
</a>
</div>
<div class="mt-1">
<div class="flex space-x-1">
<a href="https://discord.gg/5rJgQTnV4s" target="_blank">
<img
alt="Discord"
src="https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white"
/>
</a>
<a href="https://twitter.com/OpenWebUI" target="_blank">
<img
alt="X (formerly Twitter) Follow"
src="https://img.shields.io/twitter/follow/OpenWebUI"
/>
</a>
<a href="https://github.com/open-webui/open-webui" target="_blank">
<img
alt="Github Repo"
src="https://img.shields.io/github/stars/open-webui/open-webui?style=social&label=Star us on Github"
/>
</a>
</div>
</div>
{/if}
{/if}
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class="my-3 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Show Admin Details in Account Pending Overlay')}
</div>
<Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
</div>
<div class="my-3 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable Community Sharing')}</div>
<Switch bind:state={adminConfig.ENABLE_COMMUNITY_SHARING} />
</div>
<div class="my-3 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable Message Rating')}</div>
<Switch bind:state={adminConfig.ENABLE_MESSAGE_RATING} />
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('WebUI URL')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={`e.g.) "http://localhost:3000"`}
bind:value={adminConfig.WEBUI_URL}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.'
)}
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={`e.g.) "30m","1h", "10d". `}
bind:value={adminConfig.JWT_EXPIRES_IN}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Valid time units:')}
<span class=" text-gray-300 font-medium"
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Webhook URL')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={`https://example.com/webhook`}
bind:value={webhookUrl}
/>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class="pt-1 flex w-full justify-between pr-2">
<div class=" self-center text-sm font-medium">
{$i18n.t('Channels')} ({$i18n.t('Beta')})
</div>
<Switch bind:state={adminConfig.ENABLE_CHANNELS} />
</div>
</div>
{/if}
<hr class=" border-gray-50 dark:border-gray-850" />
<div class=" space-y-3">
<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('LDAP')}</div>
<div class="mt-1">
<Switch
bind:state={ENABLE_LDAP}
on:change={async () => {
updateLdapConfig(localStorage.token, ENABLE_LDAP);
}}
/>
</div>
</div>
{#if ENABLE_LDAP}
<div class="flex flex-col gap-1">
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Label')}
<div class="mb-2.5">
<div class="flex w-full justify-between items-center">
<div class="text-xs pr-2">
<div class="">
{$i18n.t('License')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Enter server label')}
bind:value={LDAP_SERVER.label}
/>
{#if $config?.license_metadata}
<a
href="https://docs.openwebui.com/enterprise"
target="_blank"
class="text-gray-500 mt-0.5"
>
<span class=" capitalize text-black dark:text-white"
>{$config?.license_metadata?.type}
license</span
>
registered to
<span class=" capitalize text-black dark:text-white"
>{$config?.license_metadata?.organization_name}</span
>
for
<span class=" font-medium text-black dark:text-white"
>{$config?.license_metadata?.seats ?? 'Unlimited'} users.</span
>
</a>
{#if $config?.license_metadata?.html}
<div class="mt-0.5">
{@html DOMPurify.sanitize($config?.license_metadata?.html)}
</div>
{/if}
{:else}
<a
class=" text-xs hover:underline"
href="https://docs.openwebui.com/enterprise"
target="_blank"
>
<span class="text-gray-500">
{$i18n.t(
'Upgrade to a licensed plan for enhanced capabilities, including custom theming and branding, and dedicated support.'
)}
</span>
</a>
{/if}
</div>
<div class="w-full"></div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Host')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Enter server host')}
bind:value={LDAP_SERVER.host}
/>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Port')}
</div>
<Tooltip
placement="top-start"
content={$i18n.t('Default to 389 or 636 if TLS is enabled')}
className="w-full"
>
<input
class="w-full bg-transparent outline-none py-0.5"
type="number"
placeholder={$i18n.t('Enter server port')}
bind:value={LDAP_SERVER.port}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN')}
</div>
<Tooltip
content={$i18n.t('The Application Account DN you bind with for search')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Enter Application DN')}
bind:value={LDAP_SERVER.app_dn}
/>
</Tooltip>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN Password')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Application DN Password')}
bind:value={LDAP_SERVER.app_dn_password}
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Mail')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the mail that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Example: mail')}
bind:value={LDAP_SERVER.attribute_for_mail}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Username')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the username that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Example: sAMAccountName or uid or userPrincipalName')}
bind:value={LDAP_SERVER.attribute_for_username}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Base')}
</div>
<Tooltip content={$i18n.t('The base to search for users')} placement="top-start">
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Example: ou=users,dc=foo,dc=example')}
bind:value={LDAP_SERVER.search_base}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Filters')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
placeholder={$i18n.t('Example: (&(objectClass=inetOrgPerson)(uid=%s))')}
bind:value={LDAP_SERVER.search_filters}
/>
</div>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
<a
class=" text-gray-300 font-medium underline"
href="https://ldap.com/ldap-filters/"
target="_blank"
<!-- <button
class="flex-shrink-0 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"
>
{$i18n.t('Click here for filter guides.')}
</a>
{$i18n.t('Activate')}
</button> -->
</div>
<div>
</div>
</div>
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Authentication')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_USER_ROLE}
placeholder="Select a role"
>
<option value="pending">{$i18n.t('pending')}</option>
<option value="user">{$i18n.t('user')}</option>
<option value="admin">{$i18n.t('admin')}</option>
</select>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
<Switch bind:state={adminConfig.ENABLE_SIGNUP} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Show Admin Details in Account Pending Overlay')}
</div>
<Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
</div>
<div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key')}</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY} />
</div>
{#if adminConfig?.ENABLE_API_KEY}
<div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('API Key Endpoint Restrictions')}
</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS} />
</div>
{#if adminConfig?.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS}
<div class=" flex w-full flex-col pr-2">
<div class=" text-xs font-medium">
{$i18n.t('Allowed Endpoints')}
</div>
<input
class="w-full mt-1 rounded-lg text-sm dark:text-gray-300 bg-transparent outline-hidden"
type="text"
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
bind:value={adminConfig.API_KEY_ALLOWED_ENDPOINTS}
/>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<!-- https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints -->
<a
href="https://docs.openwebui.com/getting-started/api-endpoints"
target="_blank"
class=" text-gray-300 font-medium underline"
>
{$i18n.t('To learn more about available endpoints, visit our documentation.')}
</a>
</div>
</div>
{/if}
{/if}
<div class=" mb-2.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={`e.g.) "30m","1h", "10d". `}
bind:value={adminConfig.JWT_EXPIRES_IN}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Valid time units:')}
<span class=" text-gray-300 font-medium"
>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
>
</div>
</div>
<div class=" space-y-3">
<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('TLS')}</div>
<div class=" font-medium">{$i18n.t('LDAP')}</div>
<div class="mt-1">
<Switch bind:state={LDAP_SERVER.use_tls} />
<Switch
bind:state={ENABLE_LDAP}
on:change={async () => {
updateLdapConfig(localStorage.token, ENABLE_LDAP);
}}
/>
</div>
</div>
{#if LDAP_SERVER.use_tls}
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1 mt-1">
{$i18n.t('Certificate Path')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Enter certificate path')}
bind:value={LDAP_SERVER.certificate_path}
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Ciphers')}
</div>
<Tooltip content={$i18n.t('Default to ALL')} placement="top-start">
{#if ENABLE_LDAP}
<div class="flex flex-col gap-1">
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Label')}
</div>
<input
class="w-full bg-transparent outline-none py-0.5"
placeholder={$i18n.t('Example: ALL')}
bind:value={LDAP_SERVER.ciphers}
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter server label')}
bind:value={LDAP_SERVER.label}
/>
</Tooltip>
</div>
<div class="w-full"></div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Host')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter server host')}
bind:value={LDAP_SERVER.host}
/>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Port')}
</div>
<Tooltip
placement="top-start"
content={$i18n.t('Default to 389 or 636 if TLS is enabled')}
className="w-full"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
type="number"
placeholder={$i18n.t('Enter server port')}
bind:value={LDAP_SERVER.port}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN')}
</div>
<Tooltip
content={$i18n.t('The Application Account DN you bind with for search')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter Application DN')}
bind:value={LDAP_SERVER.app_dn}
/>
</Tooltip>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Application DN Password')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Application DN Password')}
bind:value={LDAP_SERVER.app_dn_password}
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Mail')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the mail that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Example: mail')}
bind:value={LDAP_SERVER.attribute_for_mail}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Username')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the username that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t(
'Example: sAMAccountName or uid or userPrincipalName'
)}
bind:value={LDAP_SERVER.attribute_for_username}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Base')}
</div>
<Tooltip
content={$i18n.t('The base to search for users')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Example: ou=users,dc=foo,dc=example')}
bind:value={LDAP_SERVER.search_base}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Search Filters')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Example: (&(objectClass=inetOrgPerson)(uid=%s))')}
bind:value={LDAP_SERVER.search_filters}
/>
</div>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
<a
class=" text-gray-300 font-medium underline"
href="https://ldap.com/ldap-filters/"
target="_blank"
>
{$i18n.t('Click here for filter guides.')}
</a>
</div>
<div>
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('TLS')}</div>
<div class="mt-1">
<Switch bind:state={LDAP_SERVER.use_tls} />
</div>
</div>
{#if LDAP_SERVER.use_tls}
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1 mt-1">
{$i18n.t('Certificate Path')}
</div>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter certificate path')}
bind:value={LDAP_SERVER.certificate_path}
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Ciphers')}
</div>
<Tooltip content={$i18n.t('Default to ALL')} placement="top-start">
<input
class="w-full bg-transparent outline-hidden py-0.5"
placeholder={$i18n.t('Example: ALL')}
bind:value={LDAP_SERVER.ciphers}
/>
</Tooltip>
</div>
<div class="w-full"></div>
</div>
{/if}
</div>
<div class="w-full"></div>
</div>
{/if}
</div>
</div>
{/if}
</div>
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Features')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enable Community Sharing')}
</div>
<Switch bind:state={adminConfig.ENABLE_COMMUNITY_SHARING} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable Message Rating')}</div>
<Switch bind:state={adminConfig.ENABLE_MESSAGE_RATING} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Channels')} ({$i18n.t('Beta')})
</div>
<Switch bind:state={adminConfig.ENABLE_CHANNELS} />
</div>
<div class="mb-2.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('WebUI URL')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={`e.g.) "http://localhost:3000"`}
bind:value={adminConfig.WEBUI_URL}
/>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t(
'Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.'
)}
</div>
</div>
<div class=" w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Webhook URL')}</div>
</div>
<div class="flex mt-2 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={`https://example.com/webhook`}
bind:value={webhookUrl}
/>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
+39 -17
View File
@@ -261,6 +261,9 @@
} else if (config.engine === 'openai' && config.openai.OPENAI_API_KEY === '') {
toast.error($i18n.t('OpenAI API Key is required.'));
config.enabled = false;
} else if (config.engine === 'gemini' && config.gemini.GEMINI_API_KEY === '') {
toast.error($i18n.t('Gemini API Key is required.'));
config.enabled = false;
}
}
@@ -284,7 +287,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Image Generation Engine')}</div>
<div class="flex items-center relative">
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.engine}
placeholder={$i18n.t('Select Engine')}
on:change={async () => {
@@ -294,11 +297,12 @@
<option value="openai">{$i18n.t('Default (Open AI)')}</option>
<option value="comfyui">{$i18n.t('ComfyUI')}</option>
<option value="automatic1111">{$i18n.t('Automatic1111')}</option>
<option value="gemini">{$i18n.t('Gemini')}</option>
</select>
</div>
</div>
</div>
<hr class=" dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="flex flex-col gap-2">
{#if (config?.engine ?? 'automatic1111') === 'automatic1111'}
@@ -307,7 +311,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter URL (e.g. http://127.0.0.1:7860/)')}
bind:value={config.automatic1111.AUTOMATIC1111_BASE_URL}
/>
@@ -386,7 +390,7 @@
<Tooltip content={$i18n.t('Enter Sampler (e.g. Euler a)')} placement="top-start">
<input
list="sampler-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter Sampler (e.g. Euler a)')}
bind:value={config.automatic1111.AUTOMATIC1111_SAMPLER}
/>
@@ -408,7 +412,7 @@
<Tooltip content={$i18n.t('Enter Scheduler (e.g. Karras)')} placement="top-start">
<input
list="scheduler-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter Scheduler (e.g. Karras)')}
bind:value={config.automatic1111.AUTOMATIC1111_SCHEDULER}
/>
@@ -429,7 +433,7 @@
<div class="flex-1 mr-2">
<Tooltip content={$i18n.t('Enter CFG Scale (e.g. 7.0)')} placement="top-start">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter CFG Scale (e.g. 7.0)')}
bind:value={config.automatic1111.AUTOMATIC1111_CFG_SCALE}
/>
@@ -443,7 +447,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter URL (e.g. http://127.0.0.1:7860/)')}
bind:value={config.comfyui.COMFYUI_BASE_URL}
/>
@@ -497,7 +501,7 @@
{#if config.comfyui.COMFYUI_WORKFLOW}
<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-none disabled:text-gray-600 resize-none"
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}
required
@@ -525,7 +529,7 @@
/>
<button
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-800 dark:hover:bg-gray-850 text-center rounded-xl"
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"
type="button"
on:click={() => {
document.getElementById('upload-comfyui-workflow-input')?.click();
@@ -548,7 +552,7 @@
<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-shrink-0">
<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"
>
@@ -558,7 +562,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-none border-r dark:border-gray-850"
class="py-1 px-3 w-24 text-xs text-center bg-transparent outline-hidden border-r dark:border-gray-850"
placeholder="Key"
bind:value={node.key}
required
@@ -572,7 +576,7 @@
placement="top-start"
>
<input
class="w-full py-1 px-4 rounded-r-lg text-xs bg-transparent outline-none"
class="w-full py-1 px-4 rounded-r-lg text-xs bg-transparent outline-hidden"
placeholder="Node Ids"
bind:value={node.node_ids}
/>
@@ -593,7 +597,7 @@
<div class="flex gap-2 mb-1">
<input
class="flex-1 w-full text-sm bg-transparent outline-none"
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={config.openai.OPENAI_API_BASE_URL}
required
@@ -605,11 +609,29 @@
/>
</div>
</div>
{:else if config?.engine === 'gemini'}
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('Gemini API Config')}</div>
<div class="flex gap-2 mb-1">
<input
class="flex-1 w-full text-sm bg-transparent outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={config.gemini.GEMINI_API_BASE_URL}
required
/>
<SensitiveInput
placeholder={$i18n.t('API Key')}
bind:value={config.gemini.GEMINI_API_KEY}
/>
</div>
</div>
{/if}
</div>
{#if config?.enabled}
<hr class=" dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Default Model')}</div>
@@ -620,7 +642,7 @@
<Tooltip content={$i18n.t('Enter Model ID')} placement="top-start">
<input
list="model-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={imageGenerationConfig.MODEL}
placeholder="Select a model"
required
@@ -644,7 +666,7 @@
<div class="flex-1 mr-2">
<Tooltip content={$i18n.t('Enter Image Size (e.g. 512x512)')} placement="top-start">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter Image Size (e.g. 512x512)')}
bind:value={imageGenerationConfig.IMAGE_SIZE}
required
@@ -660,7 +682,7 @@
<div class="flex-1 mr-2">
<Tooltip content={$i18n.t('Enter Number of Steps (e.g. 50)')} placement="top-start">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter Number of Steps (e.g. 50)')}
bind:value={imageGenerationConfig.IMAGE_STEPS}
required
+233 -197
View File
@@ -23,6 +23,7 @@
let taskConfig = {
TASK_MODEL: '',
TASK_MODEL_EXTERNAL: '',
ENABLE_TITLE_GENERATION: true,
TITLE_GENERATION_PROMPT_TEMPLATE: '',
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_AUTOCOMPLETE_GENERATION: true,
@@ -31,7 +32,8 @@
ENABLE_TAGS_GENERATION: true,
ENABLE_SEARCH_QUERY_GENERATION: true,
ENABLE_RETRIEVAL_QUERY_GENERATION: true,
QUERY_GENERATION_PROMPT_TEMPLATE: ''
QUERY_GENERATION_PROMPT_TEMPLATE: '',
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: ''
};
let promptSuggestions = [];
@@ -49,7 +51,7 @@
onMount(async () => {
taskConfig = await getTaskConfig(localStorage.token);
promptSuggestions = $config?.default_prompt_suggestions;
promptSuggestions = $config?.default_prompt_suggestions ?? [];
banners = await getBanners(localStorage.token);
});
@@ -67,9 +69,13 @@
}}
>
<div class=" overflow-y-scroll scrollbar-hidden h-full pr-1.5">
<div>
<div class=" mb-2.5 text-sm font-medium flex items-center">
<div class=" mr-1">{$i18n.t('Set Task Model')}</div>
<div class="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Tasks')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-1 font-medium flex items-center">
<div class=" text-xs mr-1">{$i18n.t('Set Task Model')}</div>
<Tooltip
content={$i18n.t(
'A task model is used when performing tasks such as generating titles for chats and web search queries'
@@ -91,11 +97,12 @@
</svg>
</Tooltip>
</div>
<div class="flex w-full gap-2">
<div class=" mb-2.5 flex w-full gap-2">
<div class="flex-1">
<div class=" text-xs mb-1">{$i18n.t('Local Models')}</div>
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={taskConfig.TASK_MODEL}
placeholder={$i18n.t('Select a model')}
>
@@ -111,7 +118,7 @@
<div class="flex-1">
<div class=" text-xs mb-1">{$i18n.t('External Models')}</div>
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={taskConfig.TASK_MODEL_EXTERNAL}
placeholder={$i18n.t('Select a model')}
>
@@ -125,72 +132,33 @@
</div>
</div>
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Title Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.TITLE_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Image Prompt Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class="my-3 flex w-full items-center justify-between">
<div class="mb-2.5 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Autocomplete Generation')}
{$i18n.t('Title Generation')}
</div>
<Tooltip content={$i18n.t('Enable autocomplete generation for chat messages')}>
<Switch bind:state={taskConfig.ENABLE_AUTOCOMPLETE_GENERATION} />
</Tooltip>
<Switch bind:state={taskConfig.ENABLE_TITLE_GENERATION} />
</div>
{#if taskConfig.ENABLE_AUTOCOMPLETE_GENERATION}
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">
{$i18n.t('Autocomplete Generation Input Max Length')}
</div>
{#if taskConfig.ENABLE_TITLE_GENERATION}
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Title Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Character limit for autocomplete generation input')}
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<input
class="w-full outline-none bg-transparent"
bind:value={taskConfig.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}
placeholder={$i18n.t('-1 for no limit, or a positive integer for a specific limit')}
<Textarea
bind:value={taskConfig.TITLE_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
{/if}
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class="my-3 flex w-full items-center justify-between">
<div class="mb-2.5 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Tags Generation')}
</div>
@@ -199,8 +167,8 @@
</div>
{#if taskConfig.ENABLE_TAGS_GENERATION}
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Tags Generation Prompt')}</div>
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Tags Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
@@ -216,9 +184,7 @@
</div>
{/if}
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class="my-3 flex w-full items-center justify-between">
<div class="mb-2.5 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Retrieval Query Generation')}
</div>
@@ -226,7 +192,7 @@
<Switch bind:state={taskConfig.ENABLE_RETRIEVAL_QUERY_GENERATION} />
</div>
<div class="my-3 flex w-full items-center justify-between">
<div class="mb-2.5 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search Query Generation')}
</div>
@@ -234,8 +200,8 @@
<Switch bind:state={taskConfig.ENABLE_SEARCH_QUERY_GENERATION} />
</div>
<div class="">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Query Generation Prompt')}</div>
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Query Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
@@ -249,117 +215,96 @@
/>
</Tooltip>
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class=" space-y-3 {banners.length > 0 ? ' mb-3' : ''}">
<div class="flex w-full justify-between">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Banners')}
<div class="mb-2.5 flex w-full items-center justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Autocomplete Generation')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
type="button"
on:click={() => {
if (banners.length === 0 || banners.at(-1).content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
<Tooltip content={$i18n.t('Enable autocomplete generation for chat messages')}>
<Switch bind:state={taskConfig.ENABLE_AUTOCOMPLETE_GENERATION} />
</Tooltip>
</div>
<div class="flex flex-col space-y-1">
{#each banners as banner, bannerIdx}
<div class=" flex justify-between">
<div class="flex flex-row flex-1 border rounded-xl dark:border-gray-800">
<select
class="w-fit capitalize rounded-xl py-2 px-4 text-xs bg-transparent outline-none"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900"
>{$i18n.t('Type')}</option
>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<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-none"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
/>
<div class="relative top-1.5 -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="px-2"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
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>
</button>
{#if taskConfig.ENABLE_AUTOCOMPLETE_GENERATION}
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium">
{$i18n.t('Autocomplete Generation Input Max Length')}
</div>
{/each}
<Tooltip
content={$i18n.t('Character limit for autocomplete generation input')}
placement="top-start"
>
<input
class="w-full outline-hidden bg-transparent"
bind:value={taskConfig.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}
placeholder={$i18n.t('-1 for no limit, or a positive integer for a specific limit')}
/>
</Tooltip>
</div>
{/if}
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Image Prompt Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Tools Function Calling Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
</div>
{#if $user.role === 'admin'}
<div class=" space-y-3">
<div class="flex w-full justify-between mb-2">
<div class="mb-3.5">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('UI')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" {banners.length > 0 ? ' mb-3' : ''}">
<div class="mb-2.5 flex w-full justify-between">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Default Prompt Suggestions')}
{$i18n.t('Banners')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
if (promptSuggestions.length === 0 || promptSuggestions.at(-1).content !== '') {
promptSuggestions = [...promptSuggestions, { content: '', title: ['', ''] }];
if (banners.length === 0 || banners.at(-1).content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
@@ -375,40 +320,48 @@
</svg>
</button>
</div>
<div class="grid lg:grid-cols-2 flex-col gap-1.5">
{#each promptSuggestions as prompt, promptIdx}
<div
class=" flex border border-gray-100 dark:border-none dark:bg-gray-850 rounded-xl py-1.5"
>
<div class="flex flex-col flex-1 pl-1">
<div class="flex border-b border-gray-100 dark:border-gray-800 w-full">
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800"
placeholder={$i18n.t('Title (e.g. Tell me a fun fact)')}
bind:value={prompt.title[0]}
/>
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800"
placeholder={$i18n.t('Subtitle (e.g. about the Roman Empire)')}
bind:value={prompt.title[1]}
/>
</div>
<div class=" flex flex-col space-y-1">
{#each banners as banner, bannerIdx}
<div class=" flex justify-between">
<div
class="flex flex-row flex-1 border rounded-xl border-gray-100 dark:border-gray-850"
>
<select
class="w-fit capitalize rounded-xl py-2 px-4 text-xs bg-transparent outline-hidden"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900"
>{$i18n.t('Type')}</option
>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<textarea
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r border-gray-100 dark:border-gray-800 resize-none"
placeholder={$i18n.t('Prompt (e.g. Tell me a fun fact about the Roman Empire)')}
rows="3"
bind:value={prompt.content}
<input
class="pr-5 py-1.5 text-xs w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
/>
<div class="relative top-1.5 -left-2">
<Tooltip content={$i18n.t('Dismissible')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="px-3"
class="px-2"
type="button"
on:click={() => {
promptSuggestions.splice(promptIdx, 1);
promptSuggestions = promptSuggestions;
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<svg
@@ -425,14 +378,97 @@
</div>
{/each}
</div>
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2">
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div>
{/if}
</div>
{/if}
{#if $user.role === 'admin'}
<div class=" space-y-3">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-semibold">
{$i18n.t('Default Prompt Suggestions')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
if (promptSuggestions.length === 0 || promptSuggestions.at(-1).content !== '') {
promptSuggestions = [...promptSuggestions, { content: '', title: ['', ''] }];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
<div class="grid lg:grid-cols-2 flex-col gap-1.5">
{#each promptSuggestions as prompt, promptIdx}
<div
class=" flex border border-gray-100 dark:border-none dark:bg-gray-850 rounded-xl py-1.5"
>
<div class="flex flex-col flex-1 pl-1">
<div class="flex border-b border-gray-100 dark:border-gray-850 w-full">
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-hidden border-r border-gray-100 dark:border-gray-850"
placeholder={$i18n.t('Title (e.g. Tell me a fun fact)')}
bind:value={prompt.title[0]}
/>
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-hidden border-r border-gray-100 dark:border-gray-850"
placeholder={$i18n.t('Subtitle (e.g. about the Roman Empire)')}
bind:value={prompt.title[1]}
/>
</div>
<textarea
class="px-3 py-1.5 text-xs w-full bg-transparent outline-hidden border-r border-gray-100 dark:border-gray-850 resize-none"
placeholder={$i18n.t(
'Prompt (e.g. Tell me a fun fact about the Roman Empire)'
)}
rows="3"
bind:value={prompt.content}
/>
</div>
<button
class="px-3"
type="button"
on:click={() => {
promptSuggestions.splice(promptIdx, 1);
promptSuggestions = promptSuggestions;
}}
>
<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>
</button>
</div>
{/each}
</div>
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2">
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="flex justify-end text-sm font-medium">
@@ -68,7 +68,7 @@
const init = async () => {
workspaceModels = await getBaseModels(localStorage.token);
baseModels = await getModels(localStorage.token, true);
baseModels = await getModels(localStorage.token, null, true);
models = baseModels.map((m) => {
const workspaceModel = workspaceModels.find((wm) => wm.id === m.id);
@@ -111,7 +111,12 @@
}
}
_models.set(await getModels(localStorage.token));
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
await init();
};
@@ -133,7 +138,12 @@
}
// await init();
_models.set(await getModels(localStorage.token));
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
onMount(async () => {
@@ -189,7 +199,7 @@
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm py-1 rounded-r-xl outline-none bg-transparent"
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={searchValue}
placeholder={$i18n.t('Search Models')}
/>
@@ -330,7 +340,13 @@
}
}
await _models.set(await getModels(localStorage.token));
await _models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
init();
};
@@ -16,6 +16,8 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
export let show = false;
export let initHandler = () => {};
@@ -26,6 +28,9 @@
let defaultModelIds = [];
let modelIds = [];
let sortKey = '';
let sortOrder = '';
let loading = false;
let showResetModal = false;
@@ -71,6 +76,9 @@
// Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically
...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
];
sortKey = '';
sortOrder = '';
};
const submitHandler = async () => {
loading = true;
@@ -145,9 +153,45 @@
>
<div>
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<button
class="mb-1 flex gap-2"
type="button"
on:click={() => {
sortKey = 'model';
if (sortOrder === 'asc') {
sortOrder = 'desc';
} else {
sortOrder = 'asc';
}
modelIds = modelIds
.filter((id) => id !== '')
.sort((a, b) => {
const nameA = $models.find((model) => model.id === a)?.name || a;
const nameB = $models.find((model) => model.id === b)?.name || b;
return sortOrder === 'desc'
? nameA.localeCompare(nameB)
: nameB.localeCompare(nameA);
});
}}
>
<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
</div>
{#if sortKey === 'model'}
<span class="font-normal self-center">
{#if sortOrder === 'asc'}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-3" />
</span>
{/if}
</button>
<ModelList bind:modelIds />
</div>
@@ -165,7 +209,7 @@
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
@@ -186,7 +230,7 @@
<div class=" text-sm flex-1 py-1 rounded-lg">
{$models.find((model) => model.id === modelId)?.name}
</div>
<div class="flex-shrink-0">
<div class="shrink-0">
<button
type="button"
on:click={() => {
@@ -12,7 +12,7 @@
{#if ollamaConfig}
<div class="flex-1 mb-2.5 pr-1.5 rounded-lg bg-gray-50 dark:text-gray-300 dark:bg-gray-850">
<select
class="w-full py-2 px-4 text-sm outline-none bg-transparent"
class="w-full py-2 px-4 text-sm outline-hidden bg-transparent"
bind:value={selectedUrlIdx}
placeholder={$i18n.t('Select an Ollama instance')}
>
@@ -3,7 +3,7 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_NAME, models, MODEL_DOWNLOAD_POOL, user, config } from '$lib/stores';
import { WEBUI_NAME, models, MODEL_DOWNLOAD_POOL, user, config, settings } from '$lib/stores';
import { splitStream } from '$lib/utils';
import {
@@ -235,7 +235,12 @@
})
);
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
} else {
toast.error($i18n.t('Download canceled'));
}
@@ -394,7 +399,12 @@
modelTransferring = false;
uploadProgress = null;
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
const deleteModelHandler = async () => {
@@ -407,7 +417,12 @@
}
deleteModelTag = '';
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
const cancelModelPullHandler = async (model: string) => {
@@ -506,7 +521,12 @@
}
}
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
createModelLoading = false;
@@ -578,7 +598,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
modelTag: 'mistral:7b'
})}
@@ -720,7 +740,7 @@
class="flex-1 mr-2 pr-1.5 rounded-lg bg-gray-50 dark:text-gray-300 dark:bg-gray-850"
>
<select
class="w-full py-2 px-4 text-sm outline-none bg-transparent"
class="w-full py-2 px-4 text-sm outline-hidden bg-transparent"
bind:value={deleteModelTag}
placeholder={$i18n.t('Select a model')}
>
@@ -761,7 +781,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2 flex flex-col gap-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
modelTag: 'my-modelfile'
})}
@@ -771,7 +791,7 @@
<textarea
bind:value={createModelObject}
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none scrollbar-hidden"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-100 dark:bg-gray-850 outline-hidden resize-none scrollbar-hidden"
rows="6"
placeholder={`e.g. {"model": "my-modelfile", "from": "ollama:7b"})`}
disabled={createModelLoading}
@@ -850,7 +870,7 @@
<div class=" text-sm font-medium">{$i18n.t('Upload a GGUF model')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
if (modelUploadMode === 'file') {
modelUploadMode = 'url';
@@ -902,7 +922,7 @@
{:else}
<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
<input
class="w-full rounded-lg text-left py-2 px-4 bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !==
class="w-full rounded-lg text-left py-2 px-4 bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden {modelFileUrl !==
''
? 'mr-2'
: ''}"
@@ -978,7 +998,7 @@
</div>
<textarea
bind:value={modelFileContent}
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-100 dark:bg-gray-850 outline-hidden resize-none"
rows="6"
/>
</div>
@@ -21,14 +21,24 @@
modelIds = modelList;
};
onMount(() => {
sortable = Sortable.create(modelListElement, {
animation: 150,
onUpdate: async (event) => {
positionChangeHandler();
}
});
});
$: if (modelIds) {
init();
}
const init = () => {
if (sortable) {
sortable.destroy();
}
if (modelListElement) {
sortable = Sortable.create(modelListElement, {
animation: 150,
onUpdate: async (event) => {
positionChangeHandler();
}
});
}
};
</script>
{#if modelIds.length > 0}
@@ -2,7 +2,7 @@
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { models } from '$lib/stores';
import { config, models, settings } from '$lib/stores';
import { getContext, onMount, tick } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
@@ -63,7 +63,12 @@
if (res) {
toast.success($i18n.t('Valves updated successfully'));
setPipelines();
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
saveHandler();
}
} else {
@@ -125,7 +130,12 @@
if (res) {
toast.success($i18n.t('Pipeline downloaded successfully'));
setPipelines();
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
downloading = false;
@@ -150,7 +160,12 @@
if (res) {
toast.success($i18n.t('Pipeline downloaded successfully'));
setPipelines();
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
} else {
toast.error($i18n.t('No file selected'));
@@ -179,7 +194,12 @@
if (res) {
toast.success($i18n.t('Pipeline deleted successfully'));
setPipelines();
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
};
@@ -214,7 +234,7 @@
<div class="flex gap-2">
<div class="flex-1">
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={selectedPipelinesUrlIdx}
placeholder={$i18n.t('Select a pipeline url')}
on:change={async () => {
@@ -251,7 +271,7 @@
/>
<button
class="w-full text-sm font-medium py-2 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-800 dark:hover:bg-gray-850 text-center rounded-xl"
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"
type="button"
on:click={() => {
document.getElementById('pipelines-upload-input')?.click();
@@ -328,7 +348,7 @@
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter Github Raw URL')}
bind:value={pipelineDownloadUrl}
/>
@@ -398,7 +418,7 @@
</div>
</div>
<hr class=" dark:border-gray-800 my-3 w-full" />
<hr class="border-gray-100 dark:border-gray-850 my-3 w-full" />
{#if pipelines !== null}
{#if pipelines.length > 0}
@@ -412,7 +432,7 @@
<div class="flex gap-2">
<div class="flex-1">
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={selectedPipelineIdx}
placeholder={$i18n.t('Select a pipeline')}
on:change={async () => {
@@ -462,7 +482,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
valves[property] = (valves[property] ?? null) === null ? '' : null;
@@ -482,7 +502,7 @@
<div class=" flex-1">
{#if valves_spec.properties[property]?.enum ?? null}
<select
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={valves[property]}
>
{#each valves_spec.properties[property].enum as option}
@@ -503,7 +523,7 @@
</div>
{:else}
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={valves_spec.properties[property].title}
bind:value={valves[property]}
+368 -226
View File
@@ -6,6 +6,7 @@
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
@@ -18,14 +19,18 @@
'brave',
'kagi',
'mojeek',
'bocha',
'serpstack',
'serper',
'serply',
'searchapi',
'serpapi',
'duckduckgo',
'tavily',
'jina',
'bing'
'bing',
'exa',
'perplexity'
];
let youtubeLanguage = 'en';
@@ -33,6 +38,16 @@
let youtubeProxyUrl = '';
const submitHandler = async () => {
// Convert domain filter string to array before sending
if (webConfig.search.domain_filter_list) {
webConfig.search.domain_filter_list = webConfig.search.domain_filter_list
.split(',')
.map((domain) => domain.trim())
.filter((domain) => domain.length > 0);
} else {
webConfig.search.domain_filter_list = [];
}
const res = await updateRAGConfig(localStorage.token, {
web: webConfig,
youtube: {
@@ -41,6 +56,8 @@
proxy_url: youtubeProxyUrl
}
});
webConfig.search.domain_filter_list = webConfig.search.domain_filter_list.join(', ');
};
onMount(async () => {
@@ -48,6 +65,10 @@
if (res) {
webConfig = res.web;
// Convert array back to comma-separated string for display
if (webConfig?.search?.domain_filter_list) {
webConfig.search.domain_filter_list = webConfig.search.domain_filter_list.join(', ');
}
youtubeLanguage = res.youtube.language.join(',');
youtubeTranslation = res.youtube.translation;
@@ -65,306 +86,427 @@
>
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if webConfig}
<div>
<div class=" mb-1 text-sm font-medium">
{$i18n.t('Web Search')}
</div>
<div class="">
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Enable Web Search')}
{$i18n.t('Web Search')}
</div>
<div class="flex items-center relative">
<Switch bind:state={webConfig.search.enabled} />
</div>
<Switch bind:state={webConfig.search.enabled} />
</div>
</div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Web Search Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
bind:value={webConfig.search.engine}
placeholder={$i18n.t('Select a engine')}
required
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each webSearchEngines as engine}
<option value={engine}>{engine}</option>
{/each}
</select>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search Engine')}
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={webConfig.search.engine}
placeholder={$i18n.t('Select a engine')}
required
>
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
{#each webSearchEngines as engine}
<option value={engine}>{engine}</option>
{/each}
</select>
</div>
</div>
</div>
{#if webConfig.search.engine !== ''}
<div class="mt-1.5">
{#if webConfig.search.engine !== ''}
{#if webConfig.search.engine === 'searxng'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Searxng Query URL')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Searxng Query URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={$i18n.t('Enter Searxng Query URL')}
bind:value={webConfig.search.searxng_query_url}
autocomplete="off"
/>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter Searxng Query URL')}
bind:value={webConfig.search.searxng_query_url}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{:else if webConfig.search.engine === 'google_pse'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Google PSE API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Google PSE API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Google PSE API Key')}
bind:value={webConfig.search.google_pse_api_key}
/>
</div>
<div class="mt-1.5">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Google PSE Engine Id')}
<SensitiveInput
placeholder={$i18n.t('Enter Google PSE API Key')}
bind:value={webConfig.search.google_pse_api_key}
/>
</div>
<div class="mt-1.5">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Google PSE Engine Id')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={$i18n.t('Enter Google PSE Engine Id')}
bind:value={webConfig.search.google_pse_engine_id}
autocomplete="off"
/>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter Google PSE Engine Id')}
bind:value={webConfig.search.google_pse_engine_id}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{:else if webConfig.search.engine === 'brave'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Brave Search API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Brave Search API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Brave Search API Key')}
bind:value={webConfig.search.brave_search_api_key}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Brave Search API Key')}
bind:value={webConfig.search.brave_search_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'kagi'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Kagi Search API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Kagi Search API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Kagi Search API Key')}
bind:value={webConfig.search.kagi_search_api_key}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Kagi Search API Key')}
bind:value={webConfig.search.kagi_search_api_key}
/>
</div>
.
</div>
{:else if webConfig.search.engine === 'mojeek'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Mojeek Search API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Mojeek Search API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Mojeek Search API Key')}
bind:value={webConfig.search.mojeek_search_api_key}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Mojeek Search API Key')}
bind:value={webConfig.search.mojeek_search_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'bocha'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bocha Search API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Bocha Search API Key')}
bind:value={webConfig.search.bocha_search_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'serpstack'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serpstack API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serpstack API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Serpstack API Key')}
bind:value={webConfig.search.serpstack_api_key}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Serpstack API Key')}
bind:value={webConfig.search.serpstack_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'serper'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serper API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serper API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Serper API Key')}
bind:value={webConfig.search.serper_api_key}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Serper API Key')}
bind:value={webConfig.search.serper_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'serply'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serply API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Serply API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Serply API Key')}
bind:value={webConfig.search.serply_api_key}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Serply API Key')}
bind:value={webConfig.search.serply_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'searchapi'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('SearchApi API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('SearchApi API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter SearchApi API Key')}
bind:value={webConfig.search.searchapi_api_key}
/>
<SensitiveInput
placeholder={$i18n.t('Enter SearchApi API Key')}
bind:value={webConfig.search.searchapi_api_key}
/>
</div>
<div class="mt-1.5">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('SearchApi Engine')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter SearchApi Engine')}
bind:value={webConfig.search.searchapi_engine}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
<div class="mt-1.5">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('SearchApi Engine')}
</div>
{:else if webConfig.search.engine === 'serpapi'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('SerpApi API Key')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={$i18n.t('Enter SearchApi Engine')}
bind:value={webConfig.search.searchapi_engine}
autocomplete="off"
/>
<SensitiveInput
placeholder={$i18n.t('Enter SerpApi API Key')}
bind:value={webConfig.search.serpapi_api_key}
/>
</div>
<div class="mt-1.5">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('SerpApi Engine')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter SerpApi Engine')}
bind:value={webConfig.search.serpapi_engine}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
{:else if webConfig.search.engine === 'tavily'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Tavily API Key')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Tavily API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Tavily API Key')}
bind:value={webConfig.search.tavily_api_key}
/>
<SensitiveInput
placeholder={$i18n.t('Enter Tavily API Key')}
bind:value={webConfig.search.tavily_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'jina'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Jina API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Jina API Key')}
bind:value={webConfig.search.jina_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'exa'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Exa API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Exa API Key')}
bind:value={webConfig.search.exa_api_key}
/>
</div>
</div>
{:else if webConfig.search.engine === 'perplexity'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Jina API Key')}
{$i18n.t('Perplexity API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Jina API Key')}
bind:value={webConfig.search.jina_api_key}
placeholder={$i18n.t('Enter Perplexity API Key')}
bind:value={webConfig.search.perplexity_api_key}
/>
</div>
{:else if webConfig.search.engine === 'bing'}
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bing Search V7 Endpoint')}
</div>
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bing Search V7 Endpoint')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={$i18n.t('Enter Bing Search V7 Endpoint')}
bind:value={webConfig.search.bing_search_v7_endpoint}
autocomplete="off"
/>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter Bing Search V7 Endpoint')}
bind:value={webConfig.search.bing_search_v7_endpoint}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
<div class="mt-2">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bing Search V7 Subscription Key')}
<div class="mt-2">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Bing Search V7 Subscription Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Bing Search V7 Subscription Key')}
bind:value={webConfig.search.bing_search_v7_subscription_key}
/>
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Bing Search V7 Subscription Key')}
bind:value={webConfig.search.bing_search_v7_subscription_key}
/>
</div>
{/if}
</div>
{/if}
{/if}
{#if webConfig.search.enabled}
<div class="mt-2 flex gap-2 mb-1">
<div class="w-full">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Search Result Count')}
{#if webConfig.search.enabled}
<div class="mb-2.5 flex w-full flex-col">
<div class="flex gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Search Result Count')}
</div>
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Search Result Count')}
bind:value={webConfig.search.result_count}
required
/>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Concurrent Requests')}
</div>
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Concurrent Requests')}
bind:value={webConfig.search.concurrent_requests}
required
/>
</div>
</div>
</div>
<div class="mb-2.5 flex w-full flex-col">
<div class=" text-xs font-medium mb-1">
{$i18n.t('Domain Filter List')}
</div>
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('Search Result Count')}
bind:value={webConfig.search.result_count}
required
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t(
'Enter domains separated by commas (e.g., example.com,site.org)'
)}
bind:value={webConfig.search.domain_filter_list}
/>
</div>
{/if}
<div class="w-full">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Concurrent Requests')}
</div>
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('Concurrent Requests')}
bind:value={webConfig.search.concurrent_requests}
required
/>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
<Tooltip content={$i18n.t('Full Context Mode')} placement="top-start">
{$i18n.t('Bypass Embedding and Retrieval')}
</Tooltip>
</div>
<div class="flex items-center relative">
<Tooltip
content={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
? 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.'
: 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.'}
>
<Switch bind:state={webConfig.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL} />
</Tooltip>
</div>
</div>
{/if}
</div>
<hr class=" dark:border-gray-850 my-2" />
<div>
<div class=" mb-1 text-sm font-medium">
{$i18n.t('Web Loader Settings')}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Trust Proxy Environment')}
</div>
<div class="flex items-center relative">
<Tooltip
content={webConfig.search.trust_env
? 'Use proxy designated by http_proxy and https_proxy environment variables to fetch page contents'
: 'Use no proxy to fetch page contents.'}
>
<Switch bind:state={webConfig.search.trust_env} />
</Tooltip>
</div>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class="mb-3">
<div class=" mb-2.5 text-base font-medium">{$i18n.t('Loader')}</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Bypass SSL verification for Websites')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
webConfig.web_loader_ssl_verification = !webConfig.web_loader_ssl_verification;
submitHandler();
}}
type="button"
>
{#if webConfig.web_loader_ssl_verification === false}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
<div class="flex items-center relative">
<Switch bind:state={webConfig.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION} />
</div>
</div>
</div>
<div class=" mt-2 mb-1 text-sm font-medium">
{$i18n.t('Youtube Loader Settings')}
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" w-20 text-xs font-medium self-center">{$i18n.t('Language')}</div>
<div class=" flex-1 self-center">
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Youtube Language')}
</div>
<div class="flex items-center relative">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter language codes')}
bind:value={youtubeLanguage}
@@ -372,14 +514,14 @@
/>
</div>
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" w-20 text-xs font-medium self-center">{$i18n.t('Proxy URL')}</div>
<div class=" flex-1 self-center">
<div class=" mb-2.5 flex flex-col w-full justify-between">
<div class=" mb-1 text-xs font-medium">
{$i18n.t('Youtube Proxy URL')}
</div>
<div class="flex items-center relative">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Enter proxy URL (e.g. https://user:password@host:port)')}
bind:value={youtubeProxyUrl}
+5 -4
View File
@@ -61,7 +61,8 @@
},
features: {
web_search: true,
image_generation: true
image_generation: true,
code_interpreter: true
}
};
@@ -139,7 +140,7 @@
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={search}
placeholder={$i18n.t('Search')}
/>
@@ -194,7 +195,7 @@
<div class="w-full"></div>
</div>
<hr class="mt-1.5 border-gray-50 dark:border-gray-850" />
<hr class="mt-1.5 border-gray-100 dark:border-gray-850" />
{#each filteredGroups as group}
<div class="my-2">
@@ -204,7 +205,7 @@
</div>
{/if}
<hr class="mb-2 border-gray-50 dark:border-gray-850" />
<hr class="mb-2 border-gray-100 dark:border-gray-850" />
<GroupModal
bind:show={showDefaultPermissionsModal}
@@ -78,7 +78,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Group Name')}
@@ -94,7 +94,7 @@
<div class="flex-1">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none resize-none"
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden resize-none"
rows={2}
bind:value={description}
placeholder={$i18n.t('Group Description')}
@@ -16,7 +16,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Group Name')}
@@ -36,7 +36,7 @@
<div class="text-gray-500">#</div>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={color}
placeholder={$i18n.t('Hex Color')}
@@ -52,7 +52,7 @@
<div class="flex-1">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none resize-none"
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden resize-none"
rows={4}
bind:value={description}
placeholder={$i18n.t('Group Description')}
@@ -45,7 +45,8 @@
},
features: {
web_search: true,
image_generation: true
image_generation: true,
code_interpreter: true
}
};
export let userIds = [];
@@ -22,7 +22,8 @@
},
features: {
web_search: true,
image_generation: true
image_generation: true,
code_interpreter: true
}
};
@@ -75,7 +76,7 @@
<div class=" text-sm flex-1 rounded-lg">
{modelId}
</div>
<div class="flex-shrink-0">
<div class="shrink-0">
<button
type="button"
on:click={() => {
@@ -101,7 +102,7 @@
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
@@ -136,7 +137,7 @@
<div class="flex-1 mr-2">
<select
class="w-full bg-transparent outline-none py-0.5 text-sm"
class="w-full bg-transparent outline-hidden py-0.5 text-sm"
bind:value={permissions.model.default_id}
placeholder="Select a model"
>
@@ -149,7 +150,7 @@
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" /> -->
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> -->
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Workspace Permissions')}</div>
@@ -191,7 +192,7 @@
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
@@ -237,7 +238,7 @@
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
@@ -257,5 +258,13 @@
<Switch bind:state={permissions.features.image_generation} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Code Interpreter')}
</div>
<Switch bind:state={permissions.features.code_interpreter} />
</div>
</div>
</div>
@@ -64,7 +64,7 @@
</svg>
</div>
<input
class=" w-full text-sm pr-4 rounded-r-xl outline-none bg-transparent"
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search')}
/>
+44 -7
View File
@@ -6,7 +6,9 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
import { toast } from 'svelte-sonner';
@@ -26,6 +28,7 @@
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import About from '$lib/components/chat/Settings/About.svelte';
import Banner from '$lib/components/common/Banner.svelte';
const i18n = getContext('i18n');
@@ -83,8 +86,9 @@
return true;
} else {
let name = user.name.toLowerCase();
let email = user.email.toLowerCase();
const query = search.toLowerCase();
return name.includes(query);
return name.includes(query) || email.includes(query);
}
})
.sort((a, b) => {
@@ -121,12 +125,43 @@
/>
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
{#if ($config?.license_metadata?.seats ?? null) !== null && users.length > $config?.license_metadata?.seats}
<div class=" mt-1 mb-2 text-xs text-red-500">
<Banner
className="mx-0"
banner={{
type: 'error',
title: 'License Error',
content:
'Exceeded the number of seats in your license. Please contact support to increase the number of seats.',
dismissable: true
}}
/>
</div>
{/if}
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Users')}
<div class="flex-shrink-0">
{$i18n.t('Users')}
</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span>
{#if ($config?.license_metadata?.seats ?? null) !== null}
{#if users.length > $config?.license_metadata?.seats}
<span class="text-lg font-medium text-red-500"
>{users.length} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">available users</span></span
>
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{users.length} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">available users</span></span
>
{/if}
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span>
{/if}
</div>
<div class="flex gap-1">
@@ -147,7 +182,7 @@
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={search}
placeholder={$i18n.t('Search')}
/>
@@ -169,9 +204,11 @@
</div>
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5">
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded"
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
@@ -364,7 +401,7 @@
</td>
<td class=" px-3 py-1">
{dayjs(user.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
{dayjs(user.created_at * 1000).format('LL')}
</td>
<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </td>
@@ -181,7 +181,7 @@
<div class="flex-1">
<select
class="w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-none"
class="w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-hidden"
bind:value={_user.role}
placeholder={$i18n.t('Enter Your Role')}
required
@@ -198,7 +198,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="text"
bind:value={_user.name}
placeholder={$i18n.t('Enter Your Full Name')}
@@ -208,14 +208,14 @@
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2.5 w-full" />
<hr class=" border-gray-100 dark:border-gray-850 my-2.5 w-full" />
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
@@ -229,7 +229,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="password"
bind:value={_user.password}
placeholder={$i18n.t('Enter Your Password')}
@@ -249,7 +249,7 @@
/>
<button
class="w-full text-sm font-medium py-3 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-800 dark:hover:bg-gray-850 text-center rounded-xl"
class="w-full text-sm font-medium py-3 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl"
type="button"
on:click={() => {
document.getElementById('upload-user-csv-input')?.click();
@@ -7,9 +7,11 @@
import { updateUserById } from '$lib/apis/users';
import Modal from '$lib/components/common/Modal.svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
dayjs.extend(localizedFormat);
export let show = false;
export let selectedUser;
@@ -63,7 +65,7 @@
</svg>
</button>
</div>
<hr class=" dark:border-gray-800" />
<hr class="border-gray-100 dark:border-gray-850" />
<div class="flex flex-col md:flex-row w-full p-5 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">
@@ -87,12 +89,12 @@
<div class="text-xs text-gray-500">
{$i18n.t('Created at')}
{dayjs(selectedUser.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
{dayjs(selectedUser.created_at * 1000).format('LL')}
</div>
</div>
</div>
<hr class=" dark:border-gray-800 my-3 w-full" />
<hr class="border-gray-100 dark:border-gray-850 my-3 w-full" />
<div class=" flex flex-col space-y-1.5">
<div class="flex flex-col w-full">
@@ -100,7 +102,7 @@
<div class="flex-1">
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
autocomplete="off"
@@ -115,7 +117,7 @@
<div class="flex-1">
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-hidden"
type="text"
bind:value={_user.name}
autocomplete="off"
@@ -129,7 +131,7 @@
<div class="flex-1">
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-hidden"
type="password"
bind:value={_user.password}
autocomplete="new-password"
@@ -2,8 +2,10 @@
import { toast } from 'svelte-sonner';
import dayjs from 'dayjs';
import { getContext, createEventDispatcher } from 'svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat';
const dispatch = createEventDispatcher();
dayjs.extend(localizedFormat);
import { getChatListByUserId, deleteChatById, getArchivedChatList } from '$lib/apis/chats';
@@ -80,7 +82,7 @@
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left text-gray-600 dark:text-gray-400 table-auto">
<thead
class="text-xs text-gray-700 uppercase bg-transparent dark:text-gray-200 border-b-2 dark:border-gray-800"
class="text-xs text-gray-700 uppercase bg-transparent dark:text-gray-200 border-b-2 dark:border-gray-850"
>
<tr>
<th
@@ -130,7 +132,7 @@
<td class=" px-3 py-1 hidden md:flex h-[2.5rem] justify-end">
<div class="my-auto shrink-0">
{dayjs(chat.updated_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
{dayjs(chat.updated_at * 1000).format('LLL')}
</div>
</td>
+1 -1
View File
@@ -281,7 +281,7 @@
<PaneResizer
class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850"
>
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm">
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs">
<EllipsisVertical className="size-4 invisible group-hover:visible" />
</div>
</PaneResizer>
+38 -50
View File
@@ -103,7 +103,9 @@
return;
}
if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
if (
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
let reader = new FileReader();
reader.onload = async (event) => {
@@ -155,22 +157,6 @@
}
files = [...files, fileItem];
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
console.log(res);
const blob = new Blob([res.text], { type: 'text/plain' });
file = blobToFile(blob, `${file.name}.txt`);
fileItem.name = file.name;
fileItem.size = file.size;
}
}
try {
// During the file upload, file content is automatically extracted.
@@ -200,7 +186,7 @@
files = files.filter((item) => item?.itemId !== tempItemId);
}
} catch (e) {
toast.error(e);
toast.error(`${e}`);
files = files.filter((item) => item?.itemId !== tempItemId);
}
};
@@ -398,7 +384,7 @@
dir={$settings?.chatDirection ?? 'LTR'}
>
{#if files.length > 0}
<div class="mx-1 mt-2.5 mb-1 flex flex-wrap gap-2">
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
{#each files as file, fileIdx}
{#if file.type === 'image'}
<div class=" relative group">
@@ -411,7 +397,7 @@
</div>
<div class=" absolute -top-1 -right-1">
<button
class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
type="button"
on:click={() => {
files.splice(fileIdx, 1);
@@ -453,35 +439,9 @@
</div>
{/if}
<div class=" flex">
<div class="ml-1 self-end mb-1.5 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-2 outline-none focus:outline-none"
type="button"
aria-label="More"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</InputMenu>
</div>
<div class="px-2.5">
<div
class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
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"
>
<RichTextInput
bind:value={content}
@@ -528,8 +488,36 @@
}}
/>
</div>
</div>
<div class="self-end mb-1.5 flex space-x-1 mr-1">
<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"
>
<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>
</div>
<div class="self-end flex space-x-1 mr-1">
{#if content === ''}
<Tooltip content={$i18n.t('Record voice')}>
<button
@@ -591,7 +579,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-6"
class="size-5"
>
<path
fill-rule="evenodd"
@@ -44,7 +44,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
class="w-full max-w-[200px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={15}
alignOffset={-8}
side="top"
@@ -3,10 +3,12 @@
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
dayjs.extend(localizedFormat);
import { getContext, onMount } from 'svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
@@ -70,7 +72,7 @@
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
>
<div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-800"
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-850"
>
<ReactionPicker
onClose={() => (showButtons = false)}
@@ -136,7 +138,7 @@
dir={$settings.chatDirection}
>
<div
class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
class={`shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
>
{#if showUserProfile}
<ProfilePreview user={message.user}>
@@ -151,11 +153,9 @@
{#if message.created_at}
<div
class="mt-1.5 flex flex-shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-medium first-letter:capitalize"
class="mt-1.5 flex shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-medium first-letter:capitalize"
>
<Tooltip
content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
>
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
{dayjs(message.created_at / 1000000).format('HH:mm')}
</Tooltip>
</div>
@@ -174,9 +174,7 @@
<div
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
>
<Tooltip
content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
>
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
</Tooltip>
</div>
@@ -208,7 +206,7 @@
{#if edit}
<div class="py-2">
<Textarea
className=" bg-transparent outline-none w-full resize-none"
className=" bg-transparent outline-hidden w-full resize-none"
bind:value={editedContent}
onKeydown={(e) => {
if (e.key === 'Escape') {
@@ -29,7 +29,7 @@
<slot name="content">
<DropdownMenu.Content
class="max-w-full w-[240px] rounded-lg z-[9999] bg-white dark:bg-black dark:text-white shadow-lg"
class="max-w-full w-[240px] rounded-lg z-9999 bg-white dark:bg-black dark:text-white shadow-lg"
sideOffset={8}
{side}
{align}
@@ -107,7 +107,7 @@
<slot />
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="max-w-full w-80 bg-gray-50 dark:bg-gray-850 rounded-lg z-[9999] shadow-lg dark:text-white"
class="max-w-full w-80 bg-gray-50 dark:bg-gray-850 rounded-lg z-9999 shadow-lg dark:text-white"
sideOffset={8}
{side}
{align}
@@ -116,7 +116,7 @@
<div class="mb-1 px-3 pt-2 pb-2">
<input
type="text"
class="w-full text-sm bg-transparent outline-none"
class="w-full text-sm bg-transparent outline-hidden"
placeholder="Search all emojis"
bind:value={search}
/>
+1 -1
View File
@@ -18,7 +18,7 @@
<nav class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center drag-region">
<div
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
></div>
<div class=" flex max-w-full w-full mx-auto px-1 pt-0.5 bg-transparent">
+164 -90
View File
@@ -41,11 +41,13 @@
convertMessagesToHistory,
copyToClipboard,
getMessageContentParts,
createMessagesList,
extractSentencesForAudio,
promptTemplate,
splitStream,
sleep,
removeDetailsWithReasoning
removeDetails,
getPromptVariables
} from '$lib/utils';
import { generateChatCompletion } from '$lib/apis/ollama';
@@ -82,10 +84,12 @@
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
import Placeholder from './Placeholder.svelte';
import NotificationToast from '../NotificationToast.svelte';
import Spinner from '../common/Spinner.svelte';
export let chatIdProp = '';
let loaded = false;
let loading = false;
const eventTarget = new EventTarget();
let controlPane;
let controlPaneComponent;
@@ -114,7 +118,7 @@
let selectedToolIds = [];
let imageGenerationEnabled = false;
let webSearchEnabled = false;
let codeInterpreterEnabled = false;
let chat = null;
let tags = [];
@@ -133,6 +137,7 @@
$: if (chatIdProp) {
(async () => {
loading = true;
console.log(chatIdProp);
prompt = '';
@@ -141,11 +146,9 @@
webSearchEnabled = false;
imageGenerationEnabled = false;
loaded = false;
if (chatIdProp && (await loadChat())) {
await tick();
loaded = true;
loading = false;
if (localStorage.getItem(`chat-input-${chatIdProp}`)) {
try {
@@ -184,15 +187,20 @@
setToolIds();
}
$: if (atSelectedModel || selectedModels) {
setToolIds();
}
const setToolIds = async () => {
if (!$tools) {
tools.set(await getTools(localStorage.token));
}
if (selectedModels.length !== 1) {
if (selectedModels.length !== 1 && !atSelectedModel) {
return;
}
const model = $models.find((m) => m.id === selectedModels[0]);
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
if (model) {
selectedToolIds = (model?.info?.meta?.toolIds ?? []).filter((id) =>
$tools.find((t) => t.id === id)
@@ -223,7 +231,7 @@
}
await tick();
saveChatHandler(_chatId);
saveChatHandler(_chatId, history);
};
const chatEventHandler = async (event, cb) => {
@@ -627,7 +635,7 @@
} catch (e) {
// Remove the failed doc from the files array
files = files.filter((f) => f.name !== url);
toast.error(e);
toast.error(`${e}`);
}
};
@@ -715,6 +723,7 @@
if ($page.url.searchParams.get('web-search') === 'true') {
webSearchEnabled = true;
}
if ($page.url.searchParams.get('image-generation') === 'true') {
imageGenerationEnabled = true;
}
@@ -823,20 +832,6 @@
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
};
const createMessagesList = (responseMessageId) => {
if (responseMessageId === null) {
return [];
}
const message = history.messages[responseMessageId];
if (message?.parentId) {
return [...createMessagesList(message.parentId), message];
} else {
return [message];
}
};
const chatCompletedHandler = async (chatId, modelId, responseMessageId, messages) => {
const res = await chatCompleted(localStorage.token, {
model: modelId,
@@ -846,8 +841,10 @@
content: m.content,
info: m.info ? m.info : undefined,
timestamp: m.timestamp,
...(m.usage ? { usage: m.usage } : {}),
...(m.sources ? { sources: m.sources } : {})
})),
model_item: $models.find((m) => m.id === modelId),
chat_id: chatId,
session_id: $socket?.id,
id: responseMessageId
@@ -893,7 +890,7 @@
};
const chatActionHandler = async (chatId, actionId, modelId, responseMessageId, event = null) => {
const messages = createMessagesList(responseMessageId);
const messages = createMessagesList(history, responseMessageId);
const res = await chatAction(localStorage.token, actionId, {
model: modelId,
@@ -906,6 +903,7 @@
...(m.sources ? { sources: m.sources } : {})
})),
...(event ? { event: event } : {}),
model_item: $models.find((m) => m.id === modelId),
chat_id: chatId,
session_id: $socket?.id,
id: responseMessageId
@@ -962,7 +960,7 @@
const modelId = selectedModels[0];
const model = $models.filter((m) => m.id === modelId).at(0);
const messages = createMessagesList(history.currentId);
const messages = createMessagesList(history, history.currentId);
const parentMessage = messages.length !== 0 ? messages.at(-1) : null;
const userMessageId = uuidv4();
@@ -1007,9 +1005,9 @@
}
if (messages.length === 0) {
await initChatHandler();
await initChatHandler(history);
} else {
await saveChatHandler($chatId);
await saveChatHandler($chatId, history);
}
}
};
@@ -1071,9 +1069,9 @@
}
if (messages.length === 0) {
await initChatHandler();
await initChatHandler(history);
} else {
await saveChatHandler($chatId);
await saveChatHandler($chatId, history);
}
};
@@ -1207,7 +1205,12 @@
);
history.messages[message.id] = message;
await chatCompletedHandler(chatId, message.model, message.id, createMessagesList(message.id));
await chatCompletedHandler(
chatId,
message.model,
message.id,
createMessagesList(history, message.id)
);
}
console.log(data);
@@ -1223,7 +1226,7 @@
const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
console.log('submitPrompt', userPrompt, $chatId);
const messages = createMessagesList(history.currentId);
const messages = createMessagesList(history, history.currentId);
const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
@@ -1231,7 +1234,7 @@
selectedModels = _selectedModels;
}
if (userPrompt === '') {
if (userPrompt === '' && files.length === 0) {
toast.error($i18n.t('Please enter a prompt'));
return;
}
@@ -1244,7 +1247,7 @@
// Response not done
return;
}
if (messages.length != 0 && messages.at(-1).error) {
if (messages.length != 0 && messages.at(-1).error && !messages.at(-1).content) {
// Error in response
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
return;
@@ -1271,13 +1274,14 @@
}
prompt = '';
await tick();
// Reset chat input textarea
const chatInputElement = document.getElementById('chat-input');
if (chatInputElement) {
await tick();
chatInputElement.style.height = '';
chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 320) + 'px';
}
const _files = JSON.parse(JSON.stringify(files));
@@ -1313,34 +1317,25 @@
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
// Wait until history/message have been updated
await tick();
// focus on chat input
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
saveSessionSelectedModels();
await sendPrompt(userPrompt, userMessageId, { newChat: true });
await sendPrompt(history, userPrompt, userMessageId, { newChat: true });
};
const sendPrompt = async (
_history,
prompt: string,
parentId: string,
{ modelId = null, modelIdx = null, newChat = false } = {}
) => {
// Create new chat if newChat is true and first user message
if (
newChat &&
history.messages[history.currentId].parentId === null &&
history.messages[history.currentId].role === 'user'
) {
await initChatHandler();
} else {
await saveChatHandler($chatId);
}
let _chatId = JSON.parse(JSON.stringify($chatId));
_history = JSON.parse(JSON.stringify(_history));
const responseMessageIds: Record<PropertyKey, string> = {};
// If modelId is provided, use it, else use selected model
let selectedModelIds = modelId
? [modelId]
@@ -1349,7 +1344,6 @@
: selectedModels;
// Create response messages for each selected model
const responseMessageIds: Record<PropertyKey, string> = {};
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
const model = $models.filter((m) => m.id === modelId).at(0);
@@ -1384,19 +1378,26 @@
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId;
}
}
history = history;
// Create new chat if newChat is true and first user message
if (newChat && _history.messages[_history.currentId].parentId === null) {
_chatId = await initChatHandler(_history);
}
await tick();
_history = JSON.parse(JSON.stringify(history));
// Save chat after all messages have been created
await saveChatHandler($chatId);
await saveChatHandler(_chatId, _history);
const _chatId = JSON.parse(JSON.stringify($chatId));
await Promise.all(
selectedModelIds.map(async (modelId, _modelIdx) => {
console.log('modelId', modelId);
const model = $models.filter((m) => m.id === modelId).at(0);
if (model) {
const messages = createMessagesList(parentId);
const messages = createMessagesList(_history, parentId);
// If there are image files, check if model is vision capable
const hasImages = messages.some((message) =>
message.files?.some((file) => file.type === 'image')
@@ -1412,7 +1413,7 @@
let responseMessageId =
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`];
let responseMessage = history.messages[responseMessageId];
let responseMessage = _history.messages[responseMessageId];
let userContext = null;
if ($settings?.memory ?? false) {
@@ -1441,7 +1442,7 @@
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
scrollToBottom();
await sendPromptSocket(model, responseMessageId, _chatId);
await sendPromptSocket(_history, model, responseMessageId, _chatId);
if (chatEventEmitter) clearInterval(chatEventEmitter);
} else {
@@ -1454,9 +1455,9 @@
chats.set(await getChatList(localStorage.token, $currentChatPage));
};
const sendPromptSocket = async (model, responseMessageId, _chatId) => {
const responseMessage = history.messages[responseMessageId];
const userMessage = history.messages[responseMessage.parentId];
const sendPromptSocket = async (_history, model, responseMessageId, _chatId) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
let files = JSON.parse(JSON.stringify(chatFiles));
files.push(
@@ -1487,7 +1488,7 @@
params?.stream_response ??
true;
const messages = [
let messages = [
params?.system || $settings.system || (responseMessage?.userContext ?? null)
? {
role: 'system',
@@ -1495,7 +1496,10 @@
params?.system ?? $settings?.system ?? '',
$user.name,
$settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token)
? await getAndUpdateUserLocation(localStorage.token).catch((err) => {
console.error(err);
return undefined;
})
: undefined
)}${
(responseMessage?.userContext ?? null)
@@ -1504,12 +1508,13 @@
}`
}
: undefined,
...createMessagesList(responseMessageId).map((message) => ({
...createMessagesList(_history, responseMessageId).map((message) => ({
...message,
content: removeDetailsWithReasoning(message.content)
content: removeDetails(message.content, ['reasoning', 'code_interpreter'])
}))
]
.filter((message) => message?.content?.trim())
].filter((message) => message);
messages = messages
.map((message, idx, arr) => ({
role: message.role,
...((message.files?.filter((file) => file.type === 'image').length > 0 ?? false) &&
@@ -1533,7 +1538,8 @@
: {
content: message?.merged?.content ?? message.content
})
}));
}))
.filter((message) => message?.role === 'user' || message?.content?.trim());
const res = await generateOpenAIChatCompletion(
localStorage.token,
@@ -1557,10 +1563,36 @@
files: (files?.length ?? 0) > 0 ? files : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
features: {
image_generation: imageGenerationEnabled,
web_search: webSearchEnabled
image_generation:
$config?.features?.enable_image_generation &&
($user.role === 'admin' || $user?.permissions?.features?.image_generation)
? imageGenerationEnabled
: false,
code_interpreter:
$config?.features?.enable_code_interpreter &&
($user.role === 'admin' || $user?.permissions?.features?.code_interpreter)
? codeInterpreterEnabled
: false,
web_search:
$config?.features?.enable_web_search &&
($user.role === 'admin' || $user?.permissions?.features?.web_search)
? webSearchEnabled || ($settings?.webSearch ?? false) === 'always'
: false
},
variables: {
...getPromptVariables(
$user.name,
$settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token).catch((err) => {
console.error(err);
return undefined;
})
: undefined
)
},
model_item: $models.find((m) => m.id === model.id),
session_id: $socket?.id,
chat_id: $chatId,
@@ -1571,7 +1603,7 @@
(messages.length == 2 &&
messages.at(0)?.role === 'system' &&
messages.at(1)?.role === 'user')) &&
selectedModels[0] === model.id
(selectedModels[0] === model.id || atSelectedModel !== undefined)
? {
background_tasks: {
title_generation: $settings?.title?.auto ?? true,
@@ -1590,12 +1622,15 @@
},
`${WEBUI_BASE_URL}/api`
).catch((error) => {
console.log(error);
toast.error(`${error}`);
responseMessage.error = {
content: error
};
responseMessage.done = true;
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
return null;
});
@@ -1693,7 +1728,7 @@
history.currentId = userMessageId;
await tick();
await sendPrompt(userPrompt, userMessageId);
await sendPrompt(history, userPrompt, userMessageId);
};
const regenerateResponse = async (message) => {
@@ -1705,11 +1740,11 @@
if ((userMessage?.models ?? [...selectedModels]).length == 1) {
// If user message has only one model selected, sendPrompt automatically selects it for regeneration
await sendPrompt(userPrompt, userMessage.id);
await sendPrompt(history, userPrompt, userMessage.id);
} else {
// If there are multiple models selected, use the model of the response message for regeneration
// e.g. many model chat
await sendPrompt(userPrompt, userMessage.id, {
await sendPrompt(history, userPrompt, userMessage.id, {
modelId: message.model,
modelIdx: message.modelIdx
});
@@ -1731,7 +1766,7 @@
.at(0);
if (model) {
await sendPromptSocket(model, responseMessage.id, _chatId);
await sendPromptSocket(history, model, responseMessage.id, _chatId);
}
}
};
@@ -1774,7 +1809,7 @@
}
}
await saveChatHandler(_chatId);
await saveChatHandler(_chatId, history);
} else {
console.error(res);
}
@@ -1783,42 +1818,48 @@
}
};
const initChatHandler = async () => {
const initChatHandler = async (history) => {
let _chatId = $chatId;
if (!$temporaryChatEnabled) {
chat = await createNewChat(localStorage.token, {
id: $chatId,
id: _chatId,
title: $i18n.t('New Chat'),
models: selectedModels,
system: $settings.system ?? undefined,
params: params,
history: history,
messages: createMessagesList(history.currentId),
messages: createMessagesList(history, history.currentId),
tags: [],
timestamp: Date.now()
});
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await chatId.set(chat.id);
_chatId = chat.id;
await chatId.set(_chatId);
window.history.replaceState(history.state, '', `/c/${chat.id}`);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
currentChatPage.set(1);
window.history.replaceState(history.state, '', `/c/${_chatId}`);
} else {
_chatId = 'local';
await chatId.set('local');
}
await tick();
return _chatId;
};
const saveChatHandler = async (_chatId) => {
const saveChatHandler = async (_chatId, history) => {
if ($chatId == _chatId) {
if (!$temporaryChatEnabled) {
chat = await updateChatById(localStorage.token, _chatId, {
models: selectedModels,
history: history,
messages: createMessagesList(history.currentId),
messages: createMessagesList(history, history.currentId),
params: params,
files: chatFiles
});
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
@@ -1861,7 +1902,7 @@
: ' '} w-full max-w-full flex flex-col"
id="chat-container"
>
{#if !chatIdProp || (loaded && chatIdProp)}
{#if chatIdProp === '' || (!loading && chatIdProp)}
{#if $settings?.backgroundImageUrl ?? null}
<div
class="absolute {$showSidebar
@@ -1871,7 +1912,7 @@
/>
<div
class="absolute top-0 left-0 w-full h-full bg-gradient-to-t from-white to-white/85 dark:from-gray-900 dark:to-[#171717]/90 z-0"
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
/>
{/if}
@@ -1899,6 +1940,30 @@
{#if $banners.length > 0 && !history.currentId && !$chatId && selectedModels.length <= 1}
<div class="absolute top-12 left-0 right-0 w-full z-30">
<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}
@@ -1921,8 +1986,8 @@
</div>
{/if}
<div class="flex flex-col flex-auto z-10 w-full">
{#if $settings?.landingPageMode === 'chat' || createMessagesList(history.currentId).length > 0}
<div class="flex flex-col flex-auto z-10 w-full @container">
{#if $settings?.landingPageMode === 'chat' || createMessagesList(history, history.currentId).length > 0}
<div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden"
id="messages-container"
@@ -1940,6 +2005,7 @@
bind:autoScroll
bind:prompt
{selectedModels}
{atSelectedModel}
{sendPrompt}
{showMessage}
{submitMessage}
@@ -1962,6 +2028,7 @@
bind:autoScroll
bind:selectedToolIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false}
@@ -1986,7 +2053,7 @@
}
}}
on:submit={async (e) => {
if (e.detail) {
if (e.detail || files.length > 0) {
await tick();
submitPrompt(
($settings?.richTextInput ?? true)
@@ -2013,6 +2080,7 @@
bind:autoScroll
bind:selectedToolIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false}
@@ -2028,7 +2096,7 @@
}
}}
on:submit={async (e) => {
if (e.detail) {
if (e.detail || files.length > 0) {
await tick();
submitPrompt(
($settings?.richTextInput ?? true)
@@ -2065,5 +2133,11 @@
{eventTarget}
/>
</PaneGroup>
{:else if loading}
<div class=" flex items-center justify-center h-full w-full">
<div class="m-auto">
<Spinner />
</div>
</div>
{/if}
</div>
+2 -2
View File
@@ -195,7 +195,7 @@
{#if $showControls}
<PaneResizer class="relative flex w-2 items-center justify-center bg-background group">
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm">
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs">
<EllipsisVertical className="size-4 invisible group-hover:visible" />
</div>
</PaneResizer>
@@ -230,7 +230,7 @@
<div
class="w-full {($showOverview || $showArtifacts) && !$showCallOverlay
? ' '
: 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 border border-gray-50 dark:border-gray-850'} rounded-xl z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
: 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 border border-gray-100 dark:border-gray-850'} rounded-xl z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
>
{#if $showCallOverlay}
<div class="w-full h-full flex justify-center">
@@ -16,6 +16,7 @@
export let modelIds = [];
export let models = [];
export let atSelectedModel;
export let submitPrompt;
@@ -126,7 +127,8 @@
<div class=" w-full font-primary" in:fade={{ duration: 200, delay: 300 }}>
<Suggestions
className="grid grid-cols-2"
suggestionPrompts={models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ??
models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
$config?.default_prompt_suggestions ??
[]}
on:select={(e) => {
@@ -121,7 +121,8 @@
toast.error('Model not selected');
return;
}
prompt = `Explain this section to me in more detail\n\n\`\`\`\n${selectedText}\n\`\`\``;
const explainText = $i18n.t('Explain this section to me in more detail');
prompt = `${explainText}\n\n\`\`\`\n${selectedText}\n\`\`\``;
responseContent = '';
const [res, controller] = await chatCompletion(localStorage.token, {
@@ -221,7 +222,7 @@
<div
id={`floating-buttons-${id}`}
class="absolute rounded-lg mt-1 text-xs z-[9999]"
class="absolute rounded-lg mt-1 text-xs z-9999"
style="display: none"
>
{#if responseContent === null}
@@ -230,7 +231,7 @@
class="flex flex-row gap-0.5 shrink-0 p-1 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-lg shadow-xl"
>
<button
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-sm flex items-center gap-1 min-w-fit"
on:click={async () => {
selectedText = window.getSelection().toString();
floatingInput = true;
@@ -246,10 +247,10 @@
>
<ChatBubble className="size-3 shrink-0" />
<div class="shrink-0">Ask</div>
<div class="shrink-0">{$i18n.t('Ask')}</div>
</button>
<button
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-sm flex items-center gap-1 min-w-fit"
on:click={() => {
selectedText = window.getSelection().toString();
explainHandler();
@@ -257,17 +258,17 @@
>
<LightBlub className="size-3 shrink-0" />
<div class="shrink-0">Explain</div>
<div class="shrink-0">{$i18n.t('Explain')}</div>
</button>
</div>
{:else}
<div
class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-800 w-72 rounded-full shadow-xl"
class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-850 w-72 rounded-full shadow-xl"
>
<input
type="text"
id="floating-message-input"
class="ml-5 bg-transparent outline-none w-full flex-1 text-sm"
class="ml-5 bg-transparent outline-hidden w-full flex-1 text-sm"
placeholder={$i18n.t('Ask a question')}
bind:value={floatingInputValue}
on:keydown={(e) => {
@@ -74,7 +74,7 @@
<div class="" slot="content">
<textarea
bind:value={params.system}
class="w-full text-xs py-1.5 bg-transparent outline-none resize-none"
class="w-full text-xs py-1.5 bg-transparent outline-hidden resize-none"
rows="4"
placeholder={$i18n.t('Enter system prompt')}
/>
@@ -148,7 +148,7 @@
<div class="flex gap-2">
<div class="flex-1">
<select
class=" w-full rounded text-xs py-2 px-1 bg-transparent outline-none"
class=" w-full rounded-sm text-xs py-2 px-1 bg-transparent outline-hidden"
bind:value={tab}
placeholder="Select"
>
@@ -161,7 +161,7 @@
<div class="flex-1">
<select
class="w-full rounded py-2 px-1 text-xs bg-transparent outline-none"
class="w-full rounded-sm py-2 px-1 text-xs bg-transparent outline-hidden"
bind:value={selectedId}
on:change={async () => {
await tick();
+300 -174
View File
@@ -2,6 +2,7 @@
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker';
import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
const dispatch = createEventDispatcher();
@@ -16,29 +17,35 @@
showCallOverlay,
tools,
user as _user,
showControls
showControls,
TTSWorker
} from '$lib/stores';
import { blobToFile, compressImage, createMessagesList, findWordIndices } from '$lib/utils';
import { transcribeAudio } from '$lib/apis/audio';
import { uploadFile } from '$lib/apis/files';
import { getTools } from '$lib/apis/tools';
import { generateAutoCompletion } from '$lib/apis';
import { deleteFileById } from '$lib/apis/files';
import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import Tooltip from '../common/Tooltip.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import Headphone from '../icons/Headphone.svelte';
import VoiceRecording from './MessageInput/VoiceRecording.svelte';
import FileItem from '../common/FileItem.svelte';
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import Commands from './MessageInput/Commands.svelte';
import XMark from '../icons/XMark.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
import { generateAutoCompletion } from '$lib/apis';
import { error, text } from '@sveltejs/kit';
import Tooltip from '../common/Tooltip.svelte';
import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte';
import { deleteFileById } from '$lib/apis/files';
import XMark from '../icons/XMark.svelte';
import Headphone from '../icons/Headphone.svelte';
import GlobeAlt from '../icons/GlobeAlt.svelte';
import PhotoSolid from '../icons/PhotoSolid.svelte';
import Photo from '../icons/Photo.svelte';
import CommandLine from '../icons/CommandLine.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
const i18n = getContext('i18n');
@@ -65,6 +72,7 @@
export let imageGenerationEnabled = false;
export let webSearchEnabled = false;
export let codeInterpreterEnabled = false;
$: onChange({
prompt,
@@ -166,22 +174,6 @@
}
files = [...files, fileItem];
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
console.log(res);
const blob = new Blob([res.text], { type: 'text/plain' });
file = blobToFile(blob, `${file.name}.txt`);
fileItem.name = file.name;
fileItem.size = file.size;
}
}
try {
// During the file upload, file content is automatically extracted.
@@ -211,7 +203,7 @@
files = files.filter((item) => item?.itemId !== tempItemId);
}
} catch (e) {
toast.error(e);
toast.error(`${e}`);
files = files.filter((item) => item?.itemId !== tempItemId);
}
};
@@ -242,7 +234,9 @@
return;
}
if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
if (
['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
) {
if (visionCapableModels.length === 0) {
toast.error($i18n.t('Selected model(s) do not support image inputs'));
return;
@@ -385,9 +379,9 @@
</div>
<div class="w-full relative">
{#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled || imageGenerationEnabled}
{#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled || ($settings?.webSearch ?? false) === 'always' || imageGenerationEnabled || codeInterpreterEnabled}
<div
class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-gradient-to-t from-white dark:from-gray-900 z-10"
class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-linear-to-t from-white dark:from-gray-900 z-10"
>
{#if selectedToolIds.length > 0}
<div class="flex items-center justify-between w-full">
@@ -406,7 +400,7 @@
}) as tool, toolIdx (toolIdx)}
<Tooltip
content={tool?.meta?.description ?? ''}
className=" {toolIdx !== 0 ? 'pl-0.5' : ''} flex-shrink-0"
className=" {toolIdx !== 0 ? 'pl-0.5' : ''} shrink-0"
placement="top"
>
{tool.name}
@@ -421,23 +415,39 @@
</div>
{/if}
{#if webSearchEnabled || ($config?.features?.enable_web_search && ($settings?.webSearch ?? false)) === 'always'}
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
<div class="pl-1">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-blue-500" />
</span>
</div>
<div class=" translate-y-[0.5px]">{$i18n.t('Search the internet')}</div>
</div>
</div>
{/if}
{#if imageGenerationEnabled}
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
<div class="pl-1">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
<span class="relative inline-flex rounded-full size-2 bg-teal-500" />
</span>
</div>
<div class=" ">{$i18n.t('Image generation')}</div>
<div class=" translate-y-[0.5px]">{$i18n.t('Generate an image')}</div>
</div>
</div>
{/if}
{#if webSearchEnabled}
{#if codeInterpreterEnabled}
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
<div class="pl-1">
@@ -448,7 +458,7 @@
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
<div class=" ">{$i18n.t('Search the web')}</div>
<div class=" translate-y-[0.5px]">{$i18n.t('Execute code for analysis')}</div>
</div>
</div>
{/if}
@@ -568,7 +578,7 @@
dir={$settings?.chatDirection ?? 'LTR'}
>
{#if files.length > 0}
<div class="mx-1 mt-2.5 mb-1 flex items-center flex-wrap gap-2">
<div class="mx-2 mt-2.5 -mb-1 flex items-center flex-wrap gap-2">
{#each files as file, fileIdx}
{#if file.type === 'image'}
<div class=" relative group">
@@ -606,7 +616,7 @@
</div>
<div class=" absolute -top-1 -right-1">
<button
class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
type="button"
on:click={() => {
files.splice(fileIdx, 1);
@@ -617,7 +627,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
class="size-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"
@@ -656,80 +666,26 @@
</div>
{/if}
<div class=" flex">
<div class="ml-1 self-end mb-1.5 flex space-x-1">
<InputMenu
bind:imageGenerationEnabled
bind:webSearchEnabled
bind:selectedToolIds
{screenCaptureHandler}
uploadFilesHandler={() => {
filesInputElement.click();
}}
uploadGoogleDriveHandler={async () => {
try {
const fileData = await createPicker();
if (fileData) {
const file = new File([fileData.blob], fileData.name, {
type: fileData.blob.type
});
await uploadFileHandler(file);
} else {
console.log('No file was selected from Google Drive');
}
} catch (error) {
console.error('Google Drive Error:', error);
toast.error(
$i18n.t('Error accessing Google Drive: {{error}}', {
error: error.message
})
);
}
}}
onClose={async () => {
await tick();
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
}}
>
<button
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-2 outline-none focus:outline-none"
type="button"
aria-label="More"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</InputMenu>
</div>
<div class="px-2.5">
{#if $settings?.richTextInput ?? true}
<div
class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
class="scrollbar-hidden 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:this={chatInputElement}
bind:value={prompt}
id="chat-input"
messageInput={true}
shiftEnter={!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
))}
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
largeTextAsFile={$settings?.largeTextAsFile ?? false}
autocomplete={true}
autocomplete={$config?.features.enable_autocomplete_generation}
generateAutoCompletion={async (text) => {
if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
toast.error($i18n.t('Please select a model first.'));
@@ -850,15 +806,20 @@
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();
}
// Uses keyCode '13' for Enter key for chinese/japanese keyboards.
//
// Depending on the user's settings, it will send the message
// either when Enter is pressed or when Ctrl+Enter is pressed.
const enterPressed =
($settings?.ctrlEnterToSend ?? false)
? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
: (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
// Submit the prompt when Enter key is pressed
if (prompt !== '' && e.keyCode === 13 && !e.shiftKey) {
dispatch('submit', prompt);
if (enterPressed) {
e.preventDefault();
if (prompt !== '' || files.length > 0) {
dispatch('submit', prompt);
}
}
}
}
@@ -918,37 +879,20 @@
<textarea
id="chat-input"
bind:this={chatInputElement}
class="scrollbar-hidden bg-transparent dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
class="scrollbar-hidden bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 resize-none"
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt}
on:keypress={(e) => {
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
}
// Submit the prompt when Enter key is pressed
if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
dispatch('submit', prompt);
}
}
}}
on:keydown={async (e) => {
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
console.log('keydown', e);
const commandsContainerElement =
document.getElementById('commands-container');
if (e.key === 'Escape') {
stopResponse();
}
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
@@ -984,51 +928,83 @@
editButton?.click();
}
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
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' });
}
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (e.shiftKey) {
prompt = `${prompt}\n`;
} else if (commandOptionButton) {
commandOptionButton?.click();
} else {
document.getElementById('send-message-button')?.click();
}
}
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
if (e.shiftKey) {
prompt = `${prompt}\n`;
} else if (commandOptionButton) {
commandOptionButton?.click();
} else {
document.getElementById('send-message-button')?.click();
}
} else {
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
console.log('keypress', e);
// Prevent Enter key from creating a new line
const isCtrlPressed = e.ctrlKey || e.metaKey;
const enterPressed =
($settings?.ctrlEnterToSend ?? false)
? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
: (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
console.log('Enter pressed:', enterPressed);
if (enterPressed) {
e.preventDefault();
}
// Submit the prompt when Enter key is pressed
if ((prompt !== '' || files.length > 0) && enterPressed) {
dispatch('submit', prompt);
}
}
}
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton?.click();
} else if (e.key === 'Tab') {
if (e.key === 'Tab') {
const words = findWordIndices(prompt);
if (words.length > 0) {
@@ -1107,8 +1083,140 @@
}}
/>
{/if}
</div>
<div class="self-end mb-1.5 flex space-x-1 mr-1">
<div class=" flex justify-between mt-1.5 mb-2.5 mx-0.5 max-w-full">
<div class="ml-1 self-end gap-0.5 flex items-center flex-1 max-w-[80%]">
<InputMenu
bind:selectedToolIds
{screenCaptureHandler}
{inputFilesHandler}
uploadFilesHandler={() => {
filesInputElement.click();
}}
uploadGoogleDriveHandler={async () => {
try {
const fileData = await createPicker();
if (fileData) {
const file = new File([fileData.blob], fileData.name, {
type: fileData.blob.type
});
await uploadFileHandler(file);
} else {
console.log('No file was selected from Google Drive');
}
} catch (error) {
console.error('Google Drive Error:', error);
toast.error(
$i18n.t('Error accessing Google Drive: {{error}}', {
error: error.message
})
);
}
}}
uploadOneDriveHandler={async () => {
try {
const fileData = await pickAndDownloadFile();
if (fileData) {
const file = new File([fileData.blob], fileData.name, {
type: fileData.blob.type || 'application/octet-stream'
});
await uploadFileHandler(file);
} else {
console.log('No file was selected from OneDrive');
}
} catch (error) {
console.error('OneDrive Error:', error);
}
}}
onClose={async () => {
await tick();
const chatInput = document.getElementById('chat-input');
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"
>
<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>
<div class="flex gap-0.5 items-center overflow-x-auto scrollbar-none flex-1">
{#if $_user}
{#if $config?.features?.enable_web_search && ($_user.role === 'admin' || $_user?.permissions?.features?.web_search)}
<Tooltip content={$i18n.t('Search the internet')} placement="top">
<button
on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
type="button"
class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
($settings?.webSearch ?? false) === 'always'
? 'bg-blue-100 dark:bg-blue-500/20 text-blue-500 dark:text-blue-400'
: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'}"
>
<GlobeAlt className="size-5" strokeWidth="1.75" />
<span
class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
>{$i18n.t('Web Search')}</span
>
</button>
</Tooltip>
{/if}
{#if $config?.features?.enable_image_generation && ($_user.role === 'admin' || $_user?.permissions?.features?.image_generation)}
<Tooltip content={$i18n.t('Generate an image')} placement="top">
<button
on:click|preventDefault={() =>
(imageGenerationEnabled = !imageGenerationEnabled)}
type="button"
class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
? 'bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400'
: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 '}"
>
<Photo className="size-5" strokeWidth="1.75" />
<span
class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
>{$i18n.t('Image')}</span
>
</button>
</Tooltip>
{/if}
{#if $config?.features?.enable_code_interpreter && ($_user.role === 'admin' || $_user?.permissions?.features?.code_interpreter)}
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top">
<button
on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button"
class="px-1.5 @sm:px-2.5 py-1.5 flex gap-1.5 items-center text-sm rounded-full font-medium transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {codeInterpreterEnabled
? 'bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400'
: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 '}"
>
<CommandLine className="size-5" strokeWidth="1.75" />
<span
class="hidden @sm:block whitespace-nowrap overflow-hidden text-ellipsis translate-y-[0.5px] mr-0.5"
>{$i18n.t('Code Interpreter')}</span
>
</button>
</Tooltip>
{/if}
{/if}
</div>
</div>
<div class="self-end flex space-x-1 mr-1 shrink-0">
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
<Tooltip content={$i18n.t('Record voice')}>
<button
@@ -1159,11 +1267,14 @@
{/if}
{#if !history.currentId || history.messages[history.currentId]?.done == true}
{#if prompt === ''}
{#if prompt === '' && files.length === 0}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Call')}>
<button
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-2 self-center"
class=" {webSearchEnabled ||
($settings?.webSearch ?? false) === 'always'
? 'bg-blue-500 text-white hover:bg-blue-400 '
: 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100'} transition rounded-full p-1.5 self-center"
type="button"
on:click={async () => {
if (selectedModels.length > 1) {
@@ -1195,6 +1306,19 @@
stream = null;
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
// If the user has not initialized the TTS worker, initialize it
if (!$TTSWorker) {
await TTSWorker.set(
new KokoroWorker({
dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
})
);
await $TTSWorker.init();
}
}
showCallOverlay.set(true);
showControls.set(true);
} catch (err) {
@@ -1215,17 +1339,19 @@
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{prompt !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
class="{!(prompt === '' && files.length === 0)
? webSearchEnabled || ($settings?.webSearch ?? false) === 'always'
? 'bg-blue-500 text-white hover:bg-blue-400 '
: '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={prompt === ''}
disabled={prompt === '' && files.length === 0}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-6"
class="size-5"
>
<path
fill-rule="evenodd"
@@ -1250,7 +1376,7 @@
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
class="size-5"
>
<path
fill-rule="evenodd"
@@ -1,5 +1,5 @@
<script lang="ts">
import { config, models, settings, showCallOverlay } from '$lib/stores';
import { config, models, settings, showCallOverlay, TTSWorker } from '$lib/stores';
import { onMount, tick, getContext, onDestroy, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@@ -12,6 +12,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
const i18n = getContext('i18n');
@@ -230,7 +231,6 @@
mediaRecorder.onstart = () => {
console.log('Recording started');
audioChunks = [];
analyseAudio(audioStream);
};
mediaRecorder.ondataavailable = (event) => {
@@ -244,7 +244,7 @@
stopRecordingCallback();
};
mediaRecorder.start();
analyseAudio(audioStream);
}
};
@@ -320,6 +320,9 @@
if (hasSound) {
// BIG RED TEXT
console.log('%c%s', 'color: red; font-size: 20px;', '🔊 Sound detected');
if (mediaRecorder && mediaRecorder.state !== 'recording') {
mediaRecorder.start();
}
if (!hasStartedSpeaking) {
hasStartedSpeaking = true;
@@ -459,7 +462,21 @@
}
}
if ($config.audio.tts.engine !== '') {
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
const blob = await $TTSWorker
.generate({
text: content,
voice: $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice
})
.catch((error) => {
console.error(error);
toast.error(`${error}`);
});
if (blob) {
audioCache.set(content, new Audio(blob));
}
} else if ($config.audio.tts.engine !== '') {
const res = await synthesizeOpenAISpeech(
localStorage.token,
$settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice
@@ -26,7 +26,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[180px] rounded-lg px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-[9999] bg-white dark:bg-gray-900 dark:text-white shadow-sm"
class="w-full max-w-[180px] rounded-lg px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-9999 bg-white dark:bg-gray-900 dark:text-white shadow-xs"
sideOffset={6}
side="top"
align="start"
@@ -112,7 +112,7 @@
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
@@ -161,7 +161,7 @@
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
@@ -185,25 +185,25 @@
<div class=" font-medium text-black dark:text-gray-100 flex items-center gap-1">
{#if item.legacy}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
Legacy
</div>
{:else if item?.meta?.document}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
Document
</div>
{:else if item?.type === 'file'}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
File
</div>
{:else}
<div
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
Collection
</div>
@@ -238,7 +238,7 @@
class=" font-medium text-black dark:text-gray-100 flex items-center gap-1"
>
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded uppercase text-xs font-bold px-1 flex-shrink-0"
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
>
File
</div>
@@ -70,7 +70,7 @@
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
@@ -74,7 +74,13 @@
}
if (command.content.includes('{{USER_LOCATION}}')) {
const location = await getUserPosition();
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));
}
@@ -139,7 +145,7 @@
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-50 dark:border-gray-850">
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
@@ -19,12 +19,12 @@
bind:this={overlayElement}
class="fixed {$showSidebar
? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]'
: 'left-0'} fixed top-0 right-0 bottom-0 w-full h-full flex z-[9999] touch-none pointer-events-none"
: 'left-0'} fixed top-0 right-0 bottom-0 w-full h-full flex z-9999 touch-none pointer-events-none"
id="dropzone"
role="region"
aria-label="Drag and Drop Container"
>
<div class="absolute w-full h-full backdrop-blur bg-gray-800/40 flex justify-center">
<div class="absolute w-full h-full backdrop-blur-sm bg-gray-800/40 flex justify-center">
<div class="m-auto pt-64 flex flex-col justify-center">
<div class="max-w-md">
<AddFilesPlaceholder />
@@ -5,6 +5,7 @@
import { config, user, tools as _tools, mobile } from '$lib/stores';
import { createPicker } from '$lib/utils/google-drive-picker';
import { getTools } from '$lib/apis/tools';
import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -15,39 +16,31 @@
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
import PhotoSolid from '$lib/components/icons/PhotoSolid.svelte';
import CommandLineSolid from '$lib/components/icons/CommandLineSolid.svelte';
const i18n = getContext('i18n');
export let screenCaptureHandler: Function;
export let uploadFilesHandler: Function;
export let inputFilesHandler: Function;
export let uploadGoogleDriveHandler: Function;
export let uploadOneDriveHandler: Function;
export let selectedToolIds: string[] = [];
export let webSearchEnabled: boolean;
export let imageGenerationEnabled: boolean;
export let onClose: Function;
let tools = {};
let show = false;
let showImageGeneration = false;
$: showImageGeneration =
$config?.features?.enable_image_generation &&
($user.role === 'admin' || $user?.permissions?.features?.image_generation);
let showWebSearch = false;
$: showWebSearch =
$config?.features?.enable_web_search &&
($user.role === 'admin' || $user?.permissions?.features?.web_search);
$: if (show) {
init();
}
let fileUploadEnabled = true;
$: fileUploadEnabled = $user.role === 'admin' || $user?.permissions?.chat?.file_upload;
const init = async () => {
if ($_tools === null) {
await _tools.set(await getTools(localStorage.token));
@@ -62,8 +55,31 @@
return a;
}, {});
};
const detectMobile = () => {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
return /android|iphone|ipad|ipod|windows phone/i.test(userAgent);
};
function handleFileChange(event) {
const inputFiles = Array.from(event.target?.files);
if (inputFiles && inputFiles.length > 0) {
console.log(inputFiles);
inputFilesHandler(inputFiles);
}
}
</script>
<!-- Hidden file input used to open the camera on mobile -->
<input
id="camera-input"
type="file"
accept="image/*"
capture="environment"
on:change={handleFileChange}
style="display: none;"
/>
<Dropdown
bind:show
on:change={(e) => {
@@ -78,7 +94,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[220px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
class="w-full max-w-[220px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={15}
alignOffset={-8}
side="top"
@@ -100,7 +116,7 @@
placement="top-start"
className="flex flex-1 gap-2 items-center"
>
<div class="flex-shrink-0">
<div class="shrink-0">
<WrenchSolid />
</div>
@@ -108,7 +124,7 @@
</Tooltip>
</div>
<div class=" flex-shrink-0">
<div class=" shrink-0">
<Switch
state={tools[toolId].enabled}
on:change={async (e) => {
@@ -129,63 +145,51 @@
<hr class="border-black/5 dark:border-white/5 my-1" />
{/if}
{#if showImageGeneration}
<button
class="flex w-full justify-between gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
on:click={() => {
imageGenerationEnabled = !imageGenerationEnabled;
}}
>
<div class="flex-1 flex items-center gap-2">
<PhotoSolid />
<div class=" line-clamp-1">{$i18n.t('Image')}</div>
</div>
<Switch state={imageGenerationEnabled} />
</button>
{/if}
{#if showWebSearch}
<button
class="flex w-full justify-between gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
on:click={() => {
webSearchEnabled = !webSearchEnabled;
}}
>
<div class="flex-1 flex items-center gap-2">
<GlobeAltSolid />
<div class=" line-clamp-1">{$i18n.t('Web Search')}</div>
</div>
<Switch state={webSearchEnabled} />
</button>
{/if}
{#if showImageGeneration || showWebSearch}
<hr class="border-black/5 dark:border-white/5 my-1" />
{/if}
{#if !$mobile}
<Tooltip
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files') : ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
screenCaptureHandler();
if (fileUploadEnabled) {
if (!detectMobile()) {
screenCaptureHandler();
} else {
const cameraInputElement = document.getElementById('camera-input');
if (cameraInputElement) {
cameraInputElement.click();
}
}
}
}}
>
<CameraSolid />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
{/if}
</Tooltip>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadFilesHandler();
}}
<Tooltip
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files') : ''}
className="w-full"
>
<DocumentArrowUpSolid />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
uploadFilesHandler();
}
}}
>
<DocumentArrowUpSolid />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
</Tooltip>
{#if $config?.features?.enable_google_drive_integration}
<DropdownMenu.Item
@@ -223,6 +227,97 @@
<div class="line-clamp-1">{$i18n.t('Google Drive')}</div>
</DropdownMenu.Item>
{/if}
{#if $config?.features?.enable_onedrive_integration}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler();
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="w-5 h-5" fill="none">
<mask
id="mask0_87_7796"
style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="0"
y="6"
width="32"
height="20"
>
<path
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
fill="#C4C4C4"
/>
</mask>
<g mask="url(#mask0_87_7796)">
<path
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
fill="url(#paint0_linear_87_7796)"
/>
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
<div class="line-clamp-1">{$i18n.t('OneDrive')}</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</div>
</Dropdown>
@@ -362,7 +362,7 @@
{#each visualizerData.slice().reverse() as rms}
<div class="flex items-center h-full">
<div
class="w-[2px] flex-shrink-0
class="w-[2px] shrink-0
{loading
? ' bg-gray-500 dark:bg-gray-400 '
+24 -11
View File
@@ -1,6 +1,14 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import { chats, config, settings, user as _user, mobile, currentChatPage } from '$lib/stores';
import {
chats,
config,
settings,
user as _user,
mobile,
currentChatPage,
temporaryChatEnabled
} from '$lib/stores';
import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@@ -24,6 +32,7 @@
export let prompt;
export let history = {};
export let selectedModels;
export let atSelectedModel;
let messages = [];
@@ -85,15 +94,17 @@
};
const updateChat = async () => {
history = history;
await tick();
await updateChatById(localStorage.token, chatId, {
history: history,
messages: messages
});
if (!$temporaryChatEnabled) {
history = history;
await tick();
await updateChatById(localStorage.token, chatId, {
history: history,
messages: messages
});
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
};
const showPreviousMessage = async (message) => {
@@ -217,7 +228,8 @@
role: 'user',
content: userPrompt,
...(history.messages[messageId].files && { files: history.messages[messageId].files }),
models: selectedModels
models: selectedModels,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
let messageParentId = history.messages[messageId].parentId;
@@ -233,7 +245,7 @@
history.currentId = userMessageId;
await tick();
await sendPrompt(userPrompt, userMessageId);
await sendPrompt(history, userPrompt, userMessageId);
} else {
// Edit user message
history.messages[messageId].content = content;
@@ -339,6 +351,7 @@
{#if Object.keys(history?.messages ?? {}).length == 0}
<ChatPlaceholder
modelIds={selectedModels}
{atSelectedModel}
submitPrompt={async (p) => {
let text = p;
@@ -7,6 +7,7 @@
const i18n = getContext('i18n');
export let id = '';
export let sources = [];
let citations = [];
@@ -42,6 +43,7 @@
}
$: {
console.log('sources', sources);
citations = sources.reduce((acc, source) => {
if (Object.keys(source).length === 0) {
return acc;
@@ -52,7 +54,7 @@
const distance = source.distances?.[index];
// Within the same citation there could be multiple documents
const id = metadata?.source ?? 'N/A';
const id = metadata?.source ?? source?.source?.id ?? 'N/A';
let _source = source?.source;
if (metadata?.name) {
@@ -97,11 +99,11 @@
{#if citations.length > 0}
<div class=" py-0.5 -mx-0.5 w-full flex gap-1 items-center flex-wrap">
{#if citations.length <= 3}
<div class="flex text-xs font-medium">
<div class="flex text-xs font-medium flex-wrap">
{#each citations as citation, idx}
<button
id={`source-${citation.source.name}`}
class="no-toggle outline-none flex dark:text-gray-300 p-1 bg-white dark:bg-gray-900 rounded-xl max-w-96"
id={`source-${id}-${idx}`}
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-white dark:bg-gray-900 rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
@@ -113,7 +115,7 @@
</div>
{/if}
<div
class="flex-1 mx-1 line-clamp-1 text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition"
class="flex-1 mx-1 truncate text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition"
>
{citation.source.name}
</div>
@@ -121,17 +123,26 @@
{/each}
</div>
{:else}
<Collapsible bind:open={isCollapsibleOpen} className="w-full">
<Collapsible
id="collapsible-sources"
bind:open={isCollapsibleOpen}
className="w-full max-w-full "
buttonClassName="w-fit max-w-full"
>
<div
class="flex items-center gap-2 text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition cursor-pointer"
class="flex w-full overflow-auto items-center gap-2 text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition cursor-pointer"
>
<div class="flex-grow flex items-center gap-1 overflow-hidden">
<span class="whitespace-nowrap hidden sm:inline">{$i18n.t('References from')}</span>
<div class="flex items-center">
<div
class="flex-1 flex items-center gap-1 overflow-auto scrollbar-none w-full max-w-full"
>
<span class="whitespace-nowrap hidden sm:inline shrink-0"
>{$i18n.t('References from')}</span
>
<div class="flex items-center overflow-auto scrollbar-none w-full max-w-full flex-1">
<div class="flex text-xs font-medium items-center">
{#each citations.slice(0, 2) as citation, idx}
<button
class="no-toggle outline-none flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
@@ -145,20 +156,20 @@
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-1 line-clamp-1 truncate">
<div class="flex-1 mx-1 truncate">
{citation.source.name}
</div>
</button>
{/each}
</div>
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<div class="flex items-center gap-1 whitespace-nowrap shrink-0">
<span class="hidden sm:inline">{$i18n.t('and')}</span>
{citations.length - 2}
<span>{$i18n.t('more')}</span>
</div>
</div>
<div class="flex-shrink-0">
<div class="shrink-0">
{#if isCollapsibleOpen}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
@@ -167,10 +178,11 @@
</div>
</div>
<div slot="content">
<div class="flex text-xs font-medium">
<div class="flex text-xs font-medium flex-wrap">
{#each citations as citation, idx}
<button
class="no-toggle outline-none flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
id={`source-${id}-${idx}`}
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
@@ -181,7 +193,7 @@
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-1 line-clamp-1 truncate">
<div class="flex-1 mx-1 truncate">
{citation.source.name}
</div>
</button>
@@ -90,7 +90,7 @@
>
<div class="text-sm dark:text-gray-400 flex items-center gap-2 w-fit">
<a
class="hover:text-gray-500 hover:dark:text-gray-100 underline flex-grow"
class="hover:text-gray-500 dark:hover:text-gray-100 underline grow"
href={document?.metadata?.file_id
? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source?.url?.includes('http')
@@ -122,7 +122,9 @@
<div class="text-sm my-1 dark:text-gray-400 flex items-center gap-2 w-fit">
{#if showPercentage}
{@const percentage = calculatePercentage(document.distance)}
<span class={`px-1 rounded font-medium ${getRelevanceColor(percentage)}`}>
<span
class={`px-1 rounded-sm font-medium ${getRelevanceColor(percentage)}`}
>
{percentage.toFixed(2)}%
</span>
<span class="text-gray-500 dark:text-gray-500">
@@ -166,7 +168,7 @@
</div>
{#if documentIdx !== mergedDocuments.length - 1}
<hr class=" dark:border-gray-850 my-3" />
<hr class="border-gray-100 dark:border-gray-850 my-3" />
{/if}
{/each}
</div>
+292 -121
View File
@@ -1,11 +1,9 @@
<script lang="ts">
import hljs from 'highlight.js';
import { loadPyodide } from 'pyodide';
import mermaid from 'mermaid';
import { v4 as uuidv4 } from 'uuid';
import { getContext, getAllContexts, onMount, tick, createEventDispatcher } from 'svelte';
import { getContext, onMount, tick, onDestroy } from 'svelte';
import { copyToClipboard } from '$lib/utils';
import 'highlight.js/styles/github-dark.min.css';
@@ -13,23 +11,34 @@
import PyodideWorker from '$lib/workers/pyodide.worker?worker';
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte';
import { config } from '$lib/stores';
import { executeCode } from '$lib/apis/utils';
import { toast } from 'svelte-sonner';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronUpDown from '$lib/components/icons/ChevronUpDown.svelte';
import CommandLine from '$lib/components/icons/CommandLine.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let id = '';
export let onSave = (e) => {};
export let onCode = (e) => {};
export let save = false;
export let run = true;
export let token;
export let lang = '';
export let code = '';
export let attributes = {};
export let className = 'my-2';
export let editorClassName = '';
export let stickyButtonsClassName = 'top-8';
let pyodideWorker = null;
let _code = '';
$: if (code) {
updateCode();
@@ -49,15 +58,21 @@
let stdout = null;
let stderr = null;
let result = null;
let files = null;
let collapsed = false;
let copied = false;
let saved = false;
const collapseCodeBlock = () => {
collapsed = !collapsed;
};
const saveCode = () => {
saved = true;
code = _code;
dispatch('save', code);
onSave(code);
setTimeout(() => {
saved = false;
@@ -109,95 +124,87 @@
};
const executePython = async (code) => {
if (!code.includes('input') && !code.includes('matplotlib')) {
executePythonAsWorker(code);
} else {
result = null;
stdout = null;
stderr = null;
executing = true;
document.pyodideMplTarget = document.getElementById(`plt-canvas-${id}`);
let pyodide = await loadPyodide({
indexURL: '/pyodide/',
stdout: (text) => {
console.log('Python output:', text);
if (stdout) {
stdout += `${text}\n`;
} else {
stdout = `${text}\n`;
}
},
stderr: (text) => {
console.log('An error occurred:', text);
if (stderr) {
stderr += `${text}\n`;
} else {
stderr = `${text}\n`;
}
},
packages: ['micropip']
});
try {
const micropip = pyodide.pyimport('micropip');
// await micropip.set_index_urls('https://pypi.org/pypi/{package_name}/json');
let packages = [
code.includes('requests') ? 'requests' : null,
code.includes('bs4') ? 'beautifulsoup4' : null,
code.includes('numpy') ? 'numpy' : null,
code.includes('pandas') ? 'pandas' : null,
code.includes('matplotlib') ? 'matplotlib' : null,
code.includes('sklearn') ? 'scikit-learn' : null,
code.includes('scipy') ? 'scipy' : null,
code.includes('re') ? 'regex' : null,
code.includes('seaborn') ? 'seaborn' : null
].filter(Boolean);
console.log(packages);
await micropip.install(packages);
result = await pyodide.runPythonAsync(`from js import prompt
def input(p):
return prompt(p)
__builtins__.input = input`);
result = await pyodide.runPython(code);
if (!result) {
result = '[NO OUTPUT]';
}
console.log(result);
console.log(stdout);
console.log(stderr);
const pltCanvasElement = document.getElementById(`plt-canvas-${id}`);
if (pltCanvasElement?.innerHTML !== '') {
pltCanvasElement.classList.add('pt-4');
}
} catch (error) {
console.error('Error:', error);
stderr = error;
}
executing = false;
}
};
const executePythonAsWorker = async (code) => {
result = null;
stdout = null;
stderr = null;
executing = true;
if ($config?.code?.engine === 'jupyter') {
const output = await executeCode(localStorage.token, code).catch((error) => {
toast.error(`${error}`);
return null;
});
if (output) {
if (output['stdout']) {
stdout = output['stdout'];
const stdoutLines = stdout.split('\n');
for (const [idx, line] of stdoutLines.entries()) {
if (line.startsWith('data:image/png;base64')) {
if (files) {
files.push({
type: 'image/png',
data: line
});
} else {
files = [
{
type: 'image/png',
data: line
}
];
}
if (stdout.startsWith(`${line}\n`)) {
stdout = stdout.replace(`${line}\n`, ``);
} else if (stdout.startsWith(`${line}`)) {
stdout = stdout.replace(`${line}`, ``);
}
}
}
}
if (output['result']) {
result = output['result'];
const resultLines = result.split('\n');
for (const [idx, line] of resultLines.entries()) {
if (line.startsWith('data:image/png;base64')) {
if (files) {
files.push({
type: 'image/png',
data: line
});
} else {
files = [
{
type: 'image/png',
data: line
}
];
}
if (result.startsWith(`${line}\n`)) {
result = result.replace(`${line}\n`, ``);
} else if (result.startsWith(`${line}`)) {
result = result.replace(`${line}`, ``);
}
}
}
}
output['stderr'] && (stderr = output['stderr']);
}
executing = false;
} else {
executePythonAsWorker(code);
}
};
const executePythonAsWorker = async (code) => {
let packages = [
code.includes('requests') ? 'requests' : null,
code.includes('bs4') ? 'beautifulsoup4' : null,
@@ -206,12 +213,16 @@ __builtins__.input = input`);
code.includes('sklearn') ? 'scikit-learn' : null,
code.includes('scipy') ? 'scipy' : null,
code.includes('re') ? 'regex' : null,
code.includes('seaborn') ? 'seaborn' : null
code.includes('seaborn') ? 'seaborn' : null,
code.includes('sympy') ? 'sympy' : null,
code.includes('tiktoken') ? 'tiktoken' : null,
code.includes('matplotlib') ? 'matplotlib' : null,
code.includes('pytz') ? 'pytz' : null
].filter(Boolean);
console.log(packages);
const pyodideWorker = new PyodideWorker();
pyodideWorker = new PyodideWorker();
pyodideWorker.postMessage({
id: id,
@@ -233,7 +244,64 @@ __builtins__.input = input`);
console.log(id, data);
data['stdout'] && (stdout = data['stdout']);
if (data['stdout']) {
stdout = data['stdout'];
const stdoutLines = stdout.split('\n');
for (const [idx, line] of stdoutLines.entries()) {
if (line.startsWith('data:image/png;base64')) {
if (files) {
files.push({
type: 'image/png',
data: line
});
} else {
files = [
{
type: 'image/png',
data: line
}
];
}
if (stdout.startsWith(`${line}\n`)) {
stdout = stdout.replace(`${line}\n`, ``);
} else if (stdout.startsWith(`${line}`)) {
stdout = stdout.replace(`${line}`, ``);
}
}
}
}
if (data['result']) {
result = data['result'];
const resultLines = result.split('\n');
for (const [idx, line] of resultLines.entries()) {
if (line.startsWith('data:image/png;base64')) {
if (files) {
files.push({
type: 'image/png',
data: line
});
} else {
files = [
{
type: 'image/png',
data: line
}
];
}
if (result.startsWith(`${line}\n`)) {
result = result.replace(`${line}\n`, ``);
} else if (result.startsWith(`${line}`)) {
result = result.replace(`${line}`, ``);
}
}
}
}
data['stderr'] && (stderr = data['stderr']);
data['result'] && (result = data['result']);
@@ -277,13 +345,43 @@ __builtins__.input = input`);
render();
}
$: dispatch('code', { lang, code });
$: onCode({ lang, code });
$: if (attributes) {
onAttributesUpdate();
}
const onAttributesUpdate = () => {
if (attributes?.output) {
// Create a helper function to unescape HTML entities
const unescapeHtml = (html) => {
const textArea = document.createElement('textarea');
textArea.innerHTML = html;
return textArea.value;
};
try {
// Unescape the HTML-encoded string
const unescapedOutput = unescapeHtml(attributes.output);
// Parse the unescaped string into JSON
const output = JSON.parse(unescapedOutput);
// Assign the parsed values to variables
stdout = output.stdout;
stderr = output.stderr;
result = output.result;
} catch (error) {
console.error('Error:', error);
}
}
};
onMount(async () => {
console.log('codeblock', lang, code);
if (lang) {
dispatch('code', { lang, code });
onCode({ lang, code });
}
if (document.documentElement.classList.contains('dark')) {
mermaid.initialize({
@@ -299,6 +397,12 @@ __builtins__.input = input`);
});
}
});
onDestroy(() => {
if (pyodideWorker) {
pyodideWorker.terminate();
}
});
</script>
<div>
@@ -306,7 +410,7 @@ __builtins__.input = input`);
{#if lang === 'mermaid'}
{#if mermaidHtml}
<SvgPanZoom
className=" border border-gray-50 dark:border-gray-850 rounded-lg max-h-fit overflow-hidden"
className=" border border-gray-100 dark:border-gray-850 rounded-lg max-h-fit overflow-hidden"
svg={mermaidHtml}
content={_token.text}
/>
@@ -322,18 +426,39 @@ __builtins__.input = input`);
class="sticky {stickyButtonsClassName} mb-1 py-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white"
>
<div class="flex items-center gap-0.5 translate-y-[1px]">
<button
class="flex gap-1 items-center bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
on:click={collapseCodeBlock}
>
<div>
<ChevronUpDown className="size-3" />
</div>
<div>
{collapsed ? $i18n.t('Expand') : $i18n.t('Collapse')}
</div>
</button>
{#if lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code))}
{#if executing}
<div class="run-code-button bg-none border-none p-1 cursor-not-allowed">Running</div>
{:else if run}
<button
class="run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
on:click={async () => {
code = _code;
await tick();
executePython(code);
}}>{$i18n.t('Run')}</button
}}
>
<div>
<CommandLine className="size-3" />
</div>
<div>
{$i18n.t('Run')}
</div>
</button>
{/if}
{/if}
@@ -361,34 +486,80 @@ __builtins__.input = input`);
: 'rounded-b-lg'} overflow-hidden"
>
<div class=" pt-7 bg-gray-50 dark:bg-gray-850"></div>
<CodeEditor
value={code}
{id}
{lang}
on:save={() => {
saveCode();
}}
on:change={(e) => {
_code = e.detail.value;
}}
/>
{#if !collapsed}
<CodeEditor
value={code}
{id}
{lang}
onSave={() => {
saveCode();
}}
onChange={(value) => {
_code = value;
}}
/>
{:else}
<div
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-lg! pt-2 pb-2 px-4 flex flex-col gap-2 text-xs"
>
<span class="text-gray-500 italic">
{$i18n.t('{{COUNT}} hidden lines', {
COUNT: code.split('\n').length
})}
</span>
</div>
{/if}
</div>
<div
id="plt-canvas-{id}"
class="bg-[#202123] text-white max-w-full overflow-x-auto scrollbar-hidden"
/>
{#if !collapsed}
<div
id="plt-canvas-{id}"
class="bg-gray-50 dark:bg-[#202123] dark:text-white max-w-full overflow-x-auto scrollbar-hidden"
/>
{#if executing}
<div class="bg-[#202123] text-white px-4 py-4 rounded-b-lg">
<div class=" text-gray-500 text-xs mb-1">STDOUT/STDERR</div>
<div class="text-sm">Running...</div>
</div>
{:else if stdout || stderr || result}
<div class="bg-[#202123] text-white px-4 py-4 rounded-b-lg">
<div class=" text-gray-500 text-xs mb-1">STDOUT/STDERR</div>
<div class="text-sm">{stdout || stderr || result}</div>
</div>
{#if executing || stdout || stderr || result || files}
<div
class="bg-gray-50 dark:bg-[#202123] dark:text-white rounded-b-lg! py-4 px-4 flex flex-col gap-2"
>
{#if executing}
<div class=" ">
<div class=" text-gray-500 text-xs mb-1">STDOUT/STDERR</div>
<div class="text-sm">Running...</div>
</div>
{:else}
{#if stdout || stderr}
<div class=" ">
<div class=" text-gray-500 text-xs mb-1">STDOUT/STDERR</div>
<div
class="text-sm {stdout?.split('\n')?.length > 100
? `max-h-96`
: ''} overflow-y-auto"
>
{stdout || stderr}
</div>
</div>
{/if}
{#if result || files}
<div class=" ">
<div class=" text-gray-500 text-xs mb-1">RESULT</div>
{#if result}
<div class="text-sm">{`${JSON.stringify(result)}`}</div>
{/if}
{#if files}
<div class="flex flex-col gap-2">
{#each files as file}
{#if file.type.startsWith('image')}
<img src={file.data} alt="Output" class=" w-full max-w-[36rem]" />
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</div>
{/if}
{/if}
{/if}
</div>
@@ -99,7 +99,7 @@
{/if}
{#if codeExecution?.result?.files && codeExecution?.result?.files.length > 0}
<div class="flex flex-col w-full">
<hr class=" dark:border-gray-850 my-2" />
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<div class=" text-sm font-medium dark:text-gray-300">
{$i18n.t('Files')}
</div>
@@ -18,6 +18,8 @@
export let floatingButtons = true;
export let onSourceClick = () => {};
export let onTaskClick = () => {};
export let onAddMessages = () => {};
let contentContainerElement;
@@ -118,6 +120,11 @@
sourceIds={(sources ?? []).reduce((acc, s) => {
let ids = [];
s.document.forEach((document, index) => {
if (model?.info?.meta?.capabilities?.citations == false) {
ids.push('N/A');
return ids;
}
const metadata = s.metadata?.[index];
const id = metadata?.source ?? 'N/A';
@@ -141,6 +148,7 @@
return acc.filter((item, index) => acc.indexOf(item) === index);
}, [])}
{onSourceClick}
{onTaskClick}
on:update={(e) => {
dispatch('update', e.detail);
}}
@@ -17,7 +17,9 @@
export let save = false;
export let sourceIds = [];
export let onSourceClick = () => {};
export let onTaskClick = () => {};
let tokens = [];
@@ -42,6 +44,7 @@
{tokens}
{id}
{save}
{onTaskClick}
{onSourceClick}
on:update={(e) => {
dispatch('update', e.detail);
@@ -29,7 +29,7 @@
{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
{@html `${token.text}`}
{:else if token.text.includes(`<source_id`)}
<Source {token} onClick={onSourceClick} />
<Source {id} {token} onClick={onSourceClick} />
{:else}
{token.text}
{/if}
@@ -44,13 +44,9 @@
{:else if token.type === 'image'}
<Image src={token.href} alt={token.text} />
{:else if token.type === 'strong'}
<strong>
<svelte:self id={`${id}-strong`} tokens={token.tokens} {onSourceClick} />
</strong>
<strong><svelte:self id={`${id}-strong`} tokens={token.tokens} {onSourceClick} /></strong>
{:else if token.type === 'em'}
<em>
<svelte:self id={`${id}-em`} tokens={token.tokens} {onSourceClick} />
</em>
<em><svelte:self id={`${id}-em`} tokens={token.tokens} {onSourceClick} /></em>
{:else if token.type === 'codespan'}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
@@ -64,9 +60,7 @@
{:else if token.type === 'br'}
<br />
{:else if token.type === 'del'}
<del>
<svelte:self id={`${id}-del`} tokens={token.tokens} {onSourceClick} />
</del>
<del><svelte:self id={`${id}-del`} tokens={token.tokens} {onSourceClick} /></del>
{:else if token.type === 'inlineKatex'}
{#if token.text}
<KatexRenderer content={token.text} displayMode={false} />
@@ -17,14 +17,18 @@
import Collapsible from '$lib/components/common/Collapsible.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Source from './Source.svelte';
const dispatch = createEventDispatcher();
export let id: string;
export let tokens: Token[];
export let top = true;
export let attributes = {};
export let save = false;
export let onTaskClick: Function = () => {};
export let onSourceClick: Function = () => {};
const headerComponent = (depth: number) => {
@@ -71,9 +75,9 @@
<!-- {JSON.stringify(tokens)} -->
{#each tokens as token, tokenIdx (tokenIdx)}
{#if token.type === 'hr'}
<hr class=" border-gray-50 dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
{:else if token.type === 'heading'}
<svelte:element this={headerComponent(token.depth)}>
<svelte:element this={headerComponent(token.depth)} dir="auto">
<MarkdownInlineTokens id={`${id}-${tokenIdx}-h`} tokens={token.tokens} {onSourceClick} />
</svelte:element>
{:else if token.type === 'code'}
@@ -83,15 +87,16 @@
{token}
lang={token?.lang ?? ''}
code={token?.text ?? ''}
{attributes}
{save}
on:code={(e) => {
dispatch('code', e.detail);
onCode={(value) => {
dispatch('code', value);
}}
on:save={(e) => {
onSave={(value) => {
dispatch('update', {
raw: token.raw,
oldContent: token.text,
newContent: e.detail
newContent: value
});
}}
/>
@@ -111,11 +116,11 @@
{#each token.header as header, headerIdx}
<th
scope="col"
class="!px-3 !py-1.5 cursor-pointer border border-gray-50 dark:border-gray-850"
class="px-3! py-1.5! cursor-pointer border border-gray-100 dark:border-gray-850"
style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`}
>
<div class="flex flex-col gap-1.5 text-left">
<div class="flex-shrink-0 break-normal">
<div class="shrink-0 break-normal">
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-header-${headerIdx}`}
tokens={header.tokens}
@@ -132,7 +137,7 @@
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
{#each row ?? [] as cell, cellIdx}
<td
class="!px-3 !py-1.5 text-gray-900 dark:text-white w-max border border-gray-50 dark:border-gray-850"
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]}`}
>
<div class="flex flex-col break-normal">
@@ -165,18 +170,38 @@
</div>
</div>
{:else if token.type === 'blockquote'}
<blockquote>
<svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} />
<blockquote dir="auto">
<svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} {onTaskClick} {onSourceClick} />
</blockquote>
{:else if token.type === 'list'}
{#if token.ordered}
<ol start={token.start || 1}>
{#each token.items as item, itemIdx}
<li>
<li dir="auto" class="text-start">
{#if item?.task}
<input
class=" translate-y-[1px] -translate-x-1"
type="checkbox"
checked={item.checked}
on:change={(e) => {
onTaskClick({
id: id,
token: token,
tokenIdx: tokenIdx,
item: item,
itemIdx: itemIdx,
checked: e.target.checked
});
}}
/>
{/if}
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
tokens={item.tokens}
top={token.loose}
{onTaskClick}
{onSourceClick}
/>
</li>
{/each}
@@ -184,20 +209,51 @@
{:else}
<ul>
{#each token.items as item, itemIdx}
<li>
<li dir="auto" class="text-start">
{#if item?.task}
<input
class=" translate-y-[1px] -translate-x-1"
type="checkbox"
checked={item.checked}
on:change={(e) => {
onTaskClick({
id: id,
token: token,
tokenIdx: tokenIdx,
item: item,
itemIdx: itemIdx,
checked: e.target.checked
});
}}
/>
{/if}
<svelte:self
id={`${id}-${tokenIdx}-${itemIdx}`}
tokens={item.tokens}
top={token.loose}
{onTaskClick}
{onSourceClick}
/>
</li>
{/each}
</ul>
{/if}
{:else if token.type === 'details'}
<Collapsible title={token.summary} attributes={token?.attributes} className="w-fit space-y-1">
<Collapsible
title={token.summary}
attributes={token?.attributes}
className="w-full space-y-1"
dir="auto"
>
<div class=" mb-1.5" slot="content">
<svelte:self id={`${id}-${tokenIdx}-d`} tokens={marked.lexer(token.text)} />
<svelte:self
id={`${id}-${tokenIdx}-d`}
tokens={marked.lexer(token.text)}
attributes={token?.attributes}
{onTaskClick}
{onSourceClick}
/>
</div>
</Collapsible>
{:else if token.type === 'html'}
@@ -206,6 +262,8 @@
{@html html}
{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
{@html `${token.text}`}
{:else if token.text.includes(`<source_id`)}
<Source {id} {token} onClick={onSourceClick} />
{:else}
{token.text}
{/if}
@@ -218,7 +276,7 @@
onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';"
></iframe>
{:else if token.type === 'paragraph'}
<p>
<p dir="auto">
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-p`}
tokens={token.tokens ?? []}
@@ -227,7 +285,7 @@
</p>
{:else if token.type === 'text'}
{#if top}
<p>
<p dir="auto">
{#if token.tokens}
<MarkdownInlineTokens id={`${id}-${tokenIdx}-t`} tokens={token.tokens} {onSourceClick} />
{:else}
@@ -1,25 +1,50 @@
<script lang="ts">
export let id;
export let token;
export let onClick: Function = () => {};
let id = '';
function extractDataAttribute(input) {
// Use a regular expression to extract the value of the `data` attribute
const match = input.match(/data="([^"]*)"/);
// Check if a match was found and return the first captured group
return match ? match[1] : null;
let attributes: Record<string, string | undefined> = {};
function extractAttributes(input: string): Record<string, string> {
const regex = /(\w+)="([^"]*)"/g;
let match;
let attrs: Record<string, string> = {};
// Loop through all matches and populate the attributes object
while ((match = regex.exec(input)) !== null) {
attrs[match[1]] = match[2];
}
return attrs;
}
$: id = extractDataAttribute(token.text);
// Helper function to return only the domain from a URL
function getDomain(url: string): string {
const domain = url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0];
return domain;
}
// Helper function to check if text is a URL and return the domain
function formattedTitle(title: string): string {
if (title.startsWith('http')) {
return getDomain(title);
}
return title;
}
$: attributes = extractAttributes(token.text);
</script>
<button
class="text-xs font-medium w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/60 dark:hover:text-white bg-gray-50 text-black/60 hover:text-black transition rounded-lg"
on:click={() => {
onClick(id);
}}
>
<span class="line-clamp-1">
{id}
</span>
</button>
{#if attributes.title !== 'N/A'}
<button
class="text-xs font-medium w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/60 dark:hover:text-white bg-gray-50 text-black/60 hover:text-black transition rounded-lg"
on:click={() => {
onClick(id, attributes.data);
}}
>
<span class="line-clamp-1">
{attributes.title ? formattedTitle(attributes.title) : ''}
</span>
</button>
{/if}
@@ -78,6 +78,7 @@
{rateMessage}
{actionMessage}
{submitMessage}
{deleteMessage}
{continueResponse}
{regenerateResponse}
{addMessages}
@@ -95,6 +96,7 @@
{rateMessage}
{actionMessage}
{submitMessage}
{deleteMessage}
{continueResponse}
{regenerateResponse}
{mergeResponses}
@@ -16,7 +16,9 @@
import Markdown from './Markdown.svelte';
import Name from './Name.svelte';
import Skeleton from './Skeleton.svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat';
const i18n = getContext('i18n');
dayjs.extend(localizedFormat);
export let chatId;
export let history;
@@ -32,6 +34,8 @@
export let actionMessage: Function;
export let submitMessage: Function;
export let deleteMessage: Function;
export let continueResponse: Function;
export let regenerateResponse: Function;
export let mergeResponses: Function;
@@ -190,10 +194,10 @@
<div
class=" snap-center w-full max-w-full m-1 border {history.messages[messageId]
?.modelIdx == modelIdx
? `border-gray-100 dark:border-gray-800 border-[1.5px] ${
? `border-gray-100 dark:border-gray-850 border-[1.5px] ${
$mobile ? 'min-w-full' : 'min-w-80'
}`
: `border-gray-50 dark:border-gray-850 border-dashed ${
: `border-gray-100 dark:border-gray-850 border-dashed ${
$mobile ? 'min-w-full' : 'min-w-80'
}`} transition-all p-5 rounded-2xl"
on:click={async () => {
@@ -226,6 +230,7 @@
{editMessage}
{saveMessage}
{rateMessage}
{deleteMessage}
{actionMessage}
{submitMessage}
{continueResponse}
@@ -264,7 +269,7 @@
<span
class=" self-center invisible group-hover:visible text-gray-400 text-xs font-medium uppercase ml-0.5 -mt-0.5"
>
{dayjs(message.timestamp * 1000).format($i18n.t('h:mm a'))}
{dayjs(message.timestamp * 1000).format('LT')}
</span>
{/if}
</Name>
@@ -281,7 +286,7 @@
</div>
{#if isLastMessage}
<div class=" flex-shrink-0 text-gray-600 dark:text-gray-500 mt-1">
<div class=" shrink-0 text-gray-600 dark:text-gray-500 mt-1">
<Tooltip content={$i18n.t('Merge Responses')} placement="bottom">
<button
type="button"
@@ -110,7 +110,7 @@
{/if}
<div
class=" my-2.5 rounded-xl px-4 py-3 border border-gray-50 dark:border-gray-850"
class=" my-2.5 rounded-xl px-4 py-3 border border-gray-100 dark:border-gray-850"
id="message-feedback-{message.id}"
>
<div class="flex justify-between items-center">
@@ -142,10 +142,10 @@
<!-- 1-10 scale -->
{#each Array.from({ length: 10 }).map((_, i) => i + 1) as rating}
<button
class="size-7 text-sm border border-gray-50 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating ===
class="size-7 text-sm border border-gray-100 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating ===
rating
? 'bg-gray-100 dark:bg-gray-800'
: ''} transition rounded-full disabled:cursor-not-allowed disabled:text-gray-500 disabled:bg-white disabled:dark:bg-gray-900"
: ''} transition rounded-full disabled:cursor-not-allowed disabled:text-gray-500 disabled:bg-white dark:disabled:bg-gray-900"
on:click={() => {
detailedRating = rating;
}}
@@ -175,7 +175,7 @@
<div class="flex flex-wrap gap-1.5 text-sm mt-1.5">
{#each reasons as reason}
<button
class="px-3 py-0.5 border border-gray-50 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedReason ===
class="px-3 py-0.5 border border-gray-100 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedReason ===
reason
? 'bg-gray-100 dark:bg-gray-800'
: ''} transition rounded-xl"
@@ -223,7 +223,7 @@
<div class="mt-2">
<textarea
bind:value={comment}
class="w-full text-sm px-1 py-2 bg-transparent outline-none resize-none rounded-xl"
class="w-full text-sm px-1 py-2 bg-transparent outline-hidden resize-none rounded-xl"
placeholder={$i18n.t('Feel free to add specific details')}
rows="3"
/>
@@ -4,12 +4,18 @@
import { createEventDispatcher } from 'svelte';
import { onMount, tick, getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
const i18n = getContext<Writable<i18nType>>('i18n');
const dispatch = createEventDispatcher();
import { config, models, settings, user } from '$lib/stores';
import { createNewFeedback, getFeedbackById, updateFeedbackById } from '$lib/apis/evaluations';
import { getChatById } from '$lib/apis/chats';
import { generateTags } from '$lib/apis';
import { config, models, settings, temporaryChatEnabled, TTSWorker, user } from '$lib/stores';
import { synthesizeOpenAISpeech } from '$lib/apis/audio';
import { imageGenerations } from '$lib/apis/images';
import {
@@ -31,16 +37,14 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import WebSearchResults from './ResponseMessage/WebSearchResults.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Error from './Error.svelte';
import Citations from './Citations.svelte';
import CodeExecutions from './CodeExecutions.svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import ContentRenderer from './ContentRenderer.svelte';
import { createNewFeedback, getFeedbackById, updateFeedbackById } from '$lib/apis/evaluations';
import { getChatById } from '$lib/apis/chats';
import { generateTags } from '$lib/apis';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
interface MessageType {
id: string;
@@ -114,6 +118,7 @@
export let saveMessage: Function;
export let rateMessage: Function;
export let actionMessage: Function;
export let deleteMessage: Function;
export let submitMessage: Function;
export let continueResponse: Function;
@@ -124,6 +129,9 @@
export let isLastMessage = true;
export let readOnly = false;
let buttonsContainerElement: HTMLDivElement;
let showDeleteConfirm = false;
let model = null;
$: model = $models.find((m) => m.id === message.model);
@@ -192,62 +200,7 @@
speaking = true;
if ($config.audio.tts.engine !== '') {
loadingSpeech = true;
const messageContentParts: string[] = getMessageContentParts(
message.content,
$config?.audio?.tts?.split_on ?? 'punctuation'
);
if (!messageContentParts.length) {
console.log('No content to speak');
toast.info($i18n.t('No content to speak'));
speaking = false;
loadingSpeech = false;
return;
}
console.debug('Prepared message content for TTS', messageContentParts);
audioParts = messageContentParts.reduce(
(acc, _sentence, idx) => {
acc[idx] = null;
return acc;
},
{} as typeof audioParts
);
let lastPlayedAudioPromise = Promise.resolve(); // Initialize a promise that resolves immediately
for (const [idx, sentence] of messageContentParts.entries()) {
const res = await synthesizeOpenAISpeech(
localStorage.token,
$settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice
? ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
: $config?.audio?.tts?.voice,
sentence
).catch((error) => {
console.error(error);
toast.error(`${error}`);
speaking = false;
loadingSpeech = false;
});
if (res) {
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const audio = new Audio(blobUrl);
audio.playbackRate = $settings.audio?.tts?.playbackRate ?? 1;
audioParts[idx] = audio;
loadingSpeech = false;
lastPlayedAudioPromise = lastPlayedAudioPromise.then(() => playAudio(idx));
}
}
} else {
if ($config.audio.tts.engine === '') {
let voices = [];
const getVoicesLoop = setInterval(() => {
voices = speechSynthesis.getVoices();
@@ -282,6 +235,97 @@
speechSynthesis.speak(speak);
}
}, 100);
} else {
loadingSpeech = true;
const messageContentParts: string[] = getMessageContentParts(
message.content,
$config?.audio?.tts?.split_on ?? 'punctuation'
);
if (!messageContentParts.length) {
console.log('No content to speak');
toast.info($i18n.t('No content to speak'));
speaking = false;
loadingSpeech = false;
return;
}
console.debug('Prepared message content for TTS', messageContentParts);
audioParts = messageContentParts.reduce(
(acc, _sentence, idx) => {
acc[idx] = null;
return acc;
},
{} as typeof audioParts
);
let lastPlayedAudioPromise = Promise.resolve(); // Initialize a promise that resolves immediately
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
if (!$TTSWorker) {
await TTSWorker.set(
new KokoroWorker({
dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
})
);
await $TTSWorker.init();
}
for (const [idx, sentence] of messageContentParts.entries()) {
const blob = await $TTSWorker
.generate({
text: sentence,
voice: $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice
})
.catch((error) => {
console.error(error);
toast.error(`${error}`);
speaking = false;
loadingSpeech = false;
});
if (blob) {
const audio = new Audio(blob);
audio.playbackRate = $settings.audio?.tts?.playbackRate ?? 1;
audioParts[idx] = audio;
loadingSpeech = false;
lastPlayedAudioPromise = lastPlayedAudioPromise.then(() => playAudio(idx));
}
}
} else {
for (const [idx, sentence] of messageContentParts.entries()) {
const res = await synthesizeOpenAISpeech(
localStorage.token,
$settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice
? ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
: $config?.audio?.tts?.voice,
sentence
).catch((error) => {
console.error(error);
toast.error(`${error}`);
speaking = false;
loadingSpeech = false;
});
if (res) {
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const audio = new Audio(blobUrl);
audio.playbackRate = $settings.audio?.tts?.playbackRate ?? 1;
audioParts[idx] = audio;
loadingSpeech = false;
lastPlayedAudioPromise = lastPlayedAudioPromise.then(() => playAudio(idx));
}
}
}
}
};
@@ -461,6 +505,10 @@
feedbackLoading = false;
};
const deleteMessageHandler = async () => {
deleteMessage(message.id);
};
$: if (!edit) {
(async () => {
await tick();
@@ -471,16 +519,36 @@
// console.log('ResponseMessage mounted');
await tick();
if (buttonsContainerElement) {
console.log(buttonsContainerElement);
buttonsContainerElement.addEventListener('wheel', function (event) {
// console.log(event.deltaY);
event.preventDefault();
if (event.deltaY !== 0) {
// Adjust horizontal scroll position based on vertical scroll
buttonsContainerElement.scrollLeft += event.deltaY;
}
});
}
});
</script>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete message?')}
on:confirm={() => {
deleteMessageHandler();
}}
/>
{#key message.id}
<div
class=" flex w-full message-{message.id}"
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<div class={`shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<ProfileImage
src={model?.info?.meta?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
@@ -500,7 +568,7 @@
<div
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
>
<Tooltip content={dayjs(message.timestamp * 1000).format('dddd, DD MMMM YYYY HH:mm')}>
<Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
</Tooltip>
</div>
@@ -603,7 +671,7 @@
<textarea
id="message-edit-{message.id}"
bind:this={editTextAreaElement}
class=" bg-transparent outline-none w-full resize-none"
class=" bg-transparent outline-hidden w-full resize-none"
bind:value={editedContent}
on:input={(e) => {
e.target.style.height = '';
@@ -674,12 +742,32 @@
floatingButtons={message?.done}
save={!readOnly}
{model}
onSourceClick={(e) => {
onTaskClick={async (e) => {
console.log(e);
const sourceButton = document.getElementById(`source-${e}`);
}}
onSourceClick={async (id, idx) => {
console.log(id, idx);
let sourceButton = document.getElementById(`source-${message.id}-${idx}`);
const sourcesCollapsible = document.getElementById(`collapsible-sources`);
if (sourceButton) {
sourceButton.click();
} else if (sourcesCollapsible) {
// Open sources collapsible so we can click the source button
sourcesCollapsible
.querySelector('div:first-child')
.dispatchEvent(new PointerEvent('pointerup', {}));
// Wait for next frame to ensure DOM updates
await new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
// Try clicking the source button again
sourceButton = document.getElementById(`source-${message.id}-${idx}`);
sourceButton && sourceButton.click();
}
}}
onAddMessages={({ modelId, parentId, messages }) => {
@@ -715,7 +803,7 @@
{/if}
{#if (message?.sources || message?.citations) && (model?.info?.meta?.capabilities?.citations ?? true)}
<Citations sources={message?.sources ?? message?.citations} />
<Citations id={message?.id} sources={message?.sources ?? message?.citations} />
{/if}
{#if message.code_executions}
@@ -727,10 +815,11 @@
</div>
{#if !edit}
{#if message.done || siblings.length > 1}
<div
class=" flex justify-start overflow-x-auto buttons text-gray-600 dark:text-gray-500 mt-0.5"
>
<div
bind:this={buttonsContainerElement}
class="flex justify-start overflow-x-auto buttons text-gray-600 dark:text-gray-500 mt-0.5"
>
{#if message.done || siblings.length > 1}
{#if siblings.length > 1}
<div class="flex self-center min-w-fit" dir="ltr">
<button
@@ -1027,7 +1116,7 @@
{/if}
{#if !readOnly}
{#if $config?.features.enable_message_rating ?? true}
{#if !$temporaryChatEnabled && ($config?.features.enable_message_rating ?? true)}
<Tooltip content={$i18n.t('Good Response')} placement="bottom">
<button
class="{isLastMessage
@@ -1176,6 +1265,36 @@
</button>
</Tooltip>
{#if siblings.length > 1}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
type="button"
id="continue-response-button"
class="{isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button"
on:click={() => {
showDeleteConfirm = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
{/if}
{#if isLastMessage}
{#each model?.actions ?? [] as action}
<Tooltip content={action.name} placement="bottom">
@@ -1208,8 +1327,8 @@
{/if}
{/if}
{/if}
</div>
{/if}
{/if}
</div>
{#if message.done && showRateComment}
<RateComment
@@ -5,31 +5,41 @@
<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 mr-14" />
<div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm mr-14"
/>
<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 col-span-2"
/>
<div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded 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 col-span-1"
/>
<div
class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-2"
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 col-span-1 mr-4"
: '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>
<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded" />
<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded-sm" />
</div>
</div>
</div>
@@ -13,8 +13,12 @@
import FileItem from '$lib/components/common/FileItem.svelte';
import Markdown from './Markdown.svelte';
import Image from '$lib/components/common/Image.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat';
const i18n = getContext('i18n');
dayjs.extend(localizedFormat);
export let user;
@@ -32,6 +36,8 @@
export let isFirstMessage: boolean;
export let readOnly: boolean;
let showDeleteConfirm = false;
let edit = false;
let editedContent = '';
let messageEditTextAreaElement: HTMLTextAreaElement;
@@ -83,9 +89,17 @@
});
</script>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete message?')}
on:confirm={() => {
deleteMessageHandler();
}}
/>
<div class=" flex w-full user-message" dir={$settings.chatDirection} id="message-{message.id}">
{#if !($settings?.chatBubble ?? true)}
<div class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<div class={`shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<ProfileImage
src={message.user
? ($models.find((m) => m.id === message.user)?.info?.meta?.profile_image_url ??
@@ -112,7 +126,7 @@
<div
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
>
<Tooltip content={dayjs(message.timestamp * 1000).format('dddd, DD MMMM YYYY HH:mm')}>
<Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
</Tooltip>
</div>
@@ -143,149 +157,177 @@
</div>
{/if}
{#if edit === true}
<div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 mb-2">
<div class="max-h-96 overflow-auto">
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent}
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
on:keydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
</div>
<div class=" mt-2 mb-1 flex justify-between text-sm font-medium">
<div>
<button
id="save-edit-message-button"
class=" px-4 py-2 bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 border dark:border-gray-700 text-gray-700 dark:text-gray-200 transition rounded-3xl"
on:click={() => {
editMessageConfirmHandler(false);
{#if message.content !== ''}
{#if edit === true}
<div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 mb-2">
<div class="max-h-96 overflow-auto">
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-hidden w-full resize-none"
bind:value={editedContent}
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
>
{$i18n.t('Save')}
</button>
on:keydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('confirm-edit-message-button')?.click();
}
}}
/>
</div>
<div class="flex space-x-1.5">
<button
id="close-edit-message-button"
class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
on:click={() => {
cancelEditMessage();
}}
>
{$i18n.t('Cancel')}
</button>
<button
id="confirm-edit-message-button"
class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
on:click={() => {
editMessageConfirmHandler();
}}
>
{$i18n.t('Send')}
</button>
</div>
</div>
</div>
{:else}
<div class="w-full">
<div class="flex {($settings?.chatBubble ?? true) ? 'justify-end pb-1' : 'w-full'}">
<div
class="rounded-3xl {($settings?.chatBubble ?? true)
? `max-w-[90%] px-5 py-2 bg-gray-50 dark:bg-gray-850 ${
message.files ? 'rounded-tr-lg' : ''
}`
: ' w-full'}"
>
{#if message.content}
<Markdown id={message.id} content={message.content} />
{/if}
</div>
</div>
<div
class=" flex {($settings?.chatBubble ?? true)
? 'justify-end'
: ''} text-gray-600 dark:text-gray-500"
>
{#if !($settings?.chatBubble ?? true)}
{#if siblings.length > 1}
<div class="flex self-center" dir="ltr">
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
</button>
<div class="text-sm tracking-widest font-semibold self-center dark:text-gray-100">
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div>
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
{/if}
{/if}
{#if !readOnly}
<Tooltip content={$i18n.t('Edit')} placement="bottom">
<div class=" mt-2 mb-1 flex justify-between text-sm font-medium">
<div>
<button
class="invisible group-hover:visible p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition edit-user-message-button"
id="save-edit-message-button"
class=" px-4 py-2 bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 border dark:border-gray-700 text-gray-700 dark:text-gray-200 transition rounded-3xl"
on:click={() => {
editMessageHandler();
editMessageConfirmHandler(false);
}}
>
{$i18n.t('Save')}
</button>
</div>
<div class="flex space-x-1.5">
<button
id="close-edit-message-button"
class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
on:click={() => {
cancelEditMessage();
}}
>
{$i18n.t('Cancel')}
</button>
<button
id="confirm-edit-message-button"
class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
on:click={() => {
editMessageConfirmHandler();
}}
>
{$i18n.t('Send')}
</button>
</div>
</div>
</div>
{:else}
<div class="w-full">
<div class="flex {($settings?.chatBubble ?? true) ? 'justify-end pb-1' : 'w-full'}">
<div
class="rounded-3xl {($settings?.chatBubble ?? true)
? `max-w-[90%] px-5 py-2 bg-gray-50 dark:bg-gray-850 ${
message.files ? 'rounded-tr-lg' : ''
}`
: ' w-full'}"
>
{#if message.content}
<Markdown id={message.id} content={message.content} />
{/if}
</div>
</div>
<div
class=" flex {($settings?.chatBubble ?? true)
? 'justify-end'
: ''} text-gray-600 dark:text-gray-500"
>
{#if !($settings?.chatBubble ?? true)}
{#if siblings.length > 1}
<div class="flex self-center" dir="ltr">
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
</button>
<div
class="text-sm tracking-widest font-semibold self-center dark:text-gray-100"
>
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div>
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
{/if}
{/if}
{#if !readOnly}
<Tooltip content={$i18n.t('Edit')} placement="bottom">
<button
class="invisible group-hover:visible p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition edit-user-message-button"
on:click={() => {
editMessageHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Copy')} placement="bottom">
<button
class="invisible group-hover:visible p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
copyToClipboard(message.content);
}}
>
<svg
@@ -299,118 +341,96 @@
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Copy')} placement="bottom">
<button
class="invisible group-hover:visible p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
copyToClipboard(message.content);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
</Tooltip>
{#if !isFirstMessage && !readOnly}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition"
on:click={() => {
deleteMessageHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
{/if}
{#if $settings?.chatBubble ?? true}
{#if siblings.length > 1}
<div class="flex self-center" dir="ltr">
{#if !isFirstMessage && !readOnly}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
class="invisible group-hover:visible p-1 rounded-sm dark:hover:text-white hover:text-black transition"
on:click={() => {
showPreviousMessage(message);
showDeleteConfirm = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
<div class="text-sm tracking-widest font-semibold self-center dark:text-gray-100">
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div>
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
</Tooltip>
{/if}
{/if}
{#if $settings?.chatBubble ?? true}
{#if siblings.length > 1}
<div class="flex self-center" dir="ltr">
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
</svg>
</button>
<div
class="text-sm tracking-widest font-semibold self-center dark:text-gray-100"
>
{siblings.indexOf(message.id) + 1}/{siblings.length}
</div>
<button
class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
{/if}
{/if}
</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
@@ -12,7 +12,15 @@
import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
import { user, MODEL_DOWNLOAD_POOL, models, mobile, temporaryChatEnabled } from '$lib/stores';
import {
user,
MODEL_DOWNLOAD_POOL,
models,
mobile,
temporaryChatEnabled,
settings,
config
} from '$lib/stores';
import { toast } from 'svelte-sonner';
import { capitalizeFirstLetter, sanitizeResponseContent, splitStream } from '$lib/utils';
import { getModels } from '$lib/apis';
@@ -44,12 +52,17 @@
export let className = 'w-[32rem]';
export let triggerClassName = 'text-lg';
let tagsContainerElement;
let show = false;
let tags = [];
let selectedModel = '';
$: selectedModel = items.find((item) => item.value === value) ?? '';
let searchValue = '';
let selectedTag = '';
let ollamaVersion = null;
let selectedModelIdx = 0;
@@ -71,10 +84,23 @@
);
$: filteredItems = searchValue
? fuse.search(searchValue).map((e) => {
return e.item;
})
: items;
? fuse
.search(searchValue)
.map((e) => {
return e.item;
})
.filter((item) => {
if (selectedTag === '') {
return true;
}
return item.model?.info?.meta?.tags?.map((tag) => tag.name).includes(selectedTag);
})
: items.filter((item) => {
if (selectedTag === '') {
return true;
}
return item.model?.info?.meta?.tags?.map((tag) => tag.name).includes(selectedTag);
});
const pullModelHandler = async () => {
const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
@@ -186,7 +212,12 @@
})
);
models.set(await getModels(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
} else {
toast.error($i18n.t('Download canceled'));
}
@@ -201,6 +232,13 @@
onMount(async () => {
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
if (items) {
tags = items.flatMap((item) => item.model?.info?.meta?.tags ?? []).map((tag) => tag.name);
// Remove duplicates and sort
tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b));
}
});
const cancelModelPullHandler = async (model: string) => {
@@ -235,7 +273,7 @@
id="model-selector-{id}-button"
>
<div
class="flex w-full text-left px-0.5 outline-none bg-transparent truncate {triggerClassName} justify-between font-medium placeholder-gray-400 focus:outline-none"
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"
>
{#if selectedModel}
{selectedModel.label}
@@ -249,20 +287,20 @@
<DropdownMenu.Content
class=" z-40 {$mobile
? `w-full`
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-none"
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden"
transition={flyAndScale}
side={$mobile ? 'bottom' : 'bottom-start'}
sideOffset={3}
>
<slot>
{#if searchEnabled}
<div class="flex items-center gap-2.5 px-5 mt-3.5 mb-3">
<div class="flex items-center gap-2.5 px-5 mt-3.5 mb-1.5">
<Search className="size-4" strokeWidth="2.5" />
<input
id="model-search-input"
bind:value={searchValue}
class="w-full text-sm bg-transparent outline-none"
class="w-full text-sm bg-transparent outline-hidden"
placeholder={searchPlaceholder}
autocomplete="off"
on:keydown={(e) => {
@@ -284,15 +322,46 @@
}}
/>
</div>
<hr class="border-gray-50 dark:border-gray-800" />
{/if}
<div class="px-3 my-2 max-h-64 overflow-y-auto scrollbar-hidden group">
<div class="px-3 mb-2 max-h-64 overflow-y-auto scrollbar-hidden group relative">
{#if tags}
<div class=" flex w-full sticky">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent px-1.5 pb-0.5"
bind:this={tagsContainerElement}
>
<button
class="min-w-fit outline-none p-1.5 {selectedTag === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
on:click={() => {
selectedTag = '';
}}
>
{$i18n.t('All')}
</button>
{#each tags as tag}
<button
class="min-w-fit outline-none p-1.5 {selectedTag === tag
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
on:click={() => {
selectedTag = tag;
}}
>
{tag}
</button>
{/each}
</div>
</div>
{/if}
{#each filteredItems as item, index}
<button
aria-label="model-item"
class="flex w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted {index ===
class="flex w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted {index ===
selectedModelIdx
? 'bg-gray-100 dark:bg-gray-800 group-hover:bg-transparent'
: ''}"
@@ -309,7 +378,7 @@
<div class="flex gap-0.5 self-start h-full mb-1.5 -translate-x-1">
{#each item.model?.info?.meta.tags as tag}
<div
class=" text-xs font-bold px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class=" text-xs font-bold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
{tag.name}
</div>
@@ -358,7 +427,24 @@
<!-- {JSON.stringify(item.info)} -->
{#if item.model.owned_by === 'openai'}
{#if item.model?.direct}
<Tooltip content={`${'Direct'}`}>
<div class="translate-y-[1px]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-3"
>
<path
fill-rule="evenodd"
d="M2 2.75A.75.75 0 0 1 2.75 2C8.963 2 14 7.037 14 13.25a.75.75 0 0 1-1.5 0c0-5.385-4.365-9.75-9.75-9.75A.75.75 0 0 1 2 2.75Zm0 4.5a.75.75 0 0 1 .75-.75 6.75 6.75 0 0 1 6.75 6.75.75.75 0 0 1-1.5 0C8 10.35 5.65 8 2.75 8A.75.75 0 0 1 2 7.25ZM3.5 11a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
clip-rule="evenodd"
/>
</svg>
</div>
</Tooltip>
{:else if item.model.owned_by === 'openai'}
<Tooltip content={`${'External'}`}>
<div class="translate-y-[1px]">
<svg
@@ -411,11 +497,13 @@
{/if}
{#if !$mobile && (item?.model?.info?.meta?.tags ?? []).length > 0}
<div class="flex gap-0.5 self-center items-center h-full translate-y-[0.5px]">
<div
class="flex gap-0.5 self-center items-center h-full translate-y-[0.5px] overflow-x-auto scrollbar-none"
>
{#each item.model?.info?.meta.tags as tag}
<Tooltip content={tag.name}>
<Tooltip content={tag.name} className="flex-shrink-0">
<div
class=" text-xs font-bold px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class=" text-xs font-bold px-1 rounded-sm uppercase bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
{tag.name}
</div>
@@ -448,7 +536,7 @@
placement="top-start"
>
<button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted"
on:click={() => {
pullModelHandler();
}}
@@ -462,7 +550,7 @@
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-lg cursor-pointer data-highlighted:bg-muted"
>
<div class="flex">
<div class="-ml-2 mr-2.5 translate-y-0.5">
@@ -497,7 +585,7 @@
Downloading "{model}"
</div>
<div class="flex-shrink-0">
<div class="shrink-0">
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
@@ -545,11 +633,11 @@
</div>
{#if showTemporaryChatControl}
<hr class="border-gray-50 dark:border-gray-800" />
<hr class="border-gray-100 dark:border-gray-800" />
<div class="flex items-center mx-2 my-2">
<button
class="flex justify-between w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 px-3 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
class="flex justify-between w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 px-3 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted"
on:click={async () => {
temporaryChatEnabled.set(!$temporaryChatEnabled);
await goto('/');
+3 -3
View File
@@ -45,7 +45,7 @@
<nav class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center drag-region">
<div
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
></div>
<div class=" flex max-w-full w-full mx-auto px-1 pt-0.5 bg-transparent">
@@ -114,7 +114,7 @@
</div>
</button>
</Menu>
{:else if $mobile && ($user.role === 'admin' || $user?.permissions.chat?.controls)}
{:else if $mobile && ($user.role === 'admin' || $user?.permissions?.chat?.controls)}
<Tooltip content={$i18n.t('Controls')}>
<button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
@@ -130,7 +130,7 @@
</Tooltip>
{/if}
{#if !$mobile && ($user.role === 'admin' || $user?.permissions.chat?.controls)}
{#if !$mobile && ($user.role === 'admin' || $user?.permissions?.chat?.controls)}
<Tooltip content={$i18n.t('Controls')}>
<button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+12 -12
View File
@@ -35,6 +35,7 @@
export let selectedToolIds = [];
export let imageGenerationEnabled = false;
export let codeInterpreterEnabled = false;
export let webSearchEnabled = false;
let models = [];
@@ -87,7 +88,7 @@
onMount(() => {});
</script>
<div class="m-auto w-full max-w-6xl px-2 xl:px-20 translate-y-6 py-24 text-center">
<div class="m-auto w-full max-w-6xl px-2 @2xl:px-20 translate-y-6 py-24 text-center">
{#if $temporaryChatEnabled}
<Tooltip
content="This chat won't appear in history and your messages will not be saved."
@@ -101,11 +102,11 @@
{/if}
<div
class="w-full text-3xl text-gray-800 dark:text-gray-100 font-medium text-center flex items-center gap-4 font-primary"
class="w-full text-3xl text-gray-800 dark:text-gray-100 text-center flex items-center gap-4 font-primary"
>
<div class="w-full flex flex-col justify-center items-center">
<div class="flex flex-row justify-center gap-3 sm:gap-3.5 w-fit px-5">
<div class="flex flex-shrink-0 justify-center">
<div class="flex flex-row justify-center gap-3 @sm:gap-3.5 w-fit px-5">
<div class="flex shrink-0 justify-center">
<div class="flex -space-x-4 mb-0.5" in:fade={{ duration: 100 }}>
{#each models as model, modelIdx}
<Tooltip
@@ -125,7 +126,7 @@
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
class=" size-9 sm:size-10 rounded-full border-[1px] border-gray-200 dark:border-none"
class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
alt="logo"
draggable="false"
/>
@@ -135,7 +136,7 @@
</div>
</div>
<div class=" text-3xl sm:text-4xl line-clamp-1" in:fade={{ duration: 100 }}>
<div class=" text-3xl @sm:text-4xl line-clamp-1" in:fade={{ duration: 100 }}>
{#if models[selectedModelIdx]?.name}
{models[selectedModelIdx]?.name}
{:else}
@@ -183,11 +184,7 @@
</div>
</div>
<div
class="text-base font-normal xl:translate-x-6 md:max-w-3xl w-full py-3 {atSelectedModel
? 'mt-2'
: ''}"
>
<div class="text-base font-normal @md:max-w-3xl w-full py-3 {atSelectedModel ? 'mt-2' : ''}">
<MessageInput
{history}
{selectedModels}
@@ -196,6 +193,7 @@
bind:autoScroll
bind:selectedToolIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
{transparentBackground}
@@ -215,9 +213,11 @@
<div class="mx-auto max-w-2xl font-primary" in:fade={{ duration: 200, delay: 200 }}>
<div class="mx-5">
<Suggestions
suggestionPrompts={models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ??
models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
$config?.default_prompt_suggestions ??
[]}
inputValue={prompt}
on:select={(e) => {
selectSuggestionPrompt(e.detail);
}}
+36 -28
View File
@@ -92,7 +92,7 @@
</div>
{#if ollamaVersion}
<hr class=" dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Ollama Version')}</div>
@@ -104,7 +104,41 @@
</div>
{/if}
<hr class=" dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
{#if $config?.license_metadata}
<div class="mb-2 text-xs">
{#if !$WEBUI_NAME.includes('Open WebUI')}
<span class=" text-gray-500 dark:text-gray-300 font-medium">{$WEBUI_NAME}</span> -
{/if}
<span class=" capitalize">{$config?.license_metadata?.type}</span> license purchased by
<span class=" capitalize">{$config?.license_metadata?.organization_name}</span>
</div>
{:else}
<div class="flex space-x-1">
<a href="https://discord.gg/5rJgQTnV4s" target="_blank">
<img
alt="Discord"
src="https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white"
/>
</a>
<a href="https://twitter.com/OpenWebUI" target="_blank">
<img
alt="X (formerly Twitter) Follow"
src="https://img.shields.io/twitter/follow/OpenWebUI"
/>
</a>
<a href="https://github.com/open-webui/open-webui" target="_blank">
<img
alt="Github Repo"
src="https://img.shields.io/github/stars/open-webui/open-webui?style=social&label=Star us on Github"
/>
</a>
</div>
{/if}
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
Emoji graphics provided by
@@ -112,29 +146,6 @@
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">CC-BY 4.0</a>.
</div>
<div class="flex space-x-1">
<a href="https://discord.gg/5rJgQTnV4s" target="_blank">
<img
alt="Discord"
src="https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white"
/>
</a>
<a href="https://twitter.com/OpenWebUI" target="_blank">
<img
alt="X (formerly Twitter) Follow"
src="https://img.shields.io/twitter/follow/OpenWebUI"
/>
</a>
<a href="https://github.com/open-webui/open-webui" target="_blank">
<img
alt="Github Repo"
src="https://img.shields.io/github/stars/open-webui/open-webui?style=social&label=Star us on Github"
/>
</a>
</div>
<div>
<pre
class="text-xs text-gray-400 dark:text-gray-500">Copyright (c) {new Date().getFullYear()} <a
@@ -172,9 +183,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{#if !$WEBUI_NAME.includes('Open WebUI')}
<span class=" text-gray-500 dark:text-gray-300 font-medium">{$WEBUI_NAME}</span> -
{/if}
{$i18n.t('Created by')}
<a
class=" text-gray-500 dark:text-gray-300 font-medium"
@@ -3,7 +3,7 @@
import { onMount, getContext } from 'svelte';
import { user, config, settings } from '$lib/stores';
import { updateUserProfile, createAPIKey, getAPIKey } from '$lib/apis/auths';
import { updateUserProfile, createAPIKey, getAPIKey, getSessionUser } from '$lib/apis/auths';
import UpdatePassword from './Account/UpdatePassword.svelte';
import { getGravatarUrl } from '$lib/apis/utils';
@@ -53,7 +53,13 @@
);
if (updatedUser) {
await user.set(updatedUser);
// Get Session User Info
const sessionUser = await getSessionUser(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
await user.set(sessionUser);
return true;
}
return false;
@@ -208,7 +214,7 @@
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-full px-4 py-0.5 bg-gray-100 dark:bg-gray-850"
on:click={async () => {
const url = await getGravatarUrl($user.email);
const url = await getGravatarUrl(localStorage.token, $user.email);
profileImageUrl = url;
}}>{$i18n.t('Use Gravatar')}</button
@@ -230,7 +236,7 @@
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
bind:value={name}
required
@@ -245,7 +251,7 @@
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="url"
placeholder={$i18n.t('Enter your webhook URL')}
bind:value={webhookUrl}
@@ -260,7 +266,7 @@
<UpdatePassword />
</div>
<hr class=" dark:border-gray-850 my-4" />
<hr class="border-gray-100 dark:border-gray-850 my-4" />
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('API keys')}</div>
@@ -60,7 +60,7 @@
<div class="flex-1">
<input
class="w-full bg-transparent dark:text-gray-300 outline-none placeholder:opacity-30"
class="w-full bg-transparent dark:text-gray-300 outline-hidden placeholder:opacity-30"
type="password"
bind:value={currentPassword}
placeholder={$i18n.t('Enter your current password')}
@@ -75,7 +75,7 @@
<div class="flex-1">
<input
class="w-full bg-transparent text-sm dark:text-gray-300 outline-none placeholder:opacity-30"
class="w-full bg-transparent text-sm dark:text-gray-300 outline-hidden placeholder:opacity-30"
type="password"
bind:value={newPassword}
placeholder={$i18n.t('Enter your new password')}
@@ -90,7 +90,7 @@
<div class="flex-1">
<input
class="w-full bg-transparent text-sm dark:text-gray-300 outline-none placeholder:opacity-30"
class="w-full bg-transparent text-sm dark:text-gray-300 outline-hidden placeholder:opacity-30"
type="password"
bind:value={newPasswordConfirm}
placeholder={$i18n.t('Confirm your new password')}
@@ -12,10 +12,12 @@
export let params = {
// Advanced
stream_response: null, // Set stream responses for this model individually
function_calling: null,
seed: null,
stop: null,
temperature: null,
reasoning_effort: null,
logit_bias: null,
frequency_penalty: null,
repeat_last_n: null,
mirostat: null,
@@ -58,7 +60,7 @@
{$i18n.t('Stream Chat Response')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
params.stream_response =
(params?.stream_response ?? null) === null
@@ -81,10 +83,39 @@
</Tooltip>
</div>
<div>
<Tooltip
content={$i18n.t(
'Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model’s built-in tool-calling capabilities, but requires the model to inherently support this feature.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Function Calling')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
params.function_calling = (params?.function_calling ?? null) === null ? 'native' : null;
}}
type="button"
>
{#if params.function_calling === 'native'}
<span class="ml-2 self-center">{$i18n.t('Native')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{/if}
</button>
</div>
</Tooltip>
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt. (Default: random)'
'Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.'
)}
placement="top-start"
className="inline-tooltip"
@@ -95,7 +126,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.seed = (params?.seed ?? null) === null ? 0 : null;
@@ -114,7 +145,7 @@
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="number"
placeholder={$i18n.t('Enter Seed')}
bind:value={params.seed}
@@ -140,7 +171,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.stop = (params?.stop ?? null) === null ? '' : null;
@@ -159,7 +190,7 @@
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
class="w-full rounded-lg py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter stop sequence')}
bind:value={params.stop}
@@ -173,7 +204,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'The temperature of the model. Increasing the temperature will make the model answer more creatively. (Default: 0.8)'
'The temperature of the model. Increasing the temperature will make the model answer more creatively.'
)}
placement="top-start"
className="inline-tooltip"
@@ -183,7 +214,7 @@
{$i18n.t('Temperature')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.temperature = (params?.temperature ?? null) === null ? 0.8 : null;
@@ -205,7 +236,7 @@
id="steps-range"
type="range"
min="0"
max="1"
max="2"
step="0.05"
bind:value={params.temperature}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
@@ -217,7 +248,7 @@
type="number"
class=" bg-transparent text-center w-14"
min="0"
max="1"
max="2"
step="any"
/>
</div>
@@ -228,7 +259,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Constrains effort on reasoning for reasoning models. Only applicable to reasoning models from specific providers that support reasoning effort. (Default: medium)'
'Constrains effort on reasoning for reasoning models. Only applicable to reasoning models from specific providers that support reasoning effort.'
)}
placement="top-start"
className="inline-tooltip"
@@ -238,7 +269,7 @@
{$i18n.t('Reasoning Effort')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.reasoning_effort = (params?.reasoning_effort ?? null) === null ? 'medium' : null;
@@ -257,7 +288,7 @@
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
class="w-full rounded-lg py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter reasoning effort')}
bind:value={params.reasoning_effort}
@@ -271,17 +302,60 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Enable Mirostat sampling for controlling perplexity. (Default: 0, 0 = Disabled, 1 = Mirostat, 2 = Mirostat 2.0)'
'Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Logit Bias')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.logit_bias = (params?.logit_bias ?? null) === null ? '' : null;
}}
>
{#if (params?.logit_bias ?? null) === null}
<span class="ml-2 self-center"> {$i18n.t('Default')} </span>
{:else}
<span class="ml-2 self-center"> {$i18n.t('Custom')} </span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.logit_bias ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
class="w-full rounded-lg pl-2 py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t(
'Enter comma-seperated "token:bias_value" pairs (example: 5432:100, 413:-100)'
)}
bind:value={params.logit_bias}
autocomplete="off"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t('Enable Mirostat sampling for controlling perplexity.')}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Mirostat')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.mirostat = (params?.mirostat ?? null) === null ? 0 : null;
@@ -326,7 +400,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Influences how quickly the algorithm responds to feedback from the generated text. A lower learning rate will result in slower adjustments, while a higher learning rate will make the algorithm more responsive. (Default: 0.1)'
'Influences how quickly the algorithm responds to feedback from the generated text. A lower learning rate will result in slower adjustments, while a higher learning rate will make the algorithm more responsive.'
)}
placement="top-start"
className="inline-tooltip"
@@ -336,7 +410,7 @@
{$i18n.t('Mirostat Eta')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.mirostat_eta = (params?.mirostat_eta ?? null) === null ? 0.1 : null;
@@ -381,7 +455,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Controls the balance between coherence and diversity of the output. A lower value will result in more focused and coherent text. (Default: 5.0)'
'Controls the balance between coherence and diversity of the output. A lower value will result in more focused and coherent text.'
)}
placement="top-start"
className="inline-tooltip"
@@ -392,7 +466,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.mirostat_tau = (params?.mirostat_tau ?? null) === null ? 5.0 : null;
@@ -437,7 +511,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative. (Default: 40)'
'Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative.'
)}
placement="top-start"
className="inline-tooltip"
@@ -447,7 +521,7 @@
{$i18n.t('Top K')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.top_k = (params?.top_k ?? null) === null ? 40 : null;
@@ -469,7 +543,7 @@
id="steps-range"
type="range"
min="0"
max="100"
max="1000"
step="0.5"
bind:value={params.top_k}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
@@ -492,7 +566,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text. (Default: 0.9)'
'Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.'
)}
placement="top-start"
className="inline-tooltip"
@@ -503,7 +577,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.top_p = (params?.top_p ?? null) === null ? 0.9 : null;
@@ -548,7 +622,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token. For example, with p=0.05 and the most likely token having a probability of 0.9, logits with a value less than 0.045 are filtered out. (Default: 0.0)'
'Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token. For example, with p=0.05 and the most likely token having a probability of 0.9, logits with a value less than 0.045 are filtered out.'
)}
placement="top-start"
className="inline-tooltip"
@@ -558,7 +632,7 @@
{$i18n.t('Min P')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.min_p = (params?.min_p ?? null) === null ? 0.0 : null;
@@ -603,7 +677,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. (Default: 1.1)'
'Sets a scaling bias against tokens to penalize repetitions, based on how many times they have appeared. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.'
)}
placement="top-start"
className="inline-tooltip"
@@ -614,7 +688,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.frequency_penalty = (params?.frequency_penalty ?? null) === null ? 1.1 : null;
@@ -635,7 +709,7 @@
<input
id="steps-range"
type="range"
min="0"
min="-2"
max="2"
step="0.05"
bind:value={params.frequency_penalty}
@@ -647,7 +721,7 @@
bind:value={params.frequency_penalty}
type="number"
class=" bg-transparent text-center w-14"
min="0"
min="-2"
max="2"
step="any"
/>
@@ -659,10 +733,120 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Sets how far back for the model to look back to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx)'
'Sets a flat bias against tokens that have appeared at least once. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Presence Penalty')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
type="button"
on:click={() => {
params.presence_penalty = (params?.presence_penalty ?? null) === null ? 0.0 : null;
}}
>
{#if (params?.presence_penalty ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.presence_penalty ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="-2"
max="2"
step="0.05"
bind:value={params.presence_penalty}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.presence_penalty}
type="number"
class=" bg-transparent text-center w-14"
min="-2"
max="2"
step="any"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Control the repetition of token sequences in the generated text. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 1.1) will be more lenient. At 1, it is disabled.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Repeat Penalty (Ollama)')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
type="button"
on:click={() => {
params.repeat_penalty = (params?.repeat_penalty ?? null) === null ? 1.1 : null;
}}
>
{#if (params?.repeat_penalty ?? null) === null}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.repeat_penalty ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
id="steps-range"
type="range"
min="-2"
max="2"
step="0.05"
bind:value={params.repeat_penalty}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params.repeat_penalty}
type="number"
class=" bg-transparent text-center w-14"
min="-2"
max="2"
step="any"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t('Sets how far back for the model to look back to prevent repetition.')}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
@@ -670,7 +854,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.repeat_last_n = (params?.repeat_last_n ?? null) === null ? 64 : null;
@@ -715,7 +899,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Tail free sampling is used to reduce the impact of less probable tokens from the output. A higher value (e.g., 2.0) will reduce the impact more, while a value of 1.0 disables this setting. (default: 1)'
'Tail free sampling is used to reduce the impact of less probable tokens from the output. A higher value (e.g., 2.0) will reduce the impact more, while a value of 1.0 disables this setting.'
)}
placement="top-start"
className="inline-tooltip"
@@ -726,7 +910,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.tfs_z = (params?.tfs_z ?? null) === null ? 1 : null;
@@ -770,9 +954,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Sets the size of the context window used to generate the next token. (Default: 2048)'
)}
content={$i18n.t('Sets the size of the context window used to generate the next token.')}
placement="top-start"
className="inline-tooltip"
>
@@ -782,7 +964,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.num_ctx = (params?.num_ctx ?? null) === null ? 2048 : null;
@@ -826,7 +1008,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'The batch size determines how many text requests are processed together at once. A higher batch size can increase the performance and speed of the model, but it also requires more memory. (Default: 512)'
'The batch size determines how many text requests are processed together at once. A higher batch size can increase the performance and speed of the model, but it also requires more memory.'
)}
placement="top-start"
className="inline-tooltip"
@@ -837,7 +1019,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.num_batch = (params?.num_batch ?? null) === null ? 512 : null;
@@ -881,7 +1063,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'This option controls how many tokens are preserved when refreshing the context. For example, if set to 2, the last 2 tokens of the conversation context will be retained. Preserving context can help maintain the continuity of a conversation, but it may reduce the ability to respond to new topics. (Default: 24)'
'This option controls how many tokens are preserved when refreshing the context. For example, if set to 2, the last 2 tokens of the conversation context will be retained. Preserving context can help maintain the continuity of a conversation, but it may reduce the ability to respond to new topics.'
)}
placement="top-start"
className="inline-tooltip"
@@ -892,7 +1074,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.num_keep = (params?.num_keep ?? null) === null ? 24 : null;
@@ -936,7 +1118,7 @@
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'This option sets the maximum number of tokens the model can generate in its response. Increasing this limit allows the model to provide longer answers, but it may also increase the likelihood of unhelpful or irrelevant content being generated. (Default: 128)'
'This option sets the maximum number of tokens the model can generate in its response. Increasing this limit allows the model to provide longer answers, but it may also increase the likelihood of unhelpful or irrelevant content being generated.'
)}
placement="top-start"
className="inline-tooltip"
@@ -947,7 +1129,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.max_tokens = (params?.max_tokens ?? null) === null ? 128 : null;
@@ -1002,7 +1184,7 @@
{$i18n.t('use_mmap (Ollama)')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.use_mmap = (params?.use_mmap ?? null) === null ? true : null;
@@ -1043,7 +1225,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.use_mlock = (params?.use_mlock ?? null) === null ? true : null;
@@ -1085,7 +1267,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.num_thread = (params?.num_thread ?? null) === null ? 2 : null;
@@ -1141,7 +1323,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.num_gpu = (params?.num_gpu ?? null) === null ? 0 : null;
@@ -1188,7 +1370,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Template')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
params.template = (params?.template ?? null) === null ? '' : null;
@@ -1206,7 +1388,7 @@
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<textarea
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-none rounded-lg -mb-1"
class="px-3 py-1.5 text-sm w-full bg-transparent border dark:border-gray-600 outline-hidden rounded-lg -mb-1"
placeholder={$i18n.t('Write your model template content here')}
rows="4"
bind:value={params.template}
+168 -22
View File
@@ -1,11 +1,14 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { KokoroTTS } from 'kokoro-js';
import { user, settings, config } from '$lib/stores';
import { getVoices as _getVoices } from '$lib/apis/audio';
import Switch from '$lib/components/common/Switch.svelte';
import { round } from '@huggingface/transformers';
import Spinner from '$lib/components/common/Spinner.svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@@ -20,6 +23,13 @@
let STTEngine = '';
let TTSEngine = '';
let TTSEngineConfig = {};
let TTSModel = null;
let TTSModelProgress = null;
let TTSModelLoading = false;
let voices = [];
let voice = '';
@@ -28,23 +38,37 @@
const speedOptions = [2, 1.75, 1.5, 1.25, 1, 0.75, 0.5];
const getVoices = async () => {
if ($config.audio.tts.engine === '') {
const getVoicesLoop = setInterval(async () => {
voices = await speechSynthesis.getVoices();
if (TTSEngine === 'browser-kokoro') {
if (!TTSModel) {
await loadKokoro();
}
// do your loop
if (voices.length > 0) {
clearInterval(getVoicesLoop);
}
}, 100);
} else {
const res = await _getVoices(localStorage.token).catch((e) => {
toast.error(e);
voices = Object.entries(TTSModel.voices).map(([key, value]) => {
return {
id: key,
name: value.name,
localService: false
};
});
} else {
if ($config.audio.tts.engine === '') {
const getVoicesLoop = setInterval(async () => {
voices = await speechSynthesis.getVoices();
if (res) {
console.log(res);
voices = res.voices;
// do your loop
if (voices.length > 0) {
clearInterval(getVoicesLoop);
}
}, 100);
} else {
const res = await _getVoices(localStorage.token).catch((e) => {
toast.error(`${e}`);
});
if (res) {
console.log(res);
voices = res.voices;
}
}
}
};
@@ -67,6 +91,9 @@
STTEngine = $settings?.audio?.stt?.engine ?? '';
TTSEngine = $settings?.audio?.tts?.engine ?? '';
TTSEngineConfig = $settings?.audio?.tts?.engineConfig ?? {};
if ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice) {
voice = $settings?.audio?.tts?.voice ?? $config.audio.tts.voice ?? '';
} else {
@@ -77,6 +104,51 @@
await getVoices();
});
$: if (TTSEngine && TTSEngineConfig) {
onTTSEngineChange();
}
const onTTSEngineChange = async () => {
if (TTSEngine === 'browser-kokoro') {
await loadKokoro();
}
};
const loadKokoro = async () => {
if (TTSEngine === 'browser-kokoro') {
voices = [];
if (TTSEngineConfig?.dtype) {
TTSModel = null;
TTSModelProgress = null;
TTSModelLoading = true;
const model_id = 'onnx-community/Kokoro-82M-v1.0-ONNX';
TTSModel = await KokoroTTS.from_pretrained(model_id, {
dtype: TTSEngineConfig.dtype, // Options: "fp32", "fp16", "q8", "q4", "q4f16"
device: !!navigator?.gpu ? 'webgpu' : 'wasm', // Detect WebGPU
progress_callback: (e) => {
TTSModelProgress = e;
console.log(e);
}
});
await getVoices();
// const rawAudio = await tts.generate(inputText, {
// // Use `tts.list_voices()` to list all available voices
// voice: voice
// });
// const blobUrl = URL.createObjectURL(await rawAudio.toBlob());
// const audio = new Audio(blobUrl);
// audio.play();
}
}
};
</script>
<form
@@ -88,6 +160,8 @@
engine: STTEngine !== '' ? STTEngine : undefined
},
tts: {
engine: TTSEngine !== '' ? TTSEngine : undefined,
engineConfig: TTSEngineConfig,
playbackRate: playbackRate,
voice: voice !== '' ? voice : undefined,
defaultVoice: $config?.audio?.tts?.voice ?? '',
@@ -107,7 +181,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Speech-to-Text Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={STTEngine}
placeholder="Select an engine"
>
@@ -124,7 +198,7 @@
</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
toggleSpeechAutoSend();
}}
@@ -142,11 +216,44 @@
<div>
<div class=" mb-1 text-sm font-medium">{$i18n.t('TTS Settings')}</div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Text-to-Speech Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={TTSEngine}
placeholder="Select an engine"
>
<option value="">{$i18n.t('Default')}</option>
<option value="browser-kokoro">{$i18n.t('Kokoro.js (Browser)')}</option>
</select>
</div>
</div>
{#if TTSEngine === 'browser-kokoro'}
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Kokoro.js Dtype')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={TTSEngineConfig.dtype}
placeholder="Select dtype"
>
<option value="" disabled selected>Select dtype</option>
<option value="fp32">fp32</option>
<option value="fp16">fp16</option>
<option value="q8">q8</option>
<option value="q4">q4</option>
</select>
</div>
</div>
{/if}
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Auto-playback response')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
toggleResponseAutoPlayback();
}}
@@ -165,7 +272,7 @@
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={playbackRate}
>
{#each speedOptions as option}
@@ -176,15 +283,54 @@
</div>
</div>
<hr class=" dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
{#if $config.audio.tts.engine === ''}
{#if TTSEngine === 'browser-kokoro'}
{#if TTSModel}
<div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Voice')}</div>
<div class="flex w-full">
<div class="flex-1">
<input
list="voice-list"
class="w-full rounded-lg py-2 px-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={voice}
placeholder="Select a voice"
/>
<datalist id="voice-list">
{#each voices as voice}
<option value={voice.id}>{voice.name}</option>
{/each}
</datalist>
</div>
</div>
</div>
{:else}
<div>
<div class=" mb-2.5 text-sm font-medium flex gap-2 items-center">
<Spinner className="size-4" />
<div class=" text-sm font-medium shimmer">
{$i18n.t('Loading Kokoro.js...')}
{TTSModelProgress && TTSModelProgress.status === 'progress'
? `(${Math.round(TTSModelProgress.progress * 10) / 10}%)`
: ''}
</div>
</div>
<div class="text-xs text-gray-500">
{$i18n.t('Please do not close the settings page while loading the model.')}
</div>
</div>
{/if}
{:else if $config.audio.tts.engine === ''}
<div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Voice')}</div>
<div class="flex w-full">
<div class="flex-1">
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={voice}
>
<option value="" selected={voice !== ''}>{$i18n.t('Default')}</option>
@@ -215,7 +361,7 @@
<div class="flex-1">
<input
list="voice-list"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={voice}
placeholder="Select a voice"
/>
@@ -154,7 +154,7 @@
</button>
</div>
<hr class=" dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div class="flex flex-col">
{#if showArchiveConfirm}
@@ -0,0 +1,156 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { getModels as _getModels } from '$lib/apis';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import { models, settings, user } from '$lib/stores';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from './Connections/Connection.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
export let saveSettings: Function;
let config = null;
let showConnectionModal = false;
const addConnectionHandler = async (connection) => {
config.OPENAI_API_BASE_URLS.push(connection.url);
config.OPENAI_API_KEYS.push(connection.key);
config.OPENAI_API_CONFIGS[config.OPENAI_API_BASE_URLS.length - 1] = connection.config;
await updateHandler();
};
const updateHandler = async () => {
// Remove trailing slashes
config.OPENAI_API_BASE_URLS = config.OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, ''));
// Check if API KEYS length is same than API URLS length
if (config.OPENAI_API_KEYS.length !== config.OPENAI_API_BASE_URLS.length) {
// if there are more keys than urls, remove the extra keys
if (config.OPENAI_API_KEYS.length > config.OPENAI_API_BASE_URLS.length) {
config.OPENAI_API_KEYS = config.OPENAI_API_KEYS.slice(
0,
config.OPENAI_API_BASE_URLS.length
);
}
// if there are more urls than keys, add empty keys
if (config.OPENAI_API_KEYS.length < config.OPENAI_API_BASE_URLS.length) {
const diff = config.OPENAI_API_BASE_URLS.length - config.OPENAI_API_KEYS.length;
for (let i = 0; i < diff; i++) {
config.OPENAI_API_KEYS.push('');
}
}
}
await saveSettings({
directConnections: config
});
};
onMount(async () => {
config = $settings?.directConnections ?? {
OPENAI_API_BASE_URLS: [],
OPENAI_API_KEYS: [],
OPENAI_API_CONFIGS: {}
};
});
</script>
<AddConnectionModal direct bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<form
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={() => {
updateHandler();
}}
>
<div class=" overflow-y-scroll scrollbar-hidden h-full">
{#if config !== null}
<div class="">
<div class="pr-1.5">
<div class="">
<div class="flex justify-between items-center mb-0.5">
<div class="font-medium">{$i18n.t('Manage Direct Connections')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
on:click={() => {
showConnectionModal = true;
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5">
{#each config?.OPENAI_API_BASE_URLS ?? [] as url, idx}
<Connection
bind:url
bind:key={config.OPENAI_API_KEYS[idx]}
bind:config={config.OPENAI_API_CONFIGS[idx]}
onSubmit={() => {
updateHandler();
}}
onDelete={() => {
config.OPENAI_API_BASE_URLS = config.OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
config.OPENAI_API_KEYS = config.OPENAI_API_KEYS.filter(
(key, keyIdx) => idx !== keyIdx
);
let newConfig = {};
config.OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] =
config.OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
config.OPENAI_API_CONFIGS = newConfig;
}}
/>
{/each}
</div>
</div>
<div class="my-1.5">
<div class="text-xs text-gray-500">
{$i18n.t('Connect to your own OpenAI compatible API endpoints.')}
<br />
{$i18n.t(
'CORS must be properly configured by the provider to allow requests from Open WebUI.'
)}
</div>
</div>
</div>
</div>
{:else}
<div class="flex h-full justify-center">
<div class="my-auto">
<Spinner className="size-6" />
</div>
</div>
{/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-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>
@@ -0,0 +1,84 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
export let onDelete = () => {};
export let onSubmit = () => {};
export let pipeline = false;
export let url = '';
export let key = '';
export let config = {};
let showConfigModal = false;
</script>
<AddConnectionModal
edit
direct
bind:show={showConfigModal}
connection={{
url,
key,
config
}}
{onDelete}
onSubmit={(connection) => {
url = connection.url;
key = connection.key;
config = connection.config;
onSubmit(connection);
}}
/>
<div class="flex w-full gap-2 items-center">
<Tooltip
className="w-full relative"
content={$i18n.t(`WebUI will make requests to "{{url}}/chat/completions"`, {
url
})}
placement="top-start"
>
{#if !(config?.enable ?? true)}
<div
class="absolute top-0 bottom-0 left-0 right-0 opacity-60 bg-white dark:bg-gray-900 z-10"
></div>
{/if}
<div class="flex w-full">
<div class="flex-1 relative">
<input
class=" outline-hidden w-full bg-transparent {pipeline ? 'pr-8' : ''}"
placeholder={$i18n.t('API Base URL')}
bind:value={url}
autocomplete="off"
/>
</div>
<SensitiveInput
inputClassName=" outline-hidden bg-transparent w-full"
placeholder={$i18n.t('API Key')}
bind:value={key}
/>
</div>
</Tooltip>
<div class="flex gap-1">
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showConfigModal = true;
}}
type="button"
>
<Cog6 />
</button>
</Tooltip>
</div>
</div>
+35 -16
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { getLanguages } from '$lib/i18n';
import { getLanguages, changeLanguage } from '$lib/i18n';
const dispatch = createEventDispatcher();
import { models, settings, theme, user } from '$lib/stores';
@@ -46,9 +46,14 @@
let params = {
// Advanced
stream_response: null,
function_calling: null,
seed: null,
temperature: null,
reasoning_effort: null,
logit_bias: null,
frequency_penalty: null,
presence_penalty: null,
repeat_penalty: null,
repeat_last_n: null,
mirostat: null,
mirostat_eta: null,
@@ -139,12 +144,10 @@
}
}
console.log(_theme);
};
if (typeof window !== 'undefined' && window.applyTheme) {
window.applyTheme();
}
const themeChangeHandler = (_theme: string) => {
theme.set(_theme);
localStorage.setItem('theme', _theme);
if (_theme.includes('oled')) {
document.documentElement.style.setProperty('--color-gray-800', '#101010');
document.documentElement.style.setProperty('--color-gray-850', '#050505');
@@ -152,6 +155,13 @@
document.documentElement.style.setProperty('--color-gray-950', '#000000');
document.documentElement.classList.add('dark');
}
console.log(_theme);
};
const themeChangeHandler = (_theme: string) => {
theme.set(_theme);
localStorage.setItem('theme', _theme);
applyTheme(_theme);
};
</script>
@@ -165,7 +175,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Theme')}</div>
<div class="flex items-center relative">
<select
class=" dark:bg-gray-900 w-fit pr-8 rounded py-2 px-2 text-xs bg-transparent outline-none text-right"
class=" dark:bg-gray-900 w-fit pr-8 rounded-sm py-2 px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={selectedTheme}
placeholder="Select a theme"
on:change={() => themeChangeHandler(selectedTheme)}
@@ -185,11 +195,11 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Language')}</div>
<div class="flex items-center relative">
<select
class=" dark:bg-gray-900 w-fit pr-8 rounded py-2 px-2 text-xs bg-transparent outline-none text-right"
class=" dark:bg-gray-900 w-fit pr-8 rounded-sm py-2 px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={lang}
placeholder="Select a language"
on:change={(e) => {
$i18n.changeLanguage(lang);
changeLanguage(lang);
}}
>
{#each languages as language}
@@ -216,7 +226,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Notifications')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
toggleNotification();
}}
@@ -233,13 +243,13 @@
</div>
{#if $user.role === 'admin' || $user?.permissions.chat?.controls}
<hr class=" dark:border-gray-850 my-3" />
<hr class="border-gray-100 dark:border-gray-850 my-3" />
<div>
<div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div>
<textarea
bind:value={system}
class="w-full rounded-lg p-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
class="w-full rounded-lg p-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-hidden resize-none"
rows="4"
/>
</div>
@@ -258,14 +268,14 @@
{#if showAdvanced}
<AdvancedParams admin={$user?.role === 'admin'} bind:params />
<hr class=" dark:border-gray-850" />
<hr class=" border-gray-100 dark:border-gray-850" />
<div class=" py-1 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Keep Alive')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
keepAlive = keepAlive === null ? '5m' : null;
@@ -282,7 +292,7 @@
{#if keepAlive !== null}
<div class="flex mt-1 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t("e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.")}
bind:value={keepAlive}
@@ -296,7 +306,7 @@
<div class=" self-center text-sm font-medium">{$i18n.t('Request Mode')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
toggleRequestFormat();
}}
@@ -332,11 +342,20 @@
system: system !== '' ? system : undefined,
params: {
stream_response: params.stream_response !== null ? params.stream_response : undefined,
function_calling:
params.function_calling !== null ? params.function_calling : undefined,
seed: (params.seed !== null ? params.seed : undefined) ?? undefined,
stop: params.stop ? params.stop.split(',').filter((e) => e) : undefined,
temperature: params.temperature !== null ? params.temperature : undefined,
reasoning_effort:
params.reasoning_effort !== null ? params.reasoning_effort : undefined,
logit_bias: params.logit_bias !== null ? params.logit_bias : undefined,
frequency_penalty:
params.frequency_penalty !== null ? params.frequency_penalty : undefined,
presence_penalty:
params.frequency_penalty !== null ? params.frequency_penalty : undefined,
repeat_penalty:
params.frequency_penalty !== null ? params.frequency_penalty : undefined,
repeat_last_n: params.repeat_last_n !== null ? params.repeat_last_n : undefined,
mirostat: params.mirostat !== null ? params.mirostat : undefined,
mirostat_eta: params.mirostat_eta !== null ? params.mirostat_eta : undefined,

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