|
|
<script lang="ts"> |
|
|
import { browser } from "$app/environment"; |
|
|
import { createEventDispatcher, onMount } from "svelte"; |
|
|
|
|
|
import HoverTooltip from "$lib/components/HoverTooltip.svelte"; |
|
|
import IconInternet from "$lib/components/icons/IconInternet.svelte"; |
|
|
import IconImageGen from "$lib/components/icons/IconImageGen.svelte"; |
|
|
import IconPaperclip from "$lib/components/icons/IconPaperclip.svelte"; |
|
|
import { useSettingsStore } from "$lib/stores/settings"; |
|
|
import { webSearchParameters } from "$lib/stores/webSearchParameters"; |
|
|
import { |
|
|
documentParserToolId, |
|
|
fetchUrlToolId, |
|
|
imageGenToolId, |
|
|
webSearchToolId, |
|
|
} from "$lib/utils/toolIds"; |
|
|
import type { Assistant } from "$lib/types/Assistant"; |
|
|
import { page } from "$app/state"; |
|
|
import type { ToolFront } from "$lib/types/Tool"; |
|
|
import ToolLogo from "../ToolLogo.svelte"; |
|
|
import { goto } from "$app/navigation"; |
|
|
import { base } from "$app/paths"; |
|
|
import IconAdd from "~icons/carbon/add"; |
|
|
import { captureScreen } from "$lib/utils/screenshot"; |
|
|
import IconScreenshot from "../icons/IconScreenshot.svelte"; |
|
|
import { loginModalOpen } from "$lib/stores/loginModal"; |
|
|
|
|
|
interface Props { |
|
|
files?: File[]; |
|
|
mimeTypes?: string[]; |
|
|
value?: string; |
|
|
placeholder?: string; |
|
|
loading?: boolean; |
|
|
disabled?: boolean; |
|
|
assistant?: Assistant | undefined; |
|
|
modelHasTools?: boolean; |
|
|
modelIsMultimodal?: boolean; |
|
|
children?: import("svelte").Snippet; |
|
|
onPaste?: (e: ClipboardEvent) => void; |
|
|
} |
|
|
|
|
|
let { |
|
|
files = $bindable([]), |
|
|
mimeTypes = [], |
|
|
value = $bindable(""), |
|
|
placeholder = "", |
|
|
loading = false, |
|
|
disabled = false, |
|
|
assistant = undefined, |
|
|
modelHasTools = false, |
|
|
modelIsMultimodal = false, |
|
|
children, |
|
|
onPaste, |
|
|
}: Props = $props(); |
|
|
|
|
|
const onFileChange = async (e: Event) => { |
|
|
if (!e.target) return; |
|
|
const target = e.target as HTMLInputElement; |
|
|
files = [...files, ...(target.files ?? [])]; |
|
|
|
|
|
if (files.some((file) => file.type.startsWith("application/"))) { |
|
|
await settings.instantSet({ |
|
|
tools: [...($settings.tools ?? []), documentParserToolId], |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
let textareaElement: HTMLTextAreaElement | undefined = $state(); |
|
|
let isCompositionOn = $state(false); |
|
|
|
|
|
const dispatch = createEventDispatcher<{ submit: void }>(); |
|
|
|
|
|
onMount(() => { |
|
|
if (!isVirtualKeyboard()) { |
|
|
textareaElement?.focus(); |
|
|
} |
|
|
function onFormSubmit() { |
|
|
adjustTextareaHeight(); |
|
|
} |
|
|
|
|
|
const formEl = textareaElement?.closest("form"); |
|
|
formEl?.addEventListener("submit", onFormSubmit); |
|
|
return () => { |
|
|
formEl?.removeEventListener("submit", onFormSubmit); |
|
|
}; |
|
|
}); |
|
|
|
|
|
function isVirtualKeyboard(): boolean { |
|
|
if (!browser) return false; |
|
|
|
|
|
// Check for touch capability |
|
|
if (navigator.maxTouchPoints > 0) return true; |
|
|
|
|
|
// Check for touch events |
|
|
if ("ontouchstart" in window) return true; |
|
|
|
|
|
// Fallback to user agent string check |
|
|
const userAgent = navigator.userAgent.toLowerCase(); |
|
|
|
|
|
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent); |
|
|
} |
|
|
|
|
|
function adjustTextareaHeight() { |
|
|
if (!textareaElement) { |
|
|
return; |
|
|
} |
|
|
|
|
|
textareaElement.style.height = "auto"; |
|
|
textareaElement.style.height = `${textareaElement.scrollHeight}px`; |
|
|
|
|
|
if (textareaElement.selectionStart === textareaElement.value.length) { |
|
|
textareaElement.scrollTop = textareaElement.scrollHeight; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleKeydown(event: KeyboardEvent) { |
|
|
if ( |
|
|
event.key === "Enter" && |
|
|
!event.shiftKey && |
|
|
!isCompositionOn && |
|
|
!isVirtualKeyboard() && |
|
|
value.trim() !== "" |
|
|
) { |
|
|
event.preventDefault(); |
|
|
dispatch("submit"); |
|
|
} |
|
|
} |
|
|
|
|
|
const settings = useSettingsStore(); |
|
|
|
|
|
|
|
|
|
|
|
let webSearchIsOn = $derived( |
|
|
modelHasTools |
|
|
? ($settings.tools?.includes(webSearchToolId) ?? false) || |
|
|
($settings.tools?.includes(fetchUrlToolId) ?? false) |
|
|
: $webSearchParameters.useSearch |
|
|
); |
|
|
let imageGenIsOn = $derived($settings.tools?.includes(imageGenToolId) ?? false); |
|
|
|
|
|
let documentParserIsOn = $derived( |
|
|
modelHasTools && files.length > 0 && files.some((file) => file.type.startsWith("application/")) |
|
|
); |
|
|
|
|
|
let extraTools = $derived( |
|
|
page.data.tools |
|
|
.filter((t: ToolFront) => $settings.tools?.includes(t._id)) |
|
|
.filter( |
|
|
(t: ToolFront) => |
|
|
![documentParserToolId, imageGenToolId, webSearchToolId, fetchUrlToolId].includes(t._id) |
|
|
) satisfies ToolFront[] |
|
|
); |
|
|
|
|
|
let showWebSearch = $derived(!assistant); |
|
|
let showImageGen = $derived(modelHasTools && !assistant); |
|
|
let showFileUpload = $derived((modelIsMultimodal || modelHasTools) && mimeTypes.length > 0); |
|
|
let showExtraTools = $derived(modelHasTools && !assistant); |
|
|
|
|
|
let showNoTools = $derived(!showWebSearch && !showImageGen && !showFileUpload && !showExtraTools); |
|
|
</script> |
|
|
|
|
|
<div class="flex min-h-full flex-1 flex-col" onpaste={onPaste}> |
|
|
<textarea |
|
|
rows="1" |
|
|
tabindex="0" |
|
|
inputmode="text" |
|
|
class="scrollbar-custom max-h-[4lh] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent px-2.5 py-2.5 outline-none focus:ring-0 focus-visible:ring-0 max-sm:text-[16px] sm:px-3" |
|
|
class:text-gray-400={disabled} |
|
|
bind:value |
|
|
bind:this={textareaElement} |
|
|
onkeydown={handleKeydown} |
|
|
oncompositionstart={() => (isCompositionOn = true)} |
|
|
oncompositionend={() => (isCompositionOn = false)} |
|
|
oninput={adjustTextareaHeight} |
|
|
onbeforeinput={(ev) => { |
|
|
if (page.data.loginRequired) { |
|
|
ev.preventDefault(); |
|
|
$loginModalOpen = true; |
|
|
} |
|
|
}} |
|
|
{placeholder} |
|
|
{disabled} |
|
|
></textarea> |
|
|
|
|
|
{#if !showNoTools} |
|
|
<div |
|
|
class={[ |
|
|
"scrollbar-custom -ml-0.5 flex max-w-[calc(100%-40px)] flex-wrap items-center justify-start gap-2.5 px-3 pb-2.5 pt-1.5 text-gray-500 dark:text-gray-400 max-md:flex-nowrap max-md:overflow-x-auto sm:gap-2", |
|
|
]} |
|
|
> |
|
|
|
|
|
</div> |
|
|
{/if} |
|
|
{@render children?.()} |
|
|
</div> |
|
|
|
|
|
<style lang="postcss"> |
|
|
:global(pre), |
|
|
:global(textarea) { |
|
|
font-family: inherit; |
|
|
box-sizing: border-box; |
|
|
line-height: 1.5; |
|
|
} |
|
|
</style> |
|
|
|