Browse Source

feat: rich text input for chat

Timothy J. Baek 6 months ago
parent
commit
f46b95300b

+ 3 - 3
cypress/e2e/chat.cy.ts

@@ -30,7 +30,7 @@ describe('Settings', () => {
 			// Select the first model
 			cy.get('button[aria-label="model-item"]').first().click();
 			// Type a message
-			cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
+			cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
 				force: true
 			});
 			// Send the message
@@ -50,7 +50,7 @@ describe('Settings', () => {
 			// Select the first model
 			cy.get('button[aria-label="model-item"]').first().click();
 			// Type a message
-			cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
+			cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
 				force: true
 			});
 			// Send the message
@@ -85,7 +85,7 @@ describe('Settings', () => {
 			// Select the first model
 			cy.get('button[aria-label="model-item"]').first().click();
 			// Type a message
-			cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', {
+			cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
 				force: true
 			});
 			// Send the message

+ 10 - 5
src/lib/components/chat/Chat.svelte

@@ -125,7 +125,7 @@
 				loaded = true;
 
 				window.setTimeout(() => scrollToBottom(), 0);
-				const chatInput = document.getElementById('chat-textarea');
+				const chatInput = document.getElementById('chat-input');
 				chatInput?.focus();
 			} else {
 				await goto('/');
@@ -264,7 +264,7 @@
 		if (event.data.type === 'input:prompt') {
 			console.debug(event.data.text);
 
-			const inputElement = document.getElementById('chat-textarea');
+			const inputElement = document.getElementById('chat-input');
 
 			if (inputElement) {
 				prompt = event.data.text;
@@ -327,7 +327,7 @@
 			}
 		});
 
-		const chatInput = document.getElementById('chat-textarea');
+		const chatInput = document.getElementById('chat-input');
 		chatInput?.focus();
 
 		chats.subscribe(() => {});
@@ -501,7 +501,7 @@
 			settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
 		}
 
-		const chatInput = document.getElementById('chat-textarea');
+		const chatInput = document.getElementById('chat-input');
 		setTimeout(() => chatInput?.focus(), 0);
 	};
 
@@ -799,7 +799,7 @@
 			);
 		} else {
 			// Reset chat input textarea
-			const chatTextAreaElement = document.getElementById('chat-textarea');
+			const chatTextAreaElement = document.getElementById('chat-input');
 
 			if (chatTextAreaElement) {
 				chatTextAreaElement.value = '';
@@ -841,6 +841,11 @@
 
 			// Wait until history/message have been updated
 			await tick();
+
+			// focus on chat input
+			const chatInput = document.getElementById('chat-input');
+			chatInput?.focus();
+
 			_responses = await sendPrompt(userPrompt, userMessageId, { newChat: true });
 		}
 

+ 190 - 138
src/lib/components/chat/MessageInput.svelte

@@ -29,6 +29,7 @@
 	import FilesOverlay from './MessageInput/FilesOverlay.svelte';
 	import Commands from './MessageInput/Commands.svelte';
 	import XMark from '../icons/XMark.svelte';
+	import RichTextInput from '../common/RichTextInput.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -53,8 +54,8 @@
 	let recording = false;
 
 	let chatTextAreaElement: HTMLTextAreaElement;
+	let chatInputContainerElement;
 	let filesInputElement;
-
 	let commandsElement;
 
 	let inputFiles;
@@ -213,7 +214,10 @@
 	};
 
 	onMount(() => {
-		window.setTimeout(() => chatTextAreaElement?.focus(), 0);
+		window.setTimeout(() => {
+			const chatInput = document.getElementById('chat-input');
+			chatInput?.focus();
+		}, 0);
 
 		window.addEventListener('keydown', handleKeyDown);
 
@@ -351,7 +355,7 @@
 							recording = false;
 
 							await tick();
-							document.getElementById('chat-textarea')?.focus();
+							document.getElementById('chat-input')?.focus();
 						}}
 						on:confirm={async (e) => {
 							const response = e.detail;
@@ -360,7 +364,7 @@
 							recording = false;
 
 							await tick();
-							document.getElementById('chat-textarea')?.focus();
+							document.getElementById('chat-input')?.focus();
 
 							if ($settings?.speechAutoSend ?? false) {
 								dispatch('submit', prompt);
@@ -500,177 +504,225 @@
 									</InputMenu>
 								</div>
 
-								<textarea
-									id="chat-textarea"
-									bind:this={chatTextAreaElement}
-									class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
-									placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
-									bind:value={prompt}
-									on:keypress={(e) => {
-										if (
-											!$mobile ||
+								<div
+									bind:this={chatInputContainerElement}
+									class="scrollbar-hidden text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px] overflow-auto"
+								>
+									<RichTextInput
+										id="chat-input"
+										placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
+										bind:value={prompt}
+										shiftEnter={!$mobile ||
 											!(
 												'ontouchstart' in window ||
 												navigator.maxTouchPoints > 0 ||
 												navigator.msMaxTouchPoints > 0
-											)
-										) {
-											// Prevent Enter key from creating a new line
-											if (e.key === 'Enter' && !e.shiftKey) {
+											)}
+										on:enter={async (e) => {
+											if (prompt !== '') {
+												dispatch('submit', prompt);
+											}
+										}}
+										on:input={async (e) => {
+											if (chatInputContainerElement) {
+												chatInputContainerElement.style.height = '';
+												chatInputContainerElement.style.height =
+													Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
+											}
+										}}
+										on:focus={async (e) => {
+											if (chatInputContainerElement) {
+												chatInputContainerElement.style.height = '';
+												chatInputContainerElement.style.height =
+													Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
+											}
+										}}
+										on:keypress={(e) => {
+											e = e.detail.event;
+											console.log(e);
+										}}
+										on:keydown={async (e) => {
+											e = e.detail.event;
+											console.log(e);
+
+											if (chatInputContainerElement) {
+												chatInputContainerElement.style.height = '';
+												chatInputContainerElement.style.height =
+													Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
+											}
+
+											const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
+											const commandsContainerElement =
+												document.getElementById('commands-container');
+
+											// Command/Ctrl + Shift + Enter to submit a message pair
+											if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
 												e.preventDefault();
+												createMessagePair(prompt);
 											}
 
-											// Submit the prompt when Enter key is pressed
-											if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
-												dispatch('submit', prompt);
+											// Check if Ctrl + R is pressed
+											if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
+												e.preventDefault();
+												console.log('regenerate');
+
+												const regenerateButton = [
+													...document.getElementsByClassName('regenerate-response-button')
+												]?.at(-1);
+
+												regenerateButton?.click();
 											}
-										}
-									}}
-									on:keydown={async (e) => {
-										const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
-										const commandsContainerElement = document.getElementById('commands-container');
-
-										// Command/Ctrl + Shift + Enter to submit a message pair
-										if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
-											e.preventDefault();
-											createMessagePair(prompt);
-										}
 
-										// Check if Ctrl + R is pressed
-										if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
-											e.preventDefault();
-											console.log('regenerate');
+											if (prompt === '' && e.key == 'ArrowUp') {
+												e.preventDefault();
 
-											const regenerateButton = [
-												...document.getElementsByClassName('regenerate-response-button')
-											]?.at(-1);
+												const userMessageElement = [
+													...document.getElementsByClassName('user-message')
+												]?.at(-1);
 
-											regenerateButton?.click();
-										}
+												const editButton = [
+													...document.getElementsByClassName('edit-user-message-button')
+												]?.at(-1);
 
-										if (prompt === '' && e.key == 'ArrowUp') {
-											e.preventDefault();
+												console.log(userMessageElement);
 
-											const userMessageElement = [
-												...document.getElementsByClassName('user-message')
-											]?.at(-1);
+												userMessageElement.scrollIntoView({ block: 'center' });
+												editButton?.click();
+											}
 
-											const editButton = [
-												...document.getElementsByClassName('edit-user-message-button')
-											]?.at(-1);
+											if (commandsContainerElement && e.key === 'ArrowUp') {
+												e.preventDefault();
+												commandsElement.selectUp();
 
-											console.log(userMessageElement);
+												const commandOptionButton = [
+													...document.getElementsByClassName('selected-command-option-button')
+												]?.at(-1);
+												commandOptionButton.scrollIntoView({ block: 'center' });
+											}
 
-											userMessageElement.scrollIntoView({ block: 'center' });
-											editButton?.click();
-										}
+											if (commandsContainerElement && e.key === 'ArrowDown') {
+												e.preventDefault();
+												commandsElement.selectDown();
 
-										if (commandsContainerElement && e.key === 'ArrowUp') {
-											e.preventDefault();
-											commandsElement.selectUp();
+												const commandOptionButton = [
+													...document.getElementsByClassName('selected-command-option-button')
+												]?.at(-1);
+												commandOptionButton.scrollIntoView({ block: 'center' });
+											}
 
-											const commandOptionButton = [
-												...document.getElementsByClassName('selected-command-option-button')
-											]?.at(-1);
-											commandOptionButton.scrollIntoView({ block: 'center' });
-										}
+											if (commandsContainerElement && e.key === 'Enter') {
+												e.preventDefault();
 
-										if (commandsContainerElement && e.key === 'ArrowDown') {
-											e.preventDefault();
-											commandsElement.selectDown();
+												const commandOptionButton = [
+													...document.getElementsByClassName('selected-command-option-button')
+												]?.at(-1);
 
-											const commandOptionButton = [
-												...document.getElementsByClassName('selected-command-option-button')
-											]?.at(-1);
-											commandOptionButton.scrollIntoView({ block: 'center' });
-										}
+												if (e.shiftKey) {
+													prompt = `${prompt}\n`;
+												} else if (commandOptionButton) {
+													commandOptionButton?.click();
+												} else {
+													document.getElementById('send-message-button')?.click();
+												}
+											}
 
-										if (commandsContainerElement && e.key === 'Enter') {
-											e.preventDefault();
+											if (commandsContainerElement && e.key === 'Tab') {
+												e.preventDefault();
 
-											const commandOptionButton = [
-												...document.getElementsByClassName('selected-command-option-button')
-											]?.at(-1);
+												const commandOptionButton = [
+													...document.getElementsByClassName('selected-command-option-button')
+												]?.at(-1);
 
-											if (e.shiftKey) {
-												prompt = `${prompt}\n`;
-											} else if (commandOptionButton) {
 												commandOptionButton?.click();
-											} else {
-												document.getElementById('send-message-button')?.click();
-											}
-										}
+											} else if (e.key === 'Tab') {
+												const words = findWordIndices(prompt);
 
-										if (commandsContainerElement && e.key === 'Tab') {
-											e.preventDefault();
+												if (words.length > 0) {
+													const word = words.at(0);
+													const fullPrompt = prompt;
 
-											const commandOptionButton = [
-												...document.getElementsByClassName('selected-command-option-button')
-											]?.at(-1);
+													prompt = prompt.substring(0, word?.endIndex + 1);
+													await tick();
 
-											commandOptionButton?.click();
-										} else if (e.key === 'Tab') {
-											const words = findWordIndices(prompt);
+													e.target.scrollTop = e.target.scrollHeight;
+													prompt = fullPrompt;
+													await tick();
 
-											if (words.length > 0) {
-												const word = words.at(0);
-												const fullPrompt = prompt;
+													e.preventDefault();
+													e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
+												}
 
-												prompt = prompt.substring(0, word?.endIndex + 1);
-												await tick();
+												e.target.style.height = '';
+												e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
+											}
 
-												e.target.scrollTop = e.target.scrollHeight;
-												prompt = fullPrompt;
-												await tick();
+											if (e.key === 'Escape') {
+												console.log('Escape');
+												atSelectedModel = undefined;
+											}
+										}}
+										on:paste={async (e) => {
+											e = e.detail.event;
+											console.log(e);
+
+											const clipboardData = e.clipboardData || window.clipboardData;
+
+											if (clipboardData && clipboardData.items) {
+												for (const item of clipboardData.items) {
+													if (item.type.indexOf('image') !== -1) {
+														const blob = item.getAsFile();
+														const reader = new FileReader();
+
+														reader.onload = function (e) {
+															files = [
+																...files,
+																{
+																	type: 'image',
+																	url: `${e.target.result}`
+																}
+															];
+														};
+
+														reader.readAsDataURL(blob);
+													}
+												}
+											}
+										}}
+									/>
+								</div>
 
+								<!-- <textarea
+									id="chat-input"
+									bind:this={chatTextAreaElement}
+									class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
+									placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
+									bind:value={prompt}
+									on:keypress={(e) => {
+										if (
+											!$mobile ||
+											!(
+												'ontouchstart' in window ||
+												navigator.maxTouchPoints > 0 ||
+												navigator.msMaxTouchPoints > 0
+											)
+										) {
+											// Prevent Enter key from creating a new line
+											if (e.key === 'Enter' && !e.shiftKey) {
 												e.preventDefault();
-												e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
 											}
 
-											e.target.style.height = '';
-											e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
-										}
-
-										if (e.key === 'Escape') {
-											console.log('Escape');
-											atSelectedModel = undefined;
-										}
-									}}
-									rows="1"
-									on:input={async (e) => {
-										e.target.style.height = '';
-										e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
-										user = null;
-									}}
-									on:focus={async (e) => {
-										e.target.style.height = '';
-										e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
-									}}
-									on:paste={async (e) => {
-										const clipboardData = e.clipboardData || window.clipboardData;
-
-										if (clipboardData && clipboardData.items) {
-											for (const item of clipboardData.items) {
-												if (item.type.indexOf('image') !== -1) {
-													const blob = item.getAsFile();
-													const reader = new FileReader();
-
-													reader.onload = function (e) {
-														files = [
-															...files,
-															{
-																type: 'image',
-																url: `${e.target.result}`
-															}
-														];
-													};
-
-													reader.readAsDataURL(blob);
-												}
+											// Submit the prompt when Enter key is pressed
+											if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
+												dispatch('submit', prompt);
 											}
 										}
 									}}
-								/>
+									
+									rows="1"
+									
+									
+									
+								/> -->
 
 								<div class="self-end mb-2 flex space-x-1 mr-1">
 									{#if !history?.currentId || history.messages[history.currentId]?.done == true}

+ 3 - 3
src/lib/components/chat/MessageInput/Commands.svelte

@@ -28,14 +28,14 @@
 	$: command = (prompt?.trim() ?? '').split(' ')?.at(-1) ?? '';
 </script>
 
-{#if ['/', '#', '@'].includes(command?.charAt(0))}
+{#if ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command.slice(0, 2)}
 	{#if command?.charAt(0) === '/'}
 		<Prompts bind:this={commandElement} bind:prompt bind:files {command} />
-	{:else if command?.charAt(0) === '#'}
+	{:else if command?.charAt(0) === '#' || '\\#' === command.slice(0, 2)}
 		<Knowledge
 			bind:this={commandElement}
 			bind:prompt
-			{command}
+			command={command.includes('\\#') ? command.slice(2) : command}
 			on:youtube={(e) => {
 				console.log(e);
 				dispatch('upload', {

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

@@ -46,7 +46,7 @@
 		dispatch('select', item);
 
 		prompt = removeLastWordFromString(prompt, command);
-		const chatInputElement = document.getElementById('chat-textarea');
+		const chatInputElement = document.getElementById('chat-input');
 
 		await tick();
 		chatInputElement?.focus();
@@ -57,7 +57,7 @@
 		dispatch('url', url);
 
 		prompt = removeLastWordFromString(prompt, command);
-		const chatInputElement = document.getElementById('chat-textarea');
+		const chatInputElement = document.getElementById('chat-input');
 
 		await tick();
 		chatInputElement?.focus();
@@ -68,7 +68,7 @@
 		dispatch('youtube', url);
 
 		prompt = removeLastWordFromString(prompt, command);
-		const chatInputElement = document.getElementById('chat-textarea');
+		const chatInputElement = document.getElementById('chat-input');
 
 		await tick();
 		chatInputElement?.focus();

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

@@ -58,7 +58,7 @@
 
 	onMount(async () => {
 		await tick();
-		const chatInputElement = document.getElementById('chat-textarea');
+		const chatInputElement = document.getElementById('chat-input');
 		await tick();
 		chatInputElement?.focus();
 		await tick();

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

@@ -110,7 +110,7 @@
 
 		prompt = text;
 
-		const chatInputElement = document.getElementById('chat-textarea');
+		const chatInputElement = document.getElementById('chat-input');
 
 		await tick();
 

+ 1 - 1
src/lib/components/chat/MessageInput/VoiceRecording.svelte

@@ -213,7 +213,7 @@
 					transcription = `${transcription}${transcript}`;
 
 					await tick();
-					document.getElementById('chat-textarea')?.focus();
+					document.getElementById('chat-input')?.focus();
 
 					// Restart the inactivity timeout
 					timeoutId = setTimeout(() => {

+ 1 - 1
src/lib/components/chat/Messages.svelte

@@ -330,7 +330,7 @@
 
 				await tick();
 
-				const chatInputElement = document.getElementById('chat-textarea');
+				const chatInputElement = document.getElementById('chat-input');
 				if (chatInputElement) {
 					prompt = p;
 

+ 1 - 1
src/lib/components/chat/Placeholder.svelte

@@ -57,7 +57,7 @@
 		console.log(prompt);
 		await tick();
 
-		const chatInputElement = document.getElementById('chat-textarea');
+		const chatInputElement = document.getElementById('chat-input');
 		if (chatInputElement) {
 			chatInputElement.style.height = '';
 			chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px';

+ 37 - 5
src/lib/components/common/RichTextInput.svelte

@@ -3,7 +3,7 @@
 	import { createEventDispatcher } from 'svelte';
 	const eventDispatch = createEventDispatcher();
 
-	import { EditorState, Plugin } from 'prosemirror-state';
+	import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
 	import { EditorView, Decoration, DecorationSet } from 'prosemirror-view';
 	import { undo, redo, history } from 'prosemirror-history';
 	import { schema, defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown';
@@ -24,6 +24,7 @@
 	export let className = 'input-prose';
 	export let shiftEnter = false;
 
+	export let id = '';
 	export let value = '';
 	export let placeholder = 'Type here...';
 
@@ -189,7 +190,7 @@
 
 					Enter: (state, dispatch, view) => {
 						if (shiftEnter) {
-							eventDispatch('submit');
+							eventDispatch('enter');
 							return true;
 						}
 						return chainCommands(
@@ -279,10 +280,40 @@
 					return false;
 				},
 				paste: (view, event) => {
-					eventDispatch('paste', { event });
+					console.log(event);
+					if (event.clipboardData) {
+						// Check if the pasted content contains image files
+						const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
+							file.type.startsWith('image/')
+						);
+
+						// Check for image in dataTransfer items (for cases where files are not available)
+						const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
+							item.type.startsWith('image/')
+						);
+
+						console.log('Has image file:', hasImageFile, 'Has image item:', hasImageItem);
+
+						if (hasImageFile) {
+							// If there's an image, dispatch the event to the parent
+							eventDispatch('paste', { event });
+							event.preventDefault();
+							return true;
+						}
+
+						if (hasImageItem) {
+							// If there's an image item, dispatch the event to the parent
+							eventDispatch('paste', { event });
+							event.preventDefault();
+							return true;
+						}
+					}
+
+					// For all other cases (text, formatted text, etc.), let ProseMirror handle it
 					return false;
 				}
-			}
+			},
+			attributes: { id }
 		});
 	});
 
@@ -292,7 +323,8 @@
 		const newState = EditorState.create({
 			doc: newDoc,
 			schema,
-			plugins: view.state.plugins
+			plugins: view.state.plugins,
+			selection: TextSelection.atEnd(newDoc) // This sets the cursor at the end
 		});
 		view.updateState(newState);
 	}

+ 2 - 1
src/lib/utils/index.ts

@@ -276,7 +276,8 @@ export const removeLastWordFromString = (inputString, wordString) => {
 	// Split the string into an array of words
 	const words = inputString.split(' ');
 
-	if (words.at(-1) === wordString) {
+	console.log(words.at(-1), wordString);
+	if (words.at(-1) === wordString || (wordString === '' && words.at(-1) === '\\#')) {
 		words.pop();
 	}
 

+ 1 - 1
src/routes/(app)/+layout.svelte

@@ -137,7 +137,7 @@
 				if (isShiftPressed && event.key === 'Escape') {
 					event.preventDefault();
 					console.log('focusInput');
-					document.getElementById('chat-textarea')?.focus();
+					document.getElementById('chat-input')?.focus();
 				}
 
 				// Check if Ctrl + Shift + ; is pressed

+ 1 - 1
static/themes/rosepine-dawn.css

@@ -23,7 +23,7 @@
 	fill: #ebbcba;
 }
 
-.rose-pine-dawn #chat-textarea {
+.rose-pine-dawn #chat-input {
 	background: #cecacd;
 	margin: 0.3rem;
 	padding: 0.5rem;

+ 1 - 1
static/themes/rosepine.css

@@ -23,7 +23,7 @@
 	fill: #c4a7e7;
 }
 
-.rose-pine #chat-textarea {
+.rose-pine #chat-input {
 	background: #393552;
 	margin: 0.3rem;
 	padding: 0.5rem;