victor HF Staff commited on
Commit
17d4d70
·
unverified ·
1 Parent(s): 0c0f258

Url attachments (#1965)

Browse files

* 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

src/lib/components/Modal.svelte CHANGED
@@ -78,7 +78,7 @@
78
  bind:this={modalEl}
79
  onkeydown={handleKeydown}
80
  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",
82
  width,
83
  ]}
84
  >
@@ -97,7 +97,7 @@
97
  onkeydown={handleKeydown}
98
  in:fly={{ y: 100 }}
99
  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",
101
  width,
102
  ]}
103
  >
 
78
  bind:this={modalEl}
79
  onkeydown={handleKeydown}
80
  class={[
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",
82
  width,
83
  ]}
84
  >
 
97
  onkeydown={handleKeydown}
98
  in:fly={{ y: 100 }}
99
  class={[
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",
101
  width,
102
  ]}
103
  >
src/lib/components/chat/UploadedFile.svelte CHANGED
@@ -73,8 +73,11 @@
73
  />
74
  {/if}
75
  {: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>
 
 
 
78
  {#if file.mime === "application/vnd.chatui.clipboard"}
79
  <p class="text-sm text-gray-500">
80
  If you prefer to inject clipboard content directly in the chat, you can disable this
@@ -95,7 +98,7 @@
95
  </div>
96
  {:then result}
97
  <pre
98
- class="w-full whitespace-pre-wrap break-words pt-0 text-sm"
99
  class:font-sans={file.mime === "text/plain" ||
100
  file.mime === "application/vnd.chatui.clipboard"}
101
  class:font-mono={file.mime !== "text/plain" &&
@@ -103,7 +106,7 @@
103
  {/await}
104
  {:else}
105
  <pre
106
- class="w-full whitespace-pre-wrap break-words pt-0 text-sm"
107
  class:font-sans={file.mime === "text/plain" ||
108
  file.mime === "application/vnd.chatui.clipboard"}
109
  class:font-mono={file.mime !== "text/plain" &&
@@ -124,7 +127,6 @@
124
  showModal = true;
125
  }
126
  }}
127
- class="mt-4"
128
  class:clickable={isClickable}
129
  role="button"
130
  tabindex="0"
@@ -161,7 +163,7 @@
161
  </div>
162
  {:else if isPlainText(file.mime)}
163
  <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"
165
  class:file-hoverable={isClickable}
166
  >
167
  <div
 
73
  />
74
  {/if}
75
  {:else if isPlainText(file.mime)}
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>
81
  {#if file.mime === "application/vnd.chatui.clipboard"}
82
  <p class="text-sm text-gray-500">
83
  If you prefer to inject clipboard content directly in the chat, you can disable this
 
98
  </div>
99
  {:then result}
100
  <pre
101
+ class="w-full whitespace-pre-wrap break-words pt-0 text-xs"
102
  class:font-sans={file.mime === "text/plain" ||
103
  file.mime === "application/vnd.chatui.clipboard"}
104
  class:font-mono={file.mime !== "text/plain" &&
 
106
  {/await}
107
  {:else}
108
  <pre
109
+ class="w-full whitespace-pre-wrap break-words pt-0 text-xs"
110
  class:font-sans={file.mime === "text/plain" ||
111
  file.mime === "application/vnd.chatui.clipboard"}
112
  class:font-mono={file.mime !== "text/plain" &&
 
127
  showModal = true;
128
  }
129
  }}
 
130
  class:clickable={isClickable}
131
  role="button"
132
  tabindex="0"
 
163
  </div>
164
  {:else if isPlainText(file.mime)}
165
  <div
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"
167
  class:file-hoverable={isClickable}
168
  >
169
  <div
src/lib/server/endpoints/openai/endpointOai.ts CHANGED
@@ -246,12 +246,27 @@ async function prepareMessages(
246
  ): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam[]> {
247
  return Promise.all(
248
  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];
 
 
 
 
 
 
 
 
 
 
 
 
253
  return { role: message.from, content: parts };
254
  }
 
 
 
255
  }
