"docs/source/en/api/schedulers/euler.md" did not exist on "eadf0e2555cfa19b033e02de53553f71ac33536f"
Unverified Commit 7e061d19 authored by perfectra1n's avatar perfectra1n Committed by GitHub
Browse files

Merge branch 'open-webui:main' into feature-external-db-reconnect

parents 81b24169 9e4dd4b8
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.3.4", "version": "0.3.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.3.4", "version": "0.3.5",
"dependencies": { "dependencies": {
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6", "@codemirror/lang-python": "^6.1.6",
......
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.3.4", "version": "0.3.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
......
...@@ -28,6 +28,10 @@ math { ...@@ -28,6 +28,10 @@ math {
@apply rounded-lg; @apply rounded-lg;
} }
.markdown a {
@apply underline;
}
ol > li { ol > li {
counter-increment: list-number; counter-increment: list-number;
display: block; display: block;
......
...@@ -205,6 +205,54 @@ export const generateTitle = async ( ...@@ -205,6 +205,54 @@ export const generateTitle = async (
return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat'; return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat';
}; };
export const generateEmoji = async (
token: string = '',
model: string,
prompt: string,
chat_id?: string
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/task/emoji/completions`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
model: model,
prompt: prompt,
...(chat_id && { chat_id: chat_id })
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
if ('detail' in err) {
error = err.detail;
}
return null;
});
if (error) {
throw error;
}
const response = res?.choices[0]?.message?.content.replace(/["']/g, '') ?? null;
if (response) {
if (/\p{Extended_Pictographic}/u.test(response)) {
return response.match(/\p{Extended_Pictographic}/gu)[0];
}
}
return null;
};
export const generateSearchQuery = async ( export const generateSearchQuery = async (
token: string = '', token: string = '',
model: string, model: string,
......
...@@ -59,6 +59,37 @@ export const addNewMemory = async (token: string, content: string) => { ...@@ -59,6 +59,37 @@ export const addNewMemory = async (token: string, content: string) => {
return res; return res;
}; };
export const updateMemoryById = async (token: string, id: string, content: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/memories/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
content: content
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const queryMemory = async (token: string, content: string) => { export const queryMemory = async (token: string, content: string) => {
let error = null; let error = null;
......
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getUserPosition } from '$lib/utils';
export const getUserPermissions = async (token: string) => { export const getUserPermissions = async (token: string) => {
let error = null; let error = null;
...@@ -198,6 +199,75 @@ export const getUserById = async (token: string, userId: string) => { ...@@ -198,6 +199,75 @@ export const getUserById = async (token: string, userId: string) => {
return res; return res;
}; };
export const getUserInfo = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/info`, {
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 updateUserInfo = async (token: string, info: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/info/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...info
})
})
.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 getAndUpdateUserLocation = async (token: string) => {
const location = await getUserPosition().catch((err) => {
throw err;
});
if (location) {
await updateUserInfo(token, { location: location });
return location;
} else {
throw new Error('Failed to get user location');
}
};
export const deleteUserById = async (token: string, userId: string) => { export const deleteUserById = async (token: string, userId: string) => {
let error = null; let error = null;
......
...@@ -44,6 +44,8 @@ ...@@ -44,6 +44,8 @@
let ENABLE_OLLAMA_API = null; let ENABLE_OLLAMA_API = null;
const verifyOpenAIHandler = async (idx) => { const verifyOpenAIHandler = async (idx) => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, ''));
OPENAI_API_BASE_URLS = await updateOpenAIUrls(localStorage.token, OPENAI_API_BASE_URLS); OPENAI_API_BASE_URLS = await updateOpenAIUrls(localStorage.token, OPENAI_API_BASE_URLS);
OPENAI_API_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS); OPENAI_API_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS);
...@@ -63,6 +65,10 @@ ...@@ -63,6 +65,10 @@
}; };
const verifyOllamaHandler = async (idx) => { const verifyOllamaHandler = async (idx) => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== '').map((url) =>
url.replace(/\/$/, '')
);
OLLAMA_BASE_URLS = await updateOllamaUrls(localStorage.token, OLLAMA_BASE_URLS); OLLAMA_BASE_URLS = await updateOllamaUrls(localStorage.token, OLLAMA_BASE_URLS);
const res = await getOllamaVersion(localStorage.token, idx).catch((error) => { const res = await getOllamaVersion(localStorage.token, idx).catch((error) => {
...@@ -78,6 +84,8 @@ ...@@ -78,6 +84,8 @@
}; };
const updateOpenAIHandler = async () => { const updateOpenAIHandler = async () => {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, ''));
// Check if API KEYS length is same than API URLS length // Check if API KEYS length is same than API URLS length
if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) { if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) {
// if there are more keys than urls, remove the extra keys // if there are more keys than urls, remove the extra keys
...@@ -100,7 +108,10 @@ ...@@ -100,7 +108,10 @@
}; };
const updateOllamaUrlsHandler = async () => { const updateOllamaUrlsHandler = async () => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== ''); OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== '').map((url) =>
url.replace(/\/$/, '')
);
console.log(OLLAMA_BASE_URLS); console.log(OLLAMA_BASE_URLS);
if (OLLAMA_BASE_URLS.length === 0) { if (OLLAMA_BASE_URLS.length === 0) {
......
...@@ -13,6 +13,8 @@ ...@@ -13,6 +13,8 @@
getRAGConfig, getRAGConfig,
updateRAGConfig updateRAGConfig
} from '$lib/apis/rag'; } from '$lib/apis/rag';
import ResetUploadDirConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ResetVectorDBConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { documents, models } from '$lib/stores'; import { documents, models } from '$lib/stores';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
...@@ -213,6 +215,34 @@ ...@@ -213,6 +215,34 @@
}); });
</script> </script>
<ResetUploadDirConfirmDialog
bind:show={showResetUploadDirConfirm}
on:confirm={() => {
const res = resetUploadDir(localStorage.token).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Success'));
}
}}
/>
<ResetVectorDBConfirmDialog
bind:show={showResetConfirm}
on:confirm={() => {
const res = resetVectorDB(localStorage.token).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Success'));
}
}}
/>
<form <form
class="flex flex-col h-full justify-between space-y-3 text-sm" class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => { on:submit|preventDefault={() => {
...@@ -640,199 +670,56 @@ ...@@ -640,199 +670,56 @@
<hr class=" dark:border-gray-850" /> <hr class=" dark:border-gray-850" />
<div> <div>
{#if showResetUploadDirConfirm} <button
<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition"> class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
<div class="flex items-center space-x-3"> on:click={() => {
<svg showResetUploadDirConfirm = true;
xmlns="http://www.w3.org/2000/svg" }}
viewBox="0 0 24 24" type="button"
fill="currentColor" >
class="size-4" <div class=" self-center mr-3">
> <svg
<path xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd" viewBox="0 0 24 24"
d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 14.25a.75.75 0 0 0 0 1.5H15a.75.75 0 0 0 0-1.5H9.75Z" fill="currentColor"
clip-rule="evenodd" class="size-4"
/> >
<path <path
d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z" fill-rule="evenodd"
/> d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 14.25a.75.75 0 0 0 0 1.5H15a.75.75 0 0 0 0-1.5H9.75Z"
</svg> clip-rule="evenodd"
<span>{$i18n.t('Are you sure?')}</span> />
</div> <path
d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
<div class="flex space-x-1.5 items-center"> />
<button </svg>
class="hover:text-white transition"
on:click={() => {
const res = resetUploadDir(localStorage.token).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Success'));
}
showResetUploadDirConfirm = false;
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
class="hover:text-white transition"
type="button"
on:click={() => {
showResetUploadDirConfirm = false;
}}
>
<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>
</div> </div>
{:else} <div class=" self-center text-sm font-medium">{$i18n.t('Reset Upload Directory')}</div>
<button </button>
class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => { <button
showResetUploadDirConfirm = true; class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
}} on:click={() => {
type="button" showResetConfirm = true;
> }}
<div class=" self-center mr-3"> type="button"
<svg >
xmlns="http://www.w3.org/2000/svg" <div class=" self-center mr-3">
viewBox="0 0 24 24" <svg
fill="currentColor" xmlns="http://www.w3.org/2000/svg"
class="size-4" viewBox="0 0 16 16"
> fill="currentColor"
<path class="w-4 h-4"
fill-rule="evenodd" >
d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 14.25a.75.75 0 0 0 0 1.5H15a.75.75 0 0 0 0-1.5H9.75Z" <path
clip-rule="evenodd" fill-rule="evenodd"
/> d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
<path clip-rule="evenodd"
d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z" />
/> </svg>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Reset Upload Directory')}</div>
</button>
{/if}
{#if showResetConfirm}
<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
<div class="flex items-center space-x-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
clip-rule="evenodd"
/>
</svg>
<span>{$i18n.t('Are you sure?')}</span>
</div>
<div class="flex space-x-1.5 items-center">
<button
class="hover:text-white transition"
on:click={() => {
const res = resetVectorDB(localStorage.token).catch((error) => {
toast.error(error);
return null;
});
if (res) {
toast.success($i18n.t('Success'));
}
showResetConfirm = false;
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
class="hover:text-white transition"
on:click={() => {
showResetConfirm = false;
}}
type="button"
>
<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>
</div> </div>
{:else} <div class=" self-center text-sm font-medium">{$i18n.t('Reset Vector Storage')}</div>
<button </button>
class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
showResetConfirm = true;
}}
type="button"
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Reset Vector Storage')}</div>
</button>
{/if}
</div> </div>
</div> </div>
<div class="flex justify-end pt-3 text-sm font-medium"> <div class="flex justify-end pt-3 text-sm font-medium">
......
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, models, MODEL_DOWNLOAD_POOL, user, config } from '$lib/stores';
import { splitStream } from '$lib/utils';
import { import {
createModel, createModel,
...@@ -11,15 +16,11 @@ ...@@ -11,15 +16,11 @@
uploadModel, uploadModel,
getOllamaConfig getOllamaConfig
} from '$lib/apis/ollama'; } from '$lib/apis/ollama';
import { getModels as _getModels } from '$lib/apis';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, models, MODEL_DOWNLOAD_POOL, user, config } from '$lib/stores';
import { splitStream } from '$lib/utils';
import { onMount, getContext } from 'svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import { getModels as _getModels } from '$lib/apis'; import ModelDeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
...@@ -29,6 +30,8 @@ ...@@ -29,6 +30,8 @@
let modelUploadInputElement: HTMLInputElement; let modelUploadInputElement: HTMLInputElement;
let showModelDeleteConfirm = false;
// Models // Models
let ollamaEnabled = null; let ollamaEnabled = null;
...@@ -549,6 +552,13 @@ ...@@ -549,6 +552,13 @@
}); });
</script> </script>
<ModelDeleteConfirmDialog
bind:show={showModelDeleteConfirm}
on:confirm={() => {
deleteModelHandler();
}}
/>
<div class="flex flex-col h-full justify-between text-sm"> <div class="flex flex-col h-full justify-between text-sm">
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full"> <div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if ollamaEnabled} {#if ollamaEnabled}
...@@ -763,7 +773,7 @@ ...@@ -763,7 +773,7 @@
<button <button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition" class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
on:click={() => { on:click={() => {
deleteModelHandler(); showModelDeleteConfirm = true;
}} }}
> >
<svg <svg
......
...@@ -18,7 +18,8 @@ ...@@ -18,7 +18,8 @@
'serpstack', 'serpstack',
'serper', 'serper',
'serply', 'serply',
'duckduckgo' 'duckduckgo',
'tavily'
]; ];
let youtubeLanguage = 'en'; let youtubeLanguage = 'en';
...@@ -214,6 +215,24 @@ ...@@ -214,6 +215,24 @@
</div> </div>
</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="flex w-full">
<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"
type="text"
placeholder={$i18n.t('Enter Tavily API Key')}
bind:value={webConfig.search.tavily_api_key}
autocomplete="off"
/>
</div>
</div>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
......
...@@ -31,6 +31,17 @@ ...@@ -31,6 +31,17 @@
} }
})(); })();
} }
let sortKey = 'updated_at'; // default sort key
let sortOrder = 'desc'; // default sort order
function setSortKey(key) {
if (sortKey === key) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortOrder = 'asc';
}
}
</script> </script>
<Modal size="lg" bind:show> <Modal size="lg" bind:show>
...@@ -69,18 +80,56 @@ ...@@ -69,18 +80,56 @@
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-800"
> >
<tr> <tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th> <th
<th scope="col" class="px-3 py-2 hidden md:flex"> {$i18n.t('Created at')} </th> scope="col"
class="px-3 py-2 cursor-pointer select-none"
on:click={() => setSortKey('title')}
>
{$i18n.t('Title')}
{#if sortKey === 'title'}
{sortOrder === 'asc' ? '▲' : '▼'}
{:else}
<span class="invisible">▲</span>
{/if}
</th>
<th
scope="col"
class="px-3 py-2 cursor-pointer select-none"
on:click={() => setSortKey('created_at')}
>
{$i18n.t('Created at')}
{#if sortKey === 'created_at'}
{sortOrder === 'asc' ? '▲' : '▼'}
{:else}
<span class="invisible">▲</span>
{/if}
</th>
<th
scope="col"
class="px-3 py-2 hidden md:flex cursor-pointer select-none"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated at')}
{#if sortKey === 'updated_at'}
{sortOrder === 'asc' ? '▲' : '▼'}
{:else}
<span class="invisible">▲</span>
{/if}
</th>
<th scope="col" class="px-3 py-2 text-right" /> <th scope="col" class="px-3 py-2 text-right" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each chats as chat, idx} {#each chats.sort((a, b) => {
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
return 0;
}) as chat, idx}
<tr <tr
class="bg-transparent {idx !== chats.length - 1 && class="bg-transparent {idx !== chats.length - 1 &&
'border-b'} dark:bg-gray-900 dark:border-gray-850 text-xs" 'border-b'} dark:bg-gray-900 dark:border-gray-850 text-xs"
> >
<td class="px-3 py-1 w-2/3"> <td class="px-3 py-1">
<a href="/s/{chat.id}" target="_blank"> <a href="/s/{chat.id}" target="_blank">
<div class=" underline line-clamp-1"> <div class=" underline line-clamp-1">
{chat.title} {chat.title}
...@@ -88,11 +137,16 @@ ...@@ -88,11 +137,16 @@
</a> </a>
</td> </td>
<td class=" px-3 py-1 hidden md:flex h-[2.5rem]"> <td class=" px-3 py-1 h-[2.5rem]">
<div class="my-auto"> <div class="my-auto">
{dayjs(chat.created_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))} {dayjs(chat.created_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
</div> </div>
</td> </td>
<td class=" px-3 py-1 hidden md:flex h-[2.5rem]">
<div class="my-auto">
{dayjs(chat.updated_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
</div>
</td>
<td class="px-3 py-1 text-right"> <td class="px-3 py-1 text-right">
<div class="flex justify-end w-full"> <div class="flex justify-end w-full">
......
...@@ -30,6 +30,8 @@ ...@@ -30,6 +30,8 @@
import { import {
convertMessagesToHistory, convertMessagesToHistory,
copyToClipboard, copyToClipboard,
extractSentencesForAudio,
getUserPosition,
promptTemplate, promptTemplate,
splitStream splitStream
} from '$lib/utils'; } from '$lib/utils';
...@@ -49,7 +51,7 @@ ...@@ -49,7 +51,7 @@
import { runWebSearch } from '$lib/apis/rag'; import { runWebSearch } from '$lib/apis/rag';
import { createOpenAITextStream } from '$lib/apis/streaming'; import { createOpenAITextStream } from '$lib/apis/streaming';
import { queryMemory } from '$lib/apis/memories'; import { queryMemory } from '$lib/apis/memories';
import { getUserSettings } from '$lib/apis/users'; import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
import { chatCompleted, generateTitle, generateSearchQuery } from '$lib/apis'; import { chatCompleted, generateTitle, generateSearchQuery } from '$lib/apis';
import Banner from '../common/Banner.svelte'; import Banner from '../common/Banner.svelte';
...@@ -64,6 +66,8 @@ ...@@ -64,6 +66,8 @@
export let chatIdProp = ''; export let chatIdProp = '';
let loaded = false; let loaded = false;
const eventTarget = new EventTarget();
let stopResponseFlag = false; let stopResponseFlag = false;
let autoScroll = true; let autoScroll = true;
let processing = ''; let processing = '';
...@@ -108,7 +112,8 @@ ...@@ -108,7 +112,8 @@
$: if (chatIdProp) { $: if (chatIdProp) {
(async () => { (async () => {
if (await loadChat()) { console.log(chatIdProp);
if (chatIdProp && (await loadChat())) {
await tick(); await tick();
loaded = true; loaded = true;
...@@ -123,7 +128,11 @@ ...@@ -123,7 +128,11 @@
onMount(async () => { onMount(async () => {
if (!$chatId) { if (!$chatId) {
await initNewChat(); chatId.subscribe(async (value) => {
if (!value) {
await initNewChat();
}
});
} else { } else {
if (!($settings.saveChatHistory ?? true)) { if (!($settings.saveChatHistory ?? true)) {
await goto('/'); await goto('/');
...@@ -300,7 +309,7 @@ ...@@ -300,7 +309,7 @@
// Chat functions // Chat functions
////////////////////////// //////////////////////////
const submitPrompt = async (userPrompt, _user = null) => { const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
let _responses = []; let _responses = [];
console.log('submitPrompt', $chatId); console.log('submitPrompt', $chatId);
...@@ -344,7 +353,6 @@ ...@@ -344,7 +353,6 @@
parentId: messages.length !== 0 ? messages.at(-1).id : null, parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [], childrenIds: [],
role: 'user', role: 'user',
user: _user ?? undefined,
content: userPrompt, content: userPrompt,
files: _files.length > 0 ? _files : undefined, files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch timestamp: Math.floor(Date.now() / 1000), // Unix epoch
...@@ -362,15 +370,13 @@ ...@@ -362,15 +370,13 @@
// Wait until history/message have been updated // Wait until history/message have been updated
await tick(); await tick();
_responses = await sendPrompt(userPrompt, userMessageId, { newChat: true });
// Send prompt
_responses = await sendPrompt(userPrompt, userMessageId);
} }
return _responses; return _responses;
}; };
const sendPrompt = async (prompt, parentId, modelId = null, newChat = true) => { const sendPrompt = async (prompt, parentId, { modelId = null, newChat = false } = {}) => {
let _responses = []; let _responses = [];
// If modelId is provided, use it, else use selected model // If modelId is provided, use it, else use selected model
...@@ -490,7 +496,6 @@ ...@@ -490,7 +496,6 @@
responseMessage.userContext = userContext; responseMessage.userContext = userContext;
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId); const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
if (webSearchEnabled) { if (webSearchEnabled) {
await getWebSearchResults(model.id, parentId, responseMessageId); await getWebSearchResults(model.id, parentId, responseMessageId);
} }
...@@ -503,8 +508,6 @@ ...@@ -503,8 +508,6 @@
} }
_responses.push(_response); _responses.push(_response);
console.log('chatEventEmitter', chatEventEmitter);
if (chatEventEmitter) clearInterval(chatEventEmitter); if (chatEventEmitter) clearInterval(chatEventEmitter);
} else { } else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId })); toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
...@@ -513,88 +516,9 @@ ...@@ -513,88 +516,9 @@
); );
await chats.set(await getChatList(localStorage.token)); await chats.set(await getChatList(localStorage.token));
return _responses; return _responses;
}; };
const getWebSearchResults = async (model: string, parentId: string, responseId: string) => {
const responseMessage = history.messages[responseId];
responseMessage.statusHistory = [
{
done: false,
action: 'web_search',
description: $i18n.t('Generating search query')
}
];
messages = messages;
const prompt = history.messages[parentId].content;
let searchQuery = await generateSearchQuery(localStorage.token, model, messages, prompt).catch(
(error) => {
console.log(error);
return prompt;
}
);
if (!searchQuery) {
toast.warning($i18n.t('No search query generated'));
responseMessage.statusHistory.push({
done: true,
error: true,
action: 'web_search',
description: 'No search query generated'
});
messages = messages;
}
responseMessage.statusHistory.push({
done: false,
action: 'web_search',
description: $i18n.t(`Searching "{{searchQuery}}"`, { searchQuery })
});
messages = messages;
const results = await runWebSearch(localStorage.token, searchQuery).catch((error) => {
console.log(error);
toast.error(error);
return null;
});
if (results) {
responseMessage.statusHistory.push({
done: true,
action: 'web_search',
description: $i18n.t('Searched {{count}} sites', { count: results.filenames.length }),
query: searchQuery,
urls: results.filenames
});
if (responseMessage?.files ?? undefined === undefined) {
responseMessage.files = [];
}
responseMessage.files.push({
collection_name: results.collection_name,
name: searchQuery,
type: 'web_search_results',
urls: results.filenames
});
messages = messages;
} else {
responseMessage.statusHistory.push({
done: true,
error: true,
action: 'web_search',
description: 'No search results found'
});
messages = messages;
}
};
const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => { const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => {
let _response = null; let _response = null;
...@@ -610,7 +534,13 @@ ...@@ -610,7 +534,13 @@
$settings.system || (responseMessage?.userContext ?? null) $settings.system || (responseMessage?.userContext ?? null)
? { ? {
role: 'system', role: 'system',
content: `${promptTemplate($settings?.system ?? '', $user.name)}${ content: `${promptTemplate(
$settings?.system ?? '',
$user.name,
$settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token)
: undefined
)}${
responseMessage?.userContext ?? null responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}` ? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: '' : ''
...@@ -676,6 +606,16 @@ ...@@ -676,6 +606,16 @@
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
); );
eventTarget.dispatchEvent(
new CustomEvent('chat:start', {
detail: {
id: responseMessageId
}
})
);
await tick();
const [res, controller] = await generateChatCompletion(localStorage.token, { const [res, controller] = await generateChatCompletion(localStorage.token, {
model: model.id, model: model.id,
messages: messagesBody, messages: messagesBody,
...@@ -745,6 +685,23 @@ ...@@ -745,6 +685,23 @@
continue; continue;
} else { } else {
responseMessage.content += data.message.content; responseMessage.content += data.message.content;
const sentences = extractSentencesForAudio(responseMessage.content);
sentences.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
sentences.length > 0 &&
sentences[sentences.length - 1] !== responseMessage.lastSentence
) {
responseMessage.lastSentence = sentences[sentences.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: { id: responseMessageId, content: sentences[sentences.length - 1] }
})
);
}
messages = messages; messages = messages;
} }
} else { } else {
...@@ -771,21 +728,13 @@ ...@@ -771,21 +728,13 @@
messages = messages; messages = messages;
if ($settings.notificationEnabled && !document.hasFocus()) { if ($settings.notificationEnabled && !document.hasFocus()) {
const notification = new Notification( const notification = new Notification(`${model.id}`, {
selectedModelfile body: responseMessage.content,
? `${ icon: `${WEBUI_BASE_URL}/static/favicon.png`
selectedModelfile.title.charAt(0).toUpperCase() + });
selectedModelfile.title.slice(1)
}`
: `${model.id}`,
{
body: responseMessage.content,
icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
}
);
} }
if ($settings.responseAutoCopy) { if ($settings?.responseAutoCopy ?? false) {
copyToClipboard(responseMessage.content); copyToClipboard(responseMessage.content);
} }
...@@ -847,6 +796,23 @@ ...@@ -847,6 +796,23 @@
stopResponseFlag = false; stopResponseFlag = false;
await tick(); await tick();
let lastSentence = extractSentencesForAudio(responseMessage.content)?.at(-1) ?? '';
if (lastSentence) {
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: { id: responseMessageId, content: lastSentence }
})
);
}
eventTarget.dispatchEvent(
new CustomEvent('chat:finish', {
detail: {
id: responseMessageId,
content: responseMessage.content
}
})
);
if (autoScroll) { if (autoScroll) {
scrollToBottom(); scrollToBottom();
} }
...@@ -887,6 +853,15 @@ ...@@ -887,6 +853,15 @@
scrollToBottom(); scrollToBottom();
eventTarget.dispatchEvent(
new CustomEvent('chat:start', {
detail: {
id: responseMessageId
}
})
);
await tick();
try { try {
const [res, controller] = await generateOpenAIChatCompletion( const [res, controller] = await generateOpenAIChatCompletion(
localStorage.token, localStorage.token,
...@@ -903,7 +878,13 @@ ...@@ -903,7 +878,13 @@
$settings.system || (responseMessage?.userContext ?? null) $settings.system || (responseMessage?.userContext ?? null)
? { ? {
role: 'system', role: 'system',
content: `${promptTemplate($settings?.system ?? '', $user.name)}${ content: `${promptTemplate(
$settings?.system ?? '',
$user.name,
$settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token)
: undefined
)}${
responseMessage?.userContext ?? null responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}` ? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: '' : ''
...@@ -1007,6 +988,23 @@ ...@@ -1007,6 +988,23 @@
continue; continue;
} else { } else {
responseMessage.content += value; responseMessage.content += value;
const sentences = extractSentencesForAudio(responseMessage.content);
sentences.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
sentences.length > 0 &&
sentences[sentences.length - 1] !== responseMessage.lastSentence
) {
responseMessage.lastSentence = sentences[sentences.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: { id: responseMessageId, content: sentences[sentences.length - 1] }
})
);
}
messages = messages; messages = messages;
} }
...@@ -1057,6 +1055,24 @@ ...@@ -1057,6 +1055,24 @@
stopResponseFlag = false; stopResponseFlag = false;
await tick(); await tick();
let lastSentence = extractSentencesForAudio(responseMessage.content)?.at(-1) ?? '';
if (lastSentence) {
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: { id: responseMessageId, content: lastSentence }
})
);
}
eventTarget.dispatchEvent(
new CustomEvent('chat:finish', {
detail: {
id: responseMessageId,
content: responseMessage.content
}
})
);
if (autoScroll) { if (autoScroll) {
scrollToBottom(); scrollToBottom();
} }
...@@ -1123,9 +1139,12 @@ ...@@ -1123,9 +1139,12 @@
let userPrompt = userMessage.content; let userPrompt = userMessage.content;
if ((userMessage?.models ?? [...selectedModels]).length == 1) { if ((userMessage?.models ?? [...selectedModels]).length == 1) {
await sendPrompt(userPrompt, userMessage.id, undefined, false); // If user message has only one model selected, sendPrompt automatically selects it for regeneration
await sendPrompt(userPrompt, userMessage.id);
} else { } else {
await sendPrompt(userPrompt, userMessage.id, message.model, false); // 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, { modelId: message.model });
} }
} }
}; };
...@@ -1191,6 +1210,84 @@ ...@@ -1191,6 +1210,84 @@
} }
}; };
const getWebSearchResults = async (model: string, parentId: string, responseId: string) => {
const responseMessage = history.messages[responseId];
responseMessage.statusHistory = [
{
done: false,
action: 'web_search',
description: $i18n.t('Generating search query')
}
];
messages = messages;
const prompt = history.messages[parentId].content;
let searchQuery = await generateSearchQuery(localStorage.token, model, messages, prompt).catch(
(error) => {
console.log(error);
return prompt;
}
);
if (!searchQuery) {
toast.warning($i18n.t('No search query generated'));
responseMessage.statusHistory.push({
done: true,
error: true,
action: 'web_search',
description: 'No search query generated'
});
messages = messages;
}
responseMessage.statusHistory.push({
done: false,
action: 'web_search',
description: $i18n.t(`Searching "{{searchQuery}}"`, { searchQuery })
});
messages = messages;
const results = await runWebSearch(localStorage.token, searchQuery).catch((error) => {
console.log(error);
toast.error(error);
return null;
});
if (results) {
responseMessage.statusHistory.push({
done: true,
action: 'web_search',
description: $i18n.t('Searched {{count}} sites', { count: results.filenames.length }),
query: searchQuery,
urls: results.filenames
});
if (responseMessage?.files ?? undefined === undefined) {
responseMessage.files = [];
}
responseMessage.files.push({
collection_name: results.collection_name,
name: searchQuery,
type: 'web_search_results',
urls: results.filenames
});
messages = messages;
} else {
responseMessage.statusHistory.push({
done: true,
error: true,
action: 'web_search',
description: 'No search results found'
});
messages = messages;
}
};
const getTags = async () => { const getTags = async () => {
return await getTagsById(localStorage.token, $chatId).catch(async (error) => { return await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return []; return [];
...@@ -1206,7 +1303,18 @@ ...@@ -1206,7 +1303,18 @@
</title> </title>
</svelte:head> </svelte:head>
<CallOverlay {submitPrompt} bind:files /> <audio id="audioElement" src="" style="display: none;" />
{#if $showCallOverlay}
<CallOverlay
{submitPrompt}
{stopResponse}
bind:files
modelId={selectedModelIds?.at(0) ?? null}
chatId={$chatId}
{eventTarget}
/>
{/if}
{#if !chatIdProp || (loaded && chatIdProp)} {#if !chatIdProp || (loaded && chatIdProp)}
<div <div
......
...@@ -348,7 +348,6 @@ ...@@ -348,7 +348,6 @@
<Models <Models
bind:this={modelsElement} bind:this={modelsElement}
bind:prompt bind:prompt
bind:user
bind:chatInputPlaceholder bind:chatInputPlaceholder
{messages} {messages}
on:select={(e) => { on:select={(e) => {
...@@ -467,7 +466,7 @@ ...@@ -467,7 +466,7 @@
document.getElementById('chat-textarea')?.focus(); document.getElementById('chat-textarea')?.focus();
if ($settings?.speechAutoSend ?? false) { if ($settings?.speechAutoSend ?? false) {
submitPrompt(prompt, user); submitPrompt(prompt);
} }
}} }}
/> />
...@@ -476,7 +475,7 @@ ...@@ -476,7 +475,7 @@
class="w-full flex gap-1.5" class="w-full flex gap-1.5"
on:submit|preventDefault={() => { on:submit|preventDefault={() => {
// check if selectedModels support image input // check if selectedModels support image input
submitPrompt(prompt, user); submitPrompt(prompt);
}} }}
> >
<div <div
...@@ -718,7 +717,7 @@ ...@@ -718,7 +717,7 @@
// Submit the prompt when Enter key is pressed // Submit the prompt when Enter key is pressed
if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) { if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
submitPrompt(prompt, user); submitPrompt(prompt);
} }
} }
}} }}
......
...@@ -2,40 +2,239 @@ ...@@ -2,40 +2,239 @@
import { config, settings, showCallOverlay } from '$lib/stores'; import { config, settings, showCallOverlay } from '$lib/stores';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import { blobToFile, calculateSHA256, extractSentences, findWordIndices } from '$lib/utils'; import {
blobToFile,
calculateSHA256,
extractSentencesForAudio,
findWordIndices
} from '$lib/utils';
import { generateEmoji } from '$lib/apis';
import { synthesizeOpenAISpeech, transcribeAudio } from '$lib/apis/audio'; import { synthesizeOpenAISpeech, transcribeAudio } from '$lib/apis/audio';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte'; import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte';
import { get } from 'svelte/store';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let eventTarget: EventTarget;
export let submitPrompt: Function; export let submitPrompt: Function;
export let stopResponse: Function;
export let files; export let files;
export let chatId;
export let modelId;
let loading = false; let loading = false;
let confirmed = false; let confirmed = false;
let interrupted = false;
let assistantSpeaking = false;
let emoji = null;
let camera = false; let camera = false;
let cameraStream = null; let cameraStream = null;
let assistantSpeaking = false; let chatStreaming = false;
let assistantAudio = {};
let assistantAudioIdx = null;
let rmsLevel = 0; let rmsLevel = 0;
let hasStartedSpeaking = false; let hasStartedSpeaking = false;
let currentUtterance = null;
let mediaRecorder; let mediaRecorder;
let audioChunks = []; let audioChunks = [];
const MIN_DECIBELS = -45; let videoInputDevices = [];
let selectedVideoInputDeviceId = null;
const getVideoInputDevices = async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
videoInputDevices = devices.filter((device) => device.kind === 'videoinput');
if (!!navigator.mediaDevices.getDisplayMedia) {
videoInputDevices = [
...videoInputDevices,
{
deviceId: 'screen',
label: 'Screen Share'
}
];
}
console.log(videoInputDevices);
if (selectedVideoInputDeviceId === null && videoInputDevices.length > 0) {
selectedVideoInputDeviceId = videoInputDevices[0].deviceId;
}
};
const startCamera = async () => {
await getVideoInputDevices();
if (cameraStream === null) {
camera = true;
await tick();
try {
await startVideoStream();
} catch (err) {
console.error('Error accessing webcam: ', err);
}
}
};
const startVideoStream = async () => {
const video = document.getElementById('camera-feed');
if (video) {
if (selectedVideoInputDeviceId === 'screen') {
cameraStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always'
},
audio: false
});
} else {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: selectedVideoInputDeviceId ? { exact: selectedVideoInputDeviceId } : undefined
}
});
}
if (cameraStream) {
await getVideoInputDevices();
video.srcObject = cameraStream;
await video.play();
}
}
};
const stopVideoStream = async () => {
if (cameraStream) {
const tracks = cameraStream.getTracks();
tracks.forEach((track) => track.stop());
}
cameraStream = null;
};
const takeScreenshot = () => {
const video = document.getElementById('camera-feed');
const canvas = document.getElementById('camera-canvas');
if (!canvas) {
return;
}
const context = canvas.getContext('2d');
// Make the canvas match the video dimensions
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw the image from the video onto the canvas
context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
// Convert the canvas to a data base64 URL and console log it
const dataURL = canvas.toDataURL('image/png');
console.log(dataURL);
return dataURL;
};
const stopCamera = async () => {
await stopVideoStream();
camera = false;
};
const MIN_DECIBELS = -55;
const VISUALIZER_BUFFER_LENGTH = 300; const VISUALIZER_BUFFER_LENGTH = 300;
const transcribeHandler = async (audioBlob) => {
// Create a blob from the audio chunks
await tick();
const file = blobToFile(audioBlob, 'recording.wav');
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error);
return null;
});
if (res) {
console.log(res.text);
if (res.text !== '') {
const _responses = await submitPrompt(res.text, { _raw: true });
console.log(_responses);
}
}
};
const stopRecordingCallback = async (_continue = true) => {
if ($showCallOverlay) {
console.log('%c%s', 'color: red; font-size: 20px;', '🚨 stopRecordingCallback 🚨');
// deep copy the audioChunks array
const _audioChunks = audioChunks.slice(0);
audioChunks = [];
mediaRecorder = false;
if (_continue) {
startRecording();
}
if (confirmed) {
loading = true;
emoji = null;
if (cameraStream) {
const imageUrl = takeScreenshot();
files = [
{
type: 'image',
url: imageUrl
}
];
}
const audioBlob = new Blob(_audioChunks, { type: 'audio/wav' });
await transcribeHandler(audioBlob);
confirmed = false;
loading = false;
}
} else {
audioChunks = [];
mediaRecorder = false;
}
};
const startRecording = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.onstart = () => {
console.log('Recording started');
audioChunks = [];
analyseAudio(stream);
};
mediaRecorder.ondataavailable = (event) => {
if (hasStartedSpeaking) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = (e) => {
console.log('Recording stopped', e);
stopRecordingCallback();
};
mediaRecorder.start();
};
// Function to calculate the RMS level from time domain data // Function to calculate the RMS level from time domain data
const calculateRMS = (data: Uint8Array) => { const calculateRMS = (data: Uint8Array) => {
let sumSquares = 0; let sumSquares = 0;
...@@ -46,15 +245,6 @@ ...@@ -46,15 +245,6 @@
return Math.sqrt(sumSquares / data.length); return Math.sqrt(sumSquares / data.length);
}; };
const normalizeRMS = (rms) => {
rms = rms * 10;
const exp = 1.5; // Adjust exponent value; values greater than 1 expand larger numbers more and compress smaller numbers more
const scaledRMS = Math.pow(rms, exp);
// Scale between 0.01 (1%) and 1.0 (100%)
return Math.min(1.0, Math.max(0.01, scaledRMS));
};
const analyseAudio = (stream) => { const analyseAudio = (stream) => {
const audioContext = new AudioContext(); const audioContext = new AudioContext();
const audioStreamSource = audioContext.createMediaStreamSource(stream); const audioStreamSource = audioContext.createMediaStreamSource(stream);
...@@ -71,15 +261,23 @@ ...@@ -71,15 +261,23 @@
let lastSoundTime = Date.now(); let lastSoundTime = Date.now();
hasStartedSpeaking = false; hasStartedSpeaking = false;
console.log('🔊 Sound detection started', lastSoundTime, hasStartedSpeaking);
const detectSound = () => { const detectSound = () => {
const processFrame = () => { const processFrame = () => {
if (!mediaRecorder || !$showCallOverlay) { if (!mediaRecorder || !$showCallOverlay) {
if (mediaRecorder) {
mediaRecorder.stop();
}
return; return;
} }
if (assistantSpeaking) {
// Mute the audio if the assistant is speaking
analyser.maxDecibels = 0;
analyser.minDecibels = -1;
} else {
analyser.minDecibels = MIN_DECIBELS;
analyser.maxDecibels = -30;
}
analyser.getByteTimeDomainData(timeDomainData); analyser.getByteTimeDomainData(timeDomainData);
analyser.getByteFrequencyData(domainData); analyser.getByteFrequencyData(domainData);
...@@ -89,8 +287,14 @@ ...@@ -89,8 +287,14 @@
// Check if initial speech/noise has started // Check if initial speech/noise has started
const hasSound = domainData.some((value) => value > 0); const hasSound = domainData.some((value) => value > 0);
if (hasSound) { if (hasSound) {
stopAllAudio(); // BIG RED TEXT
hasStartedSpeaking = true; console.log('%c%s', 'color: red; font-size: 20px;', '🔊 Sound detected');
if (!hasStartedSpeaking) {
hasStartedSpeaking = true;
stopAllAudio();
}
lastSoundTime = Date.now(); lastSoundTime = Date.now();
} }
...@@ -100,7 +304,9 @@ ...@@ -100,7 +304,9 @@
confirmed = true; confirmed = true;
if (mediaRecorder) { if (mediaRecorder) {
console.log('%c%s', 'color: red; font-size: 20px;', '🔇 Silence detected');
mediaRecorder.stop(); mediaRecorder.stop();
return;
} }
} }
} }
...@@ -114,348 +320,299 @@ ...@@ -114,348 +320,299 @@
detectSound(); detectSound();
}; };
const stopAllAudio = () => { let finishedMessages = {};
if (currentUtterance) { let currentMessageId = null;
speechSynthesis.cancel(); let currentUtterance = null;
currentUtterance = null;
}
if (assistantAudio[assistantAudioIdx]) {
assistantAudio[assistantAudioIdx].pause();
assistantAudio[assistantAudioIdx].currentTime = 0;
}
const audioElement = document.getElementById('audioElement'); const speakSpeechSynthesisHandler = (content) => {
audioElement.pause(); if ($showCallOverlay) {
audioElement.currentTime = 0; return new Promise((resolve) => {
let voices = [];
const getVoicesLoop = setInterval(async () => {
voices = await speechSynthesis.getVoices();
if (voices.length > 0) {
clearInterval(getVoicesLoop);
const voice =
voices
?.filter(
(v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
)
?.at(0) ?? undefined;
currentUtterance = new SpeechSynthesisUtterance(content);
if (voice) {
currentUtterance.voice = voice;
}
assistantSpeaking = false; speechSynthesis.speak(currentUtterance);
currentUtterance.onend = async (e) => {
await new Promise((r) => setTimeout(r, 200));
resolve(e);
};
}
}, 100);
});
} else {
return Promise.resolve();
}
}; };
const playAudio = (idx) => { const playAudio = (audio) => {
if ($showCallOverlay) { if ($showCallOverlay) {
return new Promise((res) => { return new Promise((resolve) => {
assistantAudioIdx = idx;
const audioElement = document.getElementById('audioElement'); const audioElement = document.getElementById('audioElement');
const audio = assistantAudio[idx];
audioElement.src = audio.src; // Assume `assistantAudio` has objects with a `src` property
audioElement.muted = true;
audioElement
.play()
.then(() => {
audioElement.muted = false;
})
.catch((error) => {
toast.error(error);
});
audioElement.onended = async (e) => { if (audioElement) {
await new Promise((r) => setTimeout(r, 300)); audioElement.src = audio.src;
audioElement.muted = true;
if (Object.keys(assistantAudio).length - 1 === idx) {
assistantSpeaking = false; audioElement
} .play()
.then(() => {
res(e); audioElement.muted = false;
}; })
.catch((error) => {
console.error(error);
});
audioElement.onended = async (e) => {
await new Promise((r) => setTimeout(r, 100));
resolve(e);
};
}
}); });
} else { } else {
return Promise.resolve(); return Promise.resolve();
} }
}; };
const getOpenAISpeech = async (text) => { const stopAllAudio = async () => {
const res = await synthesizeOpenAISpeech( assistantSpeaking = false;
localStorage.token, interrupted = true;
$settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice,
text
).catch((error) => {
toast.error(error);
assistantSpeaking = false;
return null;
});
if (res) { if (chatStreaming) {
const blob = await res.blob(); stopResponse();
const blobUrl = URL.createObjectURL(blob);
const audio = new Audio(blobUrl);
assistantAudio = audio;
} }
};
const transcribeHandler = async (audioBlob) => { if (currentUtterance) {
// Create a blob from the audio chunks speechSynthesis.cancel();
currentUtterance = null;
}
await tick(); const audioElement = document.getElementById('audioElement');
const file = blobToFile(audioBlob, 'recording.wav'); if (audioElement) {
audioElement.muted = true;
audioElement.pause();
audioElement.currentTime = 0;
}
};
const res = await transcribeAudio(localStorage.token, file).catch((error) => { let audioAbortController = new AbortController();
toast.error(error);
return null;
});
if (res) { // Audio cache map where key is the content and value is the Audio object.
console.log(res.text); const audioCache = new Map();
const emojiCache = new Map();
if (res.text !== '') { const fetchAudio = async (content) => {
const _responses = await submitPrompt(res.text); if (!audioCache.has(content)) {
console.log(_responses); try {
// Set the emoji for the content if needed
if ($settings?.showEmojiInCall ?? false) {
const emoji = await generateEmoji(localStorage.token, modelId, content, chatId);
if (emoji) {
emojiCache.set(content, emoji);
}
}
if (_responses.at(0)) { if ($config.audio.tts.engine !== '') {
const content = _responses[0]; const res = await synthesizeOpenAISpeech(
if ((content ?? '').trim() !== '') { localStorage.token,
assistantSpeakingHandler(content); $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice,
content
).catch((error) => {
console.error(error);
return null;
});
if (res) {
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
audioCache.set(content, new Audio(blobUrl));
} }
} else {
audioCache.set(content, true);
} }
} catch (error) {
console.error('Error synthesizing speech:', error);
} }
} }
};
const assistantSpeakingHandler = async (content) => { return audioCache.get(content);
assistantSpeaking = true; };
if (($config.audio.tts.engine ?? '') == '') { let messages = {};
let voices = [];
const getVoicesLoop = setInterval(async () => {
voices = await speechSynthesis.getVoices();
if (voices.length > 0) {
clearInterval(getVoicesLoop);
const voice = const monitorAndPlayAudio = async (id, signal) => {
voices while (!signal.aborted) {
?.filter( if (messages[id] && messages[id].length > 0) {
(v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice) // Retrieve the next content string from the queue
) const content = messages[id].shift(); // Dequeues the content for playing
?.at(0) ?? undefined;
currentUtterance = new SpeechSynthesisUtterance(content); if (audioCache.has(content)) {
// If content is available in the cache, play it
if (voice) { // Set the emoji for the content if available
currentUtterance.voice = voice; if (($settings?.showEmojiInCall ?? false) && emojiCache.has(content)) {
emoji = emojiCache.get(content);
} else {
emoji = null;
} }
speechSynthesis.speak(currentUtterance); if ($config.audio.tts.engine !== '') {
} try {
}, 100); console.log(
} else if ($config.audio.tts.engine === 'openai') { '%c%s',
console.log('openai'); 'color: red; font-size: 20px;',
`Playing audio for content: ${content}`
const sentences = extractSentences(content).reduce((mergedTexts, currentText) => { );
const lastIndex = mergedTexts.length - 1;
if (lastIndex >= 0) { const audio = audioCache.get(content);
const previousText = mergedTexts[lastIndex]; await playAudio(audio); // Here ensure that playAudio is indeed correct method to execute
const wordCount = previousText.split(/\s+/).length; console.log(`Played audio for content: ${content}`);
if (wordCount < 2) { await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
mergedTexts[lastIndex] = previousText + ' ' + currentText; } catch (error) {
console.error('Error playing audio:', error);
}
} else { } else {
mergedTexts.push(currentText); await speakSpeechSynthesisHandler(content);
} }
} else { } else {
mergedTexts.push(currentText); // If not available in the cache, push it back to the queue and delay
} messages[id].unshift(content); // Re-queue the content at the start
return mergedTexts; console.log(`Audio for "${content}" not yet available in the cache, re-queued...`);
}, []); await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
console.log(sentences);
let lastPlayedAudioPromise = Promise.resolve(); // Initialize a promise that resolves immediately
for (const [idx, sentence] of sentences.entries()) {
const res = await synthesizeOpenAISpeech(
localStorage.token,
$settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice,
sentence
).catch((error) => {
toast.error(error);
assistantSpeaking = false;
return null;
});
if (res) {
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const audio = new Audio(blobUrl);
assistantAudio[idx] = audio;
lastPlayedAudioPromise = lastPlayedAudioPromise.then(() => playAudio(idx));
} }
} else if (finishedMessages[id] && messages[id] && messages[id].length === 0) {
// If the message is finished and there are no more messages to process, break the loop
assistantSpeaking = false;
break;
} else {
// No messages to process, sleep for a bit
await new Promise((resolve) => setTimeout(resolve, 200));
} }
} }
console.log(`Audio monitoring and playing stopped for message ID ${id}`);
}; };
const stopRecordingCallback = async () => { onMount(async () => {
if ($showCallOverlay) { startRecording();
if (confirmed) {
loading = true;
if (cameraStream) { const chatStartHandler = async (e) => {
const imageUrl = takeScreenshot(); const { id } = e.detail;
files = [ chatStreaming = true;
{
type: 'image',
url: imageUrl
}
];
}
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); if (currentMessageId !== id) {
await transcribeHandler(audioBlob); console.log(`Received chat start event for message ID ${id}`);
confirmed = false;
loading = false;
}
audioChunks = [];
mediaRecorder = false;
startRecording(); currentMessageId = id;
} else { if (audioAbortController) {
audioChunks = []; audioAbortController.abort();
mediaRecorder = false; }
} audioAbortController = new AbortController();
};
const startRecording = async () => { assistantSpeaking = true;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Start monitoring and playing audio for the message ID
mediaRecorder = new MediaRecorder(stream); monitorAndPlayAudio(id, audioAbortController.signal);
mediaRecorder.onstart = () => {
console.log('Recording started');
audioChunks = [];
analyseAudio(stream);
};
mediaRecorder.ondataavailable = (event) => {
if (hasStartedSpeaking) {
audioChunks.push(event.data);
} }
}; };
mediaRecorder.onstop = async () => {
console.log('Recording stopped');
await stopRecordingCallback(); const chatEventHandler = async (e) => {
}; const { id, content } = e.detail;
mediaRecorder.start(); // "id" here is message id
}; // if "id" is not the same as "currentMessageId" then do not process
// "content" here is a sentence from the assistant,
let videoInputDevices = []; // there will be many sentences for the same "id"
let selectedVideoInputDeviceId = null;
const getVideoInputDevices = async () => { if (currentMessageId === id) {
const devices = await navigator.mediaDevices.enumerateDevices(); console.log(`Received chat event for message ID ${id}: ${content}`);
videoInputDevices = devices.filter((device) => device.kind === 'videoinput');
if (!!navigator.mediaDevices.getDisplayMedia) { try {
videoInputDevices = [ if (messages[id] === undefined) {
...videoInputDevices, messages[id] = [content];
{ } else {
deviceId: 'screen', messages[id].push(content);
label: 'Screen Share'
}
];
}
console.log(videoInputDevices);
if (selectedVideoInputDeviceId === null && videoInputDevices.length > 0) {
selectedVideoInputDeviceId = videoInputDevices[0].deviceId;
}
};
const startCamera = async () => {
await getVideoInputDevices();
if (cameraStream === null) {
camera = true;
await tick();
try {
await startVideoStream();
} catch (err) {
console.error('Error accessing webcam: ', err);
}
}
};
const startVideoStream = async () => {
const video = document.getElementById('camera-feed');
if (video) {
if (selectedVideoInputDeviceId === 'screen') {
cameraStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always'
},
audio: false
});
} else {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: selectedVideoInputDeviceId ? { exact: selectedVideoInputDeviceId } : undefined
} }
});
}
if (cameraStream) {
await getVideoInputDevices();
video.srcObject = cameraStream;
await video.play();
}
}
};
const stopVideoStream = async () => {
if (cameraStream) {
const tracks = cameraStream.getTracks();
tracks.forEach((track) => track.stop());
}
cameraStream = null;
};
const takeScreenshot = () => { console.log(content);
const video = document.getElementById('camera-feed');
const canvas = document.getElementById('camera-canvas');
if (!canvas) { fetchAudio(content);
return; } catch (error) {
} console.error('Failed to fetch or play audio:', error);
}
}
};
const context = canvas.getContext('2d'); const chatFinishHandler = async (e) => {
const { id, content } = e.detail;
// "content" here is the entire message from the assistant
finishedMessages[id] = true;
// Make the canvas match the video dimensions chatStreaming = false;
canvas.width = video.videoWidth; };
canvas.height = video.videoHeight;
// Draw the image from the video onto the canvas eventTarget.addEventListener('chat:start', chatStartHandler);
context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); eventTarget.addEventListener('chat', chatEventHandler);
eventTarget.addEventListener('chat:finish', chatFinishHandler);
// Convert the canvas to a data base64 URL and console log it return async () => {
const dataURL = canvas.toDataURL('image/png'); eventTarget.removeEventListener('chat:start', chatStartHandler);
console.log(dataURL); eventTarget.removeEventListener('chat', chatEventHandler);
eventTarget.removeEventListener('chat:finish', chatFinishHandler);
return dataURL; audioAbortController.abort();
}; await tick();
const stopCamera = async () => { await stopAllAudio();
await stopVideoStream();
camera = false;
};
$: if ($showCallOverlay) { await stopRecordingCallback(false);
startRecording(); await stopCamera();
} else { };
stopCamera(); });
}
</script> </script>
{#if $showCallOverlay} {#if $showCallOverlay}
<audio id="audioElement" src="" style="display: none;" />
<div class=" absolute w-full h-screen max-h-[100dvh] flex z-[999] overflow-hidden"> <div class=" absolute w-full h-screen max-h-[100dvh] flex z-[999] overflow-hidden">
<div <div
class="absolute w-full h-screen max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center" class="absolute w-full h-screen max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
> >
<div class="max-w-lg w-full h-screen max-h-[100dvh] flex flex-col justify-between p-3 md:p-6"> <div class="max-w-lg w-full h-screen max-h-[100dvh] flex flex-col justify-between p-3 md:p-6">
{#if camera} {#if camera}
<div class="flex justify-center items-center w-full min-h-20"> <button
{#if loading} type="button"
class="flex justify-center items-center w-full h-20 min-h-20"
on:click={() => {
if (assistantSpeaking) {
stopAllAudio();
}
}}
>
{#if emoji}
<div
class=" transition-all rounded-full"
style="font-size:{rmsLevel * 100 > 4
? '4.5'
: rmsLevel * 100 > 2
? '4.25'
: rmsLevel * 100 > 1
? '3.75'
: '3.5'}rem;width: 100%; text-align:center;"
>
{emoji}
</div>
{:else if loading || assistantSpeaking}
<svg <svg
class="size-12 text-gray-900 dark:text-gray-400" class="size-12 text-gray-900 dark:text-gray-400"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -504,59 +661,81 @@ ...@@ -504,59 +661,81 @@
/> />
{/if} {/if}
<!-- navbar --> <!-- navbar -->
</div> </button>
{/if} {/if}
<div class="flex justify-center items-center flex-1 h-full w-full max-h-full"> <div class="flex justify-center items-center flex-1 h-full w-full max-h-full">
{#if !camera} {#if !camera}
{#if loading} <button
<svg type="button"
class="size-44 text-gray-900 dark:text-gray-400" on:click={() => {
viewBox="0 0 24 24" if (assistantSpeaking) {
fill="currentColor" stopAllAudio();
xmlns="http://www.w3.org/2000/svg" }
><style> }}
.spinner_qM83 { >
animation: spinner_8HQG 1.05s infinite; {#if emoji}
} <div
.spinner_oXPr { class=" transition-all rounded-full"
animation-delay: 0.1s; style="font-size:{rmsLevel * 100 > 4
} ? '13'
.spinner_ZTLf { : rmsLevel * 100 > 2
animation-delay: 0.2s; ? '12'
} : rmsLevel * 100 > 1
@keyframes spinner_8HQG { ? '11.5'
0%, : '11'}rem;width:100%;text-align:center;"
57.14% { >
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); {emoji}
transform: translate(0); </div>
{:else if loading || assistantSpeaking}
<svg
class="size-44 text-gray-900 dark:text-gray-400"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_qM83 {
animation: spinner_8HQG 1.05s infinite;
} }
28.57% { .spinner_oXPr {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); animation-delay: 0.1s;
transform: translateY(-6px);
} }
100% { .spinner_ZTLf {
transform: translate(0); animation-delay: 0.2s;
} }
} @keyframes spinner_8HQG {
</style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle 0%,
class="spinner_qM83 spinner_oXPr" 57.14% {
cx="12" animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
cy="12" transform: translate(0);
r="3" }
/><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="3" /></svg 28.57% {
> animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
{:else} transform: translateY(-6px);
<div }
class=" {rmsLevel * 100 > 4 100% {
? ' size-52' transform: translate(0);
: rmsLevel * 100 > 2 }
? 'size-48' }
: rmsLevel * 100 > 1 </style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
? 'size-[11.5rem]' class="spinner_qM83 spinner_oXPr"
: 'size-44'} transition-all bg-black dark:bg-white rounded-full" cx="12"
/> cy="12"
{/if} r="3"
/><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="3" /></svg
>
{:else}
<div
class=" {rmsLevel * 100 > 4
? ' size-52'
: rmsLevel * 100 > 2
? 'size-48'
: rmsLevel * 100 > 1
? 'size-[11.5rem]'
: 'size-44'} transition-all bg-black dark:bg-white rounded-full"
/>
{/if}
</button>
{:else} {:else}
<div <div
class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full" class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full"
...@@ -656,10 +835,19 @@ ...@@ -656,10 +835,19 @@
</div> </div>
<div> <div>
<button type="button"> <button
type="button"
on:click={() => {
if (assistantSpeaking) {
stopAllAudio();
}
}}
>
<div class=" line-clamp-1 text-sm font-medium"> <div class=" line-clamp-1 text-sm font-medium">
{#if loading} {#if loading}
{$i18n.t('Thinking...')} {$i18n.t('Thinking...')}
{:else if assistantSpeaking}
{$i18n.t('Tap to interrupt')}
{:else} {:else}
{$i18n.t('Listening...')} {$i18n.t('Listening...')}
{/if} {/if}
......
...@@ -102,17 +102,19 @@ ...@@ -102,17 +102,19 @@
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0"> <div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="flex w-full px-2"> <div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-xl text-center"> <div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">#</div> <div class=" text-lg font-semibold mt-2">#</div>
</div> </div>
<div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white"> <div
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5"> class="max-h-60 flex flex-col w-full rounded-r-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5 scrollbar-hidden">
{#each filteredItems as doc, docIdx} {#each filteredItems as doc, docIdx}
<button <button
class=" px-3 py-1.5 rounded-xl w-full text-left {docIdx === selectedIdx class=" px-3 py-1.5 rounded-xl w-full text-left {docIdx === selectedIdx
? ' bg-gray-100 selected-command-option-button' ? ' bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button'
: ''}" : ''}"
type="button" type="button"
on:click={() => { on:click={() => {
...@@ -126,17 +128,19 @@ ...@@ -126,17 +128,19 @@
on:focus={() => {}} on:focus={() => {}}
> >
{#if doc.type === 'collection'} {#if doc.type === 'collection'}
<div class=" font-medium text-black line-clamp-1"> <div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{doc?.title ?? `#${doc.name}`} {doc?.title ?? `#${doc.name}`}
</div> </div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Collection')}</div> <div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
{$i18n.t('Collection')}
</div>
{:else} {:else}
<div class=" font-medium text-black line-clamp-1"> <div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
#{doc.name} ({doc.filename}) #{doc.name} ({doc.filename})
</div> </div>
<div class=" text-xs text-gray-600 line-clamp-1"> <div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
{doc.title} {doc.title}
</div> </div>
{/if} {/if}
......
...@@ -134,17 +134,19 @@ ...@@ -134,17 +134,19 @@
{#if prompt.charAt(0) === '@'} {#if prompt.charAt(0) === '@'}
{#if filteredModels.length > 0} {#if filteredModels.length > 0}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0"> <div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="flex w-full px-2"> <div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-xl text-center"> <div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">@</div> <div class=" text-lg font-semibold mt-2">@</div>
</div> </div>
<div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white"> <div
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5"> class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100"
>
<div class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden">
{#each filteredModels as model, modelIdx} {#each filteredModels as model, modelIdx}
<button <button
class=" px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx class=" px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx
? ' bg-gray-100 selected-command-option-button' ? ' bg-gray-50 dark:bg-gray-850 selected-command-option-button'
: ''}" : ''}"
type="button" type="button"
on:click={() => { on:click={() => {
...@@ -155,7 +157,7 @@ ...@@ -155,7 +157,7 @@
}} }}
on:focus={() => {}} on:focus={() => {}}
> >
<div class=" font-medium text-black line-clamp-1"> <div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{model.name} {model.name}
</div> </div>
......
...@@ -89,17 +89,19 @@ ...@@ -89,17 +89,19 @@
{#if filteredPromptCommands.length > 0} {#if filteredPromptCommands.length > 0}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0"> <div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0">
<div class="flex w-full px-2"> <div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-100 dark:bg-gray-700 w-10 rounded-l-xl text-center"> <div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">/</div> <div class=" text-lg font-semibold mt-2">/</div>
</div> </div>
<div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white"> <div
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5"> class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100"
>
<div class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden">
{#each filteredPromptCommands as command, commandIdx} {#each filteredPromptCommands as command, commandIdx}
<button <button
class=" px-3 py-1.5 rounded-xl w-full text-left {commandIdx === selectedCommandIdx class=" px-3 py-1.5 rounded-xl w-full text-left {commandIdx === selectedCommandIdx
? ' bg-gray-100 selected-command-option-button' ? ' bg-gray-50 dark:bg-gray-850 selected-command-option-button'
: ''}" : ''}"
type="button" type="button"
on:click={() => { on:click={() => {
...@@ -110,11 +112,11 @@ ...@@ -110,11 +112,11 @@
}} }}
on:focus={() => {}} on:focus={() => {}}
> >
<div class=" font-medium text-black"> <div class=" font-medium text-black dark:text-gray-100">
{command.command} {command.command}
</div> </div>
<div class=" text-xs text-gray-600"> <div class=" text-xs text-gray-600 dark:text-gray-100">
{command.title} {command.title}
</div> </div>
</button> </button>
...@@ -122,7 +124,7 @@ ...@@ -122,7 +124,7 @@
</div> </div>
<div <div
class=" px-2 pb-1 text-xs text-gray-600 bg-white rounded-br-xl flex items-center space-x-1" class=" px-2 pb-1 text-xs text-gray-600 dark:text-gray-100 bg-white dark:bg-gray-900 rounded-br-xl flex items-center space-x-1"
> >
<div> <div>
<svg <svg
......
...@@ -79,7 +79,7 @@ ...@@ -79,7 +79,7 @@
history.currentId = userMessageId; history.currentId = userMessageId;
await tick(); await tick();
await sendPrompt(userPrompt, userMessageId, undefined, false); await sendPrompt(userPrompt, userMessageId);
}; };
const updateChatMessages = async () => { const updateChatMessages = async () => {
...@@ -202,38 +202,51 @@ ...@@ -202,38 +202,51 @@
}, 100); }, 100);
}; };
const messageDeleteHandler = async (messageId) => { const deleteMessageHandler = async (messageId) => {
const messageToDelete = history.messages[messageId]; const messageToDelete = history.messages[messageId];
const messageParentId = messageToDelete.parentId;
const messageChildrenIds = messageToDelete.childrenIds ?? []; const parentMessageId = messageToDelete.parentId;
const hasSibling = messageChildrenIds.some( const childMessageIds = messageToDelete.childrenIds ?? [];
const hasDescendantMessages = childMessageIds.some(
(childId) => history.messages[childId]?.childrenIds?.length > 0 (childId) => history.messages[childId]?.childrenIds?.length > 0
); );
messageChildrenIds.forEach((childId) => {
const child = history.messages[childId]; history.currentId = parentMessageId;
if (child && child.childrenIds) { await tick();
if (child.childrenIds.length === 0 && !hasSibling) {
// if last prompt/response pair // Remove the message itself from the parent message's children array
history.messages[messageParentId].childrenIds = []; history.messages[parentMessageId].childrenIds = history.messages[
history.currentId = messageParentId; parentMessageId
].childrenIds.filter((id) => id !== messageId);
await tick();
childMessageIds.forEach((childId) => {
const childMessage = history.messages[childId];
if (childMessage && childMessage.childrenIds) {
if (childMessage.childrenIds.length === 0 && !hasDescendantMessages) {
// If there are no other responses/prompts
history.messages[parentMessageId].childrenIds = [];
} else { } else {
child.childrenIds.forEach((grandChildId) => { childMessage.childrenIds.forEach((grandChildId) => {
if (history.messages[grandChildId]) { if (history.messages[grandChildId]) {
history.messages[grandChildId].parentId = messageParentId; history.messages[grandChildId].parentId = parentMessageId;
history.messages[messageParentId].childrenIds.push(grandChildId); history.messages[parentMessageId].childrenIds.push(grandChildId);
} }
}); });
} }
} }
// remove response
history.messages[messageParentId].childrenIds = history.messages[ // Remove child message id from the parent message's children array
messageParentId history.messages[parentMessageId].childrenIds = history.messages[
parentMessageId
].childrenIds.filter((id) => id !== childId); ].childrenIds.filter((id) => id !== childId);
}); });
// remove prompt
history.messages[messageParentId].childrenIds = history.messages[ await tick();
messageParentId
].childrenIds.filter((id) => id !== messageId);
await updateChatById(localStorage.token, chatId, { await updateChatById(localStorage.token, chatId, {
messages: messages, messages: messages,
history: history history: history
...@@ -292,7 +305,7 @@ ...@@ -292,7 +305,7 @@
> >
{#if message.role === 'user'} {#if message.role === 'user'}
<UserMessage <UserMessage
on:delete={() => messageDeleteHandler(message.id)} on:delete={() => deleteMessageHandler(message.id)}
{user} {user}
{readOnly} {readOnly}
{message} {message}
...@@ -308,7 +321,7 @@ ...@@ -308,7 +321,7 @@
copyToClipboard={copyToClipboardWithToast} copyToClipboard={copyToClipboardWithToast}
/> />
{:else if $mobile || (history.messages[message.parentId]?.models?.length ?? 1) === 1} {:else if $mobile || (history.messages[message.parentId]?.models?.length ?? 1) === 1}
{#key message.id} {#key message.id && history.currentId}
<ResponseMessage <ResponseMessage
{message} {message}
siblings={history.messages[message.parentId]?.childrenIds ?? []} siblings={history.messages[message.parentId]?.childrenIds ?? []}
......
<script lang="ts"> <script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import { marked } from 'marked';
import { config, user, models as _models } from '$lib/stores'; import { config, user, models as _models } from '$lib/stores';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { blur, fade } from 'svelte/transition'; import { blur, fade } from 'svelte/transition';
import Suggestions from '../MessageInput/Suggestions.svelte'; import Suggestions from '../MessageInput/Suggestions.svelte';
import { sanitizeResponseContent } from '$lib/utils';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
...@@ -65,8 +68,12 @@ ...@@ -65,8 +68,12 @@
<div in:fade={{ duration: 200, delay: 200 }}> <div in:fade={{ duration: 200, delay: 200 }}>
{#if models[selectedModelIdx]?.info?.meta?.description ?? null} {#if models[selectedModelIdx]?.info?.meta?.description ?? null}
<div class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3"> <div
{models[selectedModelIdx]?.info?.meta?.description} class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3 markdown"
>
{@html marked.parse(
sanitizeResponseContent(models[selectedModelIdx]?.info?.meta?.description)
)}
</div> </div>
{#if models[selectedModelIdx]?.info?.meta?.user} {#if models[selectedModelIdx]?.info?.meta?.user}
<div class="mt-0.5 text-sm font-normal text-gray-400 dark:text-gray-500"> <div class="mt-0.5 text-sm font-normal text-gray-400 dark:text-gray-500">
......
...@@ -940,68 +940,68 @@ ...@@ -940,68 +940,68 @@
> >
</button> </button>
</Tooltip> </Tooltip>
{/if}
{#if isLastMessage && !readOnly} {#if isLastMessage}
<Tooltip content={$i18n.t('Continue Response')} placement="bottom"> <Tooltip content={$i18n.t('Continue Response')} placement="bottom">
<button <button
type="button" type="button"
class="{isLastMessage class="{isLastMessage
? 'visible' ? '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" : '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={() => { on:click={() => {
continueGeneration(); continueGeneration();
}} }}
>
<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 <svg
stroke-linecap="round" xmlns="http://www.w3.org/2000/svg"
stroke-linejoin="round" fill="none"
d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" viewBox="0 0 24 24"
/> stroke-width="2.3"
<path stroke="currentColor"
stroke-linecap="round" class="w-4 h-4"
stroke-linejoin="round" >
d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" <path
/> stroke-linecap="round"
</svg> stroke-linejoin="round"
</button> d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
</Tooltip> />
<path
<Tooltip content={$i18n.t('Regenerate')} placement="bottom"> stroke-linecap="round"
<button stroke-linejoin="round"
type="button" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"
class="{isLastMessage />
? 'visible' </svg>
: '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" </button>
on:click={() => { </Tooltip>
showRateComment = false;
regenerateResponse(message); <Tooltip content={$i18n.t('Regenerate')} placement="bottom">
}} <button
> type="button"
<svg class="{isLastMessage
xmlns="http://www.w3.org/2000/svg" ? 'visible'
fill="none" : '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"
viewBox="0 0 24 24" on:click={() => {
stroke-width="2.3" showRateComment = false;
stroke="currentColor" regenerateResponse(message);
class="w-4 h-4" }}
> >
<path <svg
stroke-linecap="round" xmlns="http://www.w3.org/2000/svg"
stroke-linejoin="round" fill="none"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" viewBox="0 0 24 24"
/> stroke-width="2.3"
</svg> stroke="currentColor"
</button> class="w-4 h-4"
</Tooltip> >
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{/if}
{/if} {/if}
{/if} {/if}
</div> </div>
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment