Mit / src /lib /components /chat /ChatInput.svelte
DevLLM
Add application file
3baea8e
<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();
// tool section
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>