256
  return { role: message.from, content: message.content };
257
  })
@@ -260,18 +275,48 @@ async function prepareMessages(
260
 
261
  async function prepareFiles(
262
  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)
 
 
 
 
 
 
 
 
 
 
267
  );
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
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  }
 
246
  ): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam[]> {
247
  return Promise.all(
248
  messages.map(async (message) => {
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];
265
  return { role: message.from, content: parts };
266
  }
267
+
268
+ // Otherwise just use the text (possibly with injected file content)
269
+ return { role: message.from, content: messageText };
270
  }
271
  return { role: message.from, content: message.content };
272
  })
 
275
 
276
  async function prepareFiles(
277
  imageProcessor: ReturnType<typeof makeImageProcessor>,
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"
292
  );
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 };
322
  }
src/lib/utils/loadAttachmentsFromUrls.ts ADDED
@@ -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 CHANGED
@@ -57,7 +57,7 @@
57
  }, 5000);
58
  }
59
 
60
- const canShare = $derived(
61
  publicConfig.isHuggingChat &&
62
  Boolean(page.params?.id) &&
63
  page.route.id?.startsWith("/conversation/")
 
57
  }, 5000);
58
  }
59
 
60
+ let canShare = $derived(
61
  publicConfig.isHuggingChat &&
62
  Boolean(page.params?.id) &&
63
  page.route.id?.startsWith("/conversation/")
src/routes/+page.svelte CHANGED
@@ -14,6 +14,7 @@
14
  import { sanitizeUrlParam } from "$lib/utils/urlParams";
15
  import { onMount, tick } from "svelte";
16
  import { loading } from "$lib/stores/loading.js";
 
17
 
18
  let { data } = $props();
19
 
@@ -75,8 +76,27 @@
75
  }
76
  }
77
 
