mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 04:45:19 +00:00
parent
9b5da77ffc
commit
2e2a63c201
8 changed files with 555 additions and 304 deletions
|
|
@ -96,6 +96,8 @@
|
|||
let controlPane;
|
||||
let controlPaneComponent;
|
||||
|
||||
let messageInput;
|
||||
|
||||
let autoScroll = true;
|
||||
let processing = '';
|
||||
let messagesContainerElement: HTMLDivElement;
|
||||
|
|
@ -140,24 +142,39 @@
|
|||
let params = {};
|
||||
|
||||
$: if (chatIdProp) {
|
||||
(async () => {
|
||||
loading = true;
|
||||
navigateHandler();
|
||||
}
|
||||
|
||||
prompt = '';
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
const navigateHandler = async () => {
|
||||
loading = true;
|
||||
|
||||
if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
|
||||
prompt = '';
|
||||
messageInput?.setText('');
|
||||
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
|
||||
const storageChatInput = sessionStorage.getItem(
|
||||
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
|
||||
);
|
||||
|
||||
if (chatIdProp && (await loadChat())) {
|
||||
await tick();
|
||||
loading = false;
|
||||
window.setTimeout(() => scrollToBottom(), 0);
|
||||
|
||||
await tick();
|
||||
|
||||
if (storageChatInput) {
|
||||
try {
|
||||
const input = JSON.parse(
|
||||
sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
|
||||
);
|
||||
const input = JSON.parse(storageChatInput);
|
||||
|
||||
console.log(input);
|
||||
if (!$temporaryChatEnabled) {
|
||||
prompt = input.prompt;
|
||||
messageInput?.setText(input.prompt);
|
||||
files = input.files;
|
||||
selectedToolIds = input.selectedToolIds;
|
||||
selectedFilterIds = input.selectedFilterIds;
|
||||
|
|
@ -168,17 +185,12 @@
|
|||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (chatIdProp && (await loadChat())) {
|
||||
await tick();
|
||||
loading = false;
|
||||
window.setTimeout(() => scrollToBottom(), 0);
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput?.focus();
|
||||
} else {
|
||||
await goto('/');
|
||||
}
|
||||
})();
|
||||
}
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput?.focus();
|
||||
} else {
|
||||
await goto('/');
|
||||
}
|
||||
};
|
||||
|
||||
$: if (selectedModels && chatIdProp !== '') {
|
||||
saveSessionSelectedModels();
|
||||
|
|
@ -405,7 +417,7 @@
|
|||
const inputElement = document.getElementById('chat-input');
|
||||
|
||||
if (inputElement) {
|
||||
prompt = event.data.text;
|
||||
messageInput?.setText(event.data.text);
|
||||
inputElement.focus();
|
||||
}
|
||||
}
|
||||
|
|
@ -443,8 +455,19 @@
|
|||
}
|
||||
});
|
||||
|
||||
if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) {
|
||||
const storageChatInput = sessionStorage.getItem(
|
||||
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
|
||||
);
|
||||
|
||||
if (!chatIdProp) {
|
||||
loading = false;
|
||||
await tick();
|
||||
}
|
||||
|
||||
if (storageChatInput) {
|
||||
prompt = '';
|
||||
messageInput?.setText('');
|
||||
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
|
|
@ -453,12 +476,11 @@
|
|||
codeInterpreterEnabled = false;
|
||||
|
||||
try {
|
||||
const input = JSON.parse(
|
||||
sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
|
||||
);
|
||||
const input = JSON.parse(storageChatInput);
|
||||
console.log(input);
|
||||
|
||||
if (!$temporaryChatEnabled) {
|
||||
prompt = input.prompt;
|
||||
messageInput?.setText(input.prompt);
|
||||
files = input.files;
|
||||
selectedToolIds = input.selectedToolIds;
|
||||
selectedFilterIds = input.selectedFilterIds;
|
||||
|
|
@ -469,11 +491,6 @@
|
|||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (!chatIdProp) {
|
||||
loading = false;
|
||||
await tick();
|
||||
}
|
||||
|
||||
showControls.subscribe(async (value) => {
|
||||
if (controlPane && !$mobile) {
|
||||
try {
|
||||
|
|
@ -833,12 +850,13 @@
|
|||
}
|
||||
|
||||
if ($page.url.searchParams.get('q')) {
|
||||
prompt = $page.url.searchParams.get('q') ?? '';
|
||||
const q = $page.url.searchParams.get('q') ?? '';
|
||||
messageInput?.setText(q);
|
||||
|
||||
if (prompt) {
|
||||
if (q) {
|
||||
if (($page.url.searchParams.get('submit') ?? 'true') === 'true') {
|
||||
await tick();
|
||||
submitPrompt(prompt);
|
||||
submitPrompt(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1071,7 +1089,7 @@
|
|||
};
|
||||
|
||||
const createMessagePair = async (userPrompt) => {
|
||||
prompt = '';
|
||||
messageInput?.setText('');
|
||||
if (selectedModels.length === 0) {
|
||||
toast.error($i18n.t('Model not selected'));
|
||||
} else {
|
||||
|
|
@ -1392,7 +1410,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
prompt = '';
|
||||
messageInput?.setText('');
|
||||
|
||||
// Reset chat input textarea
|
||||
if (!($settings?.richTextInput ?? true)) {
|
||||
|
|
@ -1413,7 +1431,7 @@
|
|||
);
|
||||
|
||||
files = [];
|
||||
prompt = '';
|
||||
messageInput?.setText('');
|
||||
|
||||
// Create user message
|
||||
let userMessageId = uuidv4();
|
||||
|
|
@ -2104,6 +2122,7 @@
|
|||
|
||||
<div class=" pb-2">
|
||||
<MessageInput
|
||||
bind:this={messageInput}
|
||||
{history}
|
||||
{taskIds}
|
||||
{selectedModels}
|
||||
|
|
@ -2166,6 +2185,7 @@
|
|||
<Placeholder
|
||||
{history}
|
||||
{selectedModels}
|
||||
bind:messageInput
|
||||
bind:files
|
||||
bind:prompt
|
||||
bind:autoScroll
|
||||
|
|
|
|||
|
|
@ -30,7 +30,13 @@
|
|||
blobToFile,
|
||||
compressImage,
|
||||
createMessagesList,
|
||||
extractCurlyBraceWords
|
||||
extractCurlyBraceWords,
|
||||
getCurrentDateTime,
|
||||
getFormattedDate,
|
||||
getFormattedTime,
|
||||
getUserPosition,
|
||||
getUserTimezone,
|
||||
getWeekday
|
||||
} from '$lib/utils';
|
||||
import { uploadFile } from '$lib/apis/files';
|
||||
import { generateAutoCompletion } from '$lib/apis';
|
||||
|
|
@ -58,7 +64,6 @@
|
|||
import Sparkles from '../icons/Sparkles.svelte';
|
||||
|
||||
import { KokoroWorker } from '$lib/workers/KokoroWorker';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let transparentBackground = false;
|
||||
|
|
@ -108,6 +113,220 @@
|
|||
codeInterpreterEnabled
|
||||
});
|
||||
|
||||
export const setText = (text?: string) => {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
|
||||
if (chatInput) {
|
||||
if ($settings?.richTextInput ?? true) {
|
||||
chatInputElement.setText(text);
|
||||
} else {
|
||||
// chatInput.value = text;
|
||||
prompt = text;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getWordAtCursor(text, cursor) {
|
||||
if (typeof text !== 'string' || cursor == null) return '';
|
||||
const left = text.slice(0, cursor);
|
||||
const right = text.slice(cursor);
|
||||
const leftWord = left.match(/(?:^|\s)([^\s]*)$/)?.[1] || '';
|
||||
|
||||
const rightWord = right.match(/^([^\s]*)/)?.[1] || '';
|
||||
return leftWord + rightWord;
|
||||
}
|
||||
|
||||
const getCommand = () => {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
let word = '';
|
||||
|
||||
if (chatInput) {
|
||||
if ($settings?.richTextInput ?? true) {
|
||||
word = chatInputElement?.getWordAtDocPos();
|
||||
} else {
|
||||
const cursor = chatInput ? chatInput.selectionStart : prompt.length;
|
||||
word = getWordAtCursor(prompt, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
return word;
|
||||
};
|
||||
|
||||
function getWordBoundsAtCursor(text, cursor) {
|
||||
let start = cursor,
|
||||
end = cursor;
|
||||
while (start > 0 && !/\s/.test(text[start - 1])) --start;
|
||||
while (end < text.length && !/\s/.test(text[end])) ++end;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function replaceCommandWithText(text) {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
if (!chatInput) return;
|
||||
|
||||
if ($settings?.richTextInput ?? true) {
|
||||
chatInputElement?.replaceCommandWithText(text);
|
||||
} else {
|
||||
const cursor = chatInput.selectionStart;
|
||||
const { start, end } = getWordBoundsAtCursor(prompt, cursor);
|
||||
prompt = prompt.slice(0, start) + text + prompt.slice(end);
|
||||
chatInput.focus();
|
||||
chatInput.setSelectionRange(start + text.length, start + text.length);
|
||||
}
|
||||
}
|
||||
|
||||
const inputVariableHandler = async (text: string) => {
|
||||
return text;
|
||||
};
|
||||
|
||||
const textVariableHandler = async (text: string) => {
|
||||
if (text.includes('{{CLIPBOARD}}')) {
|
||||
const clipboardText = await navigator.clipboard.readText().catch((err) => {
|
||||
toast.error($i18n.t('Failed to read clipboard contents'));
|
||||
return '{{CLIPBOARD}}';
|
||||
});
|
||||
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
|
||||
let imageUrl = null;
|
||||
for (const item of clipboardItems) {
|
||||
// Check for known image types
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith('image/')) {
|
||||
const blob = await item.getType(type);
|
||||
imageUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
type: 'image',
|
||||
url: imageUrl
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
|
||||
}
|
||||
|
||||
if (text.includes('{{USER_LOCATION}}')) {
|
||||
let location;
|
||||
try {
|
||||
location = await getUserPosition();
|
||||
} catch (error) {
|
||||
toast.error($i18n.t('Location access not allowed'));
|
||||
location = 'LOCATION_UNKNOWN';
|
||||
}
|
||||
text = text.replaceAll('{{USER_LOCATION}}', String(location));
|
||||
}
|
||||
|
||||
if (text.includes('{{USER_NAME}}')) {
|
||||
const name = $_user?.name || 'User';
|
||||
text = text.replaceAll('{{USER_NAME}}', name);
|
||||
}
|
||||
|
||||
if (text.includes('{{USER_LANGUAGE}}')) {
|
||||
const language = localStorage.getItem('locale') || 'en-US';
|
||||
text = text.replaceAll('{{USER_LANGUAGE}}', language);
|
||||
}
|
||||
|
||||
if (text.includes('{{CURRENT_DATE}}')) {
|
||||
const date = getFormattedDate();
|
||||
text = text.replaceAll('{{CURRENT_DATE}}', date);
|
||||
}
|
||||
|
||||
if (text.includes('{{CURRENT_TIME}}')) {
|
||||
const time = getFormattedTime();
|
||||
text = text.replaceAll('{{CURRENT_TIME}}', time);
|
||||
}
|
||||
|
||||
if (text.includes('{{CURRENT_DATETIME}}')) {
|
||||
const dateTime = getCurrentDateTime();
|
||||
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
|
||||
}
|
||||
|
||||
if (text.includes('{{CURRENT_TIMEZONE}}')) {
|
||||
const timezone = getUserTimezone();
|
||||
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
|
||||
}
|
||||
|
||||
if (text.includes('{{CURRENT_WEEKDAY}}')) {
|
||||
const weekday = getWeekday();
|
||||
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
|
||||
}
|
||||
|
||||
text = await inputVariableHandler(text);
|
||||
return text;
|
||||
};
|
||||
|
||||
const insertTextAtCursor = async (text: string) => {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
if (!chatInput) return;
|
||||
|
||||
text = await textVariableHandler(text);
|
||||
if (command) {
|
||||
replaceCommandWithText(text);
|
||||
} else {
|
||||
if ($settings?.richTextInput ?? true) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(text));
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
} else {
|
||||
const cursor = chatInput.selectionStart;
|
||||
prompt = prompt.slice(0, cursor) + text + prompt.slice(cursor);
|
||||
chatInput.focus();
|
||||
chatInput.setSelectionRange(cursor + text.length, cursor + text.length);
|
||||
}
|
||||
}
|
||||
|
||||
await tick();
|
||||
const chatInputContainer = document.getElementById('chat-input-container');
|
||||
if (chatInputContainer) {
|
||||
chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
|
||||
}
|
||||
|
||||
await tick();
|
||||
if (chatInput) {
|
||||
chatInput.focus();
|
||||
chatInput.dispatchEvent(new Event('input'));
|
||||
|
||||
const words = extractCurlyBraceWords(prompt);
|
||||
|
||||
if (words.length > 0) {
|
||||
const word = words.at(0);
|
||||
await tick();
|
||||
|
||||
if (!($settings?.richTextInput ?? true)) {
|
||||
// Move scroll to the first word
|
||||
chatInput.setSelectionRange(word.startIndex, word.endIndex + 1);
|
||||
chatInput.focus();
|
||||
|
||||
const selectionRow =
|
||||
(word?.startIndex - (word?.startIndex % chatInput.cols)) / chatInput.cols;
|
||||
const lineHeight = chatInput.clientHeight / chatInput.rows;
|
||||
|
||||
chatInput.scrollTop = lineHeight * selectionRow;
|
||||
}
|
||||
} else {
|
||||
chatInput.scrollTop = chatInput.scrollHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let command = '';
|
||||
|
||||
let showCommands = false;
|
||||
$: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
|
||||
|
||||
let showTools = false;
|
||||
|
||||
let loaded = false;
|
||||
|
|
@ -583,20 +802,36 @@
|
|||
|
||||
<Commands
|
||||
bind:this={commandsElement}
|
||||
bind:prompt
|
||||
bind:files
|
||||
on:upload={(e) => {
|
||||
dispatch('upload', e.detail);
|
||||
}}
|
||||
on:select={(e) => {
|
||||
const data = e.detail;
|
||||
show={showCommands}
|
||||
{command}
|
||||
insertTextHandler={insertTextAtCursor}
|
||||
onUpload={(e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (data?.type === 'model') {
|
||||
atSelectedModel = data.data;
|
||||
if (type === 'file') {
|
||||
if (files.find((f) => f.id === data.id)) {
|
||||
return;
|
||||
}
|
||||
files = [
|
||||
...files,
|
||||
{
|
||||
...data,
|
||||
status: 'processed'
|
||||
}
|
||||
];
|
||||
} else {
|
||||
dispatch('upload', e);
|
||||
}
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'model') {
|
||||
atSelectedModel = data;
|
||||
}
|
||||
|
||||
const chatInputElement = document.getElementById('chat-input');
|
||||
chatInputElement?.focus();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -770,8 +1005,12 @@
|
|||
>
|
||||
<RichTextInput
|
||||
bind:this={chatInputElement}
|
||||
bind:value={prompt}
|
||||
id="chat-input"
|
||||
onChange={(e) => {
|
||||
prompt = e.md;
|
||||
command = getCommand();
|
||||
}}
|
||||
json={true}
|
||||
messageInput={true}
|
||||
shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
||||
(!$mobile ||
|
||||
|
|
@ -990,6 +1229,12 @@
|
|||
class="scrollbar-hidden bg-transparent dark:text-gray-200 outline-hidden w-full pt-3 px-1 resize-none"
|
||||
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
||||
bind:value={prompt}
|
||||
on:input={() => {
|
||||
command = getCommand();
|
||||
}}
|
||||
on:click={() => {
|
||||
command = getCommand();
|
||||
}}
|
||||
on:compositionstart={() => (isComposing = true)}
|
||||
on:compositionend={() => (isComposing = false)}
|
||||
on:keydown={async (e) => {
|
||||
|
|
@ -1137,17 +1382,20 @@
|
|||
|
||||
if (words.length > 0) {
|
||||
const word = words.at(0);
|
||||
const fullPrompt = prompt;
|
||||
|
||||
prompt = prompt.substring(0, word?.endIndex + 1);
|
||||
await tick();
|
||||
if (word && e.target instanceof HTMLTextAreaElement) {
|
||||
// Prevent default tab behavior
|
||||
e.preventDefault();
|
||||
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
|
||||
e.target.focus();
|
||||
|
||||
e.target.scrollTop = e.target.scrollHeight;
|
||||
prompt = fullPrompt;
|
||||
await tick();
|
||||
const selectionRow =
|
||||
(word?.startIndex - (word?.startIndex % e.target.cols)) /
|
||||
e.target.cols;
|
||||
const lineHeight = e.target.clientHeight / e.target.rows;
|
||||
|
||||
e.preventDefault();
|
||||
e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
|
||||
e.target.scrollTop = lineHeight * selectionRow;
|
||||
}
|
||||
}
|
||||
|
||||
e.target.style.height = '';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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, '<').replace(/>/g, '>').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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue