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

12
package-lock.json generated
View file

@ -37,6 +37,7 @@
"@tiptap/extensions": "^3.0.7", "@tiptap/extensions": "^3.0.7",
"@tiptap/pm": "^3.0.7", "@tiptap/pm": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7", "@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.15",
@ -3856,18 +3857,17 @@
} }
}, },
"node_modules/@tiptap/suggestion": { "node_modules/@tiptap/suggestion": {
"version": "3.0.9", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.0.9.tgz", "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz",
"integrity": "sha512-irthqfUybezo3IwR6AXvyyTOtkzwfvvst58VXZtTnR1nN6NEcrs3TQoY3bGKGbN83bdiquKh6aU2nLnZfAhoXg==", "integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^3.0.9", "@tiptap/core": "^3.4.2",
"@tiptap/pm": "^3.0.9" "@tiptap/pm": "^3.4.2"
} }
}, },
"node_modules/@tiptap/y-tiptap": { "node_modules/@tiptap/y-tiptap": {

View file

@ -81,6 +81,7 @@
"@tiptap/extensions": "^3.0.7", "@tiptap/extensions": "^3.0.7",
"@tiptap/pm": "^3.0.7", "@tiptap/pm": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7", "@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.15",

View file

@ -753,53 +753,10 @@
e = e.detail.event; e = e.detail.event;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac 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 (commandsContainerElement) { if (!suggestionsContainerElement) {
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 ( if (
!$mobile || !$mobile ||
!( !(

View file

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

View file

@ -76,6 +76,10 @@
import { KokoroWorker } from '$lib/workers/KokoroWorker'; 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'); const i18n = getContext('i18n');
export let onChange: Function = () => {}; export let onChange: Function = () => {};
@ -428,9 +432,9 @@
}; };
let command = ''; let command = '';
export let showCommands = false; export let showCommands = false;
$: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2); $: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
let suggestions = null;
let showTools = false; let showTools = false;
@ -845,6 +849,115 @@
}; };
onMount(async () => { 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; loaded = true;
window.setTimeout(() => { window.setTimeout(() => {
@ -929,78 +1042,6 @@
</div> </div>
{/if} {/if}
</div> </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>
</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" 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'} 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} {#if files.length > 0}
<div class="mx-2 mt-2.5 -mb-1 flex items-center flex-wrap gap-2"> <div class="mx-2 mt-2.5 -mb-1 flex items-center flex-wrap gap-2">
{#each files as file, fileIdx} {#each files as file, fileIdx}
@ -1075,7 +1148,7 @@
<Image <Image
src={file.url} src={file.url}
alt="" alt=""
imageClassName=" size-14 rounded-xl object-cover" imageClassName=" size-10 rounded-xl object-cover"
/> />
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length} {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
<Tooltip <Tooltip
@ -1140,6 +1213,7 @@
loading={file.status === 'uploading'} loading={file.status === 'uploading'}
dismissible={true} dismissible={true}
edit={true} edit={true}
small={true}
modal={['file', 'collection'].includes(file?.type)} modal={['file', 'collection'].includes(file?.type)}
on:dismiss={async () => { on:dismiss={async () => {
// Remove from UI state // 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" 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" id="chat-input-container"
> >
{#key $settings?.showFormattingToolbar ?? false} {#if suggestions}
<RichTextInput {#key $settings?.showFormattingToolbar ?? false}
bind:this={chatInputElement} <RichTextInput
id="chat-input" bind:this={chatInputElement}
onChange={(e) => { id="chat-input"
prompt = e.md; onChange={(e) => {
command = getCommand(); prompt = e.md;
}} command = getCommand();
json={true} }}
messageInput={true} json={true}
showFormattingToolbar={$settings?.showFormattingToolbar ?? false} messageInput={true}
floatingMenuPlacement={'top-start'} showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false} floatingMenuPlacement={'top-start'}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) && insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
(!$mobile || 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 ||
!( !(
'ontouchstart' in window || 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0 navigator.msMaxTouchPoints > 0
) ))}
) { placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
if (inOrNearComposition(e)) { largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
return; 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. const res = await generateAutoCompletion(
// localStorage.token,
// Depending on the user's settings, it will send the message selectedModelIds.at(0),
// either when Enter is pressed or when Ctrl+Enter is pressed. text,
const enterPressed = history?.currentId
($settings?.ctrlEnterToSend ?? false) ? createMessagesList(history, history.currentId)
? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed : null
: (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey; ).catch((error) => {
console.log(error);
if (enterPressed) { return null;
e.preventDefault(); });
if (prompt !== '' || files.length > 0) {
dispatch('submit', prompt); 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') { if (!suggestionsContainerElement) {
console.log('Escape'); if (
atSelectedModel = undefined; !$mobile ||
selectedToolIds = []; !(
selectedFilterIds = []; 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
webSearchEnabled = false; navigator.msMaxTouchPoints > 0
imageGenerationEnabled = false; )
codeInterpreterEnabled = false; ) {
} if (inOrNearComposition(e)) {
}} return;
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) { // Uses keyCode '13' for Enter key for chinese/japanese keyboards.
e.preventDefault(); //
const blob = new Blob([text], { type: 'text/plain' }); // Depending on the user's settings, it will send the message
const file = new File( // either when Enter is pressed or when Ctrl+Enter is pressed.
[blob], const enterPressed =
`Pasted_Text_${Date.now()}.txt`, ($settings?.ctrlEnterToSend ?? false)
{ ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
type: 'text/plain' : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
}
);
await uploadFileHandler(file, true); if (enterPressed) {
e.preventDefault();
if (prompt !== '' || files.length > 0) {
dispatch('submit', prompt);
} }
} }
} }
} }
}
}} if (e.key === 'Escape') {
/> console.log('Escape');
{/key} 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> </div>
{:else} {:else}
<textarea <textarea
@ -1428,8 +1453,8 @@
on:keydown={async (e) => { on:keydown={async (e) => {
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
const commandsContainerElement = const suggestionsContainerElement =
document.getElementById('commands-container'); document.getElementById('suggestions-container');
if (e.key === 'Escape') { if (e.key === 'Escape') {
stopResponse(); stopResponse();
@ -1470,71 +1495,7 @@
editButton?.click(); editButton?.click();
} }
if (commandsContainerElement) { if (!suggestionsContainerElement) {
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 ( if (
!$mobile || !$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 { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils'; import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
import { knowledge } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import { getNoteList, getNotes } from '$lib/apis/notes'; 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'); const i18n = getContext('i18n');
export let command = ''; export let query = '';
export let onSelect = (e) => {}; export let onSelect = (e) => {};
export let knowledge = [];
let selectedIdx = 0; let selectedIdx = 0;
let items = []; let items = [];
let fuse = null; let fuse = null;
let filteredItems = []; export let filteredItems = [];
$: if (fuse) { $: if (fuse) {
filteredItems = command.slice(1) filteredItems = [
? fuse.search(command).map((e) => { ...(query
return e.item; ? fuse.search(query).map((e) => {
}) return e.item;
: items; })
: 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; selectedIdx = 0;
} }
@ -42,32 +61,14 @@
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1); selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
}; };
let container; export const select = async () => {
let adjustHeightDebounce; // find item with data-selected=true
const item = document.querySelector(`[data-selected="true"]`);
const adjustHeight = () => { if (item) {
if (container) { // click the item
if (adjustHeightDebounce) { item.click();
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);
} }
}; };
const confirmSelect = async (type, data) => {
onSelect({
type: type,
data: data
});
};
const decodeString = (str: string) => { const decodeString = (str: string) => {
try { try {
return decodeURIComponent(str); return decodeURIComponent(str);
@ -77,22 +78,7 @@
}; };
onMount(async () => { onMount(async () => {
window.addEventListener('resize', adjustHeight); let legacy_documents = knowledge
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
.filter((item) => item?.meta?.document) .filter((item) => item?.meta?.document)
.map((item) => ({ .map((item) => ({
...item, ...item,
@ -127,16 +113,16 @@
] ]
: []; : [];
let collections = $knowledge let collections = knowledge
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
...item, ...item,
type: 'collection' type: 'collection'
})); }));
let collection_files = let collection_files =
$knowledge.length > 0 knowledge.length > 0
? [ ? [
...$knowledge ...knowledge
.reduce((a, item) => { .reduce((a, item) => {
return [ return [
...new Set([ ...new Set([
@ -158,105 +144,76 @@
] ]
: []; : [];
items = [ items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
...notes, (item) => {
...collections, return {
...collection_files, ...item,
...legacy_collections, ...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
...legacy_documents };
].map((item) => { }
return { );
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, { fuse = new Fuse(items, {
keys: ['name', 'description'] keys: ['name', 'description']
}); });
await tick(); await tick();
adjustHeight();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
}); });
</script> </script>
{#if filteredItems.length > 0 || command?.substring(1).startsWith('http')} <div class="px-2 text-xs text-gray-500 py-1">
<div {$i18n.t('Knowledge')}
id="commands-container" </div>
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="line-clamp-1"> {#if filteredItems.length > 0 || query.startsWith('http')}
{decodeString(item?.name)} {#each filteredItems as item, idx}
</div> {#if !['youtube', 'web'].includes(item.type)}
</div> <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"> <Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
{item?.description} <div class="line-clamp-1 flex-1">
</div> {decodeString(item?.name)}
</div> </div>
</button> </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} {#if !item.legacy && (item?.files ?? []).length > 0}
{#each item?.files ?? [] as file, fileIdx} {#each item?.files ?? [] as file, fileIdx}
<button <button
@ -297,57 +254,63 @@
</div> </div>
{/if} {/if}
</div> --> </div> -->
{/each} {/each}
{#if command.substring(1).startsWith('https://www.youtube.com') || command {#if query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')}
.substring(1) <button
.startsWith('https://youtu.be')} 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"
<button type="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" data-selected={true}
type="button" on:click={() => {
on:click={() => { if (isValidHttpUrl(query)) {
if (isValidHttpUrl(command.substring(1))) { onSelect({
confirmSelect('youtube', command.substring(1)); type: 'youtube',
} else { data: query
toast.error( });
$i18n.t( } else {
'Oops! Looks like the URL is invalid. Please double-check and try again.' 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"> <div class=" text-black dark:text-gray-100 line-clamp-1 flex items-center gap-1">
{command.substring(1)} <Tooltip content={$i18n.t('YouTube')} placement="top">
</div> <Youtube className="size-4" />
</Tooltip>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Youtube')}</div> <div class="truncate flex-1">
</button> {query}
{: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> </div>
</div> </div>
</div> </button>
</div> {: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} {/if}

View file

@ -9,11 +9,11 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let command = ''; export let query = '';
export let onSelect = (e) => {}; export let onSelect = (e) => {};
let selectedIdx = 0; let selectedIdx = 0;
let filteredItems = []; export let filteredItems = [];
let fuse = new Fuse( let fuse = new Fuse(
$models $models
@ -33,13 +33,13 @@
} }
); );
$: filteredItems = command.slice(1) $: filteredItems = query
? fuse.search(command.slice(1)).map((e) => { ? fuse.search(query).map((e) => {
return e.item; return e.item;
}) })
: $models.filter((model) => !model?.info?.meta?.hidden); : $models.filter((model) => !model?.info?.meta?.hidden);
$: if (command) { $: if (query) {
selectedIdx = 0; selectedIdx = 0;
} }
@ -51,85 +51,44 @@
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1); selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
}; };
let container; export const select = async () => {
let adjustHeightDebounce; const model = filteredItems[selectedIdx];
if (model) {
const adjustHeight = () => { onSelect({ type: 'model', data: model });
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);
} }
}; };
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> </script>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Models')}
</div>
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
<div {#each filteredItems as model, modelIdx}
id="commands-container" <button
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10" 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'
<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"> type="button"
<div on:click={() => {
class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden max-h-60" onSelect({ type: 'model', data: model });
id="command-options-container" }}
bind:this={container} on:mousemove={() => {
> selectedIdx = modelIdx;
{#each filteredItems as model, modelIdx} }}
<button on:focus={() => {}}
class="px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx data-selected={modelIdx === selectedIdx}
? 'bg-gray-50 dark:bg-gray-850 selected-command-option-button' >
: ''}" <div class="flex text-black dark:text-gray-100 line-clamp-1">
type="button" <img
on:click={() => { src={model?.info?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
confirmSelect(model); alt={model?.name ?? model.id}
}} class="rounded-full size-5 items-center mr-2"
on:mousemove={() => { />
selectedIdx = modelIdx; <div class="truncate">
}} {model.name}
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}
</div> </div>
</div> </div>
</div> </button>
</div> {/each}
{/if} {/if}

View file

@ -1,140 +1,71 @@
<script lang="ts"> <script lang="ts">
import { prompts, settings, user } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import {
extractCurlyBraceWords,
getUserPosition,
getFormattedDate,
getFormattedTime,
getCurrentDateTime,
getUserTimezone,
getWeekday
} from '$lib/utils';
import { tick, getContext, onMount, onDestroy } from 'svelte'; import { tick, getContext, onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let command = ''; export let query = '';
export let prompts = [];
export let onSelect = (e) => {}; export let onSelect = (e) => {};
let selectedPromptIdx = 0; let selectedPromptIdx = 0;
let filteredPrompts = []; export let filteredItems = [];
$: filteredPrompts = $prompts $: filteredItems = prompts
.filter((p) => p.command.toLowerCase().includes(command.toLowerCase())) .filter((p) => p.command.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.title.localeCompare(b.title)); .sort((a, b) => a.title.localeCompare(b.title));
$: if (command) { $: if (query) {
selectedPromptIdx = 0; selectedPromptIdx = 0;
} }
export const selectUp = () => { export const selectUp = () => {
selectedPromptIdx = Math.max(0, selectedPromptIdx - 1); selectedPromptIdx = Math.max(0, selectedPromptIdx - 1);
}; };
export const selectDown = () => { export const selectDown = () => {
selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1); selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredItems.length - 1);
}; };
let container; export const select = async () => {
let adjustHeightDebounce; const command = filteredItems[selectedPromptIdx];
if (command) {
const adjustHeight = () => { onSelect({ type: 'prompt', data: command });
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);
} }
}; };
const confirmPrompt = async (command) => {
onSelect({ type: 'prompt', data: command });
};
onMount(async () => {
window.addEventListener('resize', adjustHeight);
await tick();
adjustHeight();
});
onDestroy(() => {
window.removeEventListener('resize', adjustHeight);
});
</script> </script>
{#if filteredPrompts.length > 0} <div class="px-2 text-xs text-gray-500 py-1">
<div {$i18n.t('Prompts')}
id="commands-container" </div>
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
> {#if filteredItems.length > 0}
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850"> <div class=" space-y-0.5 scrollbar-hidden">
<div class="flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"> {#each filteredItems as promptItem, promptIdx}
<div <Tooltip content={promptItem.title} placement="top-start">
class="m-1 overflow-y-auto p-1 space-y-0.5 scrollbar-hidden max-h-60" <button
id="command-options-container" class=" px-3 py-1 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
bind:this={container} ? ' 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} <span class=" font-medium text-black dark:text-gray-100">
<button {promptItem.command}
class=" px-3 py-1.5 rounded-xl w-full text-left {promptIdx === selectedPromptIdx </span>
? ' 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>
<div class=" text-xs text-gray-600 dark:text-gray-100"> <span class=" text-xs text-gray-600 dark:text-gray-100">
{promptItem.title} {promptItem.title}
</div> </span>
</button> </button>
{/each} </Tooltip>
</div> {/each}
<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>
</div> </div>
{/if} {/if}

View file

@ -47,7 +47,6 @@
export let history; export let history;
export let selectedModels; export let selectedModels;
export let showModelSelector = true; export let showModelSelector = true;
export let showBanners = true;
export let onSaveTempChat: () => {}; export let onSaveTempChat: () => {};
export let archiveChatHandler: (id: string) => void; export let archiveChatHandler: (id: string) => void;
@ -282,30 +281,28 @@
/> />
{/if} {/if}
{#if showBanners} {#each $banners.filter((b) => ![...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]'), ...closedBannerIds].includes(b.id)) as banner (banner.id)}
{#each $banners.filter((b) => ![...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]'), ...closedBannerIds].includes(b.id)) as banner (banner.id)} <Banner
<Banner {banner}
{banner} on:dismiss={(e) => {
on:dismiss={(e) => { const bannerId = e.detail;
const bannerId = e.detail;
if (banner.dismissible) { if (banner.dismissible) {
localStorage.setItem( localStorage.setItem(
'dismissedBannerIds', 'dismissedBannerIds',
JSON.stringify( JSON.stringify(
[ [
bannerId, bannerId,
...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]') ...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]')
].filter((id) => $banners.find((b) => b.id === id)) ].filter((id) => $banners.find((b) => b.id === id))
) )
); );
} else { } else {
closedBannerIds = [...closedBannerIds, bannerId]; closedBannerIds = [...closedBannerIds, bannerId];
} }
}} }}
/> />
{/each} {/each}
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -13,7 +13,8 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let className = 'w-60'; export let className = 'w-60';
export let colorClassName = 'bg-white dark:bg-gray-850 border border-gray-50 dark:border-white/5'; export let colorClassName =
'bg-white dark:bg-gray-850 border border-gray-50 dark:border-gray-800';
export let url: string | null = null; export let url: string | null = null;
export let dismissible = false; export let dismissible = false;
@ -28,8 +29,8 @@
export let type: string; export let type: string;
export let size: number; export let size: number;
import { deleteFileById } from '$lib/apis/files'; import DocumentPage from '../icons/DocumentPage.svelte';
import Database from '../icons/Database.svelte';
let showModal = false; let showModal = false;
const decodeString = (str: string) => { const decodeString = (str: string) => {
@ -47,7 +48,7 @@
<button <button
class="relative group p-1.5 {className} flex items-center gap-1 {colorClassName} {small class="relative group p-1.5 {className} flex items-center gap-1 {colorClassName} {small
? 'rounded-xl' ? 'rounded-xl p-2'
: 'rounded-2xl'} text-left" : 'rounded-2xl'} text-left"
type="button" type="button"
on:click={async () => { on:click={async () => {
@ -91,6 +92,23 @@
<Spinner /> <Spinner />
{/if} {/if}
</div> </div>
{:else}
<div class="pl-1">
{#if !loading}
<Tooltip
content={type === 'collection' ? $i18n.t('Collection') : $i18n.t('Document')}
placement="top"
>
{#if type === 'collection'}
<Database />
{:else}
<DocumentPage />
{/if}
</Tooltip>
{:else}
<Spinner />
{/if}
</div>
{/if} {/if}
{#if !small} {#if !small}
@ -120,7 +138,7 @@
</div> </div>
{:else} {:else}
<Tooltip content={decodeString(name)} className="flex flex-col w-full" placement="top-start"> <Tooltip content={decodeString(name)} className="flex flex-col w-full" placement="top-start">
<div class="flex flex-col justify-center -space-y-0.5 px-2.5 w-full"> <div class="flex flex-col justify-center -space-y-0.5 px-1 w-full">
<div class=" dark:text-gray-100 text-sm flex justify-between items-center"> <div class=" dark:text-gray-100 text-sm flex justify-between items-center">
{#if loading} {#if loading}
<div class=" shrink-0 mr-2"> <div class=" shrink-0 mr-2">
@ -128,7 +146,11 @@
</div> </div>
{/if} {/if}
<div class="font-medium line-clamp-1 flex-1">{decodeString(name)}</div> <div class="font-medium line-clamp-1 flex-1">{decodeString(name)}</div>
<div class="text-gray-500 text-xs capitalize shrink-0">{formatFileSize(size)}</div> {#if size}
<div class="text-gray-500 text-xs capitalize shrink-0">{formatFileSize(size)}</div>
{:else}
<div class="text-gray-500 text-xs capitalize shrink-0">{type}</div>
{/if}
</div> </div>
</div> </div>
</Tooltip> </Tooltip>

View file

@ -137,13 +137,13 @@
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Mention from '@tiptap/extension-mention'; import Mention from '@tiptap/extension-mention';
import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
import { all, createLowlight } from 'lowlight';
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { all, createLowlight } from 'lowlight';
import FormattingButtons from './RichTextInput/FormattingButtons.svelte'; import MentionList from './RichTextInput/MentionList.svelte';
import { duration } from 'dayjs'; import { getSuggestionRenderer } from './RichTextInput/suggestions.js';
export let oncompositionstart = (e) => {}; export let oncompositionstart = (e) => {};
export let oncompositionend = (e) => {}; export let oncompositionend = (e) => {};
@ -166,6 +166,8 @@
export let image = false; export let image = false;
export let fileHandler = false; export let fileHandler = false;
export let suggestions = null;
export let onFileDrop = (currentEditor, files, pos) => { export let onFileDrop = (currentEditor, files, pos) => {
files.forEach((file) => { files.forEach((file) => {
const fileReader = new FileReader(); const fileReader = new FileReader();
@ -951,6 +953,7 @@
} }
console.log(bubbleMenuElement, floatingMenuElement); console.log(bubbleMenuElement, floatingMenuElement);
console.log(suggestions);
editor = new Editor({ editor = new Editor({
element: element, element: element,
@ -966,12 +969,14 @@
}), }),
Highlight, Highlight,
Typography, Typography,
...(suggestions
Mention.configure({ ? [
HTMLAttributes: { Mention.configure({
class: 'mention' HTMLAttributes: { class: 'mention' },
} suggestions: suggestions
}), })
]
: []),
TableKit.configure({ TableKit.configure({
table: { resizable: true } table: { resizable: true }
@ -1143,12 +1148,13 @@
if (event.key === 'Enter') { if (event.key === 'Enter') {
const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
const { state } = view;
const { $from } = state.selection;
const lineStart = $from.before($from.depth);
const lineEnd = $from.after($from.depth);
const lineText = state.doc.textBetween(lineStart, lineEnd, '\n', '\0').trim();
if (event.shiftKey && !isCtrlPressed) { if (event.shiftKey && !isCtrlPressed) {
const { state } = view;
const { $from } = state.selection;
const lineStart = $from.before($from.depth);
const lineEnd = $from.after($from.depth);
const lineText = state.doc.textBetween(lineStart, lineEnd, '\n', '\0').trim();
if (lineText.startsWith('```')) { if (lineText.startsWith('```')) {
// Fix GitHub issue #16337: prevent backtick removal for lines starting with ``` // Fix GitHub issue #16337: prevent backtick removal for lines starting with ```
return false; // Let ProseMirror handle the Enter key normally return false; // Let ProseMirror handle the Enter key normally
@ -1163,10 +1169,18 @@
const isInList = isInside(['listItem', 'bulletList', 'orderedList', 'taskList']); const isInList = isInside(['listItem', 'bulletList', 'orderedList', 'taskList']);
const isInHeading = isInside(['heading']); const isInHeading = isInside(['heading']);
console.log({ isInCodeBlock, isInList, isInHeading });
if (isInCodeBlock || isInList || isInHeading) { if (isInCodeBlock || isInList || isInHeading) {
// Let ProseMirror handle the normal Enter behavior // Let ProseMirror handle the normal Enter behavior
return false; return false;
} }
const suggestionsElement = document.getElementById('suggestions-container');
if (lineText.startsWith('#') && suggestionsElement) {
console.log('Letting heading suggestion handle Enter key');
return true;
}
} }
} }

View file

@ -22,7 +22,7 @@
</script> </script>
<div <div
class="flex gap-0.5 p-0.5 rounded-lg shadow-lg bg-white text-gray-800 dark:text-white dark:bg-gray-800 min-w-fit" class="flex gap-0.5 p-0.5 rounded-xl shadow-lg bg-white text-gray-800 dark:text-white dark:bg-gray-850 min-w-fit border border-gray-100 dark:border-gray-800"
> >
<Tooltip placement="top" content={$i18n.t('H1')}> <Tooltip placement="top" content={$i18n.t('H1')}>
<button <button

View file

@ -0,0 +1,85 @@
<script lang="ts">
export let query = '';
export let command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0;
let ITEMS = [
{ id: '1', label: 'alice' },
{ id: '2', label: 'alex' },
{ id: '3', label: 'bob' },
{ id: '4', label: 'charlie' },
{ id: '5', label: 'diana' },
{ id: '6', label: 'eve' },
{ id: '7', label: 'frank' },
{ id: '8', label: 'grace' },
{ id: '9', label: 'heidi' },
{ id: '10', label: 'ivan' },
{ id: '11', label: 'judy' },
{ id: '12', label: 'mallory' },
{ id: '13', label: 'oscar' },
{ id: '14', label: 'peggy' },
{ id: '15', label: 'trent' },
{ id: '16', label: 'victor' },
{ id: '17', label: 'walter' }
];
let items = ITEMS;
$: items = ITEMS.filter((u) => u.label.toLowerCase().includes(query.toLowerCase())).slice(0, 5);
const select = (index: number) => {
const item = items[index];
if (item) command(item);
};
const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') {
selectedIndex = (selectedIndex + items.length - 1) % items.length;
return true;
}
if (event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % items.length;
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
select(selectedIndex);
return true;
}
if (event.key === 'Escape') {
// tell tiptap we handled it (it will close)
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="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 overflow-y-auto scrollbar-thin max-h-60 w-52"
id="suggestions-container"
>
{#if items.length === 0}
<div class=" p-4 text-gray-400">No results</div>
{:else}
{#each items as item, i}
<button
type="button"
on:click={() => select(i)}
class=" text-left w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition px-3 py-1 {i ===
selectedIndex
? 'bg-gray-50 dark:bg-gray-800 font-medium'
: ''}"
>
@{item.label}
</button>
{/each}
{/if}
</div>

View file

@ -0,0 +1,26 @@
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
export default Extension.create({
name: 'commands',
addOptions() {
return {
suggestion: {
char: '/',
command: ({ editor, range, props }) => {
props.command({ editor, range });
}
}
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
})
];
}
});

View file

@ -0,0 +1,69 @@
import tippy from 'tippy.js';
export function getSuggestionRenderer(Component: any, ComponentProps = {}) {
return function suggestionRenderer() {
let component = null;
let container: HTMLDivElement | null = null;
let popup: TippyInstance | null = null;
return {
onStart: (props: any) => {
container = document.createElement('div');
container.className = 'suggestion-list-container';
document.body.appendChild(container);
// mount Svelte component
component = new Component({
target: container,
props: {
char: props?.text,
command: (item) => {
props.command({ id: item.id, label: item.label });
},
...ComponentProps
},
context: new Map<string, any>([['i18n', ComponentProps?.i18n]])
});
popup = tippy(document.body, {
getReferenceClientRect: props.clientRect as any,
appendTo: () => document.body,
content: container, // ✅ real element, not Svelte internals
interactive: true,
trigger: 'manual',
theme: 'transparent',
placement: 'top-start',
offset: [-10, -2],
arrow: false
});
popup?.show();
},
onUpdate: (props: any) => {
if (!component) return;
component.$set({ query: props.query });
if (props.clientRect && popup) {
popup.setProps({ getReferenceClientRect: props.clientRect as any });
}
},
onKeyDown: (props: any) => {
// forward to the Svelte components handler
// (expose this from component as `export function onKeyDown(evt)`)
// @ts-ignore
return component?._onKeyDown?.(props.event) ?? false;
},
onExit: () => {
popup?.destroy();
popup = null;
component?.$destroy();
component = null;
if (container?.parentNode) container.parentNode.removeChild(container);
container = null;
}
};
};
}

View file

@ -0,0 +1,16 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
><path d="M5 12V18C5 18 5 21 12 21C19 21 19 18 19 18V12"></path><path
d="M5 6V12C5 12 5 15 12 15C19 15 19 12 19 12V6"
></path><path d="M12 3C19 3 19 6 19 6C19 6 19 9 12 9C5 9 5 6 5 6C5 6 5 3 12 3Z"></path></svg
>

View file

@ -0,0 +1,26 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
><path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
stroke-linecap="round"
stroke-linejoin="round"
></path><path d="M8 10L16 10" stroke-linecap="round" stroke-linejoin="round"></path><path
d="M8 18L16 18"
stroke-linecap="round"
stroke-linejoin="round"
></path><path d="M8 14L12 14" stroke-linecap="round" stroke-linejoin="round"></path><path
d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>

View file

@ -0,0 +1,16 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
><path d="M14 12L10.5 14V10L14 12Z" stroke-linecap="round" stroke-linejoin="round"></path><path
d="M2 12.7075V11.2924C2 8.39705 2 6.94939 2.90549 6.01792C3.81099 5.08645 5.23656 5.04613 8.08769 4.96549C9.43873 4.92728 10.8188 4.8999 12 4.8999C13.1812 4.8999 14.5613 4.92728 15.9123 4.96549C18.7634 5.04613 20.189 5.08645 21.0945 6.01792C22 6.94939 22 8.39705 22 11.2924V12.7075C22 15.6028 22 17.0505 21.0945 17.9819C20.189 18.9134 18.7635 18.9537 15.9124 19.0344C14.5613 19.0726 13.1812 19.1 12 19.1C10.8188 19.1 9.43867 19.0726 8.0876 19.0344C5.23651 18.9537 3.81097 18.9134 2.90548 17.9819C2 17.0505 2 15.6028 2 12.7075Z"
></path></svg
>