Skip to content

Commit ce84efe

Browse files
authored
Url attachments (#1965)
* Add custom scrollbar class to Modal component Applied the 'scrollbar-custom' class to the Modal's main container to enable custom scrollbar styling. This improves the appearance and consistency of scrollbars within modals. * Add support for loading attachments from URLs Implements the ability to load file attachments via URL parameters using a new utility (loadAttachmentsFromUrls) and a server-side proxy endpoint (/api/fetch-url) to securely fetch remote files. Updates chat and model pages to handle 'attachments' query parameters, injects file content into user messages, and improves multimodal message preparation. Also includes minor UI adjustments for file display and refactors Plausible analytics script initialization. * Block redirects in fetch-url API endpoint
1 parent 45464a5 commit ce84efe

File tree

8 files changed

+317
-28
lines changed

8 files changed

+317
-28
lines changed

src/lib/components/Modal.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
bind:this={modalEl}
7979
onkeydown={handleKeydown}
8080
class={[
81-
"relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200",
81+
"scrollbar-custom relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200",
8282
width,
8383
]}
8484
>
@@ -97,7 +97,7 @@
9797
onkeydown={handleKeydown}
9898
in:fly={{ y: 100 }}
9999
class={[
100-
"relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200",
100+
"scrollbar-custom relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200",
101101
width,
102102
]}
103103
>

src/lib/components/chat/UploadedFile.svelte

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,11 @@
7373
/>
7474
{/if}
7575
{:else if isPlainText(file.mime)}
76-
<div class="relative flex h-full w-full flex-col gap-4 p-4">
77-
<h3 class="-mb-4 pt-2 text-xl font-bold">{file.name}</h3>
76+
<div class="relative flex h-full w-full flex-col gap-2 p-4">
77+
<div class="flex items-center gap-1">
78+
<CarbonDocument />
79+
<h3 class="text-lg font-semibold">{file.name}</h3>
80+
</div>
7881
{#if file.mime === "application/vnd.chatui.clipboard"}
7982
<p class="text-sm text-gray-500">
8083
If you prefer to inject clipboard content directly in the chat, you can disable this
@@ -95,15 +98,15 @@
9598
</div>
9699
{:then result}
97100
<pre
98-
class="w-full whitespace-pre-wrap break-words pt-0 text-sm"
101+
class="w-full whitespace-pre-wrap break-words pt-0 text-xs"
99102
class:font-sans={file.mime === "text/plain" ||
100103
file.mime === "application/vnd.chatui.clipboard"}
101104
class:font-mono={file.mime !== "text/plain" &&
102105
file.mime !== "application/vnd.chatui.clipboard"}>{result}</pre>
103106
{/await}
104107
{:else}
105108
<pre
106-
class="w-full whitespace-pre-wrap break-words pt-0 text-sm"
109+
class="w-full whitespace-pre-wrap break-words pt-0 text-xs"
107110
class:font-sans={file.mime === "text/plain" ||
108111
file.mime === "application/vnd.chatui.clipboard"}
109112
class:font-mono={file.mime !== "text/plain" &&
@@ -124,7 +127,6 @@
124127
showModal = true;
125128
}
126129
}}
127-
class="mt-4"
128130
class:clickable={isClickable}
129131
role="button"
130132
tabindex="0"
@@ -161,7 +163,7 @@
161163
</div>
162164
{:else if isPlainText(file.mime)}
163165
<div
164-
class="flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900"
166+
class="flex h-14 w-64 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900 2xl:w-72"
165167
class:file-hoverable={isClickable}
166168
>
167169
<div

src/lib/server/endpoints/openai/endpointOai.ts

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,27 @@ async function prepareMessages(
246246
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam[]> {
247247
return Promise.all(
248248
messages.map(async (message) => {
249-
if (message.from === "user" && isMultimodal) {
250-
const imageParts = await prepareFiles(imageProcessor, message.files ?? []);
251-
if (imageParts.length) {
252-
const parts = [{ type: "text" as const, text: message.content }, ...imageParts];
249+
if (message.from === "user" && message.files && message.files.length > 0) {
250+
const { imageParts, textContent } = await prepareFiles(
251+
imageProcessor,
252+
message.files,
253+
isMultimodal
254+
);
255+
256+
// If we have text files, prepend their content to the message
257+
let messageText = message.content;
258+
if (textContent.length > 0) {
259+
messageText = textContent + "\n\n" + message.content;
260+
}
261+
262+
// If we have images and multimodal is enabled, use structured content
263+
if (imageParts.length > 0 && isMultimodal) {
264+
const parts = [{ type: "text" as const, text: messageText }, ...imageParts];
253265
return { role: message.from, content: parts };
254266
}
267+
268+
// Otherwise just use the text (possibly with injected file content)
269+
return { role: message.from, content: messageText };
255270
}
256271
return { role: message.from, content: message.content };
257272
})
@@ -260,18 +275,48 @@ async function prepareMessages(
260275

261276
async function prepareFiles(
262277
imageProcessor: ReturnType<typeof makeImageProcessor>,
263-
files: MessageFile[]
264-
): Promise<OpenAI.Chat.Completions.ChatCompletionContentPartImage[]> {
265-
const processedFiles = await Promise.all(
266-
files.filter((file) => file.mime.startsWith("image/")).map(imageProcessor)
278+
files: MessageFile[],
279+
isMultimodal: boolean
280+
): Promise<{
281+
imageParts: OpenAI.Chat.Completions.ChatCompletionContentPartImage[];
282+
textContent: string;
283+
}> {
284+
// Separate image and text files
285+
const imageFiles = files.filter((file) => file.mime.startsWith("image/"));
286+
const textFiles = files.filter(
287+
(file) =>
288+
file.mime.startsWith("text/") ||
289+
file.mime === "application/json" ||
290+
file.mime === "application/xml" ||
291+
file.mime === "application/csv"
267292
);
268-
return processedFiles.map((file) => ({
269-
type: "image_url" as const,
270-
image_url: {
271-
url: `data:${file.mime};base64,${file.image.toString("base64")}`,
272-
// Improves compatibility with some OpenAI-compatible servers
273-
// that expect an explicit detail setting.
274-
detail: "auto",
275-
},
276-
}));
293+
294+
// Process images if multimodal is enabled
295+
let imageParts: OpenAI.Chat.Completions.ChatCompletionContentPartImage[] = [];
296+
if (isMultimodal && imageFiles.length > 0) {
297+
const processedFiles = await Promise.all(imageFiles.map(imageProcessor));
298+
imageParts = processedFiles.map((file) => ({
299+
type: "image_url" as const,
300+
image_url: {
301+
url: `data:${file.mime};base64,${file.image.toString("base64")}`,
302+
// Improves compatibility with some OpenAI-compatible servers
303+
// that expect an explicit detail setting.
304+
detail: "auto",
305+
},
306+
}));
307+
}
308+
309+
// Process text files - inject their content
310+
let textContent = "";
311+
if (textFiles.length > 0) {
312+
const textParts = await Promise.all(
313+
textFiles.map(async (file) => {
314+
const content = Buffer.from(file.value, "base64").toString("utf-8");
315+
return `<document name="${file.name}" type="${file.mime}">\n${content}\n</document>`;
316+
})
317+
);
318+
textContent = textParts.join("\n\n");
319+
}
320+
321+
return { imageParts, textContent };
277322
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { base } from "$app/paths";
2+
3+
export interface AttachmentLoadResult {
4+
files: File[];
5+
errors: string[];
6+
}
7+
8+
/**
9+
* Parse attachment URLs from query parameters
10+
* Supports both comma-separated (?attachments=url1,url2) and multiple params (?attachments=url1&attachments=url2)
11+
*/
12+
function parseAttachmentUrls(searchParams: URLSearchParams): string[] {
13+
const urls: string[] = [];
14+
15+
// Get all 'attachments' parameters
16+
const attachmentParams = searchParams.getAll("attachments");
17+
18+
for (const param of attachmentParams) {
19+
// Split by comma in case multiple URLs are in one param
20+
const splitUrls = param.split(",").map((url) => url.trim());
21+
urls.push(...splitUrls);
22+
}
23+
24+
// Filter out empty strings
25+
return urls.filter((url) => url.length > 0);
26+
}
27+
28+
/**
29+
* Extract filename from URL or Content-Disposition header
30+
*/
31+
function extractFilename(url: string, contentDisposition?: string | null): string {
32+
// Try to get filename from Content-Disposition header
33+
if (contentDisposition) {
34+
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
35+
if (match && match[1]) {
36+
return match[1].replace(/['"]/g, "");
37+
}
38+
}
39+
40+
// Fallback: extract from URL
41+
try {
42+
const urlObj = new URL(url);
43+
const pathname = urlObj.pathname;
44+
const segments = pathname.split("/");
45+
const lastSegment = segments[segments.length - 1];
46+
47+
if (lastSegment && lastSegment.length > 0) {
48+
return decodeURIComponent(lastSegment);
49+
}
50+
} catch {
51+
// Invalid URL, fall through to default
52+
}
53+
54+
return "attachment";
55+
}
56+
57+
/**
58+
* Load files from remote URLs via server-side proxy
59+
*/
60+
export async function loadAttachmentsFromUrls(
61+
searchParams: URLSearchParams
62+
): Promise<AttachmentLoadResult> {
63+
const urls = parseAttachmentUrls(searchParams);
64+
65+
if (urls.length === 0) {
66+
return { files: [], errors: [] };
67+
}
68+
69+
const files: File[] = [];
70+
const errors: string[] = [];
71+
72+
await Promise.all(
73+
urls.map(async (url) => {
74+
try {
75+
// Fetch via our proxy endpoint to bypass CORS
76+
const proxyUrl = `${base}/api/fetch-url?${new URLSearchParams({ url })}`;
77+
const response = await fetch(proxyUrl);
78+
79+
if (!response.ok) {
80+
const errorText = await response.text();
81+
errors.push(`Failed to fetch ${url}: ${errorText}`);
82+
return;
83+
}
84+
85+
const blob = await response.blob();
86+
const contentDisposition = response.headers.get("content-disposition");
87+
const filename = extractFilename(url, contentDisposition);
88+
89+
// Create File object
90+
const file = new File([blob], filename, {
91+
type: blob.type || "application/octet-stream",
92+
});
93+
94+
files.push(file);
95+
} catch (err) {
96+
const message = err instanceof Error ? err.message : "Unknown error";
97+
errors.push(`Failed to load ${url}: ${message}`);
98+
console.error(`Error loading attachment from ${url}:`, err);
99+
}
100+
})
101+
);
102+
103+
return { files, errors };
104+
}

src/routes/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
}, 5000);
5858
}
5959
60-
const canShare = $derived(
60+
let canShare = $derived(
6161
publicConfig.isHuggingChat &&
6262
Boolean(page.params?.id) &&
6363
page.route.id?.startsWith("/conversation/")

src/routes/+page.svelte

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import { sanitizeUrlParam } from "$lib/utils/urlParams";
1515
import { onMount, tick } from "svelte";
1616
import { loading } from "$lib/stores/loading.js";
17+
import { loadAttachmentsFromUrls } from "$lib/utils/loadAttachmentsFromUrls";
1718
1819
let { data } = $props();
1920
@@ -75,8 +76,27 @@
7576
}
7677
}
7778
78-
onMount(() => {
79+
onMount(async () => {
7980
try {
81+
// Handle attachments parameter first
82+
if (page.url.searchParams.has("attachments")) {
83+
const result = await loadAttachmentsFromUrls(page.url.searchParams);
84+
files = result.files;
85+
86+
// Show errors if any
87+
if (result.errors.length > 0) {
88+
console.error("Failed to load some attachments:", result.errors);
89+
error.set(
90+
`Failed to load ${result.errors.length} attachment(s). Check console for details.`
91+
);
92+
}
93+
94+
// Clean up URL
95+
const url = new URL(page.url);
96+
url.searchParams.delete("attachments");
97+
history.replaceState({}, "", url);
98+
}
99+
80100
const query = sanitizeUrlParam(page.url.searchParams.get("q"));
81101
if (query) {
82102
void createConversation(query);

0 commit comments

Comments
 (0)