|
|
@@ -1019,7 +1019,7 @@
|
|
|
}}
|
|
|
/>
|
|
|
|
|
|
- {#if recording}
|
|
|
+ <div class={recording ? '' : 'hidden'}>
|
|
|
<VoiceRecording
|
|
|
bind:recording
|
|
|
onCancel={async () => {
|
|
|
@@ -1034,7 +1034,7 @@
|
|
|
recording = false;
|
|
|
|
|
|
await tick();
|
|
|
- await insertTextAtCursor(text);
|
|
|
+ await insertTextAtCursor(`${text}`);
|
|
|
await tick();
|
|
|
document.getElementById('chat-input')?.focus();
|
|
|
|
|
|
@@ -1043,390 +1043,427 @@
|
|
|
}
|
|
|
}}
|
|
|
/>
|
|
|
- {:else}
|
|
|
- <form
|
|
|
- class="w-full flex flex-col gap-1.5"
|
|
|
- on:submit|preventDefault={() => {
|
|
|
- // check if selectedModels support image input
|
|
|
- dispatch('submit', prompt);
|
|
|
- }}
|
|
|
+ </div>
|
|
|
+ <form
|
|
|
+ class="w-full flex flex-col gap-1.5 {recording ? 'hidden' : ''}"
|
|
|
+ on:submit|preventDefault={() => {
|
|
|
+ // check if selectedModels support image input
|
|
|
+ dispatch('submit', prompt);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ id="generate-message-pair-button"
|
|
|
+ class="hidden"
|
|
|
+ on:click={() => createMessagePair(prompt)}
|
|
|
+ />
|
|
|
+
|
|
|
+ <div
|
|
|
+ id="message-input-container"
|
|
|
+ class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
|
|
|
+ ? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700'
|
|
|
+ : ' border-gray-100 dark:border-gray-850 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-1 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
|
|
|
+ dir={$settings?.chatDirection ?? 'auto'}
|
|
|
>
|
|
|
- <button
|
|
|
- id="generate-message-pair-button"
|
|
|
- class="hidden"
|
|
|
- on:click={() => createMessagePair(prompt)}
|
|
|
- />
|
|
|
-
|
|
|
- <div
|
|
|
- id="message-input-container"
|
|
|
- class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
|
|
|
- ? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700'
|
|
|
- : ' border-gray-100 dark:border-gray-850 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-1 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
|
|
|
- dir={$settings?.chatDirection ?? 'auto'}
|
|
|
- >
|
|
|
- {#if atSelectedModel !== undefined}
|
|
|
- <div class="px-3 pt-3 text-left w-full flex flex-col z-10">
|
|
|
- <div class="flex items-center justify-between w-full">
|
|
|
- <div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
|
|
|
- <img
|
|
|
- crossorigin="anonymous"
|
|
|
- alt="model profile"
|
|
|
- class="size-3.5 max-w-[28px] object-cover rounded-full"
|
|
|
- src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
|
|
|
- ?.profile_image_url ??
|
|
|
- ($i18n.language === 'dg-DG'
|
|
|
- ? `${WEBUI_BASE_URL}/doge.png`
|
|
|
- : `${WEBUI_BASE_URL}/static/favicon.png`)}
|
|
|
- />
|
|
|
- <div class="translate-y-[0.5px]">
|
|
|
- <span class="">{atSelectedModel.name}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <button
|
|
|
- class="flex items-center dark:text-gray-500"
|
|
|
- on:click={() => {
|
|
|
- atSelectedModel = undefined;
|
|
|
- }}
|
|
|
- >
|
|
|
- <XMark />
|
|
|
- </button>
|
|
|
+ {#if atSelectedModel !== undefined}
|
|
|
+ <div class="px-3 pt-3 text-left w-full flex flex-col z-10">
|
|
|
+ <div class="flex items-center justify-between w-full">
|
|
|
+ <div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
|
|
|
+ <img
|
|
|
+ crossorigin="anonymous"
|
|
|
+ alt="model profile"
|
|
|
+ class="size-3.5 max-w-[28px] object-cover rounded-full"
|
|
|
+ src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
|
|
|
+ ?.profile_image_url ??
|
|
|
+ ($i18n.language === 'dg-DG'
|
|
|
+ ? `${WEBUI_BASE_URL}/doge.png`
|
|
|
+ : `${WEBUI_BASE_URL}/static/favicon.png`)}
|
|
|
+ />
|
|
|
+ <div class="translate-y-[0.5px]">
|
|
|
+ <span class="">{atSelectedModel.name}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <div>
|
|
|
+ <button
|
|
|
+ class="flex items-center dark:text-gray-500"
|
|
|
+ on:click={() => {
|
|
|
+ atSelectedModel = undefined;
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <XMark />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- {/if}
|
|
|
-
|
|
|
- {#if files.length > 0}
|
|
|
- <div class="mx-2 mt-2.5 pb-1.5 flex items-center flex-wrap gap-2">
|
|
|
- {#each files as file, fileIdx}
|
|
|
- {#if file.type === 'image'}
|
|
|
- <div class=" relative group">
|
|
|
- <div class="relative flex items-center">
|
|
|
- <Image
|
|
|
- src={file.url}
|
|
|
- alt=""
|
|
|
- imageClassName=" size-10 rounded-xl object-cover"
|
|
|
- />
|
|
|
- {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
|
|
|
- <Tooltip
|
|
|
- className=" absolute top-1 left-1"
|
|
|
- content={$i18n.t('{{ models }}', {
|
|
|
- models: [
|
|
|
- ...(atSelectedModel ? [atSelectedModel] : selectedModels)
|
|
|
- ]
|
|
|
- .filter((id) => !visionCapableModels.includes(id))
|
|
|
- .join(', ')
|
|
|
- })}
|
|
|
- >
|
|
|
- <svg
|
|
|
- xmlns="http://www.w3.org/2000/svg"
|
|
|
- viewBox="0 0 24 24"
|
|
|
- fill="currentColor"
|
|
|
- aria-hidden="true"
|
|
|
- class="size-4 fill-yellow-300"
|
|
|
- >
|
|
|
- <path
|
|
|
- fill-rule="evenodd"
|
|
|
- d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
|
|
|
- clip-rule="evenodd"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- </Tooltip>
|
|
|
- {/if}
|
|
|
- </div>
|
|
|
- <div class=" absolute -top-1 -right-1">
|
|
|
- <button
|
|
|
- class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
|
|
|
- false)
|
|
|
- ? ''
|
|
|
- : 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
|
|
|
- type="button"
|
|
|
- aria-label={$i18n.t('Remove file')}
|
|
|
- on:click={() => {
|
|
|
- files.splice(fileIdx, 1);
|
|
|
- files = files;
|
|
|
- }}
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+
|
|
|
+ {#if files.length > 0}
|
|
|
+ <div class="mx-2 mt-2.5 pb-1.5 flex items-center flex-wrap gap-2">
|
|
|
+ {#each files as file, fileIdx}
|
|
|
+ {#if file.type === 'image'}
|
|
|
+ <div class=" relative group">
|
|
|
+ <div class="relative flex items-center">
|
|
|
+ <Image
|
|
|
+ src={file.url}
|
|
|
+ alt=""
|
|
|
+ imageClassName=" size-10 rounded-xl object-cover"
|
|
|
+ />
|
|
|
+ {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
|
|
|
+ <Tooltip
|
|
|
+ className=" absolute top-1 left-1"
|
|
|
+ content={$i18n.t('{{ models }}', {
|
|
|
+ models: [...(atSelectedModel ? [atSelectedModel] : selectedModels)]
|
|
|
+ .filter((id) => !visionCapableModels.includes(id))
|
|
|
+ .join(', ')
|
|
|
+ })}
|
|
|
>
|
|
|
<svg
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
- viewBox="0 0 20 20"
|
|
|
+ viewBox="0 0 24 24"
|
|
|
fill="currentColor"
|
|
|
aria-hidden="true"
|
|
|
- class="size-4"
|
|
|
+ class="size-4 fill-yellow-300"
|
|
|
>
|
|
|
<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"
|
|
|
+ fill-rule="evenodd"
|
|
|
+ d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
|
|
|
+ clip-rule="evenodd"
|
|
|
/>
|
|
|
</svg>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
+ </Tooltip>
|
|
|
+ {/if}
|
|
|
</div>
|
|
|
- {:else}
|
|
|
- <FileItem
|
|
|
- item={file}
|
|
|
- name={file.name}
|
|
|
- type={file.type}
|
|
|
- size={file?.size}
|
|
|
- loading={file.status === 'uploading'}
|
|
|
- dismissible={true}
|
|
|
- edit={true}
|
|
|
- small={true}
|
|
|
- modal={['file', 'collection'].includes(file?.type)}
|
|
|
- on:dismiss={async () => {
|
|
|
- // Remove from UI state
|
|
|
- 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 pb-1 px-1 resize-none h-fit max-h-96 overflow-auto {files.length ===
|
|
|
- 0
|
|
|
- ? atSelectedModel !== undefined
|
|
|
- ? 'pt-1.5'
|
|
|
- : 'pt-2.5'
|
|
|
- : ''}"
|
|
|
- id="chat-input-container"
|
|
|
- >
|
|
|
- {#if suggestions}
|
|
|
- {#key $settings?.richTextInput ?? true}
|
|
|
- {#key $settings?.showFormattingToolbar ?? false}
|
|
|
- <RichTextInput
|
|
|
- bind:this={chatInputElement}
|
|
|
- id="chat-input"
|
|
|
- onChange={(e) => {
|
|
|
- prompt = e.md;
|
|
|
- command = getCommand();
|
|
|
+ <div class=" absolute -top-1 -right-1">
|
|
|
+ <button
|
|
|
+ class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
|
|
|
+ false)
|
|
|
+ ? ''
|
|
|
+ : 'outline-hidden focus:outline-hidden group-hover:visible invisible transition'}"
|
|
|
+ type="button"
|
|
|
+ aria-label={$i18n.t('Remove file')}
|
|
|
+ on:click={() => {
|
|
|
+ files.splice(fileIdx, 1);
|
|
|
+ files = files;
|
|
|
}}
|
|
|
- json={true}
|
|
|
- richText={$settings?.richTextInput ?? true}
|
|
|
- messageInput={true}
|
|
|
- showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
|
|
|
- floatingMenuPlacement={'top-start'}
|
|
|
- insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
|
|
|
- shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
|
|
- !$mobile &&
|
|
|
- !(
|
|
|
- 'ontouchstart' in window ||
|
|
|
- navigator.maxTouchPoints > 0 ||
|
|
|
- navigator.msMaxTouchPoints > 0
|
|
|
- )}
|
|
|
- placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
|
|
- largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
|
|
|
- autocomplete={$config?.features?.enable_autocomplete_generation &&
|
|
|
- ($settings?.promptAutocomplete ?? false)}
|
|
|
- generateAutoCompletion={async (text) => {
|
|
|
- if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
|
|
|
- toast.error($i18n.t('Please select a model first.'));
|
|
|
- }
|
|
|
+ >
|
|
|
+ <svg
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ viewBox="0 0 20 20"
|
|
|
+ fill="currentColor"
|
|
|
+ aria-hidden="true"
|
|
|
+ class="size-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>
|
|
|
+ {:else}
|
|
|
+ <FileItem
|
|
|
+ item={file}
|
|
|
+ name={file.name}
|
|
|
+ type={file.type}
|
|
|
+ size={file?.size}
|
|
|
+ loading={file.status === 'uploading'}
|
|
|
+ dismissible={true}
|
|
|
+ edit={true}
|
|
|
+ small={true}
|
|
|
+ modal={['file', 'collection'].includes(file?.type)}
|
|
|
+ on:dismiss={async () => {
|
|
|
+ // Remove from UI state
|
|
|
+ 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 pb-1 px-1 resize-none h-fit max-h-96 overflow-auto {files.length ===
|
|
|
+ 0
|
|
|
+ ? atSelectedModel !== undefined
|
|
|
+ ? 'pt-1.5'
|
|
|
+ : 'pt-2.5'
|
|
|
+ : ''}"
|
|
|
+ id="chat-input-container"
|
|
|
+ >
|
|
|
+ {#if suggestions}
|
|
|
+ {#key $settings?.richTextInput ?? true}
|
|
|
+ {#key $settings?.showFormattingToolbar ?? false}
|
|
|
+ <RichTextInput
|
|
|
+ bind:this={chatInputElement}
|
|
|
+ id="chat-input"
|
|
|
+ onChange={(e) => {
|
|
|
+ prompt = e.md;
|
|
|
+ command = getCommand();
|
|
|
+ }}
|
|
|
+ json={true}
|
|
|
+ richText={$settings?.richTextInput ?? true}
|
|
|
+ messageInput={true}
|
|
|
+ showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
|
|
|
+ floatingMenuPlacement={'top-start'}
|
|
|
+ insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
|
|
|
+ shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
|
|
|
+ !$mobile &&
|
|
|
+ !(
|
|
|
+ 'ontouchstart' in window ||
|
|
|
+ navigator.maxTouchPoints > 0 ||
|
|
|
+ navigator.msMaxTouchPoints > 0
|
|
|
+ )}
|
|
|
+ placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
|
|
+ largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
|
|
|
+ autocomplete={$config?.features?.enable_autocomplete_generation &&
|
|
|
+ ($settings?.promptAutocomplete ?? false)}
|
|
|
+ generateAutoCompletion={async (text) => {
|
|
|
+ if (selectedModelIds.length === 0 || !selectedModelIds.at(0)) {
|
|
|
+ toast.error($i18n.t('Please select a model first.'));
|
|
|
+ }
|
|
|
|
|
|
- const res = await generateAutoCompletion(
|
|
|
- localStorage.token,
|
|
|
- selectedModelIds.at(0),
|
|
|
- text,
|
|
|
- history?.currentId
|
|
|
- ? createMessagesList(history, history.currentId)
|
|
|
- : null
|
|
|
- ).catch((error) => {
|
|
|
- console.log(error);
|
|
|
+ const res = await generateAutoCompletion(
|
|
|
+ localStorage.token,
|
|
|
+ selectedModelIds.at(0),
|
|
|
+ text,
|
|
|
+ history?.currentId
|
|
|
+ ? createMessagesList(history, history.currentId)
|
|
|
+ : null
|
|
|
+ ).catch((error) => {
|
|
|
+ console.log(error);
|
|
|
+
|
|
|
+ return null;
|
|
|
+ });
|
|
|
|
|
|
- return null;
|
|
|
- });
|
|
|
+ console.log(res);
|
|
|
+ return res;
|
|
|
+ }}
|
|
|
+ {suggestions}
|
|
|
+ oncompositionstart={() => (isComposing = true)}
|
|
|
+ oncompositionend={(e) => {
|
|
|
+ compositionEndedAt = e.timeStamp;
|
|
|
+ isComposing = false;
|
|
|
+ }}
|
|
|
+ on:keydown={async (e) => {
|
|
|
+ e = e.detail.event;
|
|
|
|
|
|
- console.log(res);
|
|
|
- return res;
|
|
|
- }}
|
|
|
- {suggestions}
|
|
|
- oncompositionstart={() => (isComposing = true)}
|
|
|
- oncompositionend={(e) => {
|
|
|
- compositionEndedAt = e.timeStamp;
|
|
|
- isComposing = false;
|
|
|
- }}
|
|
|
- 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');
|
|
|
|
|
|
- const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
|
|
- const suggestionsContainerElement =
|
|
|
- document.getElementById('suggestions-container');
|
|
|
+ if (e.key === 'Escape') {
|
|
|
+ stopResponse();
|
|
|
+ }
|
|
|
|
|
|
- if (e.key === 'Escape') {
|
|
|
- stopResponse();
|
|
|
- }
|
|
|
+ if (prompt === '' && e.key == 'ArrowUp') {
|
|
|
+ e.preventDefault();
|
|
|
|
|
|
- if (prompt === '' && e.key == 'ArrowUp') {
|
|
|
- e.preventDefault();
|
|
|
+ const userMessageElement = [
|
|
|
+ ...document.getElementsByClassName('user-message')
|
|
|
+ ]?.at(-1);
|
|
|
|
|
|
- const userMessageElement = [
|
|
|
- ...document.getElementsByClassName('user-message')
|
|
|
+ if (userMessageElement) {
|
|
|
+ userMessageElement.scrollIntoView({ block: 'center' });
|
|
|
+ const editButton = [
|
|
|
+ ...document.getElementsByClassName('edit-user-message-button')
|
|
|
]?.at(-1);
|
|
|
|
|
|
- if (userMessageElement) {
|
|
|
- userMessageElement.scrollIntoView({ block: 'center' });
|
|
|
- const editButton = [
|
|
|
- ...document.getElementsByClassName('edit-user-message-button')
|
|
|
- ]?.at(-1);
|
|
|
+ editButton?.click();
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- editButton?.click();
|
|
|
+ if (!suggestionsContainerElement) {
|
|
|
+ if (
|
|
|
+ !$mobile ||
|
|
|
+ !(
|
|
|
+ 'ontouchstart' in window ||
|
|
|
+ navigator.maxTouchPoints > 0 ||
|
|
|
+ navigator.msMaxTouchPoints > 0
|
|
|
+ )
|
|
|
+ ) {
|
|
|
+ if (inOrNearComposition(e)) {
|
|
|
+ return;
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- if (!suggestionsContainerElement) {
|
|
|
- if (
|
|
|
- !$mobile ||
|
|
|
- !(
|
|
|
- 'ontouchstart' in window ||
|
|
|
- navigator.maxTouchPoints > 0 ||
|
|
|
- navigator.msMaxTouchPoints > 0
|
|
|
- )
|
|
|
- ) {
|
|
|
- if (inOrNearComposition(e)) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // Uses keyCode '13' for Enter key for chinese/japanese keyboards.
|
|
|
- //
|
|
|
- // Depending on the user's settings, it will send the message
|
|
|
- // either when Enter is pressed or when Ctrl+Enter is pressed.
|
|
|
- const enterPressed =
|
|
|
- ($settings?.ctrlEnterToSend ?? false)
|
|
|
- ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
|
|
|
- : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
|
|
|
-
|
|
|
- if (enterPressed) {
|
|
|
- e.preventDefault();
|
|
|
- if (prompt !== '' || files.length > 0) {
|
|
|
- dispatch('submit', prompt);
|
|
|
- }
|
|
|
+ // Uses keyCode '13' for Enter key for chinese/japanese keyboards.
|
|
|
+ //
|
|
|
+ // Depending on the user's settings, it will send the message
|
|
|
+ // either when Enter is pressed or when Ctrl+Enter is pressed.
|
|
|
+ const enterPressed =
|
|
|
+ ($settings?.ctrlEnterToSend ?? false)
|
|
|
+ ? (e.key === 'Enter' || e.keyCode === 13) && isCtrlPressed
|
|
|
+ : (e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey;
|
|
|
+
|
|
|
+ if (enterPressed) {
|
|
|
+ e.preventDefault();
|
|
|
+ if (prompt !== '' || files.length > 0) {
|
|
|
+ dispatch('submit', prompt);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- if (e.key === 'Escape') {
|
|
|
- console.log('Escape');
|
|
|
- atSelectedModel = undefined;
|
|
|
- selectedToolIds = [];
|
|
|
- selectedFilterIds = [];
|
|
|
+ if (e.key === 'Escape') {
|
|
|
+ console.log('Escape');
|
|
|
+ atSelectedModel = undefined;
|
|
|
+ selectedToolIds = [];
|
|
|
+ selectedFilterIds = [];
|
|
|
|
|
|
- webSearchEnabled = false;
|
|
|
- imageGenerationEnabled = false;
|
|
|
- codeInterpreterEnabled = false;
|
|
|
- }
|
|
|
- }}
|
|
|
- on:paste={async (e) => {
|
|
|
- e = e.detail.event;
|
|
|
- console.log(e);
|
|
|
+ webSearchEnabled = false;
|
|
|
+ imageGenerationEnabled = false;
|
|
|
+ codeInterpreterEnabled = false;
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ on:paste={async (e) => {
|
|
|
+ e = e.detail.event;
|
|
|
+ console.log(e);
|
|
|
+
|
|
|
+ const clipboardData = e.clipboardData || window.clipboardData;
|
|
|
|
|
|
- const clipboardData = e.clipboardData || window.clipboardData;
|
|
|
+ if (clipboardData && clipboardData.items) {
|
|
|
+ for (const item of clipboardData.items) {
|
|
|
+ if (item.type.indexOf('image') !== -1) {
|
|
|
+ const blob = item.getAsFile();
|
|
|
+ const reader = new FileReader();
|
|
|
|
|
|
- if (clipboardData && clipboardData.items) {
|
|
|
- for (const item of clipboardData.items) {
|
|
|
- if (item.type.indexOf('image') !== -1) {
|
|
|
- const blob = item.getAsFile();
|
|
|
- const reader = new FileReader();
|
|
|
+ reader.onload = function (e) {
|
|
|
+ files = [
|
|
|
+ ...files,
|
|
|
+ {
|
|
|
+ type: 'image',
|
|
|
+ url: `${e.target.result}`
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ };
|
|
|
+
|
|
|
+ reader.readAsDataURL(blob);
|
|
|
+ } else if (item?.kind === 'file') {
|
|
|
+ const file = item.getAsFile();
|
|
|
+ if (file) {
|
|
|
+ const _files = [file];
|
|
|
+ await inputFilesHandler(_files);
|
|
|
+ e.preventDefault();
|
|
|
+ }
|
|
|
+ } else if (item.type === 'text/plain') {
|
|
|
+ if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
|
|
|
+ const text = clipboardData.getData('text/plain');
|
|
|
|
|
|
- reader.onload = function (e) {
|
|
|
- files = [
|
|
|
- ...files,
|
|
|
+ if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
|
|
|
+ e.preventDefault();
|
|
|
+ const blob = new Blob([text], { type: 'text/plain' });
|
|
|
+ const file = new File(
|
|
|
+ [blob],
|
|
|
+ `Pasted_Text_${Date.now()}.txt`,
|
|
|
{
|
|
|
- type: 'image',
|
|
|
- url: `${e.target.result}`
|
|
|
+ type: 'text/plain'
|
|
|
}
|
|
|
- ];
|
|
|
- };
|
|
|
-
|
|
|
- reader.readAsDataURL(blob);
|
|
|
- } else if (item?.kind === 'file') {
|
|
|
- const file = item.getAsFile();
|
|
|
- if (file) {
|
|
|
- const _files = [file];
|
|
|
- await inputFilesHandler(_files);
|
|
|
- e.preventDefault();
|
|
|
- }
|
|
|
- } else if (item.type === 'text/plain') {
|
|
|
- if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
|
|
|
- const text = clipboardData.getData('text/plain');
|
|
|
-
|
|
|
- if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {
|
|
|
- e.preventDefault();
|
|
|
- const blob = new Blob([text], { type: 'text/plain' });
|
|
|
- const file = new File(
|
|
|
- [blob],
|
|
|
- `Pasted_Text_${Date.now()}.txt`,
|
|
|
- {
|
|
|
- type: 'text/plain'
|
|
|
- }
|
|
|
- );
|
|
|
-
|
|
|
- await uploadFileHandler(file, true);
|
|
|
- }
|
|
|
+ );
|
|
|
+
|
|
|
+ await uploadFileHandler(file, true);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
- }}
|
|
|
- />
|
|
|
- {/key}
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
{/key}
|
|
|
- {/if}
|
|
|
- </div>
|
|
|
+ {/key}
|
|
|
+ {/if}
|
|
|
</div>
|
|
|
+ </div>
|
|
|
|
|
|
- <div class=" flex justify-between mt-0.5 mb-2.5 mx-0.5 max-w-full" dir="ltr">
|
|
|
- <div class="ml-1 self-end flex items-center flex-1 max-w-[80%]">
|
|
|
- <InputMenu
|
|
|
- bind:files
|
|
|
- selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels}
|
|
|
- {fileUploadCapableModels}
|
|
|
- {screenCaptureHandler}
|
|
|
- {inputFilesHandler}
|
|
|
- uploadFilesHandler={() => {
|
|
|
- filesInputElement.click();
|
|
|
- }}
|
|
|
- uploadGoogleDriveHandler={async () => {
|
|
|
- try {
|
|
|
- const fileData = await createPicker();
|
|
|
- if (fileData) {
|
|
|
- const file = new File([fileData.blob], fileData.name, {
|
|
|
- type: fileData.blob.type
|
|
|
- });
|
|
|
- await uploadFileHandler(file);
|
|
|
- } else {
|
|
|
- console.log('No file was selected from Google Drive');
|
|
|
- }
|
|
|
- } catch (error) {
|
|
|
- console.error('Google Drive Error:', error);
|
|
|
- toast.error(
|
|
|
- $i18n.t('Error accessing Google Drive: {{error}}', {
|
|
|
- error: error.message
|
|
|
- })
|
|
|
- );
|
|
|
+ <div class=" flex justify-between mt-0.5 mb-2.5 mx-0.5 max-w-full" dir="ltr">
|
|
|
+ <div class="ml-1 self-end flex items-center flex-1 max-w-[80%]">
|
|
|
+ <InputMenu
|
|
|
+ bind:files
|
|
|
+ selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels}
|
|
|
+ {fileUploadCapableModels}
|
|
|
+ {screenCaptureHandler}
|
|
|
+ {inputFilesHandler}
|
|
|
+ uploadFilesHandler={() => {
|
|
|
+ filesInputElement.click();
|
|
|
+ }}
|
|
|
+ uploadGoogleDriveHandler={async () => {
|
|
|
+ try {
|
|
|
+ const fileData = await createPicker();
|
|
|
+ if (fileData) {
|
|
|
+ const file = new File([fileData.blob], fileData.name, {
|
|
|
+ type: fileData.blob.type
|
|
|
+ });
|
|
|
+ await uploadFileHandler(file);
|
|
|
+ } else {
|
|
|
+ console.log('No file was selected from Google Drive');
|
|
|
}
|
|
|
- }}
|
|
|
- uploadOneDriveHandler={async (authorityType) => {
|
|
|
- try {
|
|
|
- const fileData = await pickAndDownloadFile(authorityType);
|
|
|
- if (fileData) {
|
|
|
- const file = new File([fileData.blob], fileData.name, {
|
|
|
- type: fileData.blob.type || 'application/octet-stream'
|
|
|
- });
|
|
|
- await uploadFileHandler(file);
|
|
|
- } else {
|
|
|
- console.log('No file was selected from OneDrive');
|
|
|
- }
|
|
|
- } catch (error) {
|
|
|
- console.error('OneDrive Error:', error);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Google Drive Error:', error);
|
|
|
+ toast.error(
|
|
|
+ $i18n.t('Error accessing Google Drive: {{error}}', {
|
|
|
+ error: error.message
|
|
|
+ })
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ uploadOneDriveHandler={async (authorityType) => {
|
|
|
+ try {
|
|
|
+ const fileData = await pickAndDownloadFile(authorityType);
|
|
|
+ if (fileData) {
|
|
|
+ const file = new File([fileData.blob], fileData.name, {
|
|
|
+ type: fileData.blob.type || 'application/octet-stream'
|
|
|
+ });
|
|
|
+ await uploadFileHandler(file);
|
|
|
+ } else {
|
|
|
+ console.log('No file was selected from OneDrive');
|
|
|
}
|
|
|
- }}
|
|
|
- onUpload={async (e) => {
|
|
|
- dispatch('upload', e);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('OneDrive Error:', error);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onUpload={async (e) => {
|
|
|
+ dispatch('upload', e);
|
|
|
+ }}
|
|
|
+ onClose={async () => {
|
|
|
+ await tick();
|
|
|
+
|
|
|
+ const chatInput = document.getElementById('chat-input');
|
|
|
+ chatInput?.focus();
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ id="input-menu-button"
|
|
|
+ class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
|
|
|
+ >
|
|
|
+ <PlusAlt className="size-5.5" />
|
|
|
+ </div>
|
|
|
+ </InputMenu>
|
|
|
+
|
|
|
+ {#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || (toggleFilters && toggleFilters.length > 0)}
|
|
|
+ <div
|
|
|
+ class="flex self-center w-[1px] h-4 mx-1 bg-gray-200/50 dark:bg-gray-800/50"
|
|
|
+ />
|
|
|
+
|
|
|
+ <IntegrationsMenu
|
|
|
+ selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels}
|
|
|
+ {toggleFilters}
|
|
|
+ {showWebSearchButton}
|
|
|
+ {showImageGenerationButton}
|
|
|
+ {showCodeInterpreterButton}
|
|
|
+ bind:selectedToolIds
|
|
|
+ bind:selectedFilterIds
|
|
|
+ bind:webSearchEnabled
|
|
|
+ bind:imageGenerationEnabled
|
|
|
+ bind:codeInterpreterEnabled
|
|
|
+ closeOnOutsideClick={integrationsMenuCloseOnOutsideClick}
|
|
|
+ onShowValves={(e) => {
|
|
|
+ const { type, id } = e;
|
|
|
+ selectedValvesType = type;
|
|
|
+ selectedValvesItemId = id;
|
|
|
+ showValvesModal = true;
|
|
|
+ integrationsMenuCloseOnOutsideClick = false;
|
|
|
}}
|
|
|
onClose={async () => {
|
|
|
await tick();
|
|
|
@@ -1436,376 +1473,334 @@
|
|
|
}}
|
|
|
>
|
|
|
<div
|
|
|
- id="input-menu-button"
|
|
|
+ id="integration-menu-button"
|
|
|
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
|
|
|
>
|
|
|
- <PlusAlt className="size-5.5" />
|
|
|
+ <Component className="size-4.5" strokeWidth="1.5" />
|
|
|
</div>
|
|
|
- </InputMenu>
|
|
|
+ </IntegrationsMenu>
|
|
|
+ {/if}
|
|
|
|
|
|
- {#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || (toggleFilters && toggleFilters.length > 0)}
|
|
|
- <div
|
|
|
- class="flex self-center w-[1px] h-4 mx-1 bg-gray-200/50 dark:bg-gray-800/50"
|
|
|
- />
|
|
|
-
|
|
|
- <IntegrationsMenu
|
|
|
- selectedModels={atSelectedModel ? [atSelectedModel.id] : selectedModels}
|
|
|
- {toggleFilters}
|
|
|
- {showWebSearchButton}
|
|
|
- {showImageGenerationButton}
|
|
|
- {showCodeInterpreterButton}
|
|
|
- bind:selectedToolIds
|
|
|
- bind:selectedFilterIds
|
|
|
- bind:webSearchEnabled
|
|
|
- bind:imageGenerationEnabled
|
|
|
- bind:codeInterpreterEnabled
|
|
|
- closeOnOutsideClick={integrationsMenuCloseOnOutsideClick}
|
|
|
- onShowValves={(e) => {
|
|
|
- const { type, id } = e;
|
|
|
- selectedValvesType = type;
|
|
|
- selectedValvesItemId = id;
|
|
|
- showValvesModal = true;
|
|
|
- integrationsMenuCloseOnOutsideClick = false;
|
|
|
- }}
|
|
|
- onClose={async () => {
|
|
|
- await tick();
|
|
|
-
|
|
|
- const chatInput = document.getElementById('chat-input');
|
|
|
- chatInput?.focus();
|
|
|
- }}
|
|
|
- >
|
|
|
- <div
|
|
|
- id="integration-menu-button"
|
|
|
+ {#if selectedModelIds.length === 1 && $models.find((m) => m.id === selectedModelIds[0])?.has_user_valves}
|
|
|
+ <div class="ml-1 flex gap-1.5">
|
|
|
+ <Tooltip content={$i18n.t('Valves')} placement="top">
|
|
|
+ <button
|
|
|
+ id="model-valves-button"
|
|
|
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
|
|
|
+ on:click={() => {
|
|
|
+ selectedValvesType = 'function';
|
|
|
+ selectedValvesItemId = selectedModelIds[0]?.split('.')[0];
|
|
|
+ showValvesModal = true;
|
|
|
+ }}
|
|
|
>
|
|
|
- <Component className="size-4.5" strokeWidth="1.5" />
|
|
|
- </div>
|
|
|
- </IntegrationsMenu>
|
|
|
- {/if}
|
|
|
+ <Knobs className="size-4" strokeWidth="1.5" />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+
|
|
|
+ <div class="ml-1 flex gap-1.5">
|
|
|
+ {#if (selectedToolIds ?? []).length > 0}
|
|
|
+ <Tooltip
|
|
|
+ content={$i18n.t('{{COUNT}} Available Tools', {
|
|
|
+ COUNT: selectedToolIds.length
|
|
|
+ })}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ class="translate-y-[0.5px] px-1 flex gap-1 items-center text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg self-center transition"
|
|
|
+ aria-label="Available Tools"
|
|
|
+ type="button"
|
|
|
+ on:click={() => {
|
|
|
+ showTools = !showTools;
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Wrench className="size-4" strokeWidth="1.75" />
|
|
|
|
|
|
- {#if selectedModelIds.length === 1 && $models.find((m) => m.id === selectedModelIds[0])?.has_user_valves}
|
|
|
- <div class="ml-1 flex gap-1.5">
|
|
|
- <Tooltip content={$i18n.t('Valves')} placement="top">
|
|
|
- <button
|
|
|
- id="model-valves-button"
|
|
|
- class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
|
|
|
- on:click={() => {
|
|
|
- selectedValvesType = 'function';
|
|
|
- selectedValvesItemId = selectedModelIds[0]?.split('.')[0];
|
|
|
- showValvesModal = true;
|
|
|
- }}
|
|
|
- >
|
|
|
- <Knobs className="size-4" strokeWidth="1.5" />
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- </div>
|
|
|
+ <span class="text-sm">
|
|
|
+ {selectedToolIds.length}
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
{/if}
|
|
|
|
|
|
- <div class="ml-1 flex gap-1.5">
|
|
|
- {#if (selectedToolIds ?? []).length > 0}
|
|
|
- <Tooltip
|
|
|
- content={$i18n.t('{{COUNT}} Available Tools', {
|
|
|
- COUNT: selectedToolIds.length
|
|
|
- })}
|
|
|
- >
|
|
|
+ {#each selectedFilterIds as filterId}
|
|
|
+ {@const filter = toggleFilters.find((f) => f.id === filterId)}
|
|
|
+ {#if filter}
|
|
|
+ <Tooltip content={filter?.name} placement="top">
|
|
|
<button
|
|
|
- class="translate-y-[0.5px] px-1 flex gap-1 items-center text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg self-center transition"
|
|
|
- aria-label="Available Tools"
|
|
|
- type="button"
|
|
|
- on:click={() => {
|
|
|
- showTools = !showTools;
|
|
|
+ on:click|preventDefault={() => {
|
|
|
+ selectedFilterIds = selectedFilterIds.filter((id) => id !== filterId);
|
|
|
}}
|
|
|
- >
|
|
|
- <Wrench className="size-4" strokeWidth="1.75" />
|
|
|
-
|
|
|
- <span class="text-sm">
|
|
|
- {selectedToolIds.length}
|
|
|
- </span>
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- {/if}
|
|
|
-
|
|
|
- {#each selectedFilterIds as filterId}
|
|
|
- {@const filter = toggleFilters.find((f) => f.id === filterId)}
|
|
|
- {#if filter}
|
|
|
- <Tooltip content={filter?.name} placement="top">
|
|
|
- <button
|
|
|
- on:click|preventDefault={() => {
|
|
|
- selectedFilterIds = selectedFilterIds.filter(
|
|
|
- (id) => id !== filterId
|
|
|
- );
|
|
|
- }}
|
|
|
- type="button"
|
|
|
- class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {selectedFilterIds.includes(
|
|
|
- filterId
|
|
|
- )
|
|
|
- ? 'text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
|
|
|
- : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} capitalize"
|
|
|
- >
|
|
|
- {#if filter?.icon}
|
|
|
- <div class="size-4 items-center flex justify-center">
|
|
|
- <img
|
|
|
- src={filter.icon}
|
|
|
- class="size-3.5 {filter.icon.includes('svg')
|
|
|
- ? 'dark:invert-[80%]'
|
|
|
- : ''}"
|
|
|
- style="fill: currentColor;"
|
|
|
- alt={filter.name}
|
|
|
- />
|
|
|
- </div>
|
|
|
- {:else}
|
|
|
- <Sparkles className="size-4" strokeWidth="1.75" />
|
|
|
- {/if}
|
|
|
- <div class="hidden group-hover:block">
|
|
|
- <XMark className="size-4" strokeWidth="1.75" />
|
|
|
- </div>
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- {/if}
|
|
|
- {/each}
|
|
|
-
|
|
|
- {#if webSearchEnabled}
|
|
|
- <Tooltip content={$i18n.t('Web Search')} placement="top">
|
|
|
- <button
|
|
|
- on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
|
|
|
- type="button"
|
|
|
- class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
|
|
|
- ($settings?.webSearch ?? false) === 'always'
|
|
|
- ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
|
|
|
- : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
|
|
|
- >
|
|
|
- <GlobeAlt className="size-4" strokeWidth="1.75" />
|
|
|
- <div class="hidden group-hover:block">
|
|
|
- <XMark className="size-4" strokeWidth="1.75" />
|
|
|
- </div>
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- {/if}
|
|
|
-
|
|
|
- {#if imageGenerationEnabled}
|
|
|
- <Tooltip content={$i18n.t('Image')} placement="top">
|
|
|
- <button
|
|
|
- on:click|preventDefault={() =>
|
|
|
- (imageGenerationEnabled = !imageGenerationEnabled)}
|
|
|
type="button"
|
|
|
- class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
|
|
|
- ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
|
|
|
- : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
|
|
|
+ class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {selectedFilterIds.includes(
|
|
|
+ filterId
|
|
|
+ )
|
|
|
+ ? 'text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
|
|
|
+ : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} capitalize"
|
|
|
>
|
|
|
- <Photo className="size-4" strokeWidth="1.75" />
|
|
|
+ {#if filter?.icon}
|
|
|
+ <div class="size-4 items-center flex justify-center">
|
|
|
+ <img
|
|
|
+ src={filter.icon}
|
|
|
+ class="size-3.5 {filter.icon.includes('svg')
|
|
|
+ ? 'dark:invert-[80%]'
|
|
|
+ : ''}"
|
|
|
+ style="fill: currentColor;"
|
|
|
+ alt={filter.name}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ {:else}
|
|
|
+ <Sparkles className="size-4" strokeWidth="1.75" />
|
|
|
+ {/if}
|
|
|
<div class="hidden group-hover:block">
|
|
|
<XMark className="size-4" strokeWidth="1.75" />
|
|
|
</div>
|
|
|
</button>
|
|
|
</Tooltip>
|
|
|
{/if}
|
|
|
+ {/each}
|
|
|
|
|
|
- {#if codeInterpreterEnabled}
|
|
|
- <Tooltip content={$i18n.t('Code Interpreter')} placement="top">
|
|
|
- <button
|
|
|
- aria-label={codeInterpreterEnabled
|
|
|
- ? $i18n.t('Disable Code Interpreter')
|
|
|
- : $i18n.t('Enable Code Interpreter')}
|
|
|
- aria-pressed={codeInterpreterEnabled}
|
|
|
- on:click|preventDefault={() =>
|
|
|
- (codeInterpreterEnabled = !codeInterpreterEnabled)}
|
|
|
- type="button"
|
|
|
- class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden {codeInterpreterEnabled
|
|
|
- ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
|
|
|
- : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} {($settings?.highContrastMode ??
|
|
|
- false)
|
|
|
- ? 'm-1'
|
|
|
- : 'focus:outline-hidden rounded-full'}"
|
|
|
- >
|
|
|
- <Terminal className="size-3.5" strokeWidth="2" />
|
|
|
+ {#if webSearchEnabled}
|
|
|
+ <Tooltip content={$i18n.t('Web Search')} placement="top">
|
|
|
+ <button
|
|
|
+ on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
|
|
|
+ type="button"
|
|
|
+ class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
|
|
|
+ ($settings?.webSearch ?? false) === 'always'
|
|
|
+ ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
|
|
|
+ : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
|
|
|
+ >
|
|
|
+ <GlobeAlt className="size-4" strokeWidth="1.75" />
|
|
|
+ <div class="hidden group-hover:block">
|
|
|
+ <XMark className="size-4" strokeWidth="1.75" />
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ {/if}
|
|
|
|
|
|
- <div class="hidden group-hover:block">
|
|
|
- <XMark className="size-4" strokeWidth="1.75" />
|
|
|
- </div>
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- {/if}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ {#if imageGenerationEnabled}
|
|
|
+ <Tooltip content={$i18n.t('Image')} placement="top">
|
|
|
+ <button
|
|
|
+ on:click|preventDefault={() =>
|
|
|
+ (imageGenerationEnabled = !imageGenerationEnabled)}
|
|
|
+ type="button"
|
|
|
+ class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
|
|
|
+ ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
|
|
|
+ : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
|
|
|
+ >
|
|
|
+ <Photo className="size-4" strokeWidth="1.75" />
|
|
|
+ <div class="hidden group-hover:block">
|
|
|
+ <XMark className="size-4" strokeWidth="1.75" />
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ {/if}
|
|
|
|
|
|
- <div class="self-end flex space-x-1 mr-1 shrink-0">
|
|
|
- {#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
|
|
|
- <!-- {$i18n.t('Record voice')} -->
|
|
|
- <Tooltip content={$i18n.t('Dictate')}>
|
|
|
+ {#if codeInterpreterEnabled}
|
|
|
+ <Tooltip content={$i18n.t('Code Interpreter')} placement="top">
|
|
|
<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"
|
|
|
+ aria-label={codeInterpreterEnabled
|
|
|
+ ? $i18n.t('Disable Code Interpreter')
|
|
|
+ : $i18n.t('Enable Code Interpreter')}
|
|
|
+ aria-pressed={codeInterpreterEnabled}
|
|
|
+ on:click|preventDefault={() =>
|
|
|
+ (codeInterpreterEnabled = !codeInterpreterEnabled)}
|
|
|
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;
|
|
|
- });
|
|
|
+ class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden {codeInterpreterEnabled
|
|
|
+ ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
|
|
|
+ : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} {($settings?.highContrastMode ??
|
|
|
+ false)
|
|
|
+ ? 'm-1'
|
|
|
+ : 'focus:outline-hidden rounded-full'}"
|
|
|
+ >
|
|
|
+ <Terminal className="size-3.5" strokeWidth="2" />
|
|
|
|
|
|
- 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'));
|
|
|
+ <div class="hidden group-hover:block">
|
|
|
+ <XMark className="size-4" strokeWidth="1.75" />
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="self-end flex space-x-1 mr-1 shrink-0">
|
|
|
+ {#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
|
|
|
+ <!-- {$i18n.t('Record voice')} -->
|
|
|
+ <Tooltip content={$i18n.t('Dictate')}>
|
|
|
+ <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}
|
|
|
+
|
|
|
+ {#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
|
|
|
+ <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={() => {
|
|
|
+ stopResponse();
|
|
|
}}
|
|
|
- aria-label="Voice Input"
|
|
|
>
|
|
|
<svg
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
- viewBox="0 0 20 20"
|
|
|
+ viewBox="0 0 24 24"
|
|
|
fill="currentColor"
|
|
|
- class="w-5 h-5 translate-y-[0.5px]"
|
|
|
+ class="size-5"
|
|
|
>
|
|
|
- <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"
|
|
|
+ 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>
|
|
|
- {/if}
|
|
|
+ </div>
|
|
|
+ {:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
|
|
+ <div class=" flex items-center">
|
|
|
+ <!-- {$i18n.t('Call')} -->
|
|
|
+ <Tooltip content={$i18n.t('Voice mode')}>
|
|
|
+ <button
|
|
|
+ class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
|
|
|
+ type="button"
|
|
|
+ on:click={async () => {
|
|
|
+ if (selectedModels.length > 1) {
|
|
|
+ toast.error($i18n.t('Select only one model to call'));
|
|
|
|
|
|
- {#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
|
|
|
- <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={() => {
|
|
|
- stopResponse();
|
|
|
- }}
|
|
|
- >
|
|
|
- <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 if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
|
|
- <div class=" flex items-center">
|
|
|
- <!-- {$i18n.t('Call')} -->
|
|
|
- <Tooltip content={$i18n.t('Voice mode')}>
|
|
|
- <button
|
|
|
- class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
|
|
|
- type="button"
|
|
|
- on:click={async () => {
|
|
|
- if (selectedModels.length > 1) {
|
|
|
- toast.error($i18n.t('Select only one model to call'));
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- return;
|
|
|
- }
|
|
|
+ if ($config.audio.stt.engine === 'web') {
|
|
|
+ toast.error(
|
|
|
+ $i18n.t('Call feature is not supported when using Web STT engine')
|
|
|
+ );
|
|
|
|
|
|
- if ($config.audio.stt.engine === 'web') {
|
|
|
- toast.error(
|
|
|
- $i18n.t('Call feature is not supported when using Web STT engine')
|
|
|
- );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // check if user has access to getUserMedia
|
|
|
+ try {
|
|
|
+ let stream = await navigator.mediaDevices.getUserMedia({
|
|
|
+ audio: true
|
|
|
+ });
|
|
|
+ // If the user grants the permission, proceed to show the call overlay
|
|
|
|
|
|
- return;
|
|
|
+ if (stream) {
|
|
|
+ const tracks = stream.getTracks();
|
|
|
+ tracks.forEach((track) => track.stop());
|
|
|
}
|
|
|
- // check if user has access to getUserMedia
|
|
|
- try {
|
|
|
- let stream = await navigator.mediaDevices.getUserMedia({
|
|
|
- audio: true
|
|
|
- });
|
|
|
- // If the user grants the permission, proceed to show the call overlay
|
|
|
-
|
|
|
- if (stream) {
|
|
|
- const tracks = stream.getTracks();
|
|
|
- tracks.forEach((track) => track.stop());
|
|
|
- }
|
|
|
|
|
|
- stream = null;
|
|
|
+ stream = null;
|
|
|
|
|
|
- if ($settings.audio?.tts?.engine === 'browser-kokoro') {
|
|
|
- // If the user has not initialized the TTS worker, initialize it
|
|
|
- if (!$TTSWorker) {
|
|
|
- await TTSWorker.set(
|
|
|
- new KokoroWorker({
|
|
|
- dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
|
|
|
- })
|
|
|
- );
|
|
|
+ if ($settings.audio?.tts?.engine === 'browser-kokoro') {
|
|
|
+ // If the user has not initialized the TTS worker, initialize it
|
|
|
+ if (!$TTSWorker) {
|
|
|
+ await TTSWorker.set(
|
|
|
+ new KokoroWorker({
|
|
|
+ dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
|
|
|
+ })
|
|
|
+ );
|
|
|
|
|
|
- await $TTSWorker.init();
|
|
|
- }
|
|
|
+ await $TTSWorker.init();
|
|
|
}
|
|
|
-
|
|
|
- showCallOverlay.set(true);
|
|
|
- showControls.set(true);
|
|
|
- } catch (err) {
|
|
|
- // If the user denies the permission or an error occurs, show an error message
|
|
|
- toast.error(
|
|
|
- $i18n.t('Permission denied when accessing media devices')
|
|
|
- );
|
|
|
}
|
|
|
- }}
|
|
|
- aria-label={$i18n.t('Voice mode')}
|
|
|
- >
|
|
|
- <Voice className="size-5" strokeWidth="2.5" />
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- </div>
|
|
|
- {:else}
|
|
|
- <div class=" flex items-center">
|
|
|
- <Tooltip content={$i18n.t('Send message')}>
|
|
|
- <button
|
|
|
- id="send-message-button"
|
|
|
- class="{!(prompt === '' && 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={prompt === '' && files.length === 0}
|
|
|
+
|
|
|
+ showCallOverlay.set(true);
|
|
|
+ showControls.set(true);
|
|
|
+ } catch (err) {
|
|
|
+ // If the user denies the permission or an error occurs, show an error message
|
|
|
+ toast.error(
|
|
|
+ $i18n.t('Permission denied when accessing media devices')
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ aria-label={$i18n.t('Voice mode')}
|
|
|
+ >
|
|
|
+ <Voice className="size-5" strokeWidth="2.5" />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ </div>
|
|
|
+ {:else}
|
|
|
+ <div class=" flex items-center">
|
|
|
+ <Tooltip content={$i18n.t('Send message')}>
|
|
|
+ <button
|
|
|
+ id="send-message-button"
|
|
|
+ class="{!(prompt === '' && 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={prompt === '' && 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 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>
|
|
|
+ <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>
|
|
|
|
|
|
- {#if $config?.license_metadata?.input_footer}
|
|
|
- <div class=" text-xs text-gray-500 text-center line-clamp-1 marked">
|
|
|
- {@html DOMPurify.sanitize(marked($config?.license_metadata?.input_footer))}
|
|
|
- </div>
|
|
|
- {:else}
|
|
|
- <div class="mb-1" />
|
|
|
- {/if}
|
|
|
- </form>
|
|
|
- {/if}
|
|
|
+ {#if $config?.license_metadata?.input_footer}
|
|
|
+ <div class=" text-xs text-gray-500 text-center line-clamp-1 marked">
|
|
|
+ {@html DOMPurify.sanitize(marked($config?.license_metadata?.input_footer))}
|
|
|
+ </div>
|
|
|
+ {:else}
|
|
|
+ <div class="mb-1" />
|
|
|
+ {/if}
|
|
|
+ </form>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|