Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
chenpangpang
open-webui
Commits
9e4dd4b8
Unverified
Commit
9e4dd4b8
authored
Jun 16, 2024
by
Timothy Jaeryang Baek
Committed by
GitHub
Jun 16, 2024
Browse files
Merge pull request #3159 from open-webui/dev
0.3.5
parents
162643a4
4559c8af
Changes
92
Hide whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
1230 additions
and
773 deletions
+1230
-773
package-lock.json
package-lock.json
+2
-2
package.json
package.json
+1
-1
src/app.css
src/app.css
+4
-0
src/lib/apis/index.ts
src/lib/apis/index.ts
+48
-0
src/lib/apis/memories/index.ts
src/lib/apis/memories/index.ts
+31
-0
src/lib/apis/users/index.ts
src/lib/apis/users/index.ts
+70
-0
src/lib/components/admin/Settings/Connections.svelte
src/lib/components/admin/Settings/Connections.svelte
+12
-1
src/lib/components/admin/Settings/Documents.svelte
src/lib/components/admin/Settings/Documents.svelte
+78
-191
src/lib/components/admin/Settings/Models.svelte
src/lib/components/admin/Settings/Models.svelte
+17
-7
src/lib/components/admin/Settings/WebSearch.svelte
src/lib/components/admin/Settings/WebSearch.svelte
+20
-1
src/lib/components/admin/UserChatsModal.svelte
src/lib/components/admin/UserChatsModal.svelte
+59
-5
src/lib/components/chat/Chat.svelte
src/lib/components/chat/Chat.svelte
+217
-109
src/lib/components/chat/MessageInput.svelte
src/lib/components/chat/MessageInput.svelte
+3
-4
src/lib/components/chat/MessageInput/CallOverlay.svelte
src/lib/components/chat/MessageInput/CallOverlay.svelte
+532
-344
src/lib/components/chat/MessageInput/Documents.svelte
src/lib/components/chat/MessageInput/Documents.svelte
+13
-9
src/lib/components/chat/MessageInput/Models.svelte
src/lib/components/chat/MessageInput/Models.svelte
+8
-6
src/lib/components/chat/MessageInput/PromptCommands.svelte
src/lib/components/chat/MessageInput/PromptCommands.svelte
+10
-8
src/lib/components/chat/Messages.svelte
src/lib/components/chat/Messages.svelte
+37
-24
src/lib/components/chat/Messages/Placeholder.svelte
src/lib/components/chat/Messages/Placeholder.svelte
+9
-2
src/lib/components/chat/Messages/ResponseMessage.svelte
src/lib/components/chat/Messages/ResponseMessage.svelte
+59
-59
No files found.
package-lock.json
View file @
9e4dd4b8
{
"name"
:
"open-webui"
,
"version"
:
"0.3.
4
"
,
"version"
:
"0.3.
5
"
,
"lockfileVersion"
:
3
,
"requires"
:
true
,
"packages"
:
{
""
:
{
"name"
:
"open-webui"
,
"version"
:
"0.3.
4
"
,
"version"
:
"0.3.
5
"
,
"dependencies"
:
{
"@codemirror/lang-javascript"
:
"^6.2.2"
,
"@codemirror/lang-python"
:
"^6.1.6"
,
...
...
package.json
View file @
9e4dd4b8
{
"name"
:
"open-webui"
,
"version"
:
"0.3.
4
"
,
"version"
:
"0.3.
5
"
,
"private"
:
true
,
"scripts"
:
{
"dev"
:
"npm run pyodide:fetch && vite dev --host"
,
...
...
src/app.css
View file @
9e4dd4b8
...
...
@@ -28,6 +28,10 @@ math {
@apply
rounded-lg;
}
.markdown
a
{
@apply
underline;
}
ol
>
li
{
counter-increment
:
list-number
;
display
:
block
;
...
...
src/lib/apis/index.ts
View file @
9e4dd4b8
...
...
@@ -205,6 +205,54 @@ export const generateTitle = async (
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
(
token
:
string
=
''
,
model
:
string
,
...
...
src/lib/apis/memories/index.ts
View file @
9e4dd4b8
...
...
@@ -59,6 +59,37 @@ export const addNewMemory = async (token: string, content: string) => {
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
)
=>
{
let
error
=
null
;
...
...
src/lib/apis/users/index.ts
View file @
9e4dd4b8
import
{
WEBUI_API_BASE_URL
}
from
'
$lib/constants
'
;
import
{
getUserPosition
}
from
'
$lib/utils
'
;
export
const
getUserPermissions
=
async
(
token
:
string
)
=>
{
let
error
=
null
;
...
...
@@ -198,6 +199,75 @@ export const getUserById = async (token: string, userId: string) => {
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
)
=>
{
let
error
=
null
;
...
...
src/lib/components/admin/Settings/Connections.svelte
View file @
9e4dd4b8
...
...
@@ -44,6 +44,8 @@
let ENABLE_OLLAMA_API = null;
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_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS);
...
...
@@ -63,6 +65,10 @@
};
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);
const res = await getOllamaVersion(localStorage.token, idx).catch((error) => {
...
...
@@ -78,6 +84,8 @@
};
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
if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) {
// if there are more keys than urls, remove the extra keys
...
...
@@ -100,7 +108,10 @@
};
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);
if (OLLAMA_BASE_URLS.length === 0) {
...
...
src/lib/components/admin/Settings/Documents.svelte
View file @
9e4dd4b8
...
...
@@ -13,6 +13,8 @@
getRAGConfig,
updateRAGConfig
} 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 { onMount, getContext } from 'svelte';
...
...
@@ -213,6 +215,34 @@
});
</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
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => {
...
...
@@ -640,199 +670,56 @@
<hr class=" dark:border-gray-850" />
<div>
{#if showResetUploadDirConfirm}
<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 24 24"
fill="currentColor"
class="size-4"
>
<path
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"
clip-rule="evenodd"
/>
<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"
/>
</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 = 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>
<button
class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
showResetUploadDirConfirm = true;
}}
type="button"
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
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"
clip-rule="evenodd"
/>
<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"
/>
</svg>
</div>
{:else}
<button
class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
showResetUploadDirConfirm = true;
}}
type="button"
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
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"
clip-rule="evenodd"
/>
<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"
/>
</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 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={() => {
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>
{:else}
<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 class=" self-center text-sm font-medium">{$i18n.t('Reset Vector Storage')}</div>
</button>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
...
...
src/lib/components/admin/Settings/Models.svelte
View file @
9e4dd4b8
<script lang="ts">
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 {
createModel,
...
...
@@ -11,15 +16,11 @@
uploadModel,
getOllamaConfig
} from '$lib/apis/ollama';
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 { getModels as _getModels } from '$lib/apis';
import Tooltip from '$lib/components/common/Tooltip.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');
...
...
@@ -29,6 +30,8 @@
let modelUploadInputElement: HTMLInputElement;
let showModelDeleteConfirm = false;
// Models
let ollamaEnabled = null;
...
...
@@ -549,6 +552,13 @@
});
</script>
<ModelDeleteConfirmDialog
bind:show={showModelDeleteConfirm}
on:confirm={() => {
deleteModelHandler();
}}
/>
<div class="flex flex-col h-full justify-between text-sm">
<div class=" space-y-3 overflow-y-scroll scrollbar-hidden h-full">
{#if ollamaEnabled}
...
...
@@ -763,7 +773,7 @@
<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"
on:click={() => {
deleteModelHandler()
;
showModelDeleteConfirm = true
;
}}
>
<svg
...
...
src/lib/components/admin/Settings/WebSearch.svelte
View file @
9e4dd4b8
...
...
@@ -18,7 +18,8 @@
'serpstack',
'serper',
'serply',
'duckduckgo'
'duckduckgo',
'tavily'
];
let youtubeLanguage = 'en';
...
...
@@ -214,6 +215,24 @@
</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}
</div>
{/if}
...
...
src/lib/components/admin/UserChatsModal.svelte
View file @
9e4dd4b8
...
...
@@ -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>
<Modal size="lg" bind:show>
...
...
@@ -69,18 +80,56 @@
class="text-xs text-gray-700 uppercase bg-transparent dark:text-gray-200 border-b-2 dark:border-gray-800"
>
<tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2 hidden md:flex"> {$i18n.t('Created at')} </th>
<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" />
</tr>
</thead>
<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
class="bg-transparent {idx !== chats.length - 1 &&
'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">
<div class=" underline line-clamp-1">
{chat.title}
...
...
@@ -88,11 +137,16 @@
</a>
</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">
{dayjs(chat.created_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
</div>
</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">
<div class="flex justify-end w-full">
...
...
src/lib/components/chat/Chat.svelte
View file @
9e4dd4b8
...
...
@@ -30,6 +30,8 @@
import {
convertMessagesToHistory,
copyToClipboard,
extractSentencesForAudio,
getUserPosition,
promptTemplate,
splitStream
} from '$lib/utils';
...
...
@@ -49,7 +51,7 @@
import { runWebSearch } from '$lib/apis/rag';
import { createOpenAITextStream } from '$lib/apis/streaming';
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 Banner from '../common/Banner.svelte';
...
...
@@ -64,6 +66,8 @@
export let chatIdProp = '';
let loaded = false;
const eventTarget = new EventTarget();
let stopResponseFlag = false;
let autoScroll = true;
let processing = '';
...
...
@@ -108,7 +112,8 @@
$: if (chatIdProp) {
(async () => {
if (await loadChat()) {
console.log(chatIdProp);
if (chatIdProp && (await loadChat())) {
await tick();
loaded = true;
...
...
@@ -123,7 +128,11 @@
onMount(async () => {
if (!$chatId) {
await initNewChat();
chatId.subscribe(async (value) => {
if (!value) {
await initNewChat();
}
});
} else {
if (!($settings.saveChatHistory ?? true)) {
await goto('/');
...
...
@@ -300,7 +309,7 @@
// Chat functions
//////////////////////////
const submitPrompt = async (userPrompt,
_user = null
) => {
const submitPrompt = async (userPrompt,
{ _raw = false } = {}
) => {
let _responses = [];
console.log('submitPrompt', $chatId);
...
...
@@ -344,7 +353,6 @@
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
role: 'user',
user: _user ?? undefined,
content: userPrompt,
files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
...
...
@@ -362,15 +370,13 @@
// Wait until history/message have been updated
await tick();
// Send prompt
_responses = await sendPrompt(userPrompt, userMessageId);
_responses = await sendPrompt(userPrompt, userMessageId, { newChat: true });
}
return _responses;
};
const sendPrompt = async (prompt, parentId, modelId = null, newChat =
true
) => {
const sendPrompt = async (prompt, parentId,
{
modelId = null, newChat =
false } = {}
) => {
let _responses = [];
// If modelId is provided, use it, else use selected model
...
...
@@ -490,7 +496,6 @@
responseMessage.userContext = userContext;
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
if (webSearchEnabled) {
await getWebSearchResults(model.id, parentId, responseMessageId);
}
...
...
@@ -503,8 +508,6 @@
}
_responses.push(_response);
console.log('chatEventEmitter', chatEventEmitter);
if (chatEventEmitter) clearInterval(chatEventEmitter);
} else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
...
...
@@ -513,88 +516,9 @@
);
await chats.set(await getChatList(localStorage.token));
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) => {
let _response = null;
...
...
@@ -610,7 +534,13 @@
$settings.system || (responseMessage?.userContext ?? null)
? {
role: 'system',
content: `${promptTemplate($settings?.system ?? '', $user.name)}${
content: `${promptTemplate(
$settings?.system ?? '',
$user.name,
$settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token)
: undefined
)}${
responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: ''
...
...
@@ -676,6 +606,16 @@
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, {
model: model.id,
messages: messagesBody,
...
...
@@ -745,6 +685,23 @@
continue;
} else {
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;
}
} else {
...
...
@@ -771,21 +728,13 @@
messages = messages;
if ($settings.notificationEnabled && !document.hasFocus()) {
const notification = new Notification(
selectedModelfile
? `${
selectedModelfile.title.charAt(0).toUpperCase() +
selectedModelfile.title.slice(1)
}`
: `${model.id}`,
{
body: responseMessage.content,
icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
}
);
const notification = new Notification(`${model.id}`, {
body: responseMessage.content,
icon: `${WEBUI_BASE_URL}/static/favicon.png`
});
}
if ($settings.responseAutoCopy) {
if ($settings
?
.responseAutoCopy
?? false
) {
copyToClipboard(responseMessage.content);
}
...
...
@@ -847,6 +796,23 @@
stopResponseFlag = false;
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) {
scrollToBottom();
}
...
...
@@ -887,6 +853,15 @@
scrollToBottom();
eventTarget.dispatchEvent(
new CustomEvent('chat:start', {
detail: {
id: responseMessageId
}
})
);
await tick();
try {
const [res, controller] = await generateOpenAIChatCompletion(
localStorage.token,
...
...
@@ -903,7 +878,13 @@
$settings.system || (responseMessage?.userContext ?? null)
? {
role: 'system',
content: `${promptTemplate($settings?.system ?? '', $user.name)}${
content: `${promptTemplate(
$settings?.system ?? '',
$user.name,
$settings?.userLocation
? await getAndUpdateUserLocation(localStorage.token)
: undefined
)}${
responseMessage?.userContext ?? null
? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
: ''
...
...
@@ -1007,6 +988,23 @@
continue;
} else {
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;
}
...
...
@@ -1057,6 +1055,24 @@
stopResponseFlag = false;
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) {
scrollToBottom();
}
...
...
@@ -1123,9 +1139,12 @@
let userPrompt = userMessage.content;
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 {
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 @@
}
};
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 () => {
return await getTagsById(localStorage.token, $chatId).catch(async (error) => {
return [];
...
...
@@ -1206,7 +1303,18 @@
</title>
</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)}
<div
...
...
src/lib/components/chat/MessageInput.svelte
View file @
9e4dd4b8
...
...
@@ -348,7 +348,6 @@
<Models
bind:this={modelsElement}
bind:prompt
bind:user
bind:chatInputPlaceholder
{messages}
on:select={(e) => {
...
...
@@ -467,7 +466,7 @@
document.getElementById('chat-textarea')?.focus();
if ($settings?.speechAutoSend ?? false) {
submitPrompt(prompt
, user
);
submitPrompt(prompt);
}
}}
/>
...
...
@@ -476,7 +475,7 @@
class="w-full flex gap-1.5"
on:submit|preventDefault={() => {
// check if selectedModels support image input
submitPrompt(prompt
, user
);
submitPrompt(prompt);
}}
>
<div
...
...
@@ -718,7 +717,7 @@
// Submit the prompt when Enter key is pressed
if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
submitPrompt(prompt
, user
);
submitPrompt(prompt);
}
}
}}
...
...
src/lib/components/chat/MessageInput/CallOverlay.svelte
View file @
9e4dd4b8
...
...
@@ -2,40 +2,239 @@
import { config, settings, showCallOverlay } from '$lib/stores';
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 { toast } from 'svelte-sonner';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte';
import { get } from 'svelte/store';
const i18n = getContext('i18n');
export let eventTarget: EventTarget;
export let submitPrompt: Function;
export let stopResponse: Function;
export let files;
export let chatId;
export let modelId;
let loading = false;
let confirmed = false;
let interrupted = false;
let assistantSpeaking = false;
let emoji = null;
let camera = false;
let cameraStream = null;
let assistantSpeaking = false;
let assistantAudio = {};
let assistantAudioIdx = null;
let chatStreaming = false;
let rmsLevel = 0;
let hasStartedSpeaking = false;
let currentUtterance = null;
let mediaRecorder;
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 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
const calculateRMS = (data: Uint8Array) => {
let sumSquares = 0;
...
...
@@ -46,15 +245,6 @@
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 audioContext = new AudioContext();
const audioStreamSource = audioContext.createMediaStreamSource(stream);
...
...
@@ -71,15 +261,23 @@
let lastSoundTime = Date.now();
hasStartedSpeaking = false;
console.log('🔊 Sound detection started', lastSoundTime, hasStartedSpeaking);
const detectSound = () => {
const processFrame = () => {
if (!mediaRecorder || !$showCallOverlay) {
if (mediaRecorder) {
mediaRecorder.stop();
}
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.getByteFrequencyData(domainData);
...
...
@@ -89,8 +287,14 @@
// Check if initial speech/noise has started
const hasSound = domainData.some((value) => value > 0);
if (hasSound) {
stopAllAudio();
hasStartedSpeaking = true;
// BIG RED TEXT
console.log('%c%s', 'color: red; font-size: 20px;', '🔊 Sound detected');
if (!hasStartedSpeaking) {
hasStartedSpeaking = true;
stopAllAudio();
}
lastSoundTime = Date.now();
}
...
...
@@ -100,7 +304,9 @@
confirmed = true;
if (mediaRecorder) {
console.log('%c%s', 'color: red; font-size: 20px;', '🔇 Silence detected');
mediaRecorder.stop();
return;
}
}
}
...
...
@@ -114,348 +320,299 @@
detectSound();
};
const stopAllAudio = () => {
if (currentUtterance) {
speechSynthesis.cancel();
currentUtterance = null;
}
if (assistantAudio[assistantAudioIdx]) {
assistantAudio[assistantAudioIdx].pause();
assistantAudio[assistantAudioIdx].currentTime = 0;
}
let finishedMessages = {};
let currentMessageId = null;
let currentUtterance = null;
const audioElement = document.getElementById('audioElement');
audioElement.pause();
audioElement.currentTime = 0;
const speakSpeechSynthesisHandler = (content) => {
if ($showCallOverlay) {
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) {
return new Promise((res) => {
assistantAudioIdx = idx;
return new Promise((resolve) => {
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) => {
await new Promise((r) => setTimeout(r, 300));
if (Object.keys(assistantAudio).length - 1 === idx) {
assistantSpeaking = false;
}
res(e);
};
if (audioElement) {
audioElement.src = audio.src;
audioElement.muted = true;
audioElement
.play()
.then(() => {
audioElement.muted = false;
})
.catch((error) => {
console.error(error);
});
audioElement.onended = async (e) => {
await new Promise((r) => setTimeout(r, 100));
resolve(e);
};
}
});
} else {
return Promise.resolve();
}
};
const getOpenAISpeech = async (text) => {
const res = await synthesizeOpenAISpeech(
localStorage.token,
$settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice,
text
).catch((error) => {
toast.error(error);
assistantSpeaking = false;
return null;
});
const stopAllAudio = async () => {
assistantSpeaking = false;
interrupted = true;
if (res) {
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const audio = new Audio(blobUrl);
assistantAudio = audio;
if (chatStreaming) {
stopResponse();
}
};
const transcribeHandler = async (audioBlob) => {
// Create a blob from the audio chunks
if (currentUtterance) {
speechSynthesis.cancel();
currentUtterance = null;
}
await tick();
const file = blobToFile(audioBlob, 'recording.wav');
const audioElement = document.getElementById('audioElement');
if (audioElement) {
audioElement.muted = true;
audioElement.pause();
audioElement.currentTime = 0;
}
};
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error);
return null;
});
let audioAbortController = new AbortController();
if (res) {
console.log(res.text);
// Audio cache map where key is the content and value is the Audio object.
const audioCache = new Map();
const emojiCache = new Map();
if (res.text !== '') {
const _responses = await submitPrompt(res.text);
console.log(_responses);
const fetchAudio = async (content) => {
if (!audioCache.has(content)) {
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)) {
const content = _responses[0];
if ((content ?? '').trim() !== '') {
assistantSpeakingHandler(content);
if ($config.audio.tts.engine !== '') {
const res = await synthesizeOpenAISpeech(
localStorage.token,
$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)
=> {
assistantSpeaking = true
;
return audioCache.get
(content)
;
}
;
if (($config.audio.tts.engine ?? '') == '') {
let voices = [];
const getVoicesLoop = setInterval(async () => {
voices = await speechSynthesis.getVoices();
if (voices.length > 0) {
clearInterval(getVoicesLoop);
let messages = {};
const voice =
voices
?.filter(
(v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
)
?.at(0) ?? undefined;
const monitorAndPlayAudio = async (id, signal) => {
while (!signal.aborted) {
if (messages[id] && messages[id].length > 0) {
// Retrieve the next content string from the queue
const content = messages[id].shift(); // Dequeues the content for playing
currentUtterance = new SpeechSynthesisUtterance(content);
if (audioCache.has(content)) {
// If content is available in the cache, play it
if (voice) {
currentUtterance.voice = voice;
// Set the emoji for the content if available
if (($settings?.showEmojiInCall ?? false) && emojiCache.has(content)) {
emoji = emojiCache.get(content);
} else {
emoji = null;
}
speechSynthesis.speak(currentUtterance);
}
}, 100);
} else if ($config.audio.tts.engine === 'openai') {
console.log('openai');
const sentences = extractSentences(content).reduce((mergedTexts, currentText) => {
const lastIndex = mergedTexts.length - 1;
if (lastIndex >= 0) {
const previousText = mergedTexts[lastIndex];
const wordCount = previousText.split(/\s+/).length;
if (wordCount < 2) {
mergedTexts[lastIndex] = previousText + ' ' + currentText;
if ($config.audio.tts.engine !== '') {
try {
console.log(
'%c%s',
'color: red; font-size: 20px;',
`Playing audio for content: ${content}`
);
const audio = audioCache.get(content);
await playAudio(audio); // Here ensure that playAudio is indeed correct method to execute
console.log(`Played audio for content: ${content}`);
await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
} catch (error) {
console.error('Error playing audio:', error);
}
} else {
mergedTexts.push(currentTex
t);
await speakSpeechSynthesisHandler(conten
t);
}
} else {
mergedTexts.push(currentText);
}
return mergedTexts;
}, []);
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));
// 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
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
}
} 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 () => {
if ($showCallOverlay) {
if (confirmed) {
loading = true;
onMount(async () => {
startRecording();
if (cameraStream)
{
const
imageUrl = takeScreenshot()
;
const chatStartHandler = async (e) =>
{
const
{ id } = e.detail
;
files = [
{
type: 'image',
url: imageUrl
}
];
}
chatStreaming = true;
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
await transcribeHandler(audioBlob);
confirmed = false;
loading = false;
}
audioChunks = [];
mediaRecorder = false;
if (currentMessageId !== id) {
console.log(`Received chat start event for message ID ${id}`);
startRecording();
} else {
audioChunks = [];
mediaRecorder = false;
}
};
currentMessageId = id;
if (audioAbortController) {
audioAbortController.abort();
}
audioAbortController = new AbortController();
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);
assistantSpeaking = true;
// Start monitoring and playing audio for the message ID
monitorAndPlayAudio(id, audioAbortController.signal);
}
};
mediaRecorder.onstop = async () => {
console.log('Recording stopped');
await stopRecordingCallback();
};
mediaRecorder.start();
};
let videoInputDevices = [];
let selectedVideoInputDeviceId = null;
const chatEventHandler = async (e) => {
const { id, content } = e.detail;
// "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,
// there will be many sentences for the same "id"
const getVideoInputDevices = async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
videoInputDevices = devices.filter((device) => device.kind === 'videoinput');
if (currentMessageId === id) {
console.log(`Received chat event for message ID ${id}: ${content}`);
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
try {
if (messages[id] === undefined) {
messages[id] = [content];
} else {
messages[id].push(content);
}
});
}
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');
console.log(content);
if (!canvas) {
return;
}
fetchAudio(content);
} 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
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
chatStreaming = false;
};
// Draw the image from the video onto the canvas
context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
eventTarget.addEventListener('chat:start', chatStartHandler);
eventTarget.addEventListener('chat', chatEventHandler);
eventTarget.addEventListener('chat:finish', chatFinishHandler);
// Convert the canvas to a data base64 URL and console log it
const dataURL = canvas.toDataURL('image/png');
console.log(dataURL);
return async () => {
eventTarget.removeEventListener('chat:start', chatStartHandler);
eventTarget.removeEventListener('chat', chatEventHandler);
eventTarget.removeEventListener('chat:finish', chatFinishHandler);
return dataURL
;
}
;
audioAbortController.abort()
;
await tick()
;
const stopCamera = async () => {
await stopVideoStream();
camera = false;
};
await stopAllAudio();
$: if ($showCallOverlay) {
startRecording();
} else {
stopCamera();
}
await stopRecordingCallback(false);
await stopCamera();
};
});
</script>
{#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] 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">
{#if camera}
<div class="flex justify-center items-center w-full min-h-20">
{#if loading}
<button
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
class="size-12 text-gray-900 dark:text-gray-400"
viewBox="0 0 24 24"
...
...
@@ -504,59 +661,81 @@
/>
{/if}
<!-- navbar -->
</
div
>
</
button
>
{/if}
<div class="flex justify-center items-center flex-1 h-full w-full max-h-full">
{#if !camera}
{#if loading}
<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;
}
.spinner_oXPr {
animation-delay: 0.1s;
}
.spinner_ZTLf {
animation-delay: 0.2s;
}
@keyframes spinner_8HQG {
0%,
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
<button
type="button"
on:click={() => {
if (assistantSpeaking) {
stopAllAudio();
}
}}
>
{#if emoji}
<div
class=" transition-all rounded-full"
style="font-size:{rmsLevel * 100 > 4
? '13'
: rmsLevel * 100 > 2
? '12'
: rmsLevel * 100 > 1
? '11.5'
: '11'}rem;width:100%;text-align:center;"
>
{emoji}
</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% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
.spinner_oXPr {
animation-delay: 0.1s;
}
100%
{
transform: translate(0)
;
.spinner_ZTLf
{
animation-delay: 0.2s
;
}
}
</style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
class="spinner_qM83 spinner_oXPr"
cx="12"
cy="12"
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}
@keyframes spinner_8HQG {
0%,
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
}
28.57% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
}
100% {
transform: translate(0);
}
}
</style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
class="spinner_qM83 spinner_oXPr"
cx="12"
cy="12"
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}
<div
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 @@
</div>
<div>
<button type="button">
<button
type="button"
on:click={() => {
if (assistantSpeaking) {
stopAllAudio();
}
}}
>
<div class=" line-clamp-1 text-sm font-medium">
{#if loading}
{$i18n.t('Thinking...')}
{:else if assistantSpeaking}
{$i18n.t('Tap to interrupt')}
{:else}
{$i18n.t('Listening...')}
{/if}
...
...
src/lib/components/chat/MessageInput/Documents.svelte
View file @
9e4dd4b8
...
...
@@ -102,17 +102,19 @@
{#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="flex w-full
px-2
">
<div class=" bg-gray-
10
0 dark:bg-gray-
70
0 w-10 rounded-l-
x
l text-center">
<div class="flex w-full
dark:border dark:border-gray-850 rounded-lg
">
<div class=" bg-gray-
5
0 dark:bg-gray-
85
0 w-10 rounded-l-l
g
text-center">
<div class=" text-lg font-semibold mt-2">#</div>
</div>
<div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white">
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5">
<div
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}
<button
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"
on:click={() => {
...
...
@@ -126,17 +128,19 @@
on:focus={() => {}}
>
{#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}`}
</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}
<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})
</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}
</div>
{/if}
...
...
src/lib/components/chat/MessageInput/Models.svelte
View file @
9e4dd4b8
...
...
@@ -134,17 +134,19 @@
{#if prompt.charAt(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="flex w-full
px-2
">
<div class=" bg-gray-
10
0 dark:bg-gray-
70
0 w-10 rounded-l-
x
l text-center">
<div class="flex w-full
dark:border dark:border-gray-850 rounded-lg
">
<div class=" bg-gray-
5
0 dark:bg-gray-
85
0 w-10 rounded-l-l
g
text-center">
<div class=" text-lg font-semibold mt-2">@</div>
</div>
<div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white">
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5">
<div
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}
<button
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"
on:click={() => {
...
...
@@ -155,7 +157,7 @@
}}
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}
</div>
...
...
src/lib/components/chat/MessageInput/PromptCommands.svelte
View file @
9e4dd4b8
...
...
@@ -89,17 +89,19 @@
{#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="flex w-full
px-2
">
<div class=" bg-gray-
10
0 dark:bg-gray-
70
0 w-10 rounded-l-
x
l text-center">
<div class="flex w-full
dark:border dark:border-gray-850 rounded-lg
">
<div class="
bg-gray-
5
0 dark:bg-gray-
85
0 w-10 rounded-l-l
g
text-center">
<div class=" text-lg font-semibold mt-2">/</div>
</div>
<div class="max-h-60 flex flex-col w-full rounded-r-xl bg-white">
<div class="m-1 overflow-y-auto p-1 rounded-r-xl space-y-0.5">
<div
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}
<button
class=" px-3 py-1.5 rounded-xl w-full text-left {commandIdx === selectedCommandIdx
? ' bg-gray-
10
0 selected-command-option-button'
? '
bg-gray-
50 dark:bg-gray-85
0 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
...
...
@@ -110,11 +112,11 @@
}}
on:focus={() => {}}
>
<div class=" font-medium text-black">
<div class=" font-medium text-black
dark:text-gray-100
">
{command.command}
</div>
<div class=" text-xs text-gray-600">
<div class=" text-xs text-gray-600
dark:text-gray-100
">
{command.title}
</div>
</button>
...
...
@@ -122,7 +124,7 @@
</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>
<svg
...
...
src/lib/components/chat/Messages.svelte
View file @
9e4dd4b8
...
...
@@ -79,7 +79,7 @@
history.currentId = userMessageId;
await tick();
await sendPrompt(userPrompt, userMessageId
, undefined, false
);
await sendPrompt(userPrompt, userMessageId);
};
const updateChatMessages = async () => {
...
...
@@ -202,38 +202,51 @@
}, 100);
};
const
messageDelet
eHandler = async (messageId) => {
const
deleteMessag
eHandler = async (messageId) => {
const messageToDelete = history.messages[messageId];
const messageParentId = messageToDelete.parentId;
const messageChildrenIds = messageToDelete.childrenIds ?? [];
const hasSibling = messageChildrenIds.some(
const parentMessageId = messageToDelete.parentId;
const childMessageIds = messageToDelete.childrenIds ?? [];
const hasDescendantMessages = childMessageIds.some(
(childId) => history.messages[childId]?.childrenIds?.length > 0
);
messageChildrenIds.forEach((childId) => {
const child = history.messages[childId];
if (child && child.childrenIds) {
if (child.childrenIds.length === 0 && !hasSibling) {
// if last prompt/response pair
history.messages[messageParentId].childrenIds = [];
history.currentId = messageParentId;
history.currentId = parentMessageId;
await tick();
// Remove the message itself from the parent message's children array
history.messages[parentMessageId].childrenIds = history.messages[
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 {
child.childrenIds.forEach((grandChildId) => {
child
Message
.childrenIds.forEach((grandChildId) => {
if (history.messages[grandChildId]) {
history.messages[grandChildId].parentId =
messageParent
Id;
history.messages[
messageParent
Id].childrenIds.push(grandChildId);
history.messages[grandChildId].parentId =
parentMessage
Id;
history.messages[
parentMessage
Id].childrenIds.push(grandChildId);
}
});
}
}
// remove response
history.messages[messageParentId].childrenIds = history.messages[
messageParentId
// Remove child message id from the parent message's children array
history.messages[parentMessageId].childrenIds = history.messages[
parentMessageId
].childrenIds.filter((id) => id !== childId);
});
// remove prompt
history.messages[messageParentId].childrenIds = history.messages[
messageParentId
].childrenIds.filter((id) => id !== messageId);
await tick();
await updateChatById(localStorage.token, chatId, {
messages: messages,
history: history
...
...
@@ -292,7 +305,7 @@
>
{#if message.role === 'user'}
<UserMessage
on:delete={() =>
messageDelet
eHandler(message.id)}
on:delete={() =>
deleteMessag
eHandler(message.id)}
{user}
{readOnly}
{message}
...
...
@@ -308,7 +321,7 @@
copyToClipboard={copyToClipboardWithToast}
/>
{:else if $mobile || (history.messages[message.parentId]?.models?.length ?? 1) === 1}
{#key message.id}
{#key message.id
&& history.currentId
}
<ResponseMessage
{message}
siblings={history.messages[message.parentId]?.childrenIds ?? []}
...
...
src/lib/components/chat/Messages/Placeholder.svelte
View file @
9e4dd4b8
<script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants';
import { marked } from 'marked';
import { config, user, models as _models } from '$lib/stores';
import { onMount, getContext } from 'svelte';
import { blur, fade } from 'svelte/transition';
import Suggestions from '../MessageInput/Suggestions.svelte';
import { sanitizeResponseContent } from '$lib/utils';
const i18n = getContext('i18n');
...
...
@@ -65,8 +68,12 @@
<div in:fade={{ duration: 200, delay: 200 }}>
{#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">
{models[selectedModelIdx]?.info?.meta?.description}
<div
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>
{#if models[selectedModelIdx]?.info?.meta?.user}
<div class="mt-0.5 text-sm font-normal text-gray-400 dark:text-gray-500">
...
...
src/lib/components/chat/Messages/ResponseMessage.svelte
View file @
9e4dd4b8
...
...
@@ -940,68 +940,68 @@
>
</button>
</Tooltip>
{/if}
{#if isLastMessage && !readOnly}
<Tooltip content={$i18n.t('Continue Response')} placement="bottom">
<button
type="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={() => {
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"
{#if isLastMessage}
<Tooltip content={$i18n.t('Continue Response')} placement="bottom">
<button
type="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={() => {
continueGeneration();
}}
>
<
path
stroke-linecap="round
"
stroke-linejoin="round
"
d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z
"
/>
<path
stroke-linecap="round
"
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"
/>
</svg>
</button>
</Tooltip
>
<Tooltip content={$i18n.t('Regenerate')} placement="bottom">
<button
type="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={() => {
showRateComment = false;
regenerateResponse(message);
}}
>
<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"
<
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="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/
>
<path
stroke-linecap="round"
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
"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Regenerate')} placement="bottom">
<button
type="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={() => {
showRateComment = false;
regenerateResponse(message);
}}
>
<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>
<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.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}
</div>
...
...
Prev
1
2
3
4
5
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment