refac/enh: commands ui

This commit is contained in:
Timothy Jaeryang Baek
2025-09-12 20:31:57 +04:00
parent d973db829f
commit 6b69c4da0f
19 changed files with 1052 additions and 847 deletions

View File

@@ -2259,7 +2259,6 @@
bind:selectedModels
shareEnabled={!!history.currentId}
{initNewChat}
showBanners={!showCommands}
archiveChatHandler={() => {}}
{moveChatHandler}
onSaveTempChat={async () => {

View File

@@ -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 ||
!(

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}