feat: rich text input for chat

This commit is contained in:
Timothy J. Baek 2024-10-18 23:54:35 -07:00
parent 5e96922eba
commit f46b95300b
15 changed files with 268 additions and 178 deletions

View File

@ -30,7 +30,7 @@ describe('Settings', () => {
// Select the first model // Select the first model
cy.get('button[aria-label="model-item"]').first().click(); cy.get('button[aria-label="model-item"]').first().click();
// Type a message // 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 force: true
}); });
// Send the message // Send the message
@ -50,7 +50,7 @@ describe('Settings', () => {
// Select the first model // Select the first model
cy.get('button[aria-label="model-item"]').first().click(); cy.get('button[aria-label="model-item"]').first().click();
// Type a message // 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 force: true
}); });
// Send the message // Send the message
@ -85,7 +85,7 @@ describe('Settings', () => {
// Select the first model // Select the first model
cy.get('button[aria-label="model-item"]').first().click(); cy.get('button[aria-label="model-item"]').first().click();
// Type a message // 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 force: true
}); });
// Send the message // Send the message

View File

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

View File

@ -29,6 +29,7 @@
import FilesOverlay from './MessageInput/FilesOverlay.svelte'; import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import Commands from './MessageInput/Commands.svelte'; import Commands from './MessageInput/Commands.svelte';
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
import RichTextInput from '../common/RichTextInput.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -53,8 +54,8 @@
let recording = false; let recording = false;
let chatTextAreaElement: HTMLTextAreaElement; let chatTextAreaElement: HTMLTextAreaElement;
let chatInputContainerElement;
let filesInputElement; let filesInputElement;
let commandsElement; let commandsElement;
let inputFiles; let inputFiles;
@ -213,7 +214,10 @@
}; };
onMount(() => { onMount(() => {
window.setTimeout(() => chatTextAreaElement?.focus(), 0); window.setTimeout(() => {
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
}, 0);
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
@ -351,7 +355,7 @@
recording = false; recording = false;
await tick(); await tick();
document.getElementById('chat-textarea')?.focus(); document.getElementById('chat-input')?.focus();
}} }}
on:confirm={async (e) => { on:confirm={async (e) => {
const response = e.detail; const response = e.detail;
@ -360,7 +364,7 @@
recording = false; recording = false;
await tick(); await tick();
document.getElementById('chat-textarea')?.focus(); document.getElementById('chat-input')?.focus();
if ($settings?.speechAutoSend ?? false) { if ($settings?.speechAutoSend ?? false) {
dispatch('submit', prompt); dispatch('submit', prompt);
@ -500,8 +504,195 @@
</InputMenu> </InputMenu>
</div> </div>
<textarea <div
id="chat-textarea" 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
)}
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);
}
// 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();
}
if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();
const userMessageElement = [
...document.getElementsByClassName('user-message')
]?.at(-1);
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
console.log(userMessageElement);
userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click();
}
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
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();
}
}
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton?.click();
} else if (e.key === 'Tab') {
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1);
await tick();
e.target.scrollTop = e.target.scrollHeight;
prompt = fullPrompt;
await tick();
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;
}
}}
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} 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]" 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')} placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
@ -526,151 +717,12 @@
} }
} }
}} }}
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');
const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button')
]?.at(-1);
regenerateButton?.click();
}
if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();
const userMessageElement = [
...document.getElementsByClassName('user-message')
]?.at(-1);
const editButton = [
...document.getElementsByClassName('edit-user-message-button')
]?.at(-1);
console.log(userMessageElement);
userMessageElement.scrollIntoView({ block: 'center' });
editButton?.click();
}
if (commandsContainerElement && e.key === 'ArrowUp') {
e.preventDefault();
commandsElement.selectUp();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'ArrowDown') {
e.preventDefault();
commandsElement.selectDown();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton.scrollIntoView({ block: 'center' });
}
if (commandsContainerElement && e.key === 'Enter') {
e.preventDefault();
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();
}
}
if (commandsContainerElement && e.key === 'Tab') {
e.preventDefault();
const commandOptionButton = [
...document.getElementsByClassName('selected-command-option-button')
]?.at(-1);
commandOptionButton?.click();
} else if (e.key === 'Tab') {
const words = findWordIndices(prompt);
if (words.length > 0) {
const word = words.at(0);
const fullPrompt = prompt;
prompt = prompt.substring(0, word?.endIndex + 1);
await tick();
e.target.scrollTop = e.target.scrollHeight;
prompt = fullPrompt;
await tick();
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" 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); /> -->
}
}
}
}}
/>
<div class="self-end mb-2 flex space-x-1 mr-1"> <div class="self-end mb-2 flex space-x-1 mr-1">
{#if !history?.currentId || history.messages[history.currentId]?.done == true} {#if !history?.currentId || history.messages[history.currentId]?.done == true}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const eventDispatch = createEventDispatcher(); const eventDispatch = createEventDispatcher();
import { EditorState, Plugin } from 'prosemirror-state'; import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
import { EditorView, Decoration, DecorationSet } from 'prosemirror-view'; import { EditorView, Decoration, DecorationSet } from 'prosemirror-view';
import { undo, redo, history } from 'prosemirror-history'; import { undo, redo, history } from 'prosemirror-history';
import { schema, defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown'; import { schema, defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown';
@ -24,6 +24,7 @@
export let className = 'input-prose'; export let className = 'input-prose';
export let shiftEnter = false; export let shiftEnter = false;
export let id = '';
export let value = ''; export let value = '';
export let placeholder = 'Type here...'; export let placeholder = 'Type here...';
@ -189,7 +190,7 @@
Enter: (state, dispatch, view) => { Enter: (state, dispatch, view) => {
if (shiftEnter) { if (shiftEnter) {
eventDispatch('submit'); eventDispatch('enter');
return true; return true;
} }
return chainCommands( return chainCommands(
@ -279,10 +280,40 @@
return false; return false;
}, },
paste: (view, event) => { 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; return false;
} }
} },
attributes: { id }
}); });
}); });
@ -292,7 +323,8 @@
const newState = EditorState.create({ const newState = EditorState.create({
doc: newDoc, doc: newDoc,
schema, schema,
plugins: view.state.plugins plugins: view.state.plugins,
selection: TextSelection.atEnd(newDoc) // This sets the cursor at the end
}); });
view.updateState(newState); view.updateState(newState);
} }

View File

@ -276,7 +276,8 @@ export const removeLastWordFromString = (inputString, wordString) => {
// Split the string into an array of words // Split the string into an array of words
const words = inputString.split(' '); 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(); words.pop();
} }

View File

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

View File

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

View File

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