fix: various rich text input issues

#15140
This commit is contained in:
Timothy Jaeryang Baek 2025-07-04 20:26:01 +04:00
parent 9b5da77ffc
commit 2e2a63c201
8 changed files with 555 additions and 304 deletions

View file

@ -96,6 +96,8 @@
let controlPane;
let controlPaneComponent;
let messageInput;
let autoScroll = true;
let processing = '';
let messagesContainerElement: HTMLDivElement;
@ -140,24 +142,39 @@
let params = {};
$: if (chatIdProp) {
(async () => {
loading = true;
navigateHandler();
}
prompt = '';
files = [];
selectedToolIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
const navigateHandler = async () => {
loading = true;
if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
prompt = '';
messageInput?.setText('');
files = [];
selectedToolIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
const storageChatInput = sessionStorage.getItem(
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
);
if (chatIdProp && (await loadChat())) {
await tick();
loading = false;
window.setTimeout(() => scrollToBottom(), 0);
await tick();
if (storageChatInput) {
try {
const input = JSON.parse(
sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
);
const input = JSON.parse(storageChatInput);
console.log(input);
if (!$temporaryChatEnabled) {
prompt = input.prompt;
messageInput?.setText(input.prompt);
files = input.files;
selectedToolIds = input.selectedToolIds;
selectedFilterIds = input.selectedFilterIds;
@ -168,17 +185,12 @@
} catch (e) {}
}
if (chatIdProp && (await loadChat())) {
await tick();
loading = false;
window.setTimeout(() => scrollToBottom(), 0);
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
} else {
await goto('/');
}
})();
}
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
} else {
await goto('/');
}
};
$: if (selectedModels && chatIdProp !== '') {
saveSessionSelectedModels();
@ -405,7 +417,7 @@
const inputElement = document.getElementById('chat-input');
if (inputElement) {
prompt = event.data.text;
messageInput?.setText(event.data.text);
inputElement.focus();
}
}
@ -443,8 +455,19 @@
}
});
if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
const storageChatInput = sessionStorage.getItem(
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
);
if (!chatIdProp) {
loading = false;
await tick();
}
if (storageChatInput) {
prompt = '';
messageInput?.setText('');
files = [];
selectedToolIds = [];
selectedFilterIds = [];
@ -453,12 +476,11 @@
codeInterpreterEnabled = false;
try {
const input = JSON.parse(
sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
);
const input = JSON.parse(storageChatInput);
console.log(input);
if (!$temporaryChatEnabled) {
prompt = input.prompt;
messageInput?.setText(input.prompt);
files = input.files;
selectedToolIds = input.selectedToolIds;
selectedFilterIds = input.selectedFilterIds;
@ -469,11 +491,6 @@
} catch (e) {}
}
if (!chatIdProp) {
loading = false;
await tick();
}
showControls.subscribe(async (value) => {
if (controlPane && !$mobile) {
try {
@ -833,12 +850,13 @@
}
if ($page.url.searchParams.get('q')) {
prompt = $page.url.searchParams.get('q') ?? '';
const q = $page.url.searchParams.get('q') ?? '';
messageInput?.setText(q);
if (prompt) {
if (q) {
if (($page.url.searchParams.get('submit') ?? 'true') === 'true') {
await tick();
submitPrompt(prompt);
submitPrompt(q);
}
}
}
@ -1071,7 +1089,7 @@
};
const createMessagePair = async (userPrompt) => {
prompt = '';
messageInput?.setText('');
if (selectedModels.length === 0) {
toast.error($i18n.t('Model not selected'));
} else {
@ -1392,7 +1410,7 @@
return;
}
prompt = '';
messageInput?.setText('');
// Reset chat input textarea
if (!($settings?.richTextInput ?? true)) {
@ -1413,7 +1431,7 @@
);
files = [];
prompt = '';
messageInput?.setText('');
// Create user message
let userMessageId = uuidv4();
@ -2104,6 +2122,7 @@
<div class=" pb-2">
<MessageInput
bind:this={messageInput}
{history}
{taskIds}
{selectedModels}
@ -2166,6 +2185,7 @@
<Placeholder
{history}
{selectedModels}
bind:messageInput
bind:files
bind:prompt
bind:autoScroll

View file

@ -30,7 +30,13 @@
blobToFile,
compressImage,
createMessagesList,
extractCurlyBraceWords
extractCurlyBraceWords,
getCurrentDateTime,
getFormattedDate,
getFormattedTime,
getUserPosition,
getUserTimezone,
getWeekday
} from '$lib/utils';
import { uploadFile } from '$lib/apis/files';
import { generateAutoCompletion } from '$lib/apis';
@ -58,7 +64,6 @@
import Sparkles from '../icons/Sparkles.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
const i18n = getContext('i18n');
export let transparentBackground = false;
@ -108,6 +113,220 @@
codeInterpreterEnabled
});
export const setText = (text?: string) => {
const chatInput = document.getElementById('chat-input');
if (chatInput) {
if ($settings?.richTextInput ?? true) {
chatInputElement.setText(text);
} else {
// chatInput.value = text;
prompt = text;
}
}
};
function getWordAtCursor(text, cursor) {
if (typeof text !== 'string' || cursor == null) return '';
const left = text.slice(0, cursor);
const right = text.slice(cursor);
const leftWord = left.match(/(?:^|\s)([^\s]*)$/)?.[1] || '';
const rightWord = right.match(/^([^\s]*)/)?.[1] || '';
return leftWord + rightWord;
}
const getCommand = () => {
const chatInput = document.getElementById('chat-input');
let word = '';
if (chatInput) {
if ($settings?.richTextInput ?? true) {
word = chatInputElement?.getWordAtDocPos();
} else {
const cursor = chatInput ? chatInput.selectionStart : prompt.length;
word = getWordAtCursor(prompt, cursor);
}
}
return word;
};
function getWordBoundsAtCursor(text, cursor) {
let start = cursor,
end = cursor;
while (start > 0 && !/\s/.test(text[start - 1])) --start;
while (end < text.length && !/\s/.test(text[end])) ++end;
return { start, end };
}
function replaceCommandWithText(text) {
const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
if ($settings?.richTextInput ?? true) {
chatInputElement?.replaceCommandWithText(text);
} else {
const cursor = chatInput.selectionStart;
const { start, end } = getWordBoundsAtCursor(prompt, cursor);
prompt = prompt.slice(0, start) + text + prompt.slice(end);
chatInput.focus();
chatInput.setSelectionRange(start + text.length, start + text.length);
}
}
const inputVariableHandler = async (text: string) => {
return text;
};
const textVariableHandler = async (text: string) => {
if (text.includes('{{CLIPBOARD}}')) {
const clipboardText = await navigator.clipboard.readText().catch((err) => {
toast.error($i18n.t('Failed to read clipboard contents'));
return '{{CLIPBOARD}}';
});
const clipboardItems = await navigator.clipboard.read();
let imageUrl = null;
for (const item of clipboardItems) {
// Check for known image types
for (const type of item.types) {
if (type.startsWith('image/')) {
const blob = await item.getType(type);
imageUrl = URL.createObjectURL(blob);
}
}
}
if (imageUrl) {
files = [
...files,
{
type: 'image',
url: imageUrl
}
];
}
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
}
if (text.includes('{{USER_LOCATION}}')) {
let location;
try {
location = await getUserPosition();
} catch (error) {
toast.error($i18n.t('Location access not allowed'));
location = 'LOCATION_UNKNOWN';
}
text = text.replaceAll('{{USER_LOCATION}}', String(location));
}
if (text.includes('{{USER_NAME}}')) {
const name = $_user?.name || 'User';
text = text.replaceAll('{{USER_NAME}}', name);
}
if (text.includes('{{USER_LANGUAGE}}')) {
const language = localStorage.getItem('locale') || 'en-US';
text = text.replaceAll('{{USER_LANGUAGE}}', language);
}
if (text.includes('{{CURRENT_DATE}}')) {
const date = getFormattedDate();
text = text.replaceAll('{{CURRENT_DATE}}', date);
}
if (text.includes('{{CURRENT_TIME}}')) {
const time = getFormattedTime();
text = text.replaceAll('{{CURRENT_TIME}}', time);
}
if (text.includes('{{CURRENT_DATETIME}}')) {
const dateTime = getCurrentDateTime();
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
}
if (text.includes('{{CURRENT_TIMEZONE}}')) {
const timezone = getUserTimezone();
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
}
if (text.includes('{{CURRENT_WEEKDAY}}')) {
const weekday = getWeekday();
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
}
text = await inputVariableHandler(text);
return text;
};
const insertTextAtCursor = async (text: string) => {
const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
text = await textVariableHandler(text);
if (command) {
replaceCommandWithText(text);
} else {
if ($settings?.richTextInput ?? true) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
} else {
const cursor = chatInput.selectionStart;
prompt = prompt.slice(0, cursor) + text + prompt.slice(cursor);
chatInput.focus();
chatInput.setSelectionRange(cursor + text.length, cursor + text.length);
}
}
await tick();
const chatInputContainer = document.getElementById('chat-input-container');
if (chatInputContainer) {
chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
}
await tick();
if (chatInput) {
chatInput.focus();
chatInput.dispatchEvent(new Event('input'));
const words = extractCurlyBraceWords(prompt);
if (words.length > 0) {
const word = words.at(0);
await tick();
if (!($settings?.richTextInput ?? true)) {
// Move scroll to the first word
chatInput.setSelectionRange(word.startIndex, word.endIndex + 1);
chatInput.focus();
const selectionRow =
(word?.startIndex - (word?.startIndex % chatInput.cols)) / chatInput.cols;
const lineHeight = chatInput.clientHeight / chatInput.rows;
chatInput.scrollTop = lineHeight * selectionRow;
}
} else {
chatInput.scrollTop = chatInput.scrollHeight;
}
}
};
let command = '';
let showCommands = false;
$: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
let showTools = false;
let loaded = false;
@ -583,20 +802,36 @@
<Commands
bind:this={commandsElement}
bind:prompt
bind:files
on:upload={(e) => {
dispatch('upload', e.detail);
}}
on:select={(e) => {
const data = e.detail;
show={showCommands}
{command}
insertTextHandler={insertTextAtCursor}
onUpload={(e) => {
const { type, data } = e;
if (data?.type === 'model') {
atSelectedModel = data.data;
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;
}
const chatInputElement = document.getElementById('chat-input');
chatInputElement?.focus();
document.getElementById('chat-input')?.focus();
}}
/>
</div>
@ -770,8 +1005,12 @@
>
<RichTextInput
bind:this={chatInputElement}
bind:value={prompt}
id="chat-input"
onChange={(e) => {
prompt = e.md;
command = getCommand();
}}
json={true}
messageInput={true}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$mobile ||
@ -990,6 +1229,12 @@
class="scrollbar-hidden bg-transparent dark:text-gray-200 outline-hidden w-full pt-3 px-1 resize-none"
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt}
on:input={() => {
command = getCommand();
}}
on:click={() => {
command = getCommand();
}}
on:compositionstart={() => (isComposing = true)}
on:compositionend={() => (isComposing = false)}
on:keydown={async (e) => {
@ -1137,17 +1382,20 @@
if (words.length > 0) {
const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1);
await tick();
if (word && e.target instanceof HTMLTextAreaElement) {
// Prevent default tab behavior
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
e.target.focus();
e.target.scrollTop = e.target.scrollHeight;
prompt = fullPrompt;
await tick();
const selectionRow =
(word?.startIndex - (word?.startIndex % e.target.cols)) /
e.target.cols;
const lineHeight = e.target.clientHeight / e.target.rows;
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
e.target.scrollTop = lineHeight * selectionRow;
}
}
e.target.style.height = '';

View file

@ -1,9 +1,4 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
const dispatch = createEventDispatcher();
import { knowledge, prompts } from '$lib/stores';
import { removeLastWordFromString } from '$lib/utils';
@ -15,8 +10,15 @@
import Models from './Commands/Models.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let prompt = '';
export let show = false;
export let files = [];
export let command = '';
export let onSelect = (e) => {};
export let onUpload = (e) => {};
export let insertTextHandler = (text) => {};
let loading = false;
let commandElement = null;
@ -29,12 +31,6 @@
commandElement?.selectDown();
};
let command = '';
$: command = prompt?.split('\n').pop()?.split(' ')?.pop() ?? '';
let show = false;
$: show = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command.slice(0, 2);
$: if (show) {
init();
}
@ -56,54 +52,63 @@
{#if show}
{#if !loading}
{#if command?.charAt(0) === '/'}
<Prompts bind:this={commandElement} bind:prompt bind:files {command} />
<Prompts
bind:this={commandElement}
{command}
onSelect={(e) => {
const { type, data } = e;
if (type === 'prompt') {
insertTextHandler(data.content);
}
}}
/>
{:else if (command?.charAt(0) === '#' && command.startsWith('#') && !command.includes('# ')) || ('\\#' === command.slice(0, 2) && command.startsWith('#') && !command.includes('# '))}
<Knowledge
bind:this={commandElement}
bind:prompt
command={command.includes('\\#') ? command.slice(2) : command}
on:youtube={(e) => {
console.log(e);
dispatch('upload', {
type: 'youtube',
data: e.detail
});
}}
on:url={(e) => {
console.log(e);
dispatch('upload', {
type: 'web',
data: e.detail
});
}}
on:select={(e) => {
console.log(e);
if (files.find((f) => f.id === e.detail.id)) {
return;
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
});
}
files = [
...files,
{
...e.detail,
status: 'processed'
}
];
dispatch('select');
}}
/>
{:else if command?.charAt(0) === '@'}
<Models
bind:this={commandElement}
{command}
on:select={(e) => {
prompt = removeLastWordFromString(prompt, command);
onSelect={(e) => {
const { type, data } = e;
dispatch('select', {
type: 'model',
data: e.detail
});
if (type === 'model') {
insertTextHandler('');
onSelect({
type: 'model',
data: data
});
}
}}
/>
{/if}

View file

@ -6,16 +6,15 @@
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
import { knowledge } from '$lib/stores';
const i18n = getContext('i18n');
export let prompt = '';
export let command = '';
export let onSelect = (e) => {};
const dispatch = createEventDispatcher();
let selectedIdx = 0;
let items = [];
@ -60,37 +59,12 @@
}, 100);
}
};
const confirmSelect = async (item) => {
dispatch('select', item);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
};
const confirmSelectWeb = async (url) => {
dispatch('url', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
};
const confirmSelectYoutube = async (url) => {
dispatch('youtube', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
const confirmSelect = async (type, data) => {
onSelect({
type: type,
data: data
});
};
const decodeString = (str: string) => {
@ -189,7 +163,7 @@
});
</script>
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
{#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"
@ -210,7 +184,7 @@
type="button"
on:click={() => {
console.log(item);
confirmSelect(item);
confirmSelect('knowledge', item);
}}
on:mousemove={() => {
selectedIdx = idx;
@ -298,18 +272,15 @@
</div> -->
{/each}
{#if prompt
.split(' ')
.some((s) => s.substring(1).startsWith('https://www.youtube.com') || s
.substring(1)
.startsWith('https://youtu.be'))}
{#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={() => {
const url = prompt.split(' ')?.at(0)?.substring(1);
if (isValidHttpUrl(url)) {
confirmSelectYoutube(url);
if (isValidHttpUrl(command.substring(1))) {
confirmSelect('youtube', command.substring(1));
} else {
toast.error(
$i18n.t(
@ -320,19 +291,18 @@
}}
>
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{prompt.split(' ')?.at(0)?.substring(1)}
{command.substring(1)}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Youtube')}</div>
</button>
{:else if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
{: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={() => {
const url = prompt.split(' ')?.at(0)?.substring(1);
if (isValidHttpUrl(url)) {
confirmSelectWeb(url);
if (isValidHttpUrl(command.substring(1))) {
confirmSelect('web', command.substring(1));
} else {
toast.error(
$i18n.t(
@ -343,7 +313,7 @@
}}
>
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{prompt.split(' ')?.at(0)?.substring(1)}
{command}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Web')}</div>

View file

@ -8,9 +8,8 @@
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let command = '';
export let onSelect = (e) => {};
let selectedIdx = 0;
let filteredItems = [];
@ -71,8 +70,7 @@
};
const confirmSelect = async (model) => {
command = '';
dispatch('select', model);
onSelect({ type: 'model', data: model });
};
onMount(async () => {

View file

@ -14,10 +14,8 @@
const i18n = getContext('i18n');
export let files;
export let prompt = '';
export let command = '';
export let onSelect = (e) => {};
let selectedPromptIdx = 0;
let filteredPrompts = [];
@ -58,137 +56,7 @@
};
const confirmPrompt = async (command) => {
let text = command.content;
if (command.content.includes('{{CLIPBOARD}}')) {
const clipboardText = await navigator.clipboard.readText().catch((err) => {
toast.error($i18n.t('Failed to read clipboard contents'));
return '{{CLIPBOARD}}';
});
const clipboardItems = await navigator.clipboard.read();
let imageUrl = null;
for (const item of clipboardItems) {
// Check for known image types
for (const type of item.types) {
if (type.startsWith('image/')) {
const blob = await item.getType(type);
imageUrl = URL.createObjectURL(blob);
}
}
}
if (imageUrl) {
files = [
...files,
{
type: 'image',
url: imageUrl
}
];
}
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
}
if (command.content.includes('{{USER_LOCATION}}')) {
let location;
try {
location = await getUserPosition();
} catch (error) {
toast.error($i18n.t('Location access not allowed'));
location = 'LOCATION_UNKNOWN';
}
text = text.replaceAll('{{USER_LOCATION}}', String(location));
}
if (command.content.includes('{{USER_NAME}}')) {
console.log($user);
const name = $user?.name || 'User';
text = text.replaceAll('{{USER_NAME}}', name);
}
if (command.content.includes('{{USER_LANGUAGE}}')) {
const language = localStorage.getItem('locale') || 'en-US';
text = text.replaceAll('{{USER_LANGUAGE}}', language);
}
if (command.content.includes('{{CURRENT_DATE}}')) {
const date = getFormattedDate();
text = text.replaceAll('{{CURRENT_DATE}}', date);
}
if (command.content.includes('{{CURRENT_TIME}}')) {
const time = getFormattedTime();
text = text.replaceAll('{{CURRENT_TIME}}', time);
}
if (command.content.includes('{{CURRENT_DATETIME}}')) {
const dateTime = getCurrentDateTime();
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
}
if (command.content.includes('{{CURRENT_TIMEZONE}}')) {
const timezone = getUserTimezone();
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
}
if (command.content.includes('{{CURRENT_WEEKDAY}}')) {
const weekday = getWeekday();
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
}
const lines = prompt.split('\n');
const lastLine = lines.pop();
const lastLineWords = lastLine.split(' ');
const lastWord = lastLineWords.pop();
if ($settings?.richTextInput ?? true) {
lastLineWords.push(
`${text.replace(/</g, '&lt;').replace(/>/g, '&gt;').replaceAll('\n', '<br/>')}`
);
lines.push(lastLineWords.join(' '));
prompt = lines.join('<br/>');
} else {
lastLineWords.push(text);
lines.push(lastLineWords.join(' '));
prompt = lines.join('\n');
}
const chatInputContainerElement = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input');
await tick();
if (chatInputContainerElement) {
chatInputContainerElement.scrollTop = chatInputContainerElement.scrollHeight;
}
await tick();
if (chatInputElement) {
chatInputElement.focus();
chatInputElement.dispatchEvent(new Event('input'));
const words = extractCurlyBraceWords(prompt);
if (words.length > 0) {
const word = words.at(0);
await tick();
if (!($settings?.richTextInput ?? true)) {
chatInputElement.setSelectionRange(word.startIndex, word.endIndex + 1);
chatInputElement.focus();
// This is a workaround to ensure the cursor is placed correctly
// after the text is inserted, especially for multiline inputs.
chatInputElement.setSelectionRange(word.startIndex, word.endIndex + 1);
}
} else {
chatInputElement.scrollTop = chatInputElement.scrollHeight;
}
}
onSelect({ type: 'prompt', data: command });
};
onMount(() => {
@ -213,14 +81,14 @@
id="command-options-container"
bind:this={container}
>
{#each filteredPrompts as prompt, promptIdx}
{#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(prompt);
confirmPrompt(promptItem);
}}
on:mousemove={() => {
selectedPromptIdx = promptIdx;
@ -228,11 +96,11 @@
on:focus={() => {}}
>
<div class=" font-medium text-black dark:text-gray-100">
{prompt.command}
{promptItem.command}
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100">
{prompt.title}
{promptItem.title}
</div>
</button>
{/each}

View file

@ -32,6 +32,7 @@
export let prompt = '';
export let files = [];
export let messageInput = null;
export let selectedToolIds = [];
export let selectedFilterIds = [];
@ -207,6 +208,7 @@
<div class="text-base font-normal @md:max-w-3xl w-full py-3 {atSelectedModel ? 'mt-2' : ''}">
<MessageInput
bind:this={messageInput}
{history}
{selectedModels}
bind:files

View file

@ -11,10 +11,12 @@
// Use turndown-plugin-gfm for proper GFM table support
turndownService.use(gfm);
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { createEventDispatcher } from 'svelte';
const eventDispatch = createEventDispatcher();
import { Fragment } from 'prosemirror-model';
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor } from '@tiptap/core';
@ -76,6 +78,135 @@
editor.commands.setContent(html);
}
export const getWordAtDocPos = () => {
if (!editor) return '';
const { state } = editor.view;
const pos = state.selection.from;
const doc = state.doc;
const resolvedPos = doc.resolve(pos);
const textBlock = resolvedPos.parent;
const paraStart = resolvedPos.start();
const text = textBlock.textContent;
const offset = resolvedPos.parentOffset;
let wordStart = offset,
wordEnd = offset;
while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--;
while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
const word = text.slice(wordStart, wordEnd);
return word;
};
// Returns {start, end} of the word at pos
function getWordBoundsAtPos(doc, pos) {
const resolvedPos = doc.resolve(pos);
const textBlock = resolvedPos.parent;
const paraStart = resolvedPos.start();
const text = textBlock.textContent;
const offset = resolvedPos.parentOffset;
let wordStart = offset,
wordEnd = offset;
while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--;
while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
return {
start: paraStart + wordStart,
end: paraStart + wordEnd
};
}
export const replaceCommandWithText = async (text) => {
const { state, dispatch } = editor.view;
const { selection } = state;
const pos = selection.from;
// Get the plain text of this document
// const docText = state.doc.textBetween(0, state.doc.content.size, '\n', '\n');
// Find the word boundaries at cursor
const { start, end } = getWordBoundsAtPos(state.doc, pos);
let tr = state.tr;
if (text.includes('\n')) {
// Split the text into lines and create a <p> node for each line
const lines = text.split('\n');
const nodes = lines.map(
(line, index) =>
index === 0
? state.schema.text(line) // First line is plain text
: state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) // Subsequent lines are paragraphs
);
// Build and dispatch the transaction to replace the word at cursor
tr = tr.replaceWith(start, end, nodes);
let newSelectionPos;
// +1 because the insert happens at start, so last para starts at (start + sum of all previous nodes' sizes)
let lastPos = start;
for (let i = 0; i < nodes.length; i++) {
lastPos += nodes[i].nodeSize;
}
// Place cursor inside the last paragraph at its end
newSelectionPos = lastPos;
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos)));
} else {
tr = tr.replaceWith(
start,
end, // replace this range
text !== '' ? state.schema.text(text) : []
);
tr = tr.setSelection(
state.selection.constructor.near(tr.doc.resolve(start + text.length + 1))
);
}
dispatch(tr);
await tick();
// selectNextTemplate(state, dispatch);
};
export const setText = (text: string) => {
if (!editor) return;
text = text.replaceAll('\n\n', '\n');
const { state, view } = editor;
if (text.includes('\n')) {
// Multiple lines: make paragraphs
const { schema, tr } = state;
const lines = text.split('\n');
// Map each line to a paragraph node (empty lines -> empty paragraph)
const nodes = lines.map((line) =>
schema.nodes.paragraph.create({}, line ? schema.text(line) : undefined)
);
// Create a document fragment containing all parsed paragraphs
const fragment = Fragment.fromArray(nodes);
// Replace current selection with these paragraphs
tr.replaceSelectionWith(fragment, false /* don't select new */);
// You probably want to move the cursor after the inserted content
// tr.setSelection(Selection.near(tr.doc.resolve(tr.selection.to)));
view.dispatch(tr);
} else if (text === '') {
// Empty: delete selection or paragraph
editor.commands.clearContent();
} else {
editor.commands.setContent(editor.state.schema.text(text));
}
selectNextTemplate(editor.view.state, editor.view.dispatch);
};
// Function to find the next template in the document
function findNextTemplate(doc, from = 0) {
const patterns = [{ start: '{{', end: '}}' }];
@ -240,9 +371,18 @@
onChange({
html: editor.getHTML(),
json: editor.getJSON(),
md: turndownService.turndown(editor.getHTML())
md: turndownService
.turndown(
editor
.getHTML()
.replace(/<p><\/p>/g, '<br/>')
.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
)
.replace(/\u00a0/g, ' ')
});
console.log(html);
if (json) {
value = editor.getJSON();
} else {
@ -308,7 +448,7 @@
if (event.key === 'Enter') {
const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
if (event.shiftKey && !isCtrlPressed) {
editor.commands.setHardBreak(); // Insert a hard break
editor.commands.enter(); // Insert a new line
view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor
event.preventDefault();
return true;