refac: channel input

This commit is contained in:
Timothy Jaeryang Baek 2025-09-12 21:43:05 +04:00
parent b2623c9799
commit 06c1426e14
11 changed files with 567 additions and 637 deletions

View file

@ -17,9 +17,12 @@
getFormattedTime, getFormattedTime,
getUserPosition, getUserPosition,
getUserTimezone, getUserTimezone,
getWeekday getWeekday,
extractCurlyBraceWords
} from '$lib/utils'; } from '$lib/utils';
import { getSessionUser } from '$lib/apis/auths';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import RichTextInput from '../common/RichTextInput.svelte'; import RichTextInput from '../common/RichTextInput.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte'; import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
@ -29,26 +32,16 @@
import FileItem from '../common/FileItem.svelte'; import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte'; import Image from '../common/Image.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import Commands from '../chat/MessageInput/Commands.svelte';
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.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 placeholder = $i18n.t('Send a Message');
export let id = null; export let id = null;
let draggedOver = false;
let recording = false;
let content = '';
let files = [];
export let chatInputElement; export let chatInputElement;
let commandsElement;
let filesInputElement;
let inputFiles;
export let typingUsers = []; export let typingUsers = [];
export let inputLoading = false; export let inputLoading = false;
@ -62,15 +55,39 @@
export let acceptFiles = true; export let acceptFiles = true;
export let showFormattingToolbar = 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 showInputVariablesModal = false;
let inputVariablesModalCallback: (variableValues: Record<string, any>) => void;
let inputVariables: Record<string, any> = {}; let inputVariables: Record<string, any> = {};
let inputVariableValues = {}; let inputVariableValues = {};
const inputVariableHandler = async (text: string) => { const inputVariableHandler = async (text: string): Promise<string> => {
inputVariables = extractInputVariables(text); 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) => { const textVariableHandler = async (text: string) => {
@ -188,68 +205,87 @@
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday); text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
} }
inputVariableHandler(text);
return text; return text;
}; };
const replaceVariables = (variables: Record<string, any>) => { const replaceVariables = (variables: Record<string, any>) => {
if (!chatInputElement) return;
console.log('Replacing variables:', variables); console.log('Replacing variables:', variables);
chatInputElement.replaceVariables(variables); const chatInput = document.getElementById('chat-input');
chatInputElement.focus();
if (chatInput) {
chatInputElement.replaceVariables(variables);
chatInputElement.focus();
}
}; };
export const setText = async (text?: string) => { export const setText = async (text?: string, cb?: (text: string) => void) => {
if (!chatInputElement) return; const chatInput = document.getElementById('chat-input');
text = await textVariableHandler(text || ''); if (chatInput) {
text = await textVariableHandler(text || '');
chatInputElement?.setText(text); chatInputElement?.setText(text);
chatInputElement?.focus(); chatInputElement?.focus();
text = await inputVariableHandler(text);
await tick();
if (cb) await cb(text);
}
}; };
const getCommand = () => { const getCommand = () => {
if (!chatInputElement) return; const chatInput = document.getElementById('chat-input');
let word = ''; let word = '';
word = chatInputElement?.getWordAtDocPos();
if (chatInput) {
word = chatInputElement?.getWordAtDocPos();
}
return word; return word;
}; };
const replaceCommandWithText = (text) => { const replaceCommandWithText = (text) => {
if (!chatInputElement) return; const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
chatInputElement?.replaceCommandWithText(text); chatInputElement?.replaceCommandWithText(text);
}; };
const insertTextAtCursor = async (text: string) => { const insertTextAtCursor = async (text: string) => {
const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
text = await textVariableHandler(text); text = await textVariableHandler(text);
if (command) { if (command) {
replaceCommandWithText(text); replaceCommandWithText(text);
} else { } else {
const selection = window.getSelection(); chatInputElement?.insertContent(text);
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);
}
} }
await tick(); await tick();
text = await inputVariableHandler(text);
await tick();
const chatInputContainer = document.getElementById('chat-input-container'); const chatInputContainer = document.getElementById('chat-input-container');
if (chatInputContainer) { if (chatInputContainer) {
chatInputContainer.scrollTop = chatInputContainer.scrollHeight; chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
} }
await tick(); await tick();
if (chatInputElement) { if (chatInput) {
chatInputElement.focus(); 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; export let showCommands = false;
$: showCommands = ['/'].includes(command?.charAt(0)); $: showCommands = ['/'].includes(command?.charAt(0));
let suggestions = null;
const screenCaptureHandler = async () => { const screenCaptureHandler = async () => {
try { try {
@ -514,6 +551,49 @@
} }
onMount(async () => { 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(() => { window.setTimeout(() => {
if (chatInputElement) { if (chatInputElement) {
chatInputElement.focus(); chatInputElement.focus();
@ -543,389 +623,392 @@
}); });
</script> </script>
<FilesOverlay show={draggedOver} /> {#if loaded}
<FilesOverlay show={draggedOver} />
{#if acceptFiles} {#if acceptFiles}
<input <input
bind:this={filesInputElement} bind:this={filesInputElement}
bind:files={inputFiles} bind:files={inputFiles}
type="file" type="file"
hidden hidden
multiple multiple
on:change={async () => { on:change={async () => {
if (inputFiles && inputFiles.length > 0) { if (inputFiles && inputFiles.length > 0) {
inputFilesHandler(Array.from(inputFiles)); inputFilesHandler(Array.from(inputFiles));
} else { } else {
toast.error($i18n.t(`File not found.`)); toast.error($i18n.t(`File not found.`));
} }
filesInputElement.value = ''; filesInputElement.value = '';
}} }}
/>
{/if}
<InputVariablesModal
bind:show={showInputVariablesModal}
variables={inputVariables}
onSave={inputVariablesModalCallback}
/> />
{/if}
<InputVariablesModal <div class="bg-transparent">
bind:show={showInputVariablesModal} <div
variables={inputVariables} class="{($settings?.widescreenMode ?? null)
onSave={(variableValues) => { ? 'max-w-full'
inputVariableValues = { ...inputVariableValues, ...variableValues }; : 'max-w-6xl'} mx-auto inset-x-0 relative"
replaceVariables(inputVariableValues); >
}} <div
/> class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center"
>
<div class="bg-transparent"> <div class="flex flex-col px-3 w-full">
<div <div class="relative">
class="{($settings?.widescreenMode ?? null) {#if scrollEnd === false}
? 'max-w-full' <div
: 'max-w-6xl'} mx-auto inset-x-0 relative" class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
>
<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();
}}
> >
<svg <button
xmlns="http://www.w3.org/2000/svg" class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
viewBox="0 0 20 20" on:click={() => {
fill="currentColor" scrollEnd = true;
class="w-5 h-5" scrollToBottom();
}}
> >
<path <svg
fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"
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" viewBox="0 0 20 20"
clip-rule="evenodd" fill="currentColor"
/> class="w-5 h-5"
</svg> >
</button> <path
</div> fill-rule="evenodd"
{/if} 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"
</div> clip-rule="evenodd"
/>
<div class="relative"> </svg>
<div class=" -mt-5"> </button>
{#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> </div>
{/if} {/if}
</div> </div>
<Commands <div class="relative">
bind:this={commandsElement} <div class=" -mt-5">
show={showCommands} {#if typingUsers.length > 0}
{command} <div class=" text-xs px-4 mb-1">
insertTextHandler={insertTextAtCursor} <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>
</div>
<div class=""> <div class="">
{#if recording} {#if recording}
<VoiceRecording <VoiceRecording
bind:recording bind:recording
onCancel={async () => { onCancel={async () => {
recording = false; recording = false;
await tick(); await tick();
if (chatInputElement) { if (chatInputElement) {
chatInputElement.focus(); chatInputElement.focus();
} }
}} }}
onConfirm={async (data) => { onConfirm={async (data) => {
const { text, filename } = data; const { text, filename } = data;
recording = false; recording = false;
await tick(); await tick();
insertTextAtCursor(text); insertTextAtCursor(text);
await tick(); await tick();
if (chatInputElement) { if (chatInputElement) {
chatInputElement.focus(); chatInputElement.focus();
} }
}} }}
/> />
{:else} {:else}
<form <form
class="w-full flex gap-1.5" class="w-full flex gap-1.5"
on:submit|preventDefault={() => { on:submit|preventDefault={() => {
submitHandler(); 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 files.length > 0} <div
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2"> 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"
{#each files as file, fileIdx} dir={$settings?.chatDirection ?? 'auto'}
{#if file.type === 'image'} >
<div class=" relative group"> {#if files.length > 0}
<div class="relative"> <div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
<Image {#each files as file, fileIdx}
src={file.url} {#if file.type === 'image'}
alt="input" <div class=" relative group">
imageClassName=" h-16 w-16 rounded-xl object-cover" <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>
<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 <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" type="button"
on:click={() => { aria-label="More"
files.splice(fileIdx, 1);
files = files;
}}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
class="w-4 h-4" class="size-5"
> >
<path <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> </svg>
</button> </button>
</div> </InputMenu>
</div> {/if}
{:else} </slot>
<FileItem </div>
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="self-end flex space-x-1 mr-1">
<div {#if content === ''}
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" <Tooltip content={$i18n.t('Record voice')}>
>
<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>
<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();
}}
>
<button <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" 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" 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 <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> </svg>
</button> </button>
</InputMenu> </Tooltip>
{/if} {/if}
</slot>
</div>
<div class="self-end flex space-x-1 mr-1"> <div class=" flex items-center">
{#if content === ''} {#if inputLoading && onStop}
<Tooltip content={$i18n.t('Record voice')}> <div class=" flex items-center">
<button <Tooltip content={$i18n.t('Stop')}>
id="voice-input-button" <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" 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"
type="button" on:click={() => {
on:click={async () => { onStop();
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"
> >
<path <svg
fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"
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" viewBox="0 0 24 24"
clip-rule="evenodd" fill="currentColor"
/> class="size-5"
</svg> >
</button> <path
</Tooltip> fill-rule="evenodd"
</div> 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"
{:else} clip-rule="evenodd"
<div class=" flex items-center"> />
<Tooltip content={$i18n.t('Send message')}> </svg>
<button </button>
id="send-message-button" </Tooltip>
class="{content !== '' || files.length !== 0 </div>
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 ' {:else}
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center" <div class=" flex items-center">
type="submit" <Tooltip content={$i18n.t('Send message')}>
disabled={content === '' && files.length === 0} <button
> id="send-message-button"
<svg class="{content !== '' || files.length !== 0
xmlns="http://www.w3.org/2000/svg" ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
viewBox="0 0 16 16" : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
fill="currentColor" type="submit"
class="size-5" disabled={content === '' && files.length === 0}
> >
<path <svg
fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"
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" viewBox="0 0 16 16"
clip-rule="evenodd" fill="currentColor"
/> class="size-5"
</svg> >
</button> <path
</Tooltip> fill-rule="evenodd"
</div> 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"
{/if} clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </form>
</form> {/if}
{/if} </div>
</div> </div>
</div> </div>
</div> {/if}

View file

@ -13,6 +13,8 @@
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte'; import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import CameraSolid from '$lib/components/icons/CameraSolid.svelte'; import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
import Camera from '$lib/components/icons/Camera.svelte';
import Clip from '$lib/components/icons/Clip.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -44,34 +46,34 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm" class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-850 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
sideOffset={15} sideOffset={4}
alignOffset={-8} alignOffset={-6}
side="top" side="bottom"
align="start" align="start"
transition={flyAndScale} transition={flyAndScale}
> >
{#if !$mobile}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
>
<CameraSolid />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl" class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => { on:click={() => {
uploadFilesHandler(); uploadFilesHandler();
}} }}
> >
<DocumentArrowUpSolid /> <Clip />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div> <div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
{#if !$mobile}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
>
<Camera />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content> </DropdownMenu.Content>
</div> </div>
</Dropdown> </Dropdown>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { models } from '$lib/stores';
export let query = '';
export let command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0;
let items = [];
$: filteredItems = $models.filter((u) => u.name.toLowerCase().includes(query.toLowerCase()));
const select = (index: number) => {
const item = filteredItems[index];
if (item) command(item);
};
const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') {
selectedIndex = (selectedIndex + filteredItems.length - 1) % filteredItems.length;
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true;
}
if (event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % filteredItems.length;
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
select(selectedIndex);
return true;
}
if (event.key === 'Escape') {
// tell tiptap we handled it (it will close)
return true;
}
return false;
};
// This method will be called from the suggestion renderer
// @ts-ignore
export function _onKeyDown(event: KeyboardEvent) {
return onKeyDown(event);
}
</script>
{#if filteredItems.length}
<div
class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-60 p-1"
id="suggestions-container"
>
<div class="overflow-y-auto scrollbar-thin max-h-60">
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Models')}
</div>
{#each filteredItems as item, i}
<button
type="button"
on:click={() => select(i)}
on:mousemove={() => {
selectedIndex = i;
}}
class="px-2.5 py-1.5 rounded-xl w-full text-left {i === selectedIndex
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''}"
data-selected={i === selectedIndex}
>
<div class="truncate">
@{item.name}
</div>
</button>
{/each}
</div>
</div>
{/if}

View file

@ -138,9 +138,7 @@
id="message-{message.id}" id="message-{message.id}"
dir={$settings.chatDirection} dir={$settings.chatDirection}
> >
<div <div class={`shrink-0 mr-3 w-9`}>
class={`shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
>
{#if showUserProfile} {#if showUserProfile}
<ProfilePreview user={message.user}> <ProfilePreview user={message.user}>
<ProfileImage <ProfileImage
@ -198,7 +196,7 @@
name={file.name} name={file.name}
type={file.type} type={file.type}
size={file?.size} size={file?.size}
colorClassName="bg-white dark:bg-gray-850 " small={true}
/> />
{/if} {/if}
</div> </div>

View file

@ -51,7 +51,6 @@
import InputMenu from './MessageInput/InputMenu.svelte'; import InputMenu from './MessageInput/InputMenu.svelte';
import VoiceRecording from './MessageInput/VoiceRecording.svelte'; import VoiceRecording from './MessageInput/VoiceRecording.svelte';
import FilesOverlay from './MessageInput/FilesOverlay.svelte'; import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import Commands from './MessageInput/Commands.svelte';
import ToolServersModal from './ToolServersModal.svelte'; import ToolServersModal from './ToolServersModal.svelte';
import RichTextInput from '../common/RichTextInput.svelte'; import RichTextInput from '../common/RichTextInput.svelte';
@ -77,7 +76,6 @@
import { KokoroWorker } from '$lib/workers/KokoroWorker'; import { KokoroWorker } from '$lib/workers/KokoroWorker';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions'; import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import MentionList from '../common/RichTextInput/MentionList.svelte';
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte'; import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -298,16 +296,6 @@
}; };
const getCommand = () => { const getCommand = () => {
const 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 chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
let word = ''; let word = '';
@ -319,14 +307,6 @@
}; };
const replaceCommandWithText = (text) => { const replaceCommandWithText = (text) => {
const 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 };
};
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
if (!chatInput) return; if (!chatInput) return;

View file

@ -1,129 +0,0 @@
<script>
import { knowledge, prompts } from '$lib/stores';
import { removeLastWordFromString } from '$lib/utils';
import { getPrompts } from '$lib/apis/prompts';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import Prompts from './Commands/Prompts.svelte';
import Knowledge from './Commands/Knowledge.svelte';
import Models from './Commands/Models.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
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;
export const selectUp = () => {
commandElement?.selectUp();
};
export const selectDown = () => {
commandElement?.selectDown();
};
$: if (show) {
init();
}
const init = async () => {
loading = true;
await Promise.all([
(async () => {
prompts.set(await getPrompts(localStorage.token));
})(),
(async () => {
knowledge.set(await getKnowledgeBases(localStorage.token));
})()
]);
loading = false;
};
</script>
{#if show}
{#if !loading}
{#if command?.charAt(0) === '/'}
<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}
command={command.includes('\\#') ? command.slice(2) : command}
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
});
}
}}
/>
{:else if command?.charAt(0) === '@'}
<Models
bind:this={commandElement}
{command}
onSelect={(e) => {
const { type, data } = e;
if (type === 'model') {
insertTextHandler('');
onSelect({
type: 'model',
data: data
});
}
}}
/>
{/if}
{:else}
<div
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
>
<div class="flex w-full rounded-xl border border-gray-100 dark:border-gray-850">
<div
class="max-h-60 flex flex-col w-full rounded-xl bg-white dark:bg-gray-900 dark:text-gray-100"
>
<Spinner />
</div>
</div>
</div>
{/if}
{/if}

View file

@ -142,7 +142,7 @@
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { all, createLowlight } from 'lowlight'; import { all, createLowlight } from 'lowlight';
import MentionList from './RichTextInput/MentionList.svelte'; import MentionList from '../channel/MessageInput/MentionList.svelte';
import { getSuggestionRenderer } from './RichTextInput/suggestions.js'; import { getSuggestionRenderer } from './RichTextInput/suggestions.js';
export let oncompositionstart = (e) => {}; export let oncompositionstart = (e) => {};
@ -1369,7 +1369,7 @@
}; };
</script> </script>
{#if showFormattingToolbar} {#if richText && showFormattingToolbar}
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0"> <div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0">
<FormattingButtons {editor} /> <FormattingButtons {editor} />
</div> </div>

View file

@ -1,85 +0,0 @@
<script lang="ts">
export let query = '';
export let command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0;
let ITEMS = [
{ id: '1', label: 'alice' },
{ id: '2', label: 'alex' },
{ id: '3', label: 'bob' },
{ id: '4', label: 'charlie' },
{ id: '5', label: 'diana' },
{ id: '6', label: 'eve' },
{ id: '7', label: 'frank' },
{ id: '8', label: 'grace' },
{ id: '9', label: 'heidi' },
{ id: '10', label: 'ivan' },
{ id: '11', label: 'judy' },
{ id: '12', label: 'mallory' },
{ id: '13', label: 'oscar' },
{ id: '14', label: 'peggy' },
{ id: '15', label: 'trent' },
{ id: '16', label: 'victor' },
{ id: '17', label: 'walter' }
];
let items = ITEMS;
$: items = ITEMS.filter((u) => u.label.toLowerCase().includes(query.toLowerCase())).slice(0, 5);
const select = (index: number) => {
const item = items[index];
if (item) command(item);
};
const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') {
selectedIndex = (selectedIndex + items.length - 1) % items.length;
return true;
}
if (event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % items.length;
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
select(selectedIndex);
return true;
}
if (event.key === 'Escape') {
// tell tiptap we handled it (it will close)
return true;
}
return false;
};
// This method will be called from the suggestion renderer
// @ts-ignore
export function _onKeyDown(event: KeyboardEvent) {
return onKeyDown(event);
}
</script>
<div
class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 overflow-y-auto scrollbar-thin max-h-60 w-52"
id="suggestions-container"
>
{#if items.length === 0}
<div class=" p-4 text-gray-400">No results</div>
{:else}
{#each items as item, i}
<button
type="button"
on:click={() => select(i)}
class=" text-left w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition px-3 py-1 {i ===
selectedIndex
? 'bg-gray-50 dark:bg-gray-800 font-medium'
: ''}"
>
@{item.label}
</button>
{/each}
{/if}
</div>

View file

@ -327,7 +327,7 @@ Based on the user's instruction, update and enhance the existing notes or select
}); });
</script> </script>
<div class="flex items-center mb-1.5 pt-1.5"> <div class="flex items-center mb-1.5 pt-1.5 pl-1.5 pr-2.5">
<div class=" -translate-x-1.5 flex items-center"> <div class=" -translate-x-1.5 flex items-center">
<button <button
class="p-0.5 bg-transparent transition rounded-lg" class="p-0.5 bg-transparent transition rounded-lg"
@ -358,7 +358,7 @@ Based on the user's instruction, update and enhance the existing notes or select
</div> </div>
</div> </div>
<div class="flex flex-col items-center mb-2 flex-1 @container"> <div class="flex flex-col items-center flex-1 @container">
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full"> <div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full relative"> <div class="mx-auto w-full md:px-0 h-full relative">
<div class=" flex flex-col h-full"> <div class=" flex flex-col h-full">
@ -375,7 +375,7 @@ Based on the user's instruction, update and enhance the existing notes or select
</div> </div>
</div> </div>
<div class=" pb-2"> <div class=" pb-[1rem] pl-1.5 pr-2.5">
{#if selectedContent} {#if selectedContent}
<div class="text-xs rounded-xl px-3.5 py-3 w-full markdown-prose-xs"> <div class="text-xs rounded-xl px-3.5 py-3 w-full markdown-prose-xs">
<blockquote> <blockquote>

View file

@ -17,7 +17,7 @@
}; };
</script> </script>
<div class="flex items-center mb-1.5 pt-1.5"> <div class="flex items-center mb-1.5 pt-1.5 pl-1.5 pr-2.5">
<div class=" -translate-x-1.5 flex items-center"> <div class=" -translate-x-1.5 flex items-center">
<button <button
class="p-0.5 bg-transparent transition rounded-lg" class="p-0.5 bg-transparent transition rounded-lg"
@ -36,7 +36,7 @@
</div> </div>
</div> </div>
<div class="mt-1"> <div class="mt-1 pl-1.5 pr-2.5">
<div class="pb-10"> <div class="pb-10">
{#if files.length > 0} {#if files.length > 0}
<div class=" text-xs font-medium pb-1">{$i18n.t('Files')}</div> <div class=" text-xs font-medium pb-1">{$i18n.t('Files')}</div>

View file

@ -98,7 +98,7 @@
{#if show} {#if show}
<div class="flex max-h-full min-h-full"> <div class="flex max-h-full min-h-full">
<div <div
class="w-full pl-1.5 pr-2.5 pt-2 bg-white dark:shadow-lg dark:bg-gray-850 z-40 pointer-events-auto overflow-y-auto scrollbar-hidden flex flex-col" class="w-full pt-2 bg-white dark:shadow-lg dark:bg-gray-850 z-40 pointer-events-auto overflow-y-auto scrollbar-hidden flex flex-col"
> >
<slot /> <slot />
</div> </div>