|
@@ -17,9 +17,12 @@
|
|
|
getFormattedTime,
|
|
|
getUserPosition,
|
|
|
getUserTimezone,
|
|
|
- getWeekday
|
|
|
+ getWeekday,
|
|
|
+ extractCurlyBraceWords
|
|
|
} from '$lib/utils';
|
|
|
|
|
|
+ import { getSessionUser } from '$lib/apis/auths';
|
|
|
+
|
|
|
import Tooltip from '../common/Tooltip.svelte';
|
|
|
import RichTextInput from '../common/RichTextInput.svelte';
|
|
|
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
|
|
@@ -29,26 +32,16 @@
|
|
|
import FileItem from '../common/FileItem.svelte';
|
|
|
import Image from '../common/Image.svelte';
|
|
|
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
|
|
- import Commands from '../chat/MessageInput/Commands.svelte';
|
|
|
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
|
|
|
- import { getSessionUser } from '$lib/apis/auths';
|
|
|
+ import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
|
|
|
+ import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
|
|
|
+ import MentionList from './MessageInput/MentionList.svelte';
|
|
|
|
|
|
export let placeholder = $i18n.t('Send a Message');
|
|
|
|
|
|
export let id = null;
|
|
|
-
|
|
|
- let draggedOver = false;
|
|
|
-
|
|
|
- let recording = false;
|
|
|
- let content = '';
|
|
|
- let files = [];
|
|
|
-
|
|
|
export let chatInputElement;
|
|
|
|
|
|
- let commandsElement;
|
|
|
- let filesInputElement;
|
|
|
- let inputFiles;
|
|
|
-
|
|
|
export let typingUsers = [];
|
|
|
export let inputLoading = false;
|
|
|
|
|
@@ -62,15 +55,39 @@
|
|
|
export let acceptFiles = true;
|
|
|
export let showFormattingToolbar = true;
|
|
|
|
|
|
+ let loaded = false;
|
|
|
+ let draggedOver = false;
|
|
|
+
|
|
|
+ let recording = false;
|
|
|
+ let content = '';
|
|
|
+ let files = [];
|
|
|
+
|
|
|
+ let filesInputElement;
|
|
|
+ let inputFiles;
|
|
|
+
|
|
|
let showInputVariablesModal = false;
|
|
|
+ let inputVariablesModalCallback: (variableValues: Record<string, any>) => void;
|
|
|
let inputVariables: Record<string, any> = {};
|
|
|
let inputVariableValues = {};
|
|
|
|
|
|
- const inputVariableHandler = async (text: string) => {
|
|
|
+ const inputVariableHandler = async (text: string): Promise<string> => {
|
|
|
inputVariables = extractInputVariables(text);
|
|
|
- if (Object.keys(inputVariables).length > 0) {
|
|
|
- showInputVariablesModal = true;
|
|
|
+
|
|
|
+ // No variables? return the original text immediately.
|
|
|
+ if (Object.keys(inputVariables).length === 0) {
|
|
|
+ return text;
|
|
|
}
|
|
|
+
|
|
|
+ // Show modal and wait for the user's input.
|
|
|
+ showInputVariablesModal = true;
|
|
|
+ return await new Promise<string>((resolve) => {
|
|
|
+ inputVariablesModalCallback = (variableValues) => {
|
|
|
+ inputVariableValues = { ...inputVariableValues, ...variableValues };
|
|
|
+ replaceVariables(inputVariableValues);
|
|
|
+ showInputVariablesModal = false;
|
|
|
+ resolve(text);
|
|
|
+ };
|
|
|
+ });
|
|
|
};
|
|
|
|
|
|
const textVariableHandler = async (text: string) => {
|
|
@@ -188,68 +205,87 @@
|
|
|
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
|
|
|
}
|
|
|
|
|
|
- inputVariableHandler(text);
|
|
|
return text;
|
|
|
};
|
|
|
|
|
|
const replaceVariables = (variables: Record<string, any>) => {
|
|
|
- if (!chatInputElement) return;
|
|
|
console.log('Replacing variables:', variables);
|
|
|
|
|
|
- chatInputElement.replaceVariables(variables);
|
|
|
- chatInputElement.focus();
|
|
|
+ const chatInput = document.getElementById('chat-input');
|
|
|
+
|
|
|
+ if (chatInput) {
|
|
|
+ chatInputElement.replaceVariables(variables);
|
|
|
+ chatInputElement.focus();
|
|
|
+ }
|
|
|
};
|
|
|
|
|
|
- export const setText = async (text?: string) => {
|
|
|
- if (!chatInputElement) return;
|
|
|
+ export const setText = async (text?: string, cb?: (text: string) => void) => {
|
|
|
+ const chatInput = document.getElementById('chat-input');
|
|
|
+
|
|
|
+ if (chatInput) {
|
|
|
+ text = await textVariableHandler(text || '');
|
|
|
|
|
|
- text = await textVariableHandler(text || '');
|
|
|
+ chatInputElement?.setText(text);
|
|
|
+ chatInputElement?.focus();
|
|
|
|
|
|
- chatInputElement?.setText(text);
|
|
|
- chatInputElement?.focus();
|
|
|
+ text = await inputVariableHandler(text);
|
|
|
+ await tick();
|
|
|
+ if (cb) await cb(text);
|
|
|
+ }
|
|
|
};
|
|
|
|
|
|
const getCommand = () => {
|
|
|
- if (!chatInputElement) return;
|
|
|
-
|
|
|
+ const chatInput = document.getElementById('chat-input');
|
|
|
let word = '';
|
|
|
- word = chatInputElement?.getWordAtDocPos();
|
|
|
+
|
|
|
+ if (chatInput) {
|
|
|
+ word = chatInputElement?.getWordAtDocPos();
|
|
|
+ }
|
|
|
|
|
|
return word;
|
|
|
};
|
|
|
|
|
|
const replaceCommandWithText = (text) => {
|
|
|
- if (!chatInputElement) return;
|
|
|
+ const chatInput = document.getElementById('chat-input');
|
|
|
+ if (!chatInput) return;
|
|
|
|
|
|
chatInputElement?.replaceCommandWithText(text);
|
|
|
};
|
|
|
|
|
|
const insertTextAtCursor = async (text: string) => {
|
|
|
+ const chatInput = document.getElementById('chat-input');
|
|
|
+ if (!chatInput) return;
|
|
|
+
|
|
|
text = await textVariableHandler(text);
|
|
|
|
|
|
if (command) {
|
|
|
replaceCommandWithText(text);
|
|
|
} else {
|
|
|
- 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);
|
|
|
- }
|
|
|
+ chatInputElement?.insertContent(text);
|
|
|
}
|
|
|
|
|
|
await tick();
|
|
|
+ text = await inputVariableHandler(text);
|
|
|
+ await tick();
|
|
|
+
|
|
|
const chatInputContainer = document.getElementById('chat-input-container');
|
|
|
if (chatInputContainer) {
|
|
|
chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
|
|
|
}
|
|
|
|
|
|
await tick();
|
|
|
- if (chatInputElement) {
|
|
|
- chatInputElement.focus();
|
|
|
+ if (chatInput) {
|
|
|
+ chatInput.focus();
|
|
|
+ chatInput.dispatchEvent(new Event('input'));
|
|
|
+
|
|
|
+ const words = extractCurlyBraceWords(prompt);
|
|
|
+
|
|
|
+ if (words.length > 0) {
|
|
|
+ const word = words.at(0);
|
|
|
+ await tick();
|
|
|
+ } else {
|
|
|
+ chatInput.scrollTop = chatInput.scrollHeight;
|
|
|
+ }
|
|
|
}
|
|
|
};
|
|
|
|
|
@@ -257,6 +293,7 @@
|
|
|
|
|
|
export let showCommands = false;
|
|
|
$: showCommands = ['/'].includes(command?.charAt(0));
|
|
|
+ let suggestions = null;
|
|
|
|
|
|
const screenCaptureHandler = async () => {
|
|
|
try {
|
|
@@ -514,6 +551,49 @@
|
|
|
}
|
|
|
|
|
|
onMount(async () => {
|
|
|
+ suggestions = [
|
|
|
+ {
|
|
|
+ char: '@',
|
|
|
+ render: getSuggestionRenderer(MentionList, {
|
|
|
+ i18n
|
|
|
+ })
|
|
|
+ },
|
|
|
+ {
|
|
|
+ char: '/',
|
|
|
+ render: getSuggestionRenderer(CommandSuggestionList, {
|
|
|
+ i18n,
|
|
|
+ onSelect: (e) => {
|
|
|
+ const { type, data } = e;
|
|
|
+
|
|
|
+ if (type === 'model') {
|
|
|
+ console.log('Selected model:', data);
|
|
|
+ }
|
|
|
+
|
|
|
+ document.getElementById('chat-input')?.focus();
|
|
|
+ },
|
|
|
+
|
|
|
+ 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'
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ loaded = true;
|
|
|
+
|
|
|
window.setTimeout(() => {
|
|
|
if (chatInputElement) {
|
|
|
chatInputElement.focus();
|
|
@@ -543,389 +623,392 @@
|
|
|
});
|
|
|
</script>
|
|
|
|
|
|
-<FilesOverlay show={draggedOver} />
|
|
|
+{#if loaded}
|
|
|
+ <FilesOverlay show={draggedOver} />
|
|
|
+
|
|
|
+ {#if acceptFiles}
|
|
|
+ <input
|
|
|
+ bind:this={filesInputElement}
|
|
|
+ bind:files={inputFiles}
|
|
|
+ type="file"
|
|
|
+ hidden
|
|
|
+ multiple
|
|
|
+ on:change={async () => {
|
|
|
+ if (inputFiles && inputFiles.length > 0) {
|
|
|
+ inputFilesHandler(Array.from(inputFiles));
|
|
|
+ } else {
|
|
|
+ toast.error($i18n.t(`File not found.`));
|
|
|
+ }
|
|
|
|
|
|
-{#if acceptFiles}
|
|
|
- <input
|
|
|
- bind:this={filesInputElement}
|
|
|
- bind:files={inputFiles}
|
|
|
- type="file"
|
|
|
- hidden
|
|
|
- multiple
|
|
|
- on:change={async () => {
|
|
|
- if (inputFiles && inputFiles.length > 0) {
|
|
|
- inputFilesHandler(Array.from(inputFiles));
|
|
|
- } else {
|
|
|
- toast.error($i18n.t(`File not found.`));
|
|
|
- }
|
|
|
+ filesInputElement.value = '';
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {/if}
|
|
|
|
|
|
- filesInputElement.value = '';
|
|
|
- }}
|
|
|
+ <InputVariablesModal
|
|
|
+ bind:show={showInputVariablesModal}
|
|
|
+ variables={inputVariables}
|
|
|
+ onSave={inputVariablesModalCallback}
|
|
|
/>
|
|
|
-{/if}
|
|
|
|
|
|
-<InputVariablesModal
|
|
|
- bind:show={showInputVariablesModal}
|
|
|
- variables={inputVariables}
|
|
|
- onSave={(variableValues) => {
|
|
|
- inputVariableValues = { ...inputVariableValues, ...variableValues };
|
|
|
- replaceVariables(inputVariableValues);
|
|
|
- }}
|
|
|
-/>
|
|
|
-
|
|
|
-<div class="bg-transparent">
|
|
|
- <div
|
|
|
- class="{($settings?.widescreenMode ?? null)
|
|
|
- ? 'max-w-full'
|
|
|
- : 'max-w-6xl'} mx-auto inset-x-0 relative"
|
|
|
- >
|
|
|
- <div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
|
|
|
- <div class="flex flex-col px-3 w-full">
|
|
|
- <div class="relative">
|
|
|
- {#if scrollEnd === false}
|
|
|
- <div
|
|
|
- class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
|
|
|
- >
|
|
|
- <button
|
|
|
- class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
|
|
|
- on:click={() => {
|
|
|
- scrollEnd = true;
|
|
|
- scrollToBottom();
|
|
|
- }}
|
|
|
+ <div class="bg-transparent">
|
|
|
+ <div
|
|
|
+ class="{($settings?.widescreenMode ?? null)
|
|
|
+ ? 'max-w-full'
|
|
|
+ : 'max-w-6xl'} mx-auto inset-x-0 relative"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center"
|
|
|
+ >
|
|
|
+ <div class="flex flex-col px-3 w-full">
|
|
|
+ <div class="relative">
|
|
|
+ {#if scrollEnd === false}
|
|
|
+ <div
|
|
|
+ class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
|
|
|
>
|
|
|
- <svg
|
|
|
- xmlns="http://www.w3.org/2000/svg"
|
|
|
- viewBox="0 0 20 20"
|
|
|
- fill="currentColor"
|
|
|
- class="w-5 h-5"
|
|
|
+ <button
|
|
|
+ class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
|
|
|
+ on:click={() => {
|
|
|
+ scrollEnd = true;
|
|
|
+ scrollToBottom();
|
|
|
+ }}
|
|
|
>
|
|
|
- <path
|
|
|
- fill-rule="evenodd"
|
|
|
- d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
|
|
|
- clip-rule="evenodd"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- {/if}
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="relative">
|
|
|
- <div class=" -mt-5">
|
|
|
- {#if typingUsers.length > 0}
|
|
|
- <div class=" text-xs px-4 mb-1">
|
|
|
- <span class=" font-normal text-black dark:text-white">
|
|
|
- {typingUsers.map((user) => user.name).join(', ')}
|
|
|
- </span>
|
|
|
- {$i18n.t('is typing...')}
|
|
|
+ <svg
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ viewBox="0 0 20 20"
|
|
|
+ fill="currentColor"
|
|
|
+ class="w-5 h-5"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ fill-rule="evenodd"
|
|
|
+ d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
|
|
|
+ clip-rule="evenodd"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
</div>
|
|
|
{/if}
|
|
|
</div>
|
|
|
|
|
|
- <Commands
|
|
|
- bind:this={commandsElement}
|
|
|
- show={showCommands}
|
|
|
- {command}
|
|
|
- insertTextHandler={insertTextAtCursor}
|
|
|
- />
|
|
|
+ <div class="relative">
|
|
|
+ <div class=" -mt-5">
|
|
|
+ {#if typingUsers.length > 0}
|
|
|
+ <div class=" text-xs px-4 mb-1">
|
|
|
+ <span class=" font-normal text-black dark:text-white">
|
|
|
+ {typingUsers.map((user) => user.name).join(', ')}
|
|
|
+ </span>
|
|
|
+ {$i18n.t('is typing...')}
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <div class="">
|
|
|
- {#if recording}
|
|
|
- <VoiceRecording
|
|
|
- bind:recording
|
|
|
- onCancel={async () => {
|
|
|
- recording = false;
|
|
|
+ <div class="">
|
|
|
+ {#if recording}
|
|
|
+ <VoiceRecording
|
|
|
+ bind:recording
|
|
|
+ onCancel={async () => {
|
|
|
+ recording = false;
|
|
|
|
|
|
- await tick();
|
|
|
+ await tick();
|
|
|
|
|
|
- if (chatInputElement) {
|
|
|
- chatInputElement.focus();
|
|
|
- }
|
|
|
- }}
|
|
|
- onConfirm={async (data) => {
|
|
|
- const { text, filename } = data;
|
|
|
- recording = false;
|
|
|
+ if (chatInputElement) {
|
|
|
+ chatInputElement.focus();
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onConfirm={async (data) => {
|
|
|
+ const { text, filename } = data;
|
|
|
+ recording = false;
|
|
|
|
|
|
- await tick();
|
|
|
- insertTextAtCursor(text);
|
|
|
+ await tick();
|
|
|
+ insertTextAtCursor(text);
|
|
|
|
|
|
- await tick();
|
|
|
+ await tick();
|
|
|
|
|
|
- if (chatInputElement) {
|
|
|
- chatInputElement.focus();
|
|
|
- }
|
|
|
- }}
|
|
|
- />
|
|
|
- {:else}
|
|
|
- <form
|
|
|
- class="w-full flex gap-1.5"
|
|
|
- on:submit|preventDefault={() => {
|
|
|
- submitHandler();
|
|
|
- }}
|
|
|
- >
|
|
|
- <div
|
|
|
- class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-600/5 dark:bg-gray-400/5 dark:text-gray-100"
|
|
|
- dir={$settings?.chatDirection ?? 'auto'}
|
|
|
+ if (chatInputElement) {
|
|
|
+ chatInputElement.focus();
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {:else}
|
|
|
+ <form
|
|
|
+ class="w-full flex gap-1.5"
|
|
|
+ on:submit|preventDefault={() => {
|
|
|
+ submitHandler();
|
|
|
+ }}
|
|
|
>
|
|
|
- {#if files.length > 0}
|
|
|
- <div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
|
|
|
- {#each files as file, fileIdx}
|
|
|
- {#if file.type === 'image'}
|
|
|
- <div class=" relative group">
|
|
|
- <div class="relative">
|
|
|
- <Image
|
|
|
- src={file.url}
|
|
|
- alt="input"
|
|
|
- imageClassName=" h-16 w-16 rounded-xl object-cover"
|
|
|
- />
|
|
|
+ <div
|
|
|
+ class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
|
|
|
+ dir={$settings?.chatDirection ?? 'auto'}
|
|
|
+ >
|
|
|
+ {#if files.length > 0}
|
|
|
+ <div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
|
|
|
+ {#each files as file, fileIdx}
|
|
|
+ {#if file.type === 'image'}
|
|
|
+ <div class=" relative group">
|
|
|
+ <div class="relative">
|
|
|
+ <Image
|
|
|
+ src={file.url}
|
|
|
+ alt=""
|
|
|
+ imageClassName=" size-10 rounded-xl object-cover"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class=" absolute -top-1 -right-1">
|
|
|
+ <button
|
|
|
+ class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
|
|
|
+ type="button"
|
|
|
+ on:click={() => {
|
|
|
+ files.splice(fileIdx, 1);
|
|
|
+ files = files;
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <svg
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ viewBox="0 0 20 20"
|
|
|
+ fill="currentColor"
|
|
|
+ class="w-4 h-4"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div class=" absolute -top-1 -right-1">
|
|
|
+ {:else}
|
|
|
+ <FileItem
|
|
|
+ item={file}
|
|
|
+ name={file.name}
|
|
|
+ type={file.type}
|
|
|
+ size={file?.size}
|
|
|
+ small={true}
|
|
|
+ loading={file.status === 'uploading'}
|
|
|
+ dismissible={true}
|
|
|
+ edit={true}
|
|
|
+ on:dismiss={() => {
|
|
|
+ files.splice(fileIdx, 1);
|
|
|
+ files = files;
|
|
|
+ }}
|
|
|
+ on:click={() => {
|
|
|
+ console.log(file);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {/if}
|
|
|
+ {/each}
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+
|
|
|
+ <div class="px-2.5">
|
|
|
+ <div
|
|
|
+ class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2.5 pb-[5px] px-1 resize-none h-fit max-h-80 overflow-auto"
|
|
|
+ >
|
|
|
+ {#key $settings?.richTextInput}
|
|
|
+ <RichTextInput
|
|
|
+ id="chat-input"
|
|
|
+ bind:this={chatInputElement}
|
|
|
+ json={true}
|
|
|
+ messageInput={true}
|
|
|
+ richText={$settings?.richTextInput ?? true}
|
|
|
+ {showFormattingToolbar}
|
|
|
+ shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
|
|
+ (!$mobile ||
|
|
|
+ !(
|
|
|
+ 'ontouchstart' in window ||
|
|
|
+ navigator.maxTouchPoints > 0 ||
|
|
|
+ navigator.msMaxTouchPoints > 0
|
|
|
+ ))}
|
|
|
+ largeTextAsFile={$settings?.largeTextAsFile ?? false}
|
|
|
+ floatingMenuPlacement={'top-start'}
|
|
|
+ {suggestions}
|
|
|
+ onChange={(e) => {
|
|
|
+ const { md } = e;
|
|
|
+ content = md;
|
|
|
+ command = getCommand();
|
|
|
+ }}
|
|
|
+ on:keydown={async (e) => {
|
|
|
+ e = e.detail.event;
|
|
|
+ const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
|
|
+
|
|
|
+ const suggestionsContainerElement =
|
|
|
+ document.getElementById('suggestions-container');
|
|
|
+
|
|
|
+ if (!suggestionsContainerElement) {
|
|
|
+ if (
|
|
|
+ !$mobile ||
|
|
|
+ !(
|
|
|
+ 'ontouchstart' in window ||
|
|
|
+ navigator.maxTouchPoints > 0 ||
|
|
|
+ navigator.msMaxTouchPoints > 0
|
|
|
+ )
|
|
|
+ ) {
|
|
|
+ // Prevent Enter key from creating a new line
|
|
|
+ // Uses keyCode '13' for Enter key for chinese/japanese keyboards
|
|
|
+ if (e.keyCode === 13 && !e.shiftKey) {
|
|
|
+ e.preventDefault();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Submit the content when Enter key is pressed
|
|
|
+ if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
|
|
|
+ submitHandler();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (e.key === 'Escape') {
|
|
|
+ console.info('Escape');
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ on:paste={async (e) => {
|
|
|
+ e = e.detail.event;
|
|
|
+ console.info(e);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {/key}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class=" flex justify-between mb-2.5 mx-0.5">
|
|
|
+ <div class="ml-1 self-end flex space-x-1 flex-1">
|
|
|
+ <slot name="menu">
|
|
|
+ {#if acceptFiles}
|
|
|
+ <InputMenu
|
|
|
+ {screenCaptureHandler}
|
|
|
+ uploadFilesHandler={() => {
|
|
|
+ filesInputElement.click();
|
|
|
+ }}
|
|
|
+ >
|
|
|
<button
|
|
|
- class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
|
|
|
+ class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
|
|
|
type="button"
|
|
|
- on:click={() => {
|
|
|
- files.splice(fileIdx, 1);
|
|
|
- files = files;
|
|
|
- }}
|
|
|
+ aria-label="More"
|
|
|
>
|
|
|
<svg
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
viewBox="0 0 20 20"
|
|
|
fill="currentColor"
|
|
|
- class="w-4 h-4"
|
|
|
+ class="size-5"
|
|
|
>
|
|
|
<path
|
|
|
- d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
|
+ d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
|
|
|
/>
|
|
|
</svg>
|
|
|
</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- {:else}
|
|
|
- <FileItem
|
|
|
- item={file}
|
|
|
- name={file.name}
|
|
|
- type={file.type}
|
|
|
- size={file?.size}
|
|
|
- loading={file.status === 'uploading'}
|
|
|
- dismissible={true}
|
|
|
- edit={true}
|
|
|
- on:dismiss={() => {
|
|
|
- files.splice(fileIdx, 1);
|
|
|
- files = files;
|
|
|
- }}
|
|
|
- on:click={() => {
|
|
|
- console.log(file);
|
|
|
- }}
|
|
|
- />
|
|
|
- {/if}
|
|
|
- {/each}
|
|
|
- </div>
|
|
|
- {/if}
|
|
|
-
|
|
|
- <div class="px-2.5">
|
|
|
- <div
|
|
|
- class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 resize-none h-fit max-h-80 overflow-auto"
|
|
|
- >
|
|
|
- <RichTextInput
|
|
|
- bind:this={chatInputElement}
|
|
|
- json={true}
|
|
|
- messageInput={true}
|
|
|
- {showFormattingToolbar}
|
|
|
- shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
|
|
- (!$mobile ||
|
|
|
- !(
|
|
|
- 'ontouchstart' in window ||
|
|
|
- navigator.maxTouchPoints > 0 ||
|
|
|
- navigator.msMaxTouchPoints > 0
|
|
|
- ))}
|
|
|
- largeTextAsFile={$settings?.largeTextAsFile ?? false}
|
|
|
- floatingMenuPlacement={'top-start'}
|
|
|
- onChange={(e) => {
|
|
|
- const { md } = e;
|
|
|
- content = md;
|
|
|
- command = getCommand();
|
|
|
- }}
|
|
|
- on:keydown={async (e) => {
|
|
|
- e = e.detail.event;
|
|
|
- const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
|
|
-
|
|
|
- const suggestionsContainerElement =
|
|
|
- document.getElementById('suggestions-container');
|
|
|
-
|
|
|
- if (!suggestionsContainerElement) {
|
|
|
- if (
|
|
|
- !$mobile ||
|
|
|
- !(
|
|
|
- 'ontouchstart' in window ||
|
|
|
- navigator.maxTouchPoints > 0 ||
|
|
|
- navigator.msMaxTouchPoints > 0
|
|
|
- )
|
|
|
- ) {
|
|
|
- // Prevent Enter key from creating a new line
|
|
|
- // Uses keyCode '13' for Enter key for chinese/japanese keyboards
|
|
|
- if (e.keyCode === 13 && !e.shiftKey) {
|
|
|
- e.preventDefault();
|
|
|
- }
|
|
|
-
|
|
|
- // Submit the content when Enter key is pressed
|
|
|
- if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
|
|
|
- submitHandler();
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (e.key === 'Escape') {
|
|
|
- console.info('Escape');
|
|
|
- }
|
|
|
- }}
|
|
|
- on:paste={async (e) => {
|
|
|
- e = e.detail.event;
|
|
|
- console.info(e);
|
|
|
- }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ </InputMenu>
|
|
|
+ {/if}
|
|
|
+ </slot>
|
|
|
+ </div>
|
|
|
|
|
|
- <div class=" flex justify-between mb-2.5 mt-1.5 mx-0.5">
|
|
|
- <div class="ml-1 self-end flex space-x-1 flex-1">
|
|
|
- <slot name="menu">
|
|
|
- {#if acceptFiles}
|
|
|
- <InputMenu
|
|
|
- {screenCaptureHandler}
|
|
|
- uploadFilesHandler={() => {
|
|
|
- filesInputElement.click();
|
|
|
- }}
|
|
|
- >
|
|
|
+ <div class="self-end flex space-x-1 mr-1">
|
|
|
+ {#if content === ''}
|
|
|
+ <Tooltip content={$i18n.t('Record voice')}>
|
|
|
<button
|
|
|
- class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
|
|
|
+ id="voice-input-button"
|
|
|
+ class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
|
|
|
type="button"
|
|
|
- aria-label="More"
|
|
|
+ on:click={async () => {
|
|
|
+ try {
|
|
|
+ let stream = await navigator.mediaDevices
|
|
|
+ .getUserMedia({ audio: true })
|
|
|
+ .catch(function (err) {
|
|
|
+ toast.error(
|
|
|
+ $i18n.t(
|
|
|
+ `Permission denied when accessing microphone: {{error}}`,
|
|
|
+ {
|
|
|
+ error: err
|
|
|
+ }
|
|
|
+ )
|
|
|
+ );
|
|
|
+ return null;
|
|
|
+ });
|
|
|
+
|
|
|
+ if (stream) {
|
|
|
+ recording = true;
|
|
|
+ const tracks = stream.getTracks();
|
|
|
+ tracks.forEach((track) => track.stop());
|
|
|
+ }
|
|
|
+ stream = null;
|
|
|
+ } catch {
|
|
|
+ toast.error($i18n.t('Permission denied when accessing microphone'));
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ aria-label="Voice Input"
|
|
|
>
|
|
|
<svg
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
viewBox="0 0 20 20"
|
|
|
fill="currentColor"
|
|
|
- class="size-5"
|
|
|
+ class="w-5 h-5 translate-y-[0.5px]"
|
|
|
>
|
|
|
+ <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
|
|
<path
|
|
|
- d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
|
|
|
+ d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
|
|
/>
|
|
|
</svg>
|
|
|
</button>
|
|
|
- </InputMenu>
|
|
|
+ </Tooltip>
|
|
|
{/if}
|
|
|
- </slot>
|
|
|
- </div>
|
|
|
|
|
|
- <div class="self-end flex space-x-1 mr-1">
|
|
|
- {#if content === ''}
|
|
|
- <Tooltip content={$i18n.t('Record voice')}>
|
|
|
- <button
|
|
|
- id="voice-input-button"
|
|
|
- class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
|
|
|
- type="button"
|
|
|
- on:click={async () => {
|
|
|
- try {
|
|
|
- let stream = await navigator.mediaDevices
|
|
|
- .getUserMedia({ audio: true })
|
|
|
- .catch(function (err) {
|
|
|
- toast.error(
|
|
|
- $i18n.t(`Permission denied when accessing microphone: {{error}}`, {
|
|
|
- error: err
|
|
|
- })
|
|
|
- );
|
|
|
- return null;
|
|
|
- });
|
|
|
-
|
|
|
- if (stream) {
|
|
|
- recording = true;
|
|
|
- const tracks = stream.getTracks();
|
|
|
- tracks.forEach((track) => track.stop());
|
|
|
- }
|
|
|
- stream = null;
|
|
|
- } catch {
|
|
|
- toast.error($i18n.t('Permission denied when accessing microphone'));
|
|
|
- }
|
|
|
- }}
|
|
|
- aria-label="Voice Input"
|
|
|
- >
|
|
|
- <svg
|
|
|
- xmlns="http://www.w3.org/2000/svg"
|
|
|
- viewBox="0 0 20 20"
|
|
|
- fill="currentColor"
|
|
|
- class="w-5 h-5 translate-y-[0.5px]"
|
|
|
- >
|
|
|
- <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
|
|
- <path
|
|
|
- d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- {/if}
|
|
|
-
|
|
|
- <div class=" flex items-center">
|
|
|
- {#if inputLoading && onStop}
|
|
|
- <div class=" flex items-center">
|
|
|
- <Tooltip content={$i18n.t('Stop')}>
|
|
|
- <button
|
|
|
- class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
|
|
|
- on:click={() => {
|
|
|
- onStop();
|
|
|
- }}
|
|
|
- >
|
|
|
- <svg
|
|
|
- xmlns="http://www.w3.org/2000/svg"
|
|
|
- viewBox="0 0 24 24"
|
|
|
- fill="currentColor"
|
|
|
- class="size-5"
|
|
|
+ <div class=" flex items-center">
|
|
|
+ {#if inputLoading && onStop}
|
|
|
+ <div class=" flex items-center">
|
|
|
+ <Tooltip content={$i18n.t('Stop')}>
|
|
|
+ <button
|
|
|
+ class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
|
|
|
+ on:click={() => {
|
|
|
+ onStop();
|
|
|
+ }}
|
|
|
>
|
|
|
- <path
|
|
|
- fill-rule="evenodd"
|
|
|
- d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
|
|
|
- clip-rule="evenodd"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- </div>
|
|
|
- {:else}
|
|
|
- <div class=" flex items-center">
|
|
|
- <Tooltip content={$i18n.t('Send message')}>
|
|
|
- <button
|
|
|
- id="send-message-button"
|
|
|
- class="{content !== '' || files.length !== 0
|
|
|
- ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
|
|
- : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
|
|
- type="submit"
|
|
|
- disabled={content === '' && files.length === 0}
|
|
|
- >
|
|
|
- <svg
|
|
|
- xmlns="http://www.w3.org/2000/svg"
|
|
|
- viewBox="0 0 16 16"
|
|
|
- fill="currentColor"
|
|
|
- class="size-5"
|
|
|
+ <svg
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ viewBox="0 0 24 24"
|
|
|
+ fill="currentColor"
|
|
|
+ class="size-5"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ fill-rule="evenodd"
|
|
|
+ d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
|
|
|
+ clip-rule="evenodd"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ </div>
|
|
|
+ {:else}
|
|
|
+ <div class=" flex items-center">
|
|
|
+ <Tooltip content={$i18n.t('Send message')}>
|
|
|
+ <button
|
|
|
+ id="send-message-button"
|
|
|
+ class="{content !== '' || files.length !== 0
|
|
|
+ ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
|
|
+ : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
|
|
|
+ type="submit"
|
|
|
+ disabled={content === '' && files.length === 0}
|
|
|
>
|
|
|
- <path
|
|
|
- fill-rule="evenodd"
|
|
|
- d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
|
|
- clip-rule="evenodd"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- </div>
|
|
|
- {/if}
|
|
|
+ <svg
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ viewBox="0 0 16 16"
|
|
|
+ fill="currentColor"
|
|
|
+ class="size-5"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ fill-rule="evenodd"
|
|
|
+ d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
|
|
+ clip-rule="evenodd"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- </form>
|
|
|
- {/if}
|
|
|
+ </form>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
-</div>
|
|
|
+{/if}
|