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

View file

@ -30,7 +30,13 @@
blobToFile, blobToFile,
compressImage, compressImage,
createMessagesList, createMessagesList,
extractCurlyBraceWords extractCurlyBraceWords,
getCurrentDateTime,
getFormattedDate,
getFormattedTime,
getUserPosition,
getUserTimezone,
getWeekday
} from '$lib/utils'; } from '$lib/utils';
import { uploadFile } from '$lib/apis/files'; import { uploadFile } from '$lib/apis/files';
import { generateAutoCompletion } from '$lib/apis'; import { generateAutoCompletion } from '$lib/apis';
@ -58,7 +64,6 @@
import Sparkles from '../icons/Sparkles.svelte'; import Sparkles from '../icons/Sparkles.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker'; import { KokoroWorker } from '$lib/workers/KokoroWorker';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let transparentBackground = false; export let transparentBackground = false;
@ -108,6 +113,220 @@
codeInterpreterEnabled 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 showTools = false;
let loaded = false; let loaded = false;
@ -583,20 +802,36 @@
<Commands <Commands
bind:this={commandsElement} bind:this={commandsElement}
bind:prompt
bind:files bind:files
on:upload={(e) => { show={showCommands}
dispatch('upload', e.detail); {command}
}} insertTextHandler={insertTextAtCursor}
on:select={(e) => { onUpload={(e) => {
const data = e.detail; const { type, data } = e;
if (data?.type === 'model') { if (type === 'file') {
atSelectedModel = data.data; 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'); document.getElementById('chat-input')?.focus();
chatInputElement?.focus();
}} }}
/> />
</div> </div>
@ -770,8 +1005,12 @@
> >
<RichTextInput <RichTextInput
bind:this={chatInputElement} bind:this={chatInputElement}
bind:value={prompt}
id="chat-input" id="chat-input"
onChange={(e) => {
prompt = e.md;
command = getCommand();
}}
json={true}
messageInput={true} messageInput={true}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) && shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$mobile || (!$mobile ||
@ -990,6 +1229,12 @@
class="scrollbar-hidden bg-transparent dark:text-gray-200 outline-hidden w-full pt-3 px-1 resize-none" 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')} placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
bind:value={prompt} bind:value={prompt}
on:input={() => {
command = getCommand();
}}
on:click={() => {
command = getCommand();
}}
on:compositionstart={() => (isComposing = true)} on:compositionstart={() => (isComposing = true)}
on:compositionend={() => (isComposing = false)} on:compositionend={() => (isComposing = false)}
on:keydown={async (e) => { on:keydown={async (e) => {
@ -1137,17 +1382,20 @@
if (words.length > 0) { if (words.length > 0) {
const word = words.at(0); const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1); if (word && e.target instanceof HTMLTextAreaElement) {
await tick(); // Prevent default tab behavior
e.preventDefault();
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
e.target.focus();
e.target.scrollTop = e.target.scrollHeight; const selectionRow =
prompt = fullPrompt; (word?.startIndex - (word?.startIndex % e.target.cols)) /
await tick(); e.target.cols;
const lineHeight = e.target.clientHeight / e.target.rows;
e.preventDefault(); e.target.scrollTop = lineHeight * selectionRow;
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1); }
} }
e.target.style.height = ''; e.target.style.height = '';

View file

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

View file

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

View file

@ -14,10 +14,8 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let files;
export let prompt = '';
export let command = ''; export let command = '';
export let onSelect = (e) => {};
let selectedPromptIdx = 0; let selectedPromptIdx = 0;
let filteredPrompts = []; let filteredPrompts = [];
@ -58,137 +56,7 @@
}; };
const confirmPrompt = async (command) => { const confirmPrompt = async (command) => {
let text = command.content; onSelect({ type: 'prompt', data: command });
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;
}
}
}; };
onMount(() => { onMount(() => {
@ -213,14 +81,14 @@
id="command-options-container" id="command-options-container"
bind:this={container} bind:this={container}
> >
{#each filteredPrompts as prompt, promptIdx} {#each filteredPrompts as promptItem, promptIdx}
<button <button
class=" px-3 py-1.5 rounded-xl w-full text-left {promptIdx === selectedPromptIdx 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={() => {
confirmPrompt(prompt); confirmPrompt(promptItem);
}} }}
on:mousemove={() => { on:mousemove={() => {
selectedPromptIdx = promptIdx; selectedPromptIdx = promptIdx;
@ -228,11 +96,11 @@
on:focus={() => {}} on:focus={() => {}}
> >
<div class=" font-medium text-black dark:text-gray-100"> <div class=" font-medium text-black dark:text-gray-100">
{prompt.command} {promptItem.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">
{prompt.title} {promptItem.title}
</div> </div>
</button> </button>
{/each} {/each}

View file

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

View file

@ -11,10 +11,12 @@
// Use turndown-plugin-gfm for proper GFM table support // Use turndown-plugin-gfm for proper GFM table support
turndownService.use(gfm); turndownService.use(gfm);
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const eventDispatch = createEventDispatcher(); const eventDispatch = createEventDispatcher();
import { Fragment } from 'prosemirror-model';
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view'; import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
@ -76,6 +78,135 @@
editor.commands.setContent(html); 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 to find the next template in the document
function findNextTemplate(doc, from = 0) { function findNextTemplate(doc, from = 0) {
const patterns = [{ start: '{{', end: '}}' }]; const patterns = [{ start: '{{', end: '}}' }];
@ -240,9 +371,18 @@
onChange({ onChange({
html: editor.getHTML(), html: editor.getHTML(),
json: editor.getJSON(), 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) { if (json) {
value = editor.getJSON(); value = editor.getJSON();
} else { } else {
@ -308,7 +448,7 @@
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
if (event.shiftKey && !isCtrlPressed) { 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 view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor
event.preventDefault(); event.preventDefault();
return true; return true;