refac/enh: commands ui
This commit is contained in:
@@ -2259,7 +2259,6 @@
|
||||
bind:selectedModels
|
||||
shareEnabled={!!history.currentId}
|
||||
{initNewChat}
|
||||
showBanners={!showCommands}
|
||||
archiveChatHandler={() => {}}
|
||||
{moveChatHandler}
|
||||
onSaveTempChat={async () => {
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
|
||||
import { KokoroWorker } from '$lib/workers/KokoroWorker';
|
||||
|
||||
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
|
||||
import MentionList from '../common/RichTextInput/MentionList.svelte';
|
||||
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let onChange: Function = () => {};
|
||||
@@ -428,9 +432,9 @@
|
||||
};
|
||||
|
||||
let command = '';
|
||||
|
||||
export let showCommands = false;
|
||||
$: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
|
||||
let suggestions = null;
|
||||
|
||||
let showTools = false;
|
||||
|
||||
@@ -845,6 +849,115 @@
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
suggestions = [
|
||||
{
|
||||
char: '@',
|
||||
render: getSuggestionRenderer(CommandSuggestionList, {
|
||||
i18n,
|
||||
onSelect: (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'model') {
|
||||
atSelectedModel = data;
|
||||
}
|
||||
|
||||
document.getElementById('chat-input')?.focus();
|
||||
},
|
||||
|
||||
insertTextHandler: insertTextAtCursor,
|
||||
onUpload: (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'file') {
|
||||
if (files.find((f) => f.id === data.id)) {
|
||||
return;
|
||||
}
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
...data,
|
||||
status: 'processed'
|
||||
}
|
||||
];
|
||||
} else {
|
||||
dispatch('upload', e);
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
char: '/',
|
||||
render: getSuggestionRenderer(CommandSuggestionList, {
|
||||
i18n,
|
||||
onSelect: (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'model') {
|
||||
atSelectedModel = data;
|
||||
}
|
||||
|
||||
document.getElementById('chat-input')?.focus();
|
||||
},
|
||||
|
||||
insertTextHandler: insertTextAtCursor,
|
||||
onUpload: (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'file') {
|
||||
if (files.find((f) => f.id === data.id)) {
|
||||
return;
|
||||
}
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
...data,
|
||||
status: 'processed'
|
||||
}
|
||||
];
|
||||
} else {
|
||||
dispatch('upload', e);
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
char: '#',
|
||||
render: getSuggestionRenderer(CommandSuggestionList, {
|
||||
i18n,
|
||||
onSelect: (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'model') {
|
||||
atSelectedModel = data;
|
||||
}
|
||||
|
||||
document.getElementById('chat-input')?.focus();
|
||||
},
|
||||
|
||||
insertTextHandler: insertTextAtCursor,
|
||||
onUpload: (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'file') {
|
||||
if (files.find((f) => f.id === data.id)) {
|
||||
return;
|
||||
}
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
...data,
|
||||
status: 'processed'
|
||||
}
|
||||
];
|
||||
} else {
|
||||
dispatch('upload', e);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
console.log(suggestions);
|
||||
loaded = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
@@ -929,78 +1042,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-full relative">
|
||||
{#if atSelectedModel !== undefined}
|
||||
<div
|
||||
class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-linear-to-t from-white dark:from-gray-900 z-10"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
alt="model profile"
|
||||
class="size-3.5 max-w-[28px] object-cover rounded-full"
|
||||
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
|
||||
?.profile_image_url ??
|
||||
($i18n.language === 'dg-DG'
|
||||
? `${WEBUI_BASE_URL}/doge.png`
|
||||
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||
/>
|
||||
<div class="translate-y-[0.5px]">
|
||||
{$i18n.t('Talk to model')}:
|
||||
<span class=" font-medium">{atSelectedModel.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="flex items-center dark:text-gray-500"
|
||||
on:click={() => {
|
||||
atSelectedModel = undefined;
|
||||
}}
|
||||
>
|
||||
<XMark />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Commands
|
||||
bind:this={commandsElement}
|
||||
bind:files
|
||||
show={showCommands}
|
||||
{command}
|
||||
insertTextHandler={insertTextAtCursor}
|
||||
onUpload={(e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'file') {
|
||||
if (files.find((f) => f.id === data.id)) {
|
||||
return;
|
||||
}
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
...data,
|
||||
status: 'processed'
|
||||
}
|
||||
];
|
||||
} else {
|
||||
dispatch('upload', e);
|
||||
}
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'model') {
|
||||
atSelectedModel = data;
|
||||
}
|
||||
|
||||
document.getElementById('chat-input')?.focus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1066,6 +1107,38 @@
|
||||
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
|
||||
dir={$settings?.chatDirection ?? 'auto'}
|
||||
>
|
||||
{#if atSelectedModel !== undefined}
|
||||
<div class="px-3 pt-3 text-left w-full flex flex-col z-10">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
alt="model profile"
|
||||
class="size-3.5 max-w-[28px] object-cover rounded-full"
|
||||
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
|
||||
?.profile_image_url ??
|
||||
($i18n.language === 'dg-DG'
|
||||
? `${WEBUI_BASE_URL}/doge.png`
|
||||
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||
/>
|
||||
<div class="translate-y-[0.5px]">
|
||||
<span class="">{atSelectedModel.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="flex items-center dark:text-gray-500"
|
||||
on:click={() => {
|
||||
atSelectedModel = undefined;
|
||||
}}
|
||||
>
|
||||
<XMark />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if files.length > 0}
|
||||
<div class="mx-2 mt-2.5 -mb-1 flex items-center flex-wrap gap-2">
|
||||
{#each files as file, fileIdx}
|
||||
@@ -1075,7 +1148,7 @@
|
||||
<Image
|
||||
src={file.url}
|
||||
alt=""
|
||||
imageClassName=" size-14 rounded-xl object-cover"
|
||||
imageClassName=" size-10 rounded-xl object-cover"
|
||||
/>
|
||||
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
|
||||
<Tooltip
|
||||
@@ -1140,6 +1213,7 @@
|
||||
loading={file.status === 'uploading'}
|
||||
dismissible={true}
|
||||
edit={true}
|
||||
small={true}
|
||||
modal={['file', 'collection'].includes(file?.type)}
|
||||
on:dismiss={async () => {
|
||||
// Remove from UI state
|
||||
@@ -1161,250 +1235,201 @@
|
||||
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2.5 pb-[5px] px-1 resize-none h-fit max-h-80 overflow-auto"
|
||||
id="chat-input-container"
|
||||
>
|
||||
{#key $settings?.showFormattingToolbar ?? false}
|
||||
<RichTextInput
|
||||
bind:this={chatInputElement}
|
||||
id="chat-input"
|
||||
onChange={(e) => {
|
||||
prompt = e.md;
|
||||
command = getCommand();
|
||||
}}
|
||||
json={true}
|
||||
messageInput={true}
|
||||
showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
|
||||
floatingMenuPlacement={'top-start'}
|
||||
insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
|
||||
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
||||
(!$mobile ||
|
||||
!(
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0
|
||||
))}
|
||||
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
||||
largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
|
||||
autocomplete={$config?.features?.enable_autocomplete_generation &&
|
||||
($settings?.promptAutocomplete ?? false)}
|
||||
generateAutoCompletion={async (text) => {
|
||||
if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
|
||||
toast.error($i18n.t('Please select a model first.'));
|
||||
}
|
||||
|
||||
const res = await generateAutoCompletion(
|
||||
localStorage.token,
|
||||
selectedModelIds.at(0),
|
||||
text,
|
||||
history?.currentId
|
||||
? createMessagesList(history, history.currentId)
|
||||
: null
|
||||
).catch((error) => {
|
||||
console.log(error);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
console.log(res);
|
||||
return res;
|
||||
}}
|
||||
oncompositionstart={() => (isComposing = true)}
|
||||
oncompositionend={(e) => {
|
||||
compositionEndedAt = e.timeStamp;
|
||||
isComposing = false;
|
||||
}}
|
||||
on:keydown={async (e) => {
|
||||
e = e.detail.event;
|
||||
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
||||
const commandsContainerElement =
|
||||
document.getElementById('commands-container');
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
stopResponse();
|
||||
}
|
||||
|
||||
// Command/Ctrl + Shift + Enter to submit a message pair
|
||||
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
createMessagePair(prompt);
|
||||
}
|
||||
|
||||
// Check if Ctrl + R is pressed
|
||||
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
|
||||
e.preventDefault();
|
||||
console.log('regenerate');
|
||||
|
||||
const regenerateButton = [
|
||||
...document.getElementsByClassName('regenerate-response-button')
|
||||
]?.at(-1);
|
||||
|
||||
regenerateButton?.click();
|
||||
}
|
||||
|
||||
if (prompt === '' && e.key == 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
|
||||
const userMessageElement = [
|
||||
...document.getElementsByClassName('user-message')
|
||||
]?.at(-1);
|
||||
|
||||
if (userMessageElement) {
|
||||
userMessageElement.scrollIntoView({ block: 'center' });
|
||||
const editButton = [
|
||||
...document.getElementsByClassName('edit-user-message-button')
|
||||
]?.at(-1);
|
||||
|
||||
editButton?.click();
|
||||
}
|
||||
}
|
||||
|
||||
if (commandsContainerElement) {
|
||||
if (commandsContainerElement && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
commandsElement.selectUp();
|
||||
|
||||
const commandOptionButton = [
|
||||
...document.getElementsByClassName(
|
||||
'selected-command-option-button'
|
||||
)
|
||||
]?.at(-1);
|
||||
commandOptionButton.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
if (commandsContainerElement && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
commandsElement.selectDown();
|
||||
|
||||
const commandOptionButton = [
|
||||
...document.getElementsByClassName(
|
||||
'selected-command-option-button'
|
||||
)
|
||||
]?.at(-1);
|
||||
commandOptionButton.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
if (commandsContainerElement && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
|
||||
const commandOptionButton = [
|
||||
...document.getElementsByClassName(
|
||||
'selected-command-option-button'
|
||||
)
|
||||
]?.at(-1);
|
||||
|
||||
commandOptionButton?.click();
|
||||
}
|
||||
|
||||
if (commandsContainerElement && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
const commandOptionButton = [
|
||||
...document.getElementsByClassName(
|
||||
'selected-command-option-button'
|
||||
)
|
||||
]?.at(-1);
|
||||
|
||||
if (commandOptionButton) {
|
||||
commandOptionButton?.click();
|
||||
} else {
|
||||
document.getElementById('send-message-button')?.click();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
!$mobile ||
|
||||
{#if suggestions}
|
||||
{#key $settings?.showFormattingToolbar ?? false}
|
||||
<RichTextInput
|
||||
bind:this={chatInputElement}
|
||||
id="chat-input"
|
||||
onChange={(e) => {
|
||||
prompt = e.md;
|
||||
command = getCommand();
|
||||
}}
|
||||
json={true}
|
||||
messageInput={true}
|
||||
showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
|
||||
floatingMenuPlacement={'top-start'}
|
||||
insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
|
||||
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
||||
(!$mobile ||
|
||||
!(
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0
|
||||
)
|
||||
) {
|
||||
if (inOrNearComposition(e)) {
|
||||
return;
|
||||
}
|
||||
))}
|
||||
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
||||
largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
|
||||
autocomplete={$config?.features?.enable_autocomplete_generation &&
|
||||
($settings?.promptAutocomplete ?? false)}
|
||||
generateAutoCompletion={async (text) => {
|
||||
if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
|
||||
toast.error($i18n.t('Please select a model first.'));
|
||||
}
|
||||
|
||||
// Uses keyCode '13' for Enter key for chinese/japanese keyboards.
|
||||
//
|
||||
// Depending on the user's settings, it will send the message
|
||||
// either when Enter is pressed or when Ctrl+Enter is pressed.
|
||||
const enterPressed =
|
||||
($settings?.ctrlEnterToSend ?? false)
|
||||
? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
|
||||
: (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
|
||||
const res = await generateAutoCompletion(
|
||||
localStorage.token,
|
||||
selectedModelIds.at(0),
|
||||
text,
|
||||
history?.currentId
|
||||
? createMessagesList(history, history.currentId)
|
||||
: null
|
||||
).catch((error) => {
|
||||
console.log(error);
|
||||
|
||||
if (enterPressed) {
|
||||
e.preventDefault();
|
||||
if (prompt !== '' || files.length > 0) {
|
||||
dispatch('submit', prompt);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
console.log(res);
|
||||
return res;
|
||||
}}
|
||||
{suggestions}
|
||||
oncompositionstart={() => (isComposing = true)}
|
||||
oncompositionend={(e) => {
|
||||
compositionEndedAt = e.timeStamp;
|
||||
isComposing = false;
|
||||
}}
|
||||
on:keydown={async (e) => {
|
||||
e = e.detail.event;
|
||||
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
||||
const suggestionsContainerElement =
|
||||
document.getElementById('suggestions-container');
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
stopResponse();
|
||||
}
|
||||
|
||||
// Command/Ctrl + Shift + Enter to submit a message pair
|
||||
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
createMessagePair(prompt);
|
||||
}
|
||||
|
||||
// Check if Ctrl + R is pressed
|
||||
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
|
||||
e.preventDefault();
|
||||
console.log('regenerate');
|
||||
|
||||
const regenerateButton = [
|
||||
...document.getElementsByClassName('regenerate-response-button')
|
||||
]?.at(-1);
|
||||
|
||||
regenerateButton?.click();
|
||||
}
|
||||
|
||||
if (prompt === '' && e.key == 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
|
||||
const userMessageElement = [
|
||||
...document.getElementsByClassName('user-message')
|
||||
]?.at(-1);
|
||||
|
||||
if (userMessageElement) {
|
||||
userMessageElement.scrollIntoView({ block: 'center' });
|
||||
const editButton = [
|
||||
...document.getElementsByClassName('edit-user-message-button')
|
||||
]?.at(-1);
|
||||
|
||||
editButton?.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
console.log('Escape');
|
||||
atSelectedModel = undefined;
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
codeInterpreterEnabled = false;
|
||||
}
|
||||
}}
|
||||
on:paste={async (e) => {
|
||||
e = e.detail.event;
|
||||
console.log(e);
|
||||
|
||||
const clipboardData = e.clipboardData || window.clipboardData;
|
||||
|
||||
if (clipboardData && clipboardData.items) {
|
||||
for (const item of clipboardData.items) {
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
const blob = item.getAsFile();
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function (e) {
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
type: 'image',
|
||||
url: `${e.target.result}`
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
} else if (item?.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const _files = [file];
|
||||
await inputFilesHandler(_files);
|
||||
e.preventDefault();
|
||||
if (!suggestionsContainerElement) {
|
||||
if (
|
||||
!$mobile ||
|
||||
!(
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0
|
||||
)
|
||||
) {
|
||||
if (inOrNearComposition(e)) {
|
||||
return;
|
||||
}
|
||||
} else if (item.type === 'text/plain') {
|
||||
if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
|
||||
const text = clipboardData.getData('text/plain');
|
||||
|
||||
if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
|
||||
e.preventDefault();
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const file = new File(
|
||||
[blob],
|
||||
`Pasted_Text_${Date.now()}.txt`,
|
||||
{
|
||||
type: 'text/plain'
|
||||
}
|
||||
);
|
||||
// Uses keyCode '13' for Enter key for chinese/japanese keyboards.
|
||||
//
|
||||
// Depending on the user's settings, it will send the message
|
||||
// either when Enter is pressed or when Ctrl+Enter is pressed.
|
||||
const enterPressed =
|
||||
($settings?.ctrlEnterToSend ?? false)
|
||||
? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
|
||||
: (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
|
||||
|
||||
await uploadFileHandler(file, true);
|
||||
if (enterPressed) {
|
||||
e.preventDefault();
|
||||
if (prompt !== '' || files.length > 0) {
|
||||
dispatch('submit', prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
console.log('Escape');
|
||||
atSelectedModel = undefined;
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
codeInterpreterEnabled = false;
|
||||
}
|
||||
}}
|
||||
on:paste={async (e) => {
|
||||
e = e.detail.event;
|
||||
console.log(e);
|
||||
|
||||
const clipboardData = e.clipboardData || window.clipboardData;
|
||||
|
||||
if (clipboardData && clipboardData.items) {
|
||||
for (const item of clipboardData.items) {
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
const blob = item.getAsFile();
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function (e) {
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
type: 'image',
|
||||
url: `${e.target.result}`
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
} else if (item?.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const _files = [file];
|
||||
await inputFilesHandler(_files);
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (item.type === 'text/plain') {
|
||||
if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
|
||||
const text = clipboardData.getData('text/plain');
|
||||
|
||||
if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
|
||||
e.preventDefault();
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const file = new File(
|
||||
[blob],
|
||||
`Pasted_Text_${Date.now()}.txt`,
|
||||
{
|
||||
type: 'text/plain'
|
||||
}
|
||||
);
|
||||
|
||||
await uploadFileHandler(file, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<textarea
|
||||
@@ -1428,8 +1453,8 @@
|
||||
on:keydown={async (e) => {
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
||||
|
||||
const commandsContainerElement =
|
||||
document.getElementById('commands-container');
|
||||
const suggestionsContainerElement =
|
||||
document.getElementById('suggestions-container');
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
stopResponse();
|
||||
@@ -1470,71 +1495,7 @@
|
||||
editButton?.click();
|
||||
}
|
||||
|
||||
if (commandsContainerElement) {
|
||||
if (commandsContainerElement && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
commandsElement.selectUp();
|
||||
|
||||
const container = document.getElementById('command-options-container');
|
||||
const commandOptionButton = [
|
||||
...document.getElementsByClassName('selected-command-option-button')
|
||||
]?.at(-1);
|
||||
|
||||
if (commandOptionButton && container) {
|
||||
const elTop = commandOptionButton.offsetTop;
|
||||
const elHeight = commandOptionButton.offsetHeight;
|
||||
const containerHeight = container.clientHeight;
|
||||
|
||||
// Center the selected button in the container
|
||||
container.scrollTop = elTop - containerHeight / 2 + elHeight / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (commandsContainerElement && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
commandsElement.selectDown();
|
||||
|
||||
const container = document.getElementById('command-options-container');
|
||||
const commandOptionButton = [
|
||||
...document.getElementsByClassName('selected-command-option-button')
|
||||
]?.at(-1);
|
||||
|
||||
if (commandOptionButton && container) {
|
||||
const elTop = commandOptionButton.offsetTop;
|
||||
const elHeight = commandOptionButton.offsetHeight;
|
||||
const containerHeight = container.clientHeight;
|
||||
|
||||
// Center the selected button in the container
|
||||
container.scrollTop = elTop - containerHeight / 2 + elHeight / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (commandsContainerElement && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
const commandOptionButton = [
|
||||
...document.getElementsByClassName('selected-command-option-button')
|
||||
]?.at(-1);
|
||||
|
||||
if (e.shiftKey) {
|
||||
prompt = `${prompt}\n`;
|
||||
} else if (commandOptionButton) {
|
||||
commandOptionButton?.click();
|
||||
} else {
|
||||
document.getElementById('send-message-button')?.click();
|
||||
}
|
||||
}
|
||||
|
||||
if (commandsContainerElement && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
|
||||
const commandOptionButton = [
|
||||
...document.getElementsByClassName('selected-command-option-button')
|
||||
]?.at(-1);
|
||||
|
||||
commandOptionButton?.click();
|
||||
}
|
||||
} else {
|
||||
if (!suggestionsContainerElement) {
|
||||
if (
|
||||
!$mobile ||
|
||||
!(
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { knowledge, prompts } from '$lib/stores';
|
||||
|
||||
import { getPrompts } from '$lib/apis/prompts';
|
||||
import { getKnowledgeBases } from '$lib/apis/knowledge';
|
||||
|
||||
import Prompts from './Commands/Prompts.svelte';
|
||||
import Knowledge from './Commands/Knowledge.svelte';
|
||||
import Models from './Commands/Models.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let char = '';
|
||||
export let query = '';
|
||||
export let command: (payload: { id: string; label: string }) => void;
|
||||
|
||||
export let onSelect = (e) => {};
|
||||
export let onUpload = (e) => {};
|
||||
export let insertTextHandler = (text) => {};
|
||||
|
||||
let suggestionElement = null;
|
||||
let loading = false;
|
||||
let filteredItems = [];
|
||||
|
||||
const init = async () => {
|
||||
loading = true;
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
prompts.set(await getPrompts(localStorage.token));
|
||||
})(),
|
||||
(async () => {
|
||||
knowledge.set(await getKnowledgeBases(localStorage.token));
|
||||
})()
|
||||
]);
|
||||
loading = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
suggestionElement?.selectUp();
|
||||
const item = document.querySelector(`[data-selected="true"]`);
|
||||
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
suggestionElement?.selectDown();
|
||||
const item = document.querySelector(`[data-selected="true"]`);
|
||||
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
suggestionElement?.select();
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// This method will be called from the suggestion renderer
|
||||
// @ts-ignore
|
||||
export function _onKeyDown(event: KeyboardEvent) {
|
||||
return onKeyDown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{(filteredItems ?? []).length > 0
|
||||
? ''
|
||||
: 'hidden'} rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
|
||||
id="suggestions-container"
|
||||
>
|
||||
<div class="overflow-y-auto scrollbar-thin max-h-72">
|
||||
{#if !loading}
|
||||
{#if char === '/'}
|
||||
<Prompts
|
||||
bind:this={suggestionElement}
|
||||
{query}
|
||||
bind:filteredItems
|
||||
prompts={$prompts ?? []}
|
||||
onSelect={(e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'prompt') {
|
||||
insertTextHandler(data.content);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else if char === '#'}
|
||||
<Knowledge
|
||||
bind:this={suggestionElement}
|
||||
{query}
|
||||
bind:filteredItems
|
||||
knowledge={$knowledge ?? []}
|
||||
onSelect={(e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'knowledge') {
|
||||
insertTextHandler('');
|
||||
|
||||
onUpload({
|
||||
type: 'file',
|
||||
data: data
|
||||
});
|
||||
} else if (type === 'youtube') {
|
||||
insertTextHandler('');
|
||||
|
||||
onUpload({
|
||||
type: 'youtube',
|
||||
data: data
|
||||
});
|
||||
} else if (type === 'web') {
|
||||
insertTextHandler('');
|
||||
|
||||
onUpload({
|
||||
type: 'web',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else if char === '@'}
|
||||
<Models
|
||||
bind:this={suggestionElement}
|
||||
{query}
|
||||
bind:filteredItems
|
||||
onSelect={(e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'model') {
|
||||
insertTextHandler('');
|
||||
|
||||
onSelect({
|
||||
type: 'model',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
|
||||
<div
|
||||
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,29 +8,48 @@
|
||||
|
||||
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
||||
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
|
||||
import { knowledge } from '$lib/stores';
|
||||
import { getNoteList, getNotes } from '$lib/apis/notes';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
||||
import Database from '$lib/components/icons/Database.svelte';
|
||||
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||
import Youtube from '$lib/components/icons/Youtube.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let command = '';
|
||||
export let query = '';
|
||||
export let onSelect = (e) => {};
|
||||
|
||||
export let knowledge = [];
|
||||
|
||||
let selectedIdx = 0;
|
||||
|
||||
let items = [];
|
||||
let fuse = null;
|
||||
|
||||
let filteredItems = [];
|
||||
export let filteredItems = [];
|
||||
$: if (fuse) {
|
||||
filteredItems = command.slice(1)
|
||||
? fuse.search(command).map((e) => {
|
||||
return e.item;
|
||||
})
|
||||
: items;
|
||||
filteredItems = [
|
||||
...(query
|
||||
? fuse.search(query).map((e) => {
|
||||
return e.item;
|
||||
})
|
||||
: items),
|
||||
|
||||
...(query.startsWith('http')
|
||||
? query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')
|
||||
? [{ type: 'youtube', name: query, description: query }]
|
||||
: [
|
||||
{
|
||||
type: 'web',
|
||||
name: query,
|
||||
description: query
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
|
||||
$: if (command) {
|
||||
$: if (query) {
|
||||
selectedIdx = 0;
|
||||
}
|
||||
|
||||
@@ -42,32 +61,14 @@
|
||||
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
|
||||
};
|
||||
|
||||
let container;
|
||||
let adjustHeightDebounce;
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (container) {
|
||||
if (adjustHeightDebounce) {
|
||||
clearTimeout(adjustHeightDebounce);
|
||||
}
|
||||
|
||||
adjustHeightDebounce = setTimeout(() => {
|
||||
if (!container) return;
|
||||
|
||||
// Ensure the container is visible before adjusting height
|
||||
const rect = container.getBoundingClientRect();
|
||||
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
|
||||
}, 100);
|
||||
export const select = async () => {
|
||||
// find item with data-selected=true
|
||||
const item = document.querySelector(`[data-selected="true"]`);
|
||||
if (item) {
|
||||
// click the item
|
||||
item.click();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSelect = async (type, data) => {
|
||||
onSelect({
|
||||
type: type,
|
||||
data: data
|
||||
});
|
||||
};
|
||||
|
||||
const decodeString = (str: string) => {
|
||||
try {
|
||||
return decodeURIComponent(str);
|
||||
@@ -77,22 +78,7 @@
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
|
||||
let notes = await getNoteList(localStorage.token).catch(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
notes = notes.map((note) => {
|
||||
return {
|
||||
...note,
|
||||
type: 'note',
|
||||
name: note.title,
|
||||
description: dayjs(note.updated_at / 1000000).fromNow()
|
||||
};
|
||||
});
|
||||
|
||||
let legacy_documents = $knowledge
|
||||
let legacy_documents = knowledge
|
||||
.filter((item) => item?.meta?.document)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
@@ -127,16 +113,16 @@
|
||||
]
|
||||
: [];
|
||||
|
||||
let collections = $knowledge
|
||||
let collections = knowledge
|
||||
.filter((item) => !item?.meta?.document)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
type: 'collection'
|
||||
}));
|
||||
let collection_files =
|
||||
$knowledge.length > 0
|
||||
knowledge.length > 0
|
||||
? [
|
||||
...$knowledge
|
||||
...knowledge
|
||||
.reduce((a, item) => {
|
||||
return [
|
||||
...new Set([
|
||||
@@ -158,105 +144,76 @@
|
||||
]
|
||||
: [];
|
||||
|
||||
items = [
|
||||
...notes,
|
||||
...collections,
|
||||
...collection_files,
|
||||
...legacy_collections,
|
||||
...legacy_documents
|
||||
].map((item) => {
|
||||
return {
|
||||
...item,
|
||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
||||
};
|
||||
});
|
||||
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
|
||||
(item) => {
|
||||
return {
|
||||
...item,
|
||||
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
fuse = new Fuse(items, {
|
||||
keys: ['name', 'description']
|
||||
});
|
||||
|
||||
await tick();
|
||||
adjustHeight();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', adjustHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if filteredItems.length > 0 || command?.substring(1).startsWith('http')}
|
||||
<div
|
||||
id="commands-container"
|
||||
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
|
||||
>
|
||||
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
|
||||
<div class="flex flex-col w-full rounded-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 max-h-60"
|
||||
id="command-options-container"
|
||||
bind:this={container}
|
||||
>
|
||||
{#each filteredItems as item, idx}
|
||||
<button
|
||||
class=" px-3 py-1.5 rounded-xl w-full text-left flex justify-between items-center {idx ===
|
||||
selectedIdx
|
||||
? ' bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button'
|
||||
: ''}"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
console.log(item);
|
||||
confirmSelect('knowledge', item);
|
||||
}}
|
||||
on:mousemove={() => {
|
||||
selectedIdx = idx;
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div class=" font-medium text-black dark:text-gray-100 flex items-center gap-1">
|
||||
{#if item.legacy}
|
||||
<div
|
||||
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
|
||||
>
|
||||
Legacy
|
||||
</div>
|
||||
{:else if item?.meta?.document}
|
||||
<div
|
||||
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
|
||||
>
|
||||
Document
|
||||
</div>
|
||||
{:else if item?.type === 'file'}
|
||||
<div
|
||||
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
|
||||
>
|
||||
File
|
||||
</div>
|
||||
{:else if item?.type === 'note'}
|
||||
<div
|
||||
class="bg-blue-500/20 text-blue-700 dark:text-blue-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
|
||||
>
|
||||
Note
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
|
||||
>
|
||||
Collection
|
||||
</div>
|
||||
{/if}
|
||||
<div class="px-2 text-xs text-gray-500 py-1">
|
||||
{$i18n.t('Knowledge')}
|
||||
</div>
|
||||
|
||||
<div class="line-clamp-1">
|
||||
{decodeString(item?.name)}
|
||||
</div>
|
||||
</div>
|
||||
{#if filteredItems.length > 0 || query.startsWith('http')}
|
||||
{#each filteredItems as item, idx}
|
||||
{#if !['youtube', 'web'].includes(item.type)}
|
||||
<button
|
||||
class=" px-2 py-1 rounded-xl w-full text-left flex justify-between items-center {idx ===
|
||||
selectedIdx
|
||||
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
|
||||
: ''}"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
console.log(item);
|
||||
onSelect({
|
||||
type: 'knowledge',
|
||||
data: item
|
||||
});
|
||||
}}
|
||||
on:mousemove={() => {
|
||||
selectedIdx = idx;
|
||||
}}
|
||||
data-selected={idx === selectedIdx}
|
||||
>
|
||||
<div class=" text-black dark:text-gray-100 flex items-center gap-1">
|
||||
<Tooltip
|
||||
content={item?.legacy
|
||||
? $i18n.t('Legacy')
|
||||
: item?.type === 'file'
|
||||
? $i18n.t('File')
|
||||
: item?.type === 'collection'
|
||||
? $i18n.t('Collection')
|
||||
: ''}
|
||||
placement="top"
|
||||
>
|
||||
{#if item?.type === 'collection'}
|
||||
<Database className="size-4" />
|
||||
{:else}
|
||||
<DocumentPage className="size-4" />
|
||||
{/if}
|
||||
</Tooltip>
|
||||
|
||||
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
|
||||
{item?.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
|
||||
<div class="line-clamp-1 flex-1">
|
||||
{decodeString(item?.name)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- <div slot="content" class=" pl-2 pt-1 flex flex-col gap-0.5">
|
||||
<!-- <div slot="content" class=" pl-2 pt-1 flex flex-col gap-0.5">
|
||||
{#if !item.legacy && (item?.files ?? []).length > 0}
|
||||
{#each item?.files ?? [] as file, fileIdx}
|
||||
<button
|
||||
@@ -297,57 +254,63 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div> -->
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#if command.substring(1).startsWith('https://www.youtube.com') || command
|
||||
.substring(1)
|
||||
.startsWith('https://youtu.be')}
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (isValidHttpUrl(command.substring(1))) {
|
||||
confirmSelect('youtube', command.substring(1));
|
||||
} else {
|
||||
toast.error(
|
||||
$i18n.t(
|
||||
'Oops! Looks like the URL is invalid. Please double-check and try again.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
|
||||
{command.substring(1)}
|
||||
</div>
|
||||
{#if query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')}
|
||||
<button
|
||||
class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button"
|
||||
type="button"
|
||||
data-selected={true}
|
||||
on:click={() => {
|
||||
if (isValidHttpUrl(query)) {
|
||||
onSelect({
|
||||
type: 'youtube',
|
||||
data: query
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
$i18n.t('Oops! Looks like the URL is invalid. Please double-check and try again.')
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" text-black dark:text-gray-100 line-clamp-1 flex items-center gap-1">
|
||||
<Tooltip content={$i18n.t('YouTube')} placement="top">
|
||||
<Youtube className="size-4" />
|
||||
</Tooltip>
|
||||
|
||||
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Youtube')}</div>
|
||||
</button>
|
||||
{:else if command.substring(1).startsWith('http')}
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (isValidHttpUrl(command.substring(1))) {
|
||||
confirmSelect('web', command.substring(1));
|
||||
} else {
|
||||
toast.error(
|
||||
$i18n.t(
|
||||
'Oops! Looks like the URL is invalid. Please double-check and try again.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
|
||||
{command}
|
||||
</div>
|
||||
|
||||
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Web')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="truncate flex-1">
|
||||
{query}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{:else if query.startsWith('http')}
|
||||
<button
|
||||
class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button"
|
||||
type="button"
|
||||
data-selected={true}
|
||||
on:click={() => {
|
||||
if (isValidHttpUrl(query)) {
|
||||
onSelect({
|
||||
type: 'web',
|
||||
data: query
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
$i18n.t('Oops! Looks like the URL is invalid. Please double-check and try again.')
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" text-black dark:text-gray-100 line-clamp-1 flex items-center gap-1">
|
||||
<Tooltip content={$i18n.t('Web')} placement="top">
|
||||
<GlobeAlt className="size-4" />
|
||||
</Tooltip>
|
||||
|
||||
<div class="truncate flex-1">
|
||||
{query}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let command = '';
|
||||
export let query = '';
|
||||
export let onSelect = (e) => {};
|
||||
|
||||
let selectedIdx = 0;
|
||||
let filteredItems = [];
|
||||
export let filteredItems = [];
|
||||
|
||||
let fuse = new Fuse(
|
||||
$models
|
||||
@@ -33,13 +33,13 @@
|
||||
}
|
||||
);
|
||||
|
||||
$: filteredItems = command.slice(1)
|
||||
? fuse.search(command.slice(1)).map((e) => {
|
||||
$: filteredItems = query
|
||||
? fuse.search(query).map((e) => {
|
||||
return e.item;
|
||||
})
|
||||
: $models.filter((model) => !model?.info?.meta?.hidden);
|
||||
|
||||
$: if (command) {
|
||||
$: if (query) {
|
||||
selectedIdx = 0;
|
||||
}
|
||||
|
||||
@@ -51,85 +51,44 @@
|
||||
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
|
||||
};
|
||||
|
||||
let container;
|
||||
let adjustHeightDebounce;
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (container) {
|
||||
if (adjustHeightDebounce) {
|
||||
clearTimeout(adjustHeightDebounce);
|
||||
}
|
||||
|
||||
adjustHeightDebounce = setTimeout(() => {
|
||||
if (!container) return;
|
||||
|
||||
// Ensure the container is visible before adjusting height
|
||||
const rect = container.getBoundingClientRect();
|
||||
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px';
|
||||
}, 100);
|
||||
export const select = async () => {
|
||||
const model = filteredItems[selectedIdx];
|
||||
if (model) {
|
||||
onSelect({ type: 'model', data: model });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSelect = async (model) => {
|
||||
onSelect({ type: 'model', data: model });
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
|
||||
await tick();
|
||||
const chatInputElement = document.getElementById('chat-input');
|
||||
await tick();
|
||||
chatInputElement?.focus();
|
||||
await tick();
|
||||
|
||||
adjustHeight();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', adjustHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="px-2 text-xs text-gray-500 py-1">
|
||||
{$i18n.t('Models')}
|
||||
</div>
|
||||
|
||||
{#if filteredItems.length > 0}
|
||||
<div
|
||||
id="commands-container"
|
||||
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
|
||||
>
|
||||
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
|
||||
<div class="flex flex-col w-full rounded-xl 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 max-h-60"
|
||||
id="command-options-container"
|
||||
bind:this={container}
|
||||
>
|
||||
{#each filteredItems as model, modelIdx}
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx
|
||||
? 'bg-gray-50 dark:bg-gray-850 selected-command-option-button'
|
||||
: ''}"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
confirmSelect(model);
|
||||
}}
|
||||
on:mousemove={() => {
|
||||
selectedIdx = modelIdx;
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
>
|
||||
<div class="flex font-medium text-black dark:text-gray-100 line-clamp-1">
|
||||
<img
|
||||
src={model?.info?.meta?.profile_image_url ??
|
||||
`${WEBUI_BASE_URL}/static/favicon.png`}
|
||||
alt={model?.name ?? model.id}
|
||||
class="rounded-full size-6 items-center mr-2"
|
||||
/>
|
||||
{model.name}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#each filteredItems as model, modelIdx}
|
||||
<button
|
||||
class="px-2.5 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx
|
||||
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
|
||||
: ''}"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onSelect({ type: 'model', data: model });
|
||||
}}
|
||||
on:mousemove={() => {
|
||||
selectedIdx = modelIdx;
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
data-selected={modelIdx === selectedIdx}
|
||||
>
|
||||
<div class="flex text-black dark:text-gray-100 line-clamp-1">
|
||||
<img
|
||||
src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
|
||||
alt={model?.name ?? model.id}
|
||||
class="rounded-full size-5 items-center mr-2"
|
||||
/>
|
||||
<div class="truncate">
|
||||
{model.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -1,140 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { prompts, settings, user } from '$lib/stores';
|
||||
import {
|
||||
extractCurlyBraceWords,
|
||||
getUserPosition,
|
||||
getFormattedDate,
|
||||
getFormattedTime,
|
||||
getCurrentDateTime,
|
||||
getUserTimezone,
|
||||
getWeekday
|
||||
} from '$lib/utils';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let command = '';
|
||||
export let query = '';
|
||||
export let prompts = [];
|
||||
export let onSelect = (e) => {};
|
||||
|
||||
let selectedPromptIdx = 0;
|
||||
let filteredPrompts = [];
|
||||
export let filteredItems = [];
|
||||
|
||||
$: filteredPrompts = $prompts
|
||||
.filter((p) => p.command.toLowerCase().includes(command.toLowerCase()))
|
||||
$: filteredItems = prompts
|
||||
.filter((p) => p.command.toLowerCase().includes(query.toLowerCase()))
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
$: if (command) {
|
||||
$: if (query) {
|
||||
selectedPromptIdx = 0;
|
||||
}
|
||||
|
||||
export const selectUp = () => {
|
||||
selectedPromptIdx = Math.max(0, selectedPromptIdx - 1);
|
||||
};
|
||||
|
||||
export const selectDown = () => {
|
||||
selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1);
|
||||
selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredItems.length - 1);
|
||||
};
|
||||
|
||||
let container;
|
||||
let adjustHeightDebounce;
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (container) {
|
||||
if (adjustHeightDebounce) {
|
||||
clearTimeout(adjustHeightDebounce);
|
||||
}
|
||||
|
||||
adjustHeightDebounce = setTimeout(() => {
|
||||
if (!container) return;
|
||||
|
||||
// Ensure the container is visible before adjusting height
|
||||
const rect = container.getBoundingClientRect();
|
||||
container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 80), 100) + 'px';
|
||||
}, 100);
|
||||
export const select = async () => {
|
||||
const command = filteredItems[selectedPromptIdx];
|
||||
if (command) {
|
||||
onSelect({ type: 'prompt', data: command });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPrompt = async (command) => {
|
||||
onSelect({ type: 'prompt', data: command });
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
|
||||
await tick();
|
||||
adjustHeight();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', adjustHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if filteredPrompts.length > 0}
|
||||
<div
|
||||
id="commands-container"
|
||||
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
|
||||
>
|
||||
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
|
||||
<div class="flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100">
|
||||
<div
|
||||
class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden max-h-60"
|
||||
id="command-options-container"
|
||||
bind:this={container}
|
||||
<div class="px-2 text-xs text-gray-500 py-1">
|
||||
{$i18n.t('Prompts')}
|
||||
</div>
|
||||
|
||||
{#if filteredItems.length > 0}
|
||||
<div class=" space-y-0.5 scrollbar-hidden">
|
||||
{#each filteredItems as promptItem, promptIdx}
|
||||
<Tooltip content={promptItem.title} placement="top-start">
|
||||
<button
|
||||
class=" px-3 py-1 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
|
||||
? ' bg-gray-50 dark:bg-gray-800 selected-command-option-button'
|
||||
: ''} truncate"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onSelect({ type: 'prompt', data: promptItem });
|
||||
}}
|
||||
on:mousemove={() => {
|
||||
selectedPromptIdx = promptIdx;
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
data-selected={promptIdx === selectedPromptIdx}
|
||||
>
|
||||
{#each filteredPrompts as promptItem, promptIdx}
|
||||
<button
|
||||
class=" px-3 py-1.5 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
|
||||
? ' bg-gray-50 dark:bg-gray-850 selected-command-option-button'
|
||||
: ''}"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
confirmPrompt(promptItem);
|
||||
}}
|
||||
on:mousemove={() => {
|
||||
selectedPromptIdx = promptIdx;
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
>
|
||||
<div class=" font-medium text-black dark:text-gray-100">
|
||||
{promptItem.command}
|
||||
</div>
|
||||
<span class=" font-medium text-black dark:text-gray-100">
|
||||
{promptItem.command}
|
||||
</span>
|
||||
|
||||
<div class=" text-xs text-gray-600 dark:text-gray-100">
|
||||
{promptItem.title}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" px-2 pt-0.5 pb-1 text-xs text-gray-600 dark:text-gray-100 bg-white dark:bg-gray-900 rounded-b-xl flex items-center space-x-1"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-3 h-3"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="line-clamp-1">
|
||||
{$i18n.t(
|
||||
'Tip: Update multiple variable slots consecutively by pressing the tab key in the chat input after each replacement.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class=" text-xs text-gray-600 dark:text-gray-100">
|
||||
{promptItem.title}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
export let history;
|
||||
export let selectedModels;
|
||||
export let showModelSelector = true;
|
||||
export let showBanners = true;
|
||||
|
||||
export let onSaveTempChat: () => {};
|
||||
export let archiveChatHandler: (id: string) => void;
|
||||
@@ -282,30 +281,28 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showBanners}
|
||||
{#each $banners.filter((b) => ![...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]'), ...closedBannerIds].includes(b.id)) as banner (banner.id)}
|
||||
<Banner
|
||||
{banner}
|
||||
on:dismiss={(e) => {
|
||||
const bannerId = e.detail;
|
||||
{#each $banners.filter((b) => ![...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]'), ...closedBannerIds].includes(b.id)) as banner (banner.id)}
|
||||
<Banner
|
||||
{banner}
|
||||
on:dismiss={(e) => {
|
||||
const bannerId = e.detail;
|
||||
|
||||
if (banner.dismissible) {
|
||||
localStorage.setItem(
|
||||
'dismissedBannerIds',
|
||||
JSON.stringify(
|
||||
[
|
||||
bannerId,
|
||||
...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]')
|
||||
].filter((id) => $banners.find((b) => b.id === id))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
closedBannerIds = [...closedBannerIds, bannerId];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
if (banner.dismissible) {
|
||||
localStorage.setItem(
|
||||
'dismissedBannerIds',
|
||||
JSON.stringify(
|
||||
[
|
||||
bannerId,
|
||||
...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]')
|
||||
].filter((id) => $banners.find((b) => b.id === id))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
closedBannerIds = [...closedBannerIds, bannerId];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user