78
- onMount(() => {
79
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  const query = sanitizeUrlParam(page.url.searchParams.get("q"));
81
  if (query) {
82
  void createConversation(query);
 
14
  import { sanitizeUrlParam } from "$lib/utils/urlParams";
15
  import { onMount, tick } from "svelte";
16
  import { loading } from "$lib/stores/loading.js";
17
+ import { loadAttachmentsFromUrls } from "$lib/utils/loadAttachmentsFromUrls";
18
 
19
  let { data } = $props();
20
 
 
76
  }
77
  }
78
 
79
+ onMount(async () => {
80
  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
+
100
  const query = sanitizeUrlParam(page.url.searchParams.get("q"));
101
  if (query) {
102
  void createConversation(query);
src/routes/api/fetch-url/+server.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { error } from "@sveltejs/kit";
2
+
3
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
4
+ const FETCH_TIMEOUT = 30000; // 30 seconds
5
+
6
+ // Validate URL safety - HTTPS only
7
+ function isValidUrl(urlString: string): boolean {
8
+ try {
9
+ const url = new URL(urlString);
10
+ // Only allow HTTPS protocol
11
+ if (url.protocol !== "https:") {
12
+ return false;
13
+ }
14
+ // Prevent localhost/private IPs (basic check)
15
+ const hostname = url.hostname.toLowerCase();
16
+ if (
17
+ hostname === "localhost" ||
18
+ hostname.startsWith("127.") ||
19
+ hostname.startsWith("192.168.") ||
20
+ hostname.startsWith("10.") ||
21
+ hostname.startsWith("172.16.") ||
22
+ hostname === "[::1]" ||
23
+ hostname === "0.0.0.0"
24
+ ) {
25
+ return false;
26
+ }
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export async function GET({ url, fetch }) {
34
+ const targetUrl = url.searchParams.get("url");
35
+
36
+ if (!targetUrl) {
37
+ throw error(400, "Missing 'url' parameter");
38
+ }
39
+
40
+ if (!isValidUrl(targetUrl)) {
41
+ throw error(400, "Invalid or unsafe URL (only HTTPS is supported)");
42
+ }
43
+
44
+ try {
45
+ // Fetch with timeout
46
+ const controller = new AbortController();
47
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
48
+
49
+ const response = await fetch(targetUrl, {
50
+ signal: controller.signal,
51
+ redirect: "error", // Block all redirects
52
+ headers: {
53
+ "User-Agent": "HuggingChat-Attachment-Fetcher/1.0",
54
+ },
55
+ }).finally(() => clearTimeout(timeoutId));
56
+
57
+ if (!response.ok) {
58
+ throw error(response.status, `Failed to fetch: ${response.statusText}`);
59
+ }
60
+
61
+ // Check content length if available
62
+ const contentLength = response.headers.get("content-length");
63
+ if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
64
+ throw error(413, "File too large (max 10MB)");
65
+ }
66
+
67
+ // Stream the response back
68
+ const contentType = response.headers.get("content-type") || "application/octet-stream";
69
+ const contentDisposition = response.headers.get("content-disposition");
70
+
71
+ const headers: HeadersInit = {
72
+ "Content-Type": contentType,
73
+ "Cache-Control": "public, max-age=3600",
74
+ };
75
+
76
+ if (contentDisposition) {
77
+ headers["Content-Disposition"] = contentDisposition;
78
+ }
79
+
80
+ // Get the body as array buffer to check size
81
+ const arrayBuffer = await response.arrayBuffer();
82
+
83
+ if (arrayBuffer.byteLength > MAX_FILE_SIZE) {
84
+ throw error(413, "File too large (max 10MB)");
85
+ }
86
+
87
+ return new Response(arrayBuffer, { headers });
88
+ } catch (err) {
89
+ if (err instanceof Error) {
90
+ if (err.name === "AbortError") {
91
+ throw error(504, "Request timeout");
92
+ }
93
+ console.error("Error fetching URL:", err);
94
+ throw error(500, `Failed to fetch URL: ${err.message}`);
95
+ }
96
+ throw error(500, "Failed to fetch URL");
97
+ }
98
+ }
src/routes/models/[...model]/+page.svelte CHANGED
@@ -11,6 +11,7 @@
11
  import { ERROR_MESSAGES, error } from "$lib/stores/errors";
12
  import { pendingMessage } from "$lib/stores/pendingMessage";
13
  import { sanitizeUrlParam } from "$lib/utils/urlParams";
 
14
 
15
  let { data } = $props();
16
 
@@ -61,8 +62,27 @@
61
  }
62
  }
63
 
64
- onMount(() => {
65
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  const query = sanitizeUrlParam(page.url.searchParams.get("q"));
67
  if (query) {
68
  void createConversation(query);
 
11
  import { ERROR_MESSAGES, error } from "$lib/stores/errors";
12
  import { pendingMessage } from "$lib/stores/pendingMessage";
13
  import { sanitizeUrlParam } from "$lib/utils/urlParams";
14
+ import { loadAttachmentsFromUrls } from "$lib/utils/loadAttachmentsFromUrls";
15
 
16
  let { data } = $props();
17
 
 
62
  }
63
  }
64
 
65
+ onMount(async () => {
66
  try {
67
+ // Handle attachments parameter first
68
+ if (page.url.searchParams.has("attachments")) {
69
+ const result = await loadAttachmentsFromUrls(page.url.searchParams);
70
+ files = result.files;
71
+
72
+ // Show errors if any
73
+ if (result.errors.length > 0) {
74
+ console.error("Failed to load some attachments:", result.errors);
75
+ error.set(
76
+ `Failed to load ${result.errors.length} attachment(s). Check console for details.`
77
+ );
78
+ }
79
+
80
+ // Clean up URL
81
+ const url = new URL(page.url);
82
+ url.searchParams.delete("attachments");
83
+ history.replaceState({}, "", url);
84
+ }
85
+
86
  const query = sanitizeUrlParam(page.url.searchParams.get("q"));
87
  if (query) {
88
  void createConversation(query);