chat-ui / src /lib /components /chat /ChatInput.svelte
victor's picture
victor HF Staff
transcribe followup
0a6eeac
<script lang="ts">
import { onMount, tick } from "svelte";
import { afterNavigate } from "$app/navigation";
import { DropdownMenu } from "bits-ui";
import IconPlus from "~icons/lucide/plus";
import CarbonImage from "~icons/carbon/image";
import CarbonDocument from "~icons/carbon/document";
import CarbonUpload from "~icons/carbon/upload";
import CarbonLink from "~icons/carbon/link";
import CarbonChevronRight from "~icons/carbon/chevron-right";
import CarbonClose from "~icons/carbon/close";
import UrlFetchModal from "./UrlFetchModal.svelte";
import { TEXT_MIME_ALLOWLIST, IMAGE_MIME_ALLOWLIST_DEFAULT } from "$lib/constants/mime";
import MCPServerManager from "$lib/components/mcp/MCPServerManager.svelte";
import IconMCP from "$lib/components/icons/IconMCP.svelte";
import { isVirtualKeyboard } from "$lib/utils/isVirtualKeyboard";
import { requireAuthUser } from "$lib/utils/auth";
import {
enabledServersCount,
selectedServerIds,
allMcpServers,
toggleServer,
disableAllServers,
} from "$lib/stores/mcpServers";
import { getMcpServerFaviconUrl } from "$lib/utils/favicon";
import { page } from "$app/state";
interface Props {
files?: File[];
mimeTypes?: string[];
value?: string;
placeholder?: string;
loading?: boolean;
disabled?: boolean;
// tools removed
modelIsMultimodal?: boolean;
// Whether the currently selected model supports tool calling (incl. overrides)
modelSupportsTools?: boolean;
children?: import("svelte").Snippet;
onPaste?: (e: ClipboardEvent) => void;
focused?: boolean;
onsubmit?: () => void;
}
let {
files = $bindable([]),
mimeTypes = [],
value = $bindable(""),
placeholder = "",
loading = false,
disabled = false,
modelIsMultimodal = false,
modelSupportsTools = true,
children,
onPaste,
focused = $bindable(false),
onsubmit,
}: Props = $props();
const onFileChange = async (e: Event) => {
if (!e.target) return;
const target = e.target as HTMLInputElement;
const selected = Array.from(target.files ?? []);
if (selected.length === 0) return;
files = [...files, ...selected];
await tick();
void focusTextarea();
};
let textareaElement: HTMLTextAreaElement | undefined = $state();
let isCompositionOn = $state(false);
let blurTimeout: ReturnType<typeof setTimeout> | null = $state(null);
let fileInputEl: HTMLInputElement | undefined = $state();
let isUrlModalOpen = $state(false);
let isMcpManagerOpen = $state(false);
let isDropdownOpen = $state(false);
function openPickerWithAccept(accept: string) {
if (!fileInputEl) return;
const allAccept = mimeTypes.join(",");
fileInputEl.setAttribute("accept", accept);
fileInputEl.click();
queueMicrotask(() => fileInputEl?.setAttribute("accept", allAccept));
}
function openFilePickerText() {
const textAccept =
mimeTypes.filter((m) => !(m === "image/*" || m.startsWith("image/"))).join(",") ||
TEXT_MIME_ALLOWLIST.join(",");
openPickerWithAccept(textAccept);
}
function openFilePickerImage() {
const imageAccept =
mimeTypes.filter((m) => m === "image/*" || m.startsWith("image/")).join(",") ||
IMAGE_MIME_ALLOWLIST_DEFAULT.join(",");
openPickerWithAccept(imageAccept);
}
const waitForAnimationFrame = () =>
typeof requestAnimationFrame === "function"
? new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
})
: Promise.resolve();
async function focusTextarea() {
if (page.data.shared && page.data.loginEnabled && !page.data.user) return;
if (!textareaElement || textareaElement.disabled || isVirtualKeyboard()) return;
if (typeof document !== "undefined" && document.activeElement === textareaElement) return;
await tick();
if (typeof requestAnimationFrame === "function") {
await waitForAnimationFrame();
await waitForAnimationFrame();
}
if (!textareaElement || textareaElement.disabled || isVirtualKeyboard()) return;
try {
textareaElement.focus({ preventScroll: true });
} catch {
textareaElement.focus();
}
}
function handleFetchedFiles(newFiles: File[]) {
if (!newFiles?.length) return;
files = [...files, ...newFiles];
queueMicrotask(async () => {
await tick();
void focusTextarea();
});
}
onMount(() => {
void focusTextarea();
});
afterNavigate(() => {
void focusTextarea();
});
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;
}
}
$effect(() => {
if (!textareaElement) return;
void value;
adjustTextareaHeight();
});
function handleKeydown(event: KeyboardEvent) {
if (
event.key === "Enter" &&
!event.shiftKey &&
!isCompositionOn &&
!isVirtualKeyboard() &&
value.trim() !== ""
) {
event.preventDefault();
tick();
onsubmit?.();
}
}
function handleFocus() {
if (requireAuthUser()) {
return;
}
if (blurTimeout) {
clearTimeout(blurTimeout);
blurTimeout = null;
}
focused = true;
}
function handleBlur() {
if (!isVirtualKeyboard()) {
focused = false;
return;
}
if (blurTimeout) {
clearTimeout(blurTimeout);
}
blurTimeout = setTimeout(() => {
blurTimeout = null;
focused = false;
});
}
// Show file upload when any mime is allowed (text always; images if multimodal)
let showFileUpload = $derived(mimeTypes.length > 0);
let showNoTools = $derived(!showFileUpload);
let selectedServers = $derived(
$allMcpServers.filter((server) => $selectedServerIds.has(server.id))
);
</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 sm:px-3 md:max-h-[8lh]"
class:text-gray-400={disabled}
bind:value
bind:this={textareaElement}
onkeydown={handleKeydown}
oncompositionstart={() => (isCompositionOn = true)}
oncompositionend={() => (isCompositionOn = false)}
{placeholder}
{disabled}
onfocus={handleFocus}
onblur={handleBlur}
onbeforeinput={requireAuthUser}
></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",
]}
>
{#if showFileUpload}
<div class="flex items-center">
<input
bind:this={fileInputEl}
disabled={loading}
class="absolute hidden size-0"
aria-label="Upload file"
type="file"
multiple
onchange={onFileChange}
onclick={(e) => {
if (requireAuthUser()) {
e.preventDefault();
}
}}
accept={mimeTypes.join(",")}
/>
<DropdownMenu.Root
bind:open={isDropdownOpen}
onOpenChange={(open) => {
if (open && requireAuthUser()) {
isDropdownOpen = false;
return;
}
isDropdownOpen = open;
}}
>
<DropdownMenu.Trigger
class="btn size-8 rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600/50 dark:text-white dark:hover:enabled:bg-gray-600 sm:size-7"
disabled={loading}
aria-label="Add attachment"
>
<IconPlus class="text-base sm:text-sm" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100"
side="top"
sideOffset={8}
align="start"
trapFocus={false}
onCloseAutoFocus={(e) => e.preventDefault()}
interactOutsideBehavior="defer-otherwise-close"
>
{#if modelIsMultimodal}
<DropdownMenu.Item
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
onSelect={() => openFilePickerImage()}
>
<CarbonImage class="size-4 opacity-90 dark:opacity-80" />
Add image(s)
</DropdownMenu.Item>
{/if}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10 sm:h-8"
>
<div class="flex items-center gap-1">
<CarbonDocument class="size-4 opacity-90 dark:opacity-80" />
Add text file
</div>
<div class="ml-auto flex items-center">
<CarbonChevronRight class="size-4 opacity-70 dark:opacity-80" />
</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100"
sideOffset={10}
trapFocus={false}
onCloseAutoFocus={(e) => e.preventDefault()}
interactOutsideBehavior="defer-otherwise-close"
>
<DropdownMenu.Item
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
onSelect={() => openFilePickerText()}
>
<CarbonUpload class="size-4 opacity-90 dark:opacity-80" />
Upload from device
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
onSelect={() => (isUrlModalOpen = true)}
>
<CarbonLink class="size-4 opacity-90 dark:opacity-80" />
Fetch from URL
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<!-- MCP Servers submenu -->
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10 sm:h-8"
>
<div class="flex items-center gap-1">
<IconMCP classNames="size-4 opacity-90 dark:opacity-80" />
MCP Servers
</div>
<div class="ml-auto flex items-center">
<CarbonChevronRight class="size-4 opacity-70 dark:opacity-80" />
</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100"
sideOffset={10}
trapFocus={false}
onCloseAutoFocus={(e) => e.preventDefault()}
interactOutsideBehavior="defer-otherwise-close"
>
{#each $allMcpServers as server (server.id)}
<DropdownMenu.CheckboxItem
checked={$selectedServerIds.has(server.id)}
onCheckedChange={() => toggleServer(server.id)}
closeOnSelect={false}
class="flex h-9 select-none items-center gap-2 rounded-md px-2 text-sm leading-none text-gray-800 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-100 dark:data-[highlighted]:bg-white/10"
>
{#snippet children({ checked })}
<img
src={getMcpServerFaviconUrl(server.url)}
alt=""
class="size-4 flex-shrink-0 rounded"
/>
<span class="max-w-52 truncate py-1">{server.name}</span>
<div class="ml-auto flex items-center">
<!-- Toggle visual -->
<span
class={[
"relative mt-px flex h-4 w-7 items-center self-center rounded-full transition-colors",
checked ? "bg-blue-600/80" : "bg-gray-300 dark:bg-gray-700",
]}
>
<span
class={[
"block size-3 translate-x-0.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-[14px]" : "translate-x-0.5",
]}
></span>
</span>
</div>
{/snippet}
</DropdownMenu.CheckboxItem>
{/each}
{#if $allMcpServers.length > 0}
<DropdownMenu.Separator class="my-1 h-px bg-gray-200 dark:bg-gray-700/60" />
{/if}
<DropdownMenu.Item
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
onSelect={() => (isMcpManagerOpen = true)}
>
Manage MCP Servers
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{#if $enabledServersCount > 0}
<div
class="ml-1.5 inline-flex h-8 items-center gap-1.5 rounded-full border border-blue-500/10 bg-blue-600/10 pl-2 pr-1 text-xs font-semibold text-blue-700 dark:bg-blue-600/20 dark:text-blue-400 sm:h-7"
class:grayscale={!modelSupportsTools}
class:opacity-60={!modelSupportsTools}
class:cursor-help={!modelSupportsTools}
title={modelSupportsTools
? "MCP servers enabled"
: "Current model doesn’t support tools"}
>
<button
class="inline-flex cursor-pointer select-none items-center gap-1 bg-transparent p-0 leading-none text-current focus:outline-none"
type="button"
title="Manage MCP Servers"
onclick={() => (isMcpManagerOpen = true)}
class:line-through={!modelSupportsTools}
>
{#if selectedServers.length}
<span class="flex items-center -space-x-1">
{#each selectedServers.slice(0, 3) as server (server.id)}
<img
src={getMcpServerFaviconUrl(server.url)}
alt=""
class="size-4 rounded bg-white p-px shadow-sm ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
/>
{/each}
{#if selectedServers.length > 3}
<span class="ml-1 text-[10px] font-semibold text-blue-800 dark:text-blue-200">
+{selectedServers.length - 3}
</span>
{/if}
</span>
{/if}
MCP ({$enabledServersCount})
</button>
<button
class="grid size-5 place-items-center rounded-full bg-blue-600/15 text-blue-700 transition-colors hover:bg-blue-600/25 dark:bg-blue-600/25 dark:text-blue-300 dark:hover:bg-blue-600/35"
aria-label="Disable all MCP servers"
onclick={() => disableAllServers()}
type="button"
>
<CarbonClose class="size-3.5" />
</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
{@render children?.()}
<UrlFetchModal
bind:open={isUrlModalOpen}
acceptMimeTypes={mimeTypes}
onfiles={handleFetchedFiles}
/>
{#if isMcpManagerOpen}
<MCPServerManager onclose={() => (isMcpManagerOpen = false)} />
{/if}
</div>
<style lang="postcss">
:global(pre),
:global(textarea) {
font-family: inherit;
box-sizing: border-box;
line-height: 1.5;
font-size: 16px;
}
</style>