| | <script lang="ts"> |
| | import { onMount, tick } from "svelte"; |
| | |
| | import { afterNavigate } from "$app/navigation"; |
| | |
| | import { DropdownMenu } from "bits-ui"; |
| | import CarbonAdd from "~icons/carbon/add"; |
| | 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, |
| | } from "$lib/stores/mcpServers"; |
| | import { getMcpServerFaviconUrl } from "$lib/utils/favicon"; |
| | |
| | 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); |
| | |
| | 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 (!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 (blurTimeout) { |
| | clearTimeout(blurTimeout); |
| | blurTimeout = null; |
| | } |
| | focused = true; |
| | } |
| | |
| | function handleBlur() { |
| | if (!isVirtualKeyboard()) { |
| | focused = false; |
| | return; |
| | } |
| | |
| | if (blurTimeout) { |
| | clearTimeout(blurTimeout); |
| | } |
| | |
| | blurTimeout = setTimeout(() => { |
| | blurTimeout = null; |
| | focused = false; |
| | }); |
| | } |
| | |
| | |
| | let showFileUpload = $derived(mimeTypes.length > 0); |
| | let showNoTools = $derived(!showFileUpload); |
| | </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" |
| | onchange={onFileChange} |
| | onclick={(e) => { |
| | if (requireAuthUser()) { |
| | e.preventDefault(); |
| | } |
| | }} |
| | accept={mimeTypes.join(",")} |
| | /> |
| | |
| | <DropdownMenu.Root> |
| | <DropdownMenu.Trigger |
| | class="btn size-7 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" |
| | disabled={loading} |
| | aria-label="Add attachment" |
| | > |
| | <CarbonAdd class="text-base" /> |
| | </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-8 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" |
| | onSelect={() => openFilePickerImage()} |
| | > |
| | <CarbonImage class="size-4 opacity-90 dark:opacity-80" /> |
| | Add image |
| | </DropdownMenu.Item> |
| | {/if} |
| | |
| | <DropdownMenu.Sub> |
| | <DropdownMenu.SubTrigger |
| | class="flex h-8 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" |
| | > |
| | <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-8 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" |
| | onSelect={() => openFilePickerText()} |
| | > |
| | <CarbonUpload class="size-4 opacity-90 dark:opacity-80" /> |
| | Upload from device |
| | </DropdownMenu.Item> |
| | <DropdownMenu.Item |
| | class="flex h-8 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" |
| | 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-8 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" |
| | > |
| | <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"> |
| | |
| | <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-8 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" |
| | onSelect={() => (isMcpManagerOpen = true)} |
| | > |
| | Manage MCP Servers |
| | </DropdownMenu.Item> |
| | </DropdownMenu.SubContent> |
| | </DropdownMenu.Sub> |
| | </DropdownMenu.Content> |
| | </DropdownMenu.Portal> |
| | </DropdownMenu.Root> |
| |
|
| | {#if $enabledServersCount > 0} |
| | <div |
| | class="ml-2 inline-flex h-7 items-center gap-1.5 rounded-full border border-blue-500/10 bg-blue-600/10 pl-3 pr-1 text-xs font-semibold text-blue-700 dark:bg-blue-600/20 dark:text-blue-400" |
| | class:grayscale={!modelSupportsTools} |
| | class:opacity-60={!modelSupportsTools} |
| | class:cursor-help={!modelSupportsTools} |
| | title={modelSupportsTools |
| | ? "MCP servers enabled" |
| | : "Current model doesn’t support tools"} |
| | > |
| | <button |
| | class="cursor-pointer select-none bg-transparent p-0 leading-none text-current focus:outline-none" |
| | type="button" |
| | title="Manage MCP Servers" |
| | onclick={() => (isMcpManagerOpen = true)} |
| | class:line-through={!modelSupportsTools} |
| | > |
| | 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={() => selectedServerIds.set(new Set())} |
| | 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> |
| |
|