浏览代码

fix: various rich text input issues

#15140
Timothy Jaeryang Baek 3 月之前
父节点
当前提交
2e2a63c201

+ 61 - 41
src/lib/components/chat/Chat.svelte

@@ -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;
+
+		prompt = '';
+		messageInput?.setText('');
 
-			if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
+		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

+ 268 - 20
src/lib/components/chat/MessageInput.svelte

@@ -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);
+						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);
+							}
 						}}
-						on:select={(e) => {
-							const data = e.detail;
+						onSelect={(e) => {
+							const { type, data } = e;
 
-							if (data?.type === 'model') {
-								atSelectedModel = data.data;
+							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 = '';

+ 53 - 48
src/lib/components/chat/MessageInput/Commands.svelte

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

+ 19 - 49
src/lib/components/chat/MessageInput/Commands/Knowledge.svelte

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

+ 2 - 4
src/lib/components/chat/MessageInput/Commands/Models.svelte

@@ -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 () => {

+ 6 - 138
src/lib/components/chat/MessageInput/Commands/Prompts.svelte

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

+ 2 - 0
src/lib/components/chat/Placeholder.svelte

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

+ 143 - 3
src/lib/components/common/RichTextInput.svelte

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