Skip to content

Commit b8e7d59

Browse files
committed
Add URL file fetch modal to chat input
Introduces UrlFetchModal.svelte for fetching files from HTTPS URLs and adds it to ChatInput.svelte. Updates file upload dropdown to support uploading text files from device or URL, and refines mime type handling in ChatWindow.svelte to always allow common text-like files and images for multimodal models.
1 parent 4d604c0 commit b8e7d59

3 files changed

Lines changed: 287 additions & 10 deletions

File tree

src/lib/components/chat/ChatInput.svelte

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
import { DropdownMenu } from "bits-ui";
77
import CarbonAdd from "~icons/carbon/add";
88
import CarbonImage from "~icons/carbon/image";
9+
import CarbonDocument from "~icons/carbon/document";
10+
import CarbonUpload from "~icons/carbon/upload";
11+
import CarbonLink from "~icons/carbon/link";
12+
import CarbonChevronRight from "~icons/carbon/chevron-right";
13+
import UrlFetchModal from "./UrlFetchModal.svelte";
914
1015
import { isVirtualKeyboard } from "$lib/utils/isVirtualKeyboard";
1116
import { requireAuthUser } from "$lib/utils/auth";
@@ -55,6 +60,28 @@
5560
let blurTimeout: ReturnType<typeof setTimeout> | null = $state(null);
5661
5762
let fileInputEl: HTMLInputElement | undefined = $state();
63+
let isUrlModalOpen = $state(false);
64+
65+
function openPickerWithAccept(accept: string) {
66+
if (!fileInputEl) return;
67+
const allAccept = mimeTypes.join(",");
68+
fileInputEl.setAttribute("accept", accept);
69+
fileInputEl.click();
70+
queueMicrotask(() => fileInputEl?.setAttribute("accept", allAccept));
71+
}
72+
73+
function openFilePickerText() {
74+
const textAccept =
75+
mimeTypes.filter((m) => !(m === "image/*" || m.startsWith("image/"))).join(",") ||
76+
"text/*,application/json,application/xml,application/csv";
77+
openPickerWithAccept(textAccept);
78+
}
79+
80+
function openFilePickerImage() {
81+
const imageAccept =
82+
mimeTypes.filter((m) => m === "image/*" || m.startsWith("image/")).join(",") || "image/*";
83+
openPickerWithAccept(imageAccept);
84+
}
5885
5986
const waitForAnimationFrame = () =>
6087
typeof requestAnimationFrame === "function"
@@ -83,6 +110,15 @@
83110
}
84111
}
85112
113+
function handleFetchedFiles(newFiles: File[]) {
114+
if (!newFiles?.length) return;
115+
files = [...files, ...newFiles];
116+
queueMicrotask(async () => {
117+
await tick();
118+
void focusTextarea();
119+
});
120+
}
121+
86122
onMount(() => {
87123
void focusTextarea();
88124
});
@@ -148,8 +184,8 @@
148184
});
149185
}
150186
151-
// Tools removed; only show file upload when applicable
152-
let showFileUpload = $derived(modelIsMultimodal && mimeTypes.length > 0);
187+
// Show file upload when any mime is allowed (text always; images if multimodal)
188+
let showFileUpload = $derived(mimeTypes.length > 0);
153189
let showNoTools = $derived(!showFileUpload);
154190
</script>
155191

@@ -199,7 +235,7 @@
199235
<DropdownMenu.Trigger
200236
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-black"
201237
disabled={loading}
202-
aria-label="Add image"
238+
aria-label="Add attachment"
203239
>
204240
<CarbonAdd class="text-base" />
205241
</DropdownMenu.Trigger>
@@ -210,13 +246,48 @@
210246
sideOffset={8}
211247
align="start"
212248
>
213-
<DropdownMenu.Item
214-
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"
215-
onSelect={() => fileInputEl?.click()}
216-
>
217-
<CarbonImage class="size-4 opacity-90 dark:opacity-80" />
218-
Add image
219-
</DropdownMenu.Item>
249+
{#if modelIsMultimodal}
250+
<DropdownMenu.Item
251+
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"
252+
onSelect={() => openFilePickerImage()}
253+
>
254+
<CarbonImage class="size-4 opacity-90 dark:opacity-80" />
255+
Add image
256+
</DropdownMenu.Item>
257+
{/if}
258+
259+
<DropdownMenu.Sub>
260+
<DropdownMenu.SubTrigger
261+
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"
262+
>
263+
<div class="flex items-center gap-1">
264+
<CarbonDocument class="size-4 opacity-90 dark:opacity-80" />
265+
Add text file
266+
</div>
267+
<div class="ml-auto flex items-center">
268+
<CarbonChevronRight class="size-4 opacity-70 dark:opacity-80" />
269+
</div>
270+
</DropdownMenu.SubTrigger>
271+
<DropdownMenu.SubContent
272+
class="z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100 dark:supports-[backdrop-filter]:bg-gray-800/80"
273+
sideOffset={10}
274+
>
275+
<DropdownMenu.Item
276+
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"
277+
onSelect={() => openFilePickerText()}
278+
>
279+
<CarbonUpload class="size-4 opacity-90 dark:opacity-80" />
280+
Upload from device
281+
</DropdownMenu.Item>
282+
<DropdownMenu.Item
283+
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"
284+
onSelect={() => (isUrlModalOpen = true)}
285+
>
286+
<CarbonLink class="size-4 opacity-90 dark:opacity-80" />
287+
Fetch from URL
288+
</DropdownMenu.Item>
289+
</DropdownMenu.SubContent>
290+
</DropdownMenu.Sub>
220291
</DropdownMenu.Content>
221292
</DropdownMenu.Portal>
222293
</DropdownMenu.Root>
@@ -225,6 +296,12 @@
225296
</div>
226297
{/if}
227298
{@render children?.()}
299+
300+
<UrlFetchModal
301+
bind:open={isUrlModalOpen}
302+
acceptMimeTypes={mimeTypes}
303+
onfiles={handleFetchedFiles}
304+
/>
228305
</div>
229306

230307
<style lang="postcss">

src/lib/components/chat/ChatWindow.svelte

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,19 @@
230230
// Respect per‑model multimodal toggle from settings (force enable)
231231
let modelIsMultimodalOverride = $derived($settings.multimodalOverrides?.[currentModel.id]);
232232
let modelIsMultimodal = $derived((modelIsMultimodalOverride ?? currentModel.multimodal) === true);
233+
234+
// Always allow common text-like files; add images only when model is multimodal
235+
const ALWAYS_TEXT_MIME_TYPES = [
236+
"text/*",
237+
"application/json",
238+
"application/xml",
239+
"application/csv",
240+
];
241+
233242
let activeMimeTypes = $derived(
234243
Array.from(
235244
new Set([
245+
...ALWAYS_TEXT_MIME_TYPES,
236246
...(modelIsMultimodal ? (currentModel.multimodalAcceptedMimetypes ?? ["image/*"]) : []),
237247
])
238248
)
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<script lang="ts">
2+
import Modal from "../Modal.svelte";
3+
import { base } from "$app/paths";
4+
import { tick } from "svelte";
5+
6+
interface Props {
7+
open?: boolean;
8+
acceptMimeTypes?: string[]; // optional client-side validation
9+
onclose?: () => void;
10+
onfiles?: (files: File[]) => void;
11+
}
12+
13+
let { open = $bindable(false), acceptMimeTypes = [], onclose, onfiles }: Props = $props();
14+
15+
let urlValue = $state("");
16+
let loading = $state(false);
17+
let errorMsg = $state("");
18+
let inputEl: HTMLInputElement | undefined = $state();
19+
20+
async function focusInputSoon() {
21+
// Wait for modal and content to mount, then focus and select
22+
await tick();
23+
await tick();
24+
setTimeout(() => {
25+
inputEl?.focus();
26+
inputEl?.select();
27+
}, 0);
28+
}
29+
30+
$effect(() => {
31+
if (open) {
32+
// reset state when opening
33+
urlValue = "";
34+
errorMsg = "";
35+
void focusInputSoon();
36+
}
37+
});
38+
39+
function isHttpsUrl(url: string) {
40+
try {
41+
const u = new URL(url);
42+
return u.protocol === "https:";
43+
} catch {
44+
return false;
45+
}
46+
}
47+
48+
function matchesAllowed(contentType: string, allowed: string[]): boolean {
49+
const ct = contentType.split(";")[0]?.trim().toLowerCase();
50+
if (!ct) return false;
51+
const [ctType, ctSubtype] = ct.split("/");
52+
for (const a of allowed) {
53+
const [aType, aSubtype] = a.toLowerCase().split("/");
54+
const typeOk = aType === "*" || aType === ctType;
55+
const subOk = aSubtype === "*" || aSubtype === ctSubtype;
56+
if (typeOk && subOk) return true;
57+
}
58+
return false;
59+
}
60+
61+
function close() {
62+
open = false;
63+
onclose?.();
64+
}
65+
66+
async function handleSubmit() {
67+
errorMsg = "";
68+
const trimmed = urlValue.trim();
69+
if (!isHttpsUrl(trimmed)) {
70+
errorMsg = "Enter a valid HTTPS URL.";
71+
return;
72+
}
73+
loading = true;
74+
try {
75+
// Use server proxy directly for one URL to validate size/types before creating File
76+
const params = new URLSearchParams({ url: trimmed });
77+
if (acceptMimeTypes.length > 0) params.set("accept", acceptMimeTypes.join(","));
78+
const proxyUrl = `${base}/api/fetch-url?${params}`;
79+
const res = await fetch(proxyUrl);
80+
if (!res.ok) {
81+
const txt = await res.text();
82+
throw new Error(txt || `Failed to fetch (${res.status})`);
83+
}
84+
const blob = await res.blob();
85+
// Optional client-side mime filter (same wildcard semantics as dropzone)
86+
if (acceptMimeTypes.length > 0 && blob.type && !matchesAllowed(blob.type, acceptMimeTypes)) {
87+
throw new Error("File type not allowed.");
88+
}
89+
const disp = res.headers.get("content-disposition");
90+
let filename = "attachment";
91+
const match = disp?.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
92+
if (match && match[1]) filename = match[1].replace(/['"]/g, "");
93+
else {
94+
try {
95+
const u = new URL(trimmed);
96+
const last = u.pathname.split("/").pop() || "attachment";
97+
filename = decodeURIComponent(last);
98+
} catch {}
99+
}
100+
const file = new File([blob], filename, { type: blob.type || "application/octet-stream" });
101+
onfiles?.([file]);
102+
close();
103+
} catch (e) {
104+
errorMsg = e instanceof Error ? e.message : "Failed to fetch URL";
105+
} finally {
106+
loading = false;
107+
}
108+
}
109+
</script>
110+
111+
{#if open}
112+
<Modal onclose={close} width="w-[90dvh] md:w-[480px]">
113+
{#snippet children()}
114+
<form
115+
class="flex w-full flex-col gap-5 p-6"
116+
onsubmit={(e) => {
117+
e.preventDefault();
118+
handleSubmit();
119+
}}
120+
>
121+
<div class="flex items-start justify-between">
122+
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Add from URL</h2>
123+
<button type="button" class="group" onclick={close} aria-label="Close">
124+
<svg
125+
xmlns="http://www.w3.org/2000/svg"
126+
viewBox="0 0 32 32"
127+
class="size-5 text-gray-700 group-hover:text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-400"
128+
>
129+
<path
130+
d="M24 9.41 22.59 8 16 14.59 9.41 8 8 9.41 14.59 16 8 22.59 9.41 24 16 17.41 22.59 24 24 22.59 17.41 16 24 9.41z"
131+
fill="currentColor"
132+
/>
133+
</svg>
134+
</button>
135+
</div>
136+
137+
<div class="flex flex-col gap-2">
138+
<label class="text-sm text-gray-600 dark:text-gray-400" for="fetch-url-input"
139+
>HTTPS URL</label
140+
>
141+
<input
142+
id="fetch-url-input"
143+
bind:this={inputEl}
144+
bind:value={urlValue}
145+
type="url"
146+
placeholder="https://example.com/file.txt"
147+
class="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-[15px] text-gray-800 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:ring-gray-700"
148+
aria-invalid={errorMsg ? "true" : "false"}
149+
onkeydown={(e) => {
150+
if (e.key === "Enter") {
151+
e.preventDefault();
152+
handleSubmit();
153+
}
154+
}}
155+
/>
156+
</div>
157+
158+
{#if errorMsg}
159+
<p class="-mt-1 text-sm text-red-600 dark:text-red-400">{errorMsg}</p>
160+
{/if}
161+
<p class="-mt-2 text-xs text-gray-500 dark:text-gray-400">Only HTTPS. Max 10MB.</p>
162+
163+
<div class="flex items-center justify-end gap-2">
164+
<button
165+
type="button"
166+
class="inline-flex items-center rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
167+
onclick={close}
168+
>
169+
Cancel
170+
</button>
171+
<button
172+
type="submit"
173+
class="inline-flex items-center rounded-xl border border-gray-900 bg-gray-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
174+
disabled={loading || urlValue.trim() === ""}
175+
>
176+
{#if loading}Fetching…{:else}Add{/if}
177+
</button>
178+
</div>
179+
</form>
180+
{/snippet}
181+
</Modal>
182+
{/if}
183+
184+
<style lang="postcss">
185+
:global(input) {
186+
font-family: inherit;
187+
}
188+
/* Uses app-level colors and rounded/blur styles via utility classes */
189+
/* The Modal itself provides consistent container + scrollbar-custom styling */
190+
</style>

0 commit comments

Comments
 (0)