refac: input commands

This commit is contained in:
Timothy J. Baek 2024-08-23 14:31:39 +02:00
parent 64c0157271
commit 591962d906
7 changed files with 304 additions and 331 deletions

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import { import {
type Model, type Model,
mobile, mobile,
@ -12,15 +13,9 @@
tools, tools,
user as _user user as _user
} from '$lib/stores'; } from '$lib/stores';
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils'; import { blobToFile, findWordIndices } from '$lib/utils';
import { processDocToVectorDB } from '$lib/apis/rag';
import { import { transcribeAudio } from '$lib/apis/audio';
processDocToVectorDB,
uploadDocToVectorDB,
uploadWebToVectorDB,
uploadYoutubeTranscriptionToVectorDB
} from '$lib/apis/rag';
import { uploadFile } from '$lib/apis/files'; import { uploadFile } from '$lib/apis/files';
import { import {
SUPPORTED_FILE_TYPE, SUPPORTED_FILE_TYPE,
@ -29,19 +24,14 @@
WEBUI_API_BASE_URL WEBUI_API_BASE_URL
} from '$lib/constants'; } from '$lib/constants';
import Prompts from './MessageInput/PromptCommands.svelte';
import Suggestions from './MessageInput/Suggestions.svelte';
import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte';
import Documents from './MessageInput/Documents.svelte';
import Models from './MessageInput/Models.svelte';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import InputMenu from './MessageInput/InputMenu.svelte'; import InputMenu from './MessageInput/InputMenu.svelte';
import Headphone from '../icons/Headphone.svelte'; import Headphone from '../icons/Headphone.svelte';
import VoiceRecording from './MessageInput/VoiceRecording.svelte'; import VoiceRecording from './MessageInput/VoiceRecording.svelte';
import { transcribeAudio } from '$lib/apis/audio';
import FileItem from '../common/FileItem.svelte'; import FileItem from '../common/FileItem.svelte';
import FilesOverlay from './MessageInput/FilesOverlay.svelte'; import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import Commands from './MessageInput/Commands.svelte';
import XMark from '../icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -60,9 +50,7 @@
let chatTextAreaElement: HTMLTextAreaElement; let chatTextAreaElement: HTMLTextAreaElement;
let filesInputElement; let filesInputElement;
let promptsElement; let commandsElement;
let documentsElement;
let modelsElement;
let inputFiles; let inputFiles;
let dragged = false; let dragged = false;
@ -180,62 +168,6 @@
} }
}; };
const uploadWeb = async (url) => {
console.log(url);
const doc = {
type: 'doc',
name: url,
collection_name: '',
status: false,
url: url,
error: ''
};
try {
files = [...files, doc];
const res = await uploadWebToVectorDB(localStorage.token, '', url);
if (res) {
doc.status = 'processed';
doc.collection_name = res.collection_name;
files = files;
}
} catch (e) {
// Remove the failed doc from the files array
files = files.filter((f) => f.name !== url);
toast.error(e);
}
};
const uploadYoutubeTranscription = async (url) => {
console.log(url);
const doc = {
type: 'doc',
name: url,
collection_name: '',
status: false,
url: url,
error: ''
};
try {
files = [...files, doc];
const res = await uploadYoutubeTranscriptionToVectorDB(localStorage.token, url);
if (res) {
doc.status = 'processed';
doc.collection_name = res.collection_name;
files = files;
}
} catch (e) {
// Remove the failed doc from the files array
files = files.filter((f) => f.name !== url);
toast.error(e);
}
};
onMount(() => { onMount(() => {
window.setTimeout(() => chatTextAreaElement?.focus(), 0); window.setTimeout(() => chatTextAreaElement?.focus(), 0);
@ -346,48 +278,9 @@
</div> </div>
<div class="w-full relative"> <div class="w-full relative">
{#if prompt.charAt(0) === '/'}
<Prompts bind:this={promptsElement} bind:prompt bind:files />
{:else if prompt.charAt(0) === '#'}
<Documents
bind:this={documentsElement}
bind:prompt
on:youtube={(e) => {
console.log(e);
uploadYoutubeTranscription(e.detail);
}}
on:url={(e) => {
console.log(e);
uploadWeb(e.detail);
}}
on:select={(e) => {
console.log(e);
files = [
...files,
{
type: e?.detail?.type ?? 'file',
...e.detail,
status: 'processed'
}
];
}}
/>
{/if}
<Models
bind:this={modelsElement}
bind:prompt
bind:chatInputPlaceholder
{messages}
on:select={(e) => {
atSelectedModel = e.detail;
chatTextAreaElement?.focus();
}}
/>
{#if atSelectedModel !== undefined} {#if atSelectedModel !== undefined}
<div <div
class="px-3 py-2.5 text-left w-full flex justify-between items-center absolute bottom-0 left-0 right-0 bg-gradient-to-t from-50% from-white dark:from-gray-900 z-50" class="px-3 py-2.5 text-left w-full flex justify-between items-center absolute bottom-0 left-0 right-0 bg-gradient-to-t from-50% from-white dark:from-gray-900 z-10"
> >
<div class="flex items-center gap-2 text-sm dark:text-gray-500"> <div class="flex items-center gap-2 text-sm dark:text-gray-500">
<img <img
@ -416,6 +309,21 @@
</div> </div>
</div> </div>
{/if} {/if}
<Commands
bind:this={commandsElement}
bind:prompt
bind:files
on:select={(e) => {
const data = e.detail;
if (data?.type === 'model') {
atSelectedModel = data.data;
}
chatTextAreaElement?.focus();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -641,6 +549,7 @@
}} }}
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 = document.getElementById('commands-container');
// Check if Ctrl + R is pressed // Check if Ctrl + R is pressed
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') { if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
@ -671,10 +580,9 @@
editButton?.click(); editButton?.click();
} }
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') { if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
commandsElement.selectUp();
(promptsElement || documentsElement || modelsElement).selectUp();
const commandOptionButton = [ const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button') ...document.getElementsByClassName('selected-command-option-button')
@ -682,10 +590,9 @@
commandOptionButton.scrollIntoView({ block: 'center' }); commandOptionButton.scrollIntoView({ block: 'center' });
} }
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') { if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
commandsElement.selectDown();
(promptsElement || documentsElement || modelsElement).selectDown();
const commandOptionButton = [ const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button') ...document.getElementsByClassName('selected-command-option-button')
@ -693,7 +600,7 @@
commandOptionButton.scrollIntoView({ block: 'center' }); commandOptionButton.scrollIntoView({ block: 'center' });
} }
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') { if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const commandOptionButton = [ const commandOptionButton = [
@ -709,7 +616,7 @@
} }
} }
if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') { if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
const commandOptionButton = [ const commandOptionButton = [
@ -789,7 +696,7 @@
type="button" type="button"
on:click={async () => { on:click={async () => {
try { try {
const res = await navigator.mediaDevices let stream = await navigator.mediaDevices
.getUserMedia({ audio: true }) .getUserMedia({ audio: true })
.catch(function (err) { .catch(function (err) {
toast.error( toast.error(
@ -803,9 +710,12 @@
return null; return null;
}); });
if (res) { if (stream) {
recording = true; recording = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
} }
stream = null;
} catch { } catch {
toast.error($i18n.t('Permission denied when accessing microphone')); toast.error($i18n.t('Permission denied when accessing microphone'));
} }

View File

@ -0,0 +1,131 @@
<script>
import { createEventDispatcher } from 'svelte';
import { toast } from 'svelte-sonner';
const dispatch = createEventDispatcher();
import Prompts from './Commands/Prompts.svelte';
import Documents from './Commands/Documents.svelte';
import Models from './Commands/Models.svelte';
import { removeLastWordFromString } from '$lib/utils';
import { uploadWebToVectorDB, uploadYoutubeTranscriptionToVectorDB } from '$lib/apis/rag';
export let prompt = '';
export let files = [];
let commandElement = null;
export const selectUp = () => {
commandElement?.selectUp();
};
export const selectDown = () => {
commandElement?.selectDown();
};
let command = '';
$: command = (prompt?.trim() ?? '').split(' ')?.at(-1) ?? '';
const uploadWeb = async (url) => {
console.log(url);
const doc = {
type: 'doc',
name: url,
collection_name: '',
status: false,
url: url,
error: ''
};
try {
files = [...files, doc];
const res = await uploadWebToVectorDB(localStorage.token, '', url);
if (res) {
doc.status = 'processed';
doc.collection_name = res.collection_name;
files = files;
}
} catch (e) {
// Remove the failed doc from the files array
files = files.filter((f) => f.name !== url);
toast.error(e);
}
};
const uploadYoutubeTranscription = async (url) => {
console.log(url);
const doc = {
type: 'doc',
name: url,
collection_name: '',
status: false,
url: url,
error: ''
};
try {
files = [...files, doc];
const res = await uploadYoutubeTranscriptionToVectorDB(localStorage.token, url);
if (res) {
doc.status = 'processed';
doc.collection_name = res.collection_name;
files = files;
}
} catch (e) {
// Remove the failed doc from the files array
files = files.filter((f) => f.name !== url);
toast.error(e);
}
};
</script>
{#if ['/', '#', '@'].includes(command?.charAt(0))}
{#if command?.charAt(0) === '/'}
<Prompts bind:this={commandElement} bind:prompt bind:files {command} />
{:else if command?.charAt(0) === '#'}
<Documents
bind:this={commandElement}
bind:prompt
{command}
on:youtube={(e) => {
console.log(e);
uploadYoutubeTranscription(e.detail);
}}
on:url={(e) => {
console.log(e);
uploadWeb(e.detail);
}}
on:select={(e) => {
console.log(e);
files = [
...files,
{
type: e?.detail?.type ?? 'file',
...e.detail,
status: 'processed'
}
];
dispatch('select');
}}
/>
{:else if command?.charAt(0) === '@'}
<Models
bind:this={commandElement}
{command}
on:select={(e) => {
prompt = removeLastWordFromString(prompt, command);
dispatch('select', {
type: 'model',
data: e.detail
});
}}
/>
{/if}
{/if}

View File

@ -9,6 +9,7 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let prompt = ''; export let prompt = '';
export let command = '';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let selectedIdx = 0; let selectedIdx = 0;
@ -43,16 +44,16 @@
]; ];
$: filteredCollections = collections $: filteredCollections = collections
.filter((collection) => findByName(collection, prompt)) .filter((collection) => findByName(collection, command))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
$: filteredDocs = $documents $: filteredDocs = $documents
.filter((doc) => findByName(doc, prompt)) .filter((doc) => findByName(doc, command))
.sort((a, b) => a.title.localeCompare(b.title)); .sort((a, b) => a.title.localeCompare(b.title));
$: filteredItems = [...filteredCollections, ...filteredDocs]; $: filteredItems = [...filteredCollections, ...filteredDocs];
$: if (prompt) { $: if (command) {
selectedIdx = 0; selectedIdx = 0;
console.log(filteredCollections); console.log(filteredCollections);
@ -62,9 +63,9 @@
name: string; name: string;
}; };
const findByName = (obj: ObjectWithName, prompt: string) => { const findByName = (obj: ObjectWithName, command: string) => {
const name = obj.name.toLowerCase(); const name = obj.name.toLowerCase();
return name.includes(prompt.toLowerCase().split(' ')?.at(0)?.substring(1) ?? ''); return name.includes(command.toLowerCase().split(' ')?.at(0)?.substring(1) ?? '');
}; };
export const selectUp = () => { export const selectUp = () => {
@ -110,7 +111,10 @@
</script> </script>
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"> <div
id="commands-container"
class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg"> <div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center"> <div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">#</div> <div class=" text-lg font-semibold mt-2">#</div>

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { tick, getContext } from 'svelte';
import { models } from '$lib/stores';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let command = '';
let selectedIdx = 0;
let filteredModels = [];
$: filteredModels = $models
.filter((p) =>
p.name.toLowerCase().includes(command.toLowerCase().split(' ')?.at(0)?.substring(1) ?? '')
)
.sort((a, b) => a.name.localeCompare(b.name));
$: if (command) {
selectedIdx = 0;
}
export const selectUp = () => {
selectedIdx = Math.max(0, selectedIdx - 1);
};
export const selectDown = () => {
selectedIdx = Math.min(selectedIdx + 1, filteredModels.length - 1);
};
const confirmSelect = async (model) => {
command = '';
dispatch('select', model);
};
onMount(async () => {
await tick();
const chatInputElement = document.getElementById('chat-textarea');
await tick();
chatInputElement?.focus();
await tick();
});
</script>
{#if filteredModels.length > 0}
<div
id="commands-container"
class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">@</div>
</div>
<div
class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100"
>
<div class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden">
{#each filteredModels as model, modelIdx}
<button
class="px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx
? 'bg-gray-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 ?? '/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>
{/if}

View File

@ -7,27 +7,30 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let files; export let files;
export let prompt = '';
let selectedCommandIdx = 0;
let filteredPromptCommands = [];
$: filteredPromptCommands = $prompts export let prompt = '';
.filter((p) => p.command.toLowerCase().includes(prompt.toLowerCase())) export let command = '';
let selectedPromptIdx = 0;
let filteredPrompts = [];
$: filteredPrompts = $prompts
.filter((p) => p.command.toLowerCase().includes(command.toLowerCase()))
.sort((a, b) => a.title.localeCompare(b.title)); .sort((a, b) => a.title.localeCompare(b.title));
$: if (prompt) { $: if (command) {
selectedCommandIdx = 0; selectedPromptIdx = 0;
} }
export const selectUp = () => { export const selectUp = () => {
selectedCommandIdx = Math.max(0, selectedCommandIdx - 1); selectedPromptIdx = Math.max(0, selectedPromptIdx - 1);
}; };
export const selectDown = () => { export const selectDown = () => {
selectedCommandIdx = Math.min(selectedCommandIdx + 1, filteredPromptCommands.length - 1); selectedPromptIdx = Math.min(selectedPromptIdx + 1, filteredPrompts.length - 1);
}; };
const confirmCommand = async (command) => { const confirmPrompt = async (command) => {
let text = command.content; let text = command.content;
if (command.content.includes('{{CLIPBOARD}}')) { if (command.content.includes('{{CLIPBOARD}}')) {
@ -79,7 +82,6 @@
await tick(); await tick();
const words = findWordIndices(prompt); const words = findWordIndices(prompt);
if (words.length > 0) { if (words.length > 0) {
const word = words.at(0); const word = words.at(0);
chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1); chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1);
@ -87,8 +89,11 @@
}; };
</script> </script>
{#if filteredPromptCommands.length > 0} {#if filteredPrompts.length > 0}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"> <div
id="commands-container"
class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg"> <div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center"> <div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">/</div> <div class=" text-lg font-semibold mt-2">/</div>
@ -98,26 +103,26 @@
class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100" class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100"
> >
<div class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden"> <div class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden">
{#each filteredPromptCommands as command, commandIdx} {#each filteredPrompts as prompt, promptIdx}
<button <button
class=" px-3 py-1.5 rounded-xl w-full text-left {commandIdx === selectedCommandIdx class=" px-3 py-1.5 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
? ' bg-gray-50 dark:bg-gray-850 selected-command-option-button' ? ' bg-gray-50 dark:bg-gray-850 selected-command-option-button'
: ''}" : ''}"
type="button" type="button"
on:click={() => { on:click={() => {
confirmCommand(command); confirmPrompt(prompt);
}} }}
on:mousemove={() => { on:mousemove={() => {
selectedCommandIdx = commandIdx; selectedPromptIdx = promptIdx;
}} }}
on:focus={() => {}} on:focus={() => {}}
> >
<div class=" font-medium text-black dark:text-gray-100"> <div class=" font-medium text-black dark:text-gray-100">
{command.command} {prompt.command}
</div> </div>
<div class=" text-xs text-gray-600 dark:text-gray-100"> <div class=" text-xs text-gray-600 dark:text-gray-100">
{command.title} {prompt.title}
</div> </div>
</button> </button>
{/each} {/each}

View File

@ -1,181 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { generatePrompt } from '$lib/apis/ollama';
import { models } from '$lib/stores';
import { splitStream } from '$lib/utils';
import { tick, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let prompt = '';
export let user = null;
export let chatInputPlaceholder = '';
export let messages = [];
let selectedIdx = 0;
let filteredModels = [];
$: filteredModels = $models
.filter((p) =>
p.name.toLowerCase().includes(prompt.toLowerCase().split(' ')?.at(0)?.substring(1) ?? '')
)
.sort((a, b) => a.name.localeCompare(b.name));
$: if (prompt) {
selectedIdx = 0;
}
export const selectUp = () => {
selectedIdx = Math.max(0, selectedIdx - 1);
};
export const selectDown = () => {
selectedIdx = Math.min(selectedIdx + 1, filteredModels.length - 1);
};
const confirmSelect = async (model) => {
prompt = '';
dispatch('select', model);
};
const confirmSelectCollaborativeChat = async (model) => {
// dispatch('select', model);
prompt = '';
user = JSON.parse(JSON.stringify(model.name));
await tick();
chatInputPlaceholder = $i18n.t('{{modelName}} is thinking...', { modelName: model.name });
const chatInputElement = document.getElementById('chat-textarea');
await tick();
chatInputElement?.focus();
await tick();
const convoText = messages.reduce((a, message, i, arr) => {
return `${a}### ${message.role.toUpperCase()}\n${message.content}\n\n`;
}, '');
const res = await generatePrompt(localStorage.token, model.name, convoText);
if (res && res.ok) {
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
let data = JSON.parse(line);
if ('detail' in data) {
throw data;
}
if ('id' in data) {
console.log(data);
} else {
if (data.done == false) {
if (prompt == '' && data.response == '\n') {
continue;
} else {
prompt += data.response;
console.log(data.response);
chatInputElement.scrollTop = chatInputElement.scrollHeight;
await tick();
}
}
}
}
}
} catch (error) {
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
}
break;
}
}
} else {
if (res !== null) {
const error = await res.json();
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
} else {
toast.error(error.error);
}
} else {
toast.error(
$i18n.t('Uh-oh! There was an issue connecting to {{provider}}.', { provider: 'llama' })
);
}
}
chatInputPlaceholder = '';
console.log(user);
};
</script>
{#if prompt.charAt(0) === '@'}
{#if filteredModels.length > 0}
<div class="pl-1 pr-12 mb-3 text-left w-full absolute bottom-0 left-0 right-0 z-10">
<div class="flex w-full dark:border dark:border-gray-850 rounded-lg">
<div class=" bg-gray-50 dark:bg-gray-850 w-10 rounded-l-lg text-center">
<div class=" text-lg font-semibold mt-2">@</div>
</div>
<div
class="max-h-60 flex flex-col w-full rounded-r-lg bg-white dark:bg-gray-900 dark:text-gray-100"
>
<div class="m-1 overflow-y-auto p-1 rounded-r-lg space-y-0.5 scrollbar-hidden">
{#each filteredModels as model, modelIdx}
<button
class="px-3 py-1.5 rounded-xl w-full text-left {modelIdx === selectedIdx
? 'bg-gray-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 ?? '/static/favicon.png'}
alt={model?.name ?? model.id}
class="rounded-full size-6 items-center mr-2"
/>
{model.name}
</div>
<!-- <div class=" text-xs text-gray-600 line-clamp-1">
{doc.title}
</div> -->
</button>
{/each}
</div>
</div>
</div>
</div>
{/if}
{/if}

View File

@ -288,6 +288,20 @@ export const findWordIndices = (text) => {
return matches; return matches;
}; };
export const removeLastWordFromString = (inputString, wordString) => {
// Split the string into an array of words
const words = inputString.split(' ');
if (words.at(-1) === wordString) {
words.pop();
}
// Join the remaining words back into a string
const resultString = words.join(' ');
return resultString;
};
export const removeFirstHashWord = (inputString) => { export const removeFirstHashWord = (inputString) => {
// Split the string into an array of words // Split the string into an array of words
const words = inputString.split(' '); const words = inputString.split(' ');