nsarrazin HF Staff commited on
Commit
c99779e
·
unverified ·
1 Parent(s): 5340bb9

feat: paste long context as plaintext files (#1549)

Browse files

* feat: paste long context as a file

* fix: anim sources

* fix: animations

* feat: actually inject plaintext files in the prompt

* filter out files that are plain text

* feat: use custom MIME type for clipboard content

* feat: add better UI affordance for pasting

* fix: cleanup animations

src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -260,7 +260,7 @@
260
  {#if message.files?.length}
261
  <div class="flex h-fit flex-wrap gap-x-5 gap-y-2">
262
  {#each message.files as file}
263
- <UploadedFile {file} canClose={false} isPreview={false} />
264
  {/each}
265
  </div>
266
  {/if}
@@ -410,7 +410,7 @@
410
  {#if message.files?.length}
411
  <div class="flex w-fit gap-4 px-5">
412
  {#each message.files as file}
413
- <UploadedFile {file} canClose={false} isPreview={false} />
414
  {/each}
415
  </div>
416
  {/if}
 
260
  {#if message.files?.length}
261
  <div class="flex h-fit flex-wrap gap-x-5 gap-y-2">
262
  {#each message.files as file}
263
+ <UploadedFile {file} canClose={false} />
264
  {/each}
265
  </div>
266
  {/if}
 
410
  {#if message.files?.length}
411
  <div class="flex w-fit gap-4 px-5">
412
  {#each message.files as file}
413
+ <UploadedFile {file} canClose={false} />
414
  {/each}
415
  </div>
416
  {/if}
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -38,6 +38,9 @@
38
  import type { ToolFront } from "$lib/types/Tool";
39
  import ModelSwitch from "./ModelSwitch.svelte";
40
 
 
 
 
41
  export let messages: Message[] = [];
42
  export let loading = false;
43
  export let pending = false;
@@ -55,6 +58,7 @@
55
  let message: string;
56
  let timeout: ReturnType<typeof setTimeout>;
57
  let isSharedRecently = false;
 
58
  $: $page.params.id && (isSharedRecently = false);
59
 
60
  const dispatch = createEventDispatcher<{
@@ -86,6 +90,21 @@
86
  };
87
 
88
  const onPaste = (e: ClipboardEvent) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  if (!e.clipboardData) {
90
  return;
91
  }
@@ -344,7 +363,10 @@
344
  class="dark:via-gray-80 pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full max-w-3xl flex-col items-center justify-center bg-gradient-to-t from-white via-white/80 to-white/0 px-3.5 py-4 dark:border-gray-800 dark:from-gray-900 dark:to-gray-900/0 max-md:border-t max-md:bg-white max-md:dark:bg-gray-900 sm:px-5 md:py-8 xl:max-w-4xl [&>*]:pointer-events-auto"
345
  >
346
  {#if sources?.length && !loading}
347
- <div class="flex flex-row flex-wrap justify-center gap-2.5 max-md:pb-3">
 
 
 
348
  {#each sources as source, index}
349
  {#await source then src}
350
  <UploadedFile
@@ -409,7 +431,10 @@
409
  {#if onDrag && isFileUploadEnabled}
410
  <FileDropzone bind:files bind:onDrag mimeTypes={activeMimeTypes} />
411
  {:else}
412
- <div class="flex w-full flex-1 border-none bg-transparent">
 
 
 
413
  {#if lastIsError}
414
  <ChatInput value="Sorry, something went wrong. Please try again." disabled={true} />
415
  {:else}
@@ -508,3 +533,22 @@
508
  </div>
509
  </div>
510
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  import type { ToolFront } from "$lib/types/Tool";
39
  import ModelSwitch from "./ModelSwitch.svelte";
40
 
41
+ import { fly } from "svelte/transition";
42
+ import { cubicInOut } from "svelte/easing";
43
+
44
  export let messages: Message[] = [];
45
  export let loading = false;
46
  export let pending = false;
 
58
  let message: string;
59
  let timeout: ReturnType<typeof setTimeout>;
60
  let isSharedRecently = false;
61
+ $: pastedLongContent = false;
62
  $: $page.params.id && (isSharedRecently = false);
63
 
64
  const dispatch = createEventDispatcher<{
 
90
  };
91
 
92
  const onPaste = (e: ClipboardEvent) => {
93
+ const textContent = e.clipboardData?.getData("text");
94
+
95
+ if (textContent && textContent.length > 256) {
96
+ e.preventDefault();
97
+ pastedLongContent = true;
98
+ setTimeout(() => {
99
+ pastedLongContent = false;
100
+ }, 1000);
101
+ const pastedFile = new File([textContent], "Pasted Content", {
102
+ type: "application/vnd.chatui.clipboard",
103
+ });
104
+
105
+ files = [...files, pastedFile];
106
+ }
107
+
108
  if (!e.clipboardData) {
109
  return;
110
  }
 
363
  class="dark:via-gray-80 pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full max-w-3xl flex-col items-center justify-center bg-gradient-to-t from-white via-white/80 to-white/0 px-3.5 py-4 dark:border-gray-800 dark:from-gray-900 dark:to-gray-900/0 max-md:border-t max-md:bg-white max-md:dark:bg-gray-900 sm:px-5 md:py-8 xl:max-w-4xl [&>*]:pointer-events-auto"
364
  >
365
  {#if sources?.length && !loading}
366
+ <div
367
+ in:fly|local={sources.length === 1 ? { y: -20, easing: cubicInOut } : undefined}
368
+ class="flex flex-row flex-wrap justify-center gap-2.5 rounded-xl max-md:pb-3"
369
+ >
370
  {#each sources as source, index}
371
  {#await source then src}
372
  <UploadedFile
 
431
  {#if onDrag && isFileUploadEnabled}
432
  <FileDropzone bind:files bind:onDrag mimeTypes={activeMimeTypes} />
433
  {:else}
434
+ <div
435
+ class="flex w-full flex-1 rounded-xl border-none bg-transparent"
436
+ class:paste-glow={pastedLongContent}
437
+ >
438
  {#if lastIsError}
439
  <ChatInput value="Sorry, something went wrong. Please try again." disabled={true} />
440
  {:else}
 
533
  </div>
534
  </div>
535
  </div>
536
+
537
+ <style lang="postcss">
538
+ .paste-glow {
539
+ animation: glow 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
540
+ will-change: box-shadow;
541
+ }
542
+
543
+ @keyframes glow {
544
+ 0% {
545
+ box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.8);
546
+ }
547
+ 50% {
548
+ box-shadow: 0 0 20px 4px rgba(59, 130, 246, 0.6);
549
+ }
550
+ 100% {
551
+ box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
552
+ }
553
+ }
554
+ </style>
src/lib/components/chat/UploadedFile.svelte CHANGED
@@ -5,13 +5,13 @@
5
  import CarbonClose from "~icons/carbon/close";
6
  import CarbonDocumentBlank from "~icons/carbon/document-blank";
7
  import CarbonDownload from "~icons/carbon/download";
8
-
9
  import Modal from "../Modal.svelte";
10
  import AudioPlayer from "../players/AudioPlayer.svelte";
 
11
 
12
  export let file: MessageFile;
13
  export let canClose = true;
14
- export let isPreview = false;
15
 
16
  $: showModal = false;
17
  $: urlNotTrailing = $page.url.pathname.replace(/\/$/, "");
@@ -38,33 +38,74 @@
38
  const isVideo = (mime: string) =>
39
  mime.startsWith("video/") || mime === "mp4" || mime === "x-mpeg";
40
 
41
- $: isClickable = isImage(file.mime) && !isPreview;
 
 
 
 
 
 
 
 
42
  </script>
43
 
44
  {#if showModal && isClickable}
45
  <!-- show the image file full screen, click outside to exit -->
46
- <Modal width="sm:max-w-[500px]" on:close={() => (showModal = false)}>
47
- {#if file.type === "hash"}
48
- <img
49
- src={urlNotTrailing + "/output/" + file.value}
50
- alt="input from user"
51
- class="aspect-auto"
52
- />
53
- {:else}
54
- <!-- handle the case where this is a base64 encoded image -->
55
- <img
56
- src={`data:${file.mime};base64,${file.value}`}
57
- alt="input from user"
58
- class="aspect-auto"
59
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  {/if}
61
  </Modal>
62
  {/if}
63
 
64
- <button on:click={() => (showModal = true)} disabled={!isClickable}>
65
  <div class="group relative flex items-center rounded-xl shadow-sm">
66
  {#if isImage(file.mime)}
67
- <div class=" overflow-hidden rounded-xl" class:size-24={isPreview} class:size-48={!isPreview}>
68
  <img
69
  src={file.type === "base64"
70
  ? `data:${file.mime};base64,${file.value}`
@@ -92,9 +133,31 @@
92
  controls
93
  />
94
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  {:else if file.mime === "octet-stream"}
96
  <div
97
  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"
 
98
  >
99
  <div
100
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
@@ -120,13 +183,14 @@
120
  {:else}
121
  <div
122
  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"
 
123
  >
124
  <div
125
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
126
  >
127
  <CarbonDocumentBlank class="text-base text-gray-700 dark:text-gray-300" />
128
  </div>
129
- <dl class="flex flex-col truncate leading-tight">
130
  <dd class="text-sm">
131
  {truncateMiddle(file.name, 28)}
132
  </dd>
@@ -137,11 +201,18 @@
137
  <!-- add a button on top that removes the image -->
138
  {#if canClose}
139
  <button
140
- class="invisible absolute -right-2 -top-2 grid size-6 place-items-center rounded-full border bg-black group-hover:visible dark:border-gray-700"
141
- on:click={() => dispatch("close")}
 
142
  >
143
  <CarbonClose class=" text-xs text-white" />
144
  </button>
145
  {/if}
146
  </div>
147
  </button>
 
 
 
 
 
 
 
5
  import CarbonClose from "~icons/carbon/close";
6
  import CarbonDocumentBlank from "~icons/carbon/document-blank";
7
  import CarbonDownload from "~icons/carbon/download";
8
+ import CarbonDocument from "~icons/carbon/document";
9
  import Modal from "../Modal.svelte";
10
  import AudioPlayer from "../players/AudioPlayer.svelte";
11
+ import EosIconsLoading from "~icons/eos-icons/loading";
12
 
13
  export let file: MessageFile;
14
  export let canClose = true;
 
15
 
16
  $: showModal = false;
17
  $: urlNotTrailing = $page.url.pathname.replace(/\/$/, "");
 
38
  const isVideo = (mime: string) =>
39
  mime.startsWith("video/") || mime === "mp4" || mime === "x-mpeg";
40
 
41
+ const isPlainText = (mime: string) =>
42
+ mime === "text/plain" ||
43
+ mime === "text/csv" ||
44
+ mime === "text/markdown" ||
45
+ mime === "application/json" ||
46
+ mime === "application/xml" ||
47
+ mime === "application/vnd.chatui.clipboard";
48
+
49
+ $: isClickable = isImage(file.mime) || isPlainText(file.mime);
50
  </script>
51
 
52
  {#if showModal && isClickable}
53
  <!-- show the image file full screen, click outside to exit -->
54
+ <Modal width="sm:max-w-[800px]" on:close={() => (showModal = false)}>
55
+ {#if isImage(file.mime)}
56
+ {#if file.type === "hash"}
57
+ <img
58
+ src={urlNotTrailing + "/output/" + file.value}
59
+ alt="input from user"
60
+ class="aspect-auto"
61
+ />
62
+ {:else}
63
+ <!-- handle the case where this is a base64 encoded image -->
64
+ <img
65
+ src={`data:${file.mime};base64,${file.value}`}
66
+ alt="input from user"
67
+ class="aspect-auto"
68
+ />
69
+ {/if}
70
+ {:else if isPlainText(file.mime)}
71
+ <div class="relative flex h-full w-full flex-col gap-4 p-4">
72
+ <h3 class="-mb-2 pt-2 text-xl font-bold">{file.name}</h3>
73
+ <button
74
+ class="absolute right-4 top-4 text-xl text-gray-500 hover:text-gray-800"
75
+ on:click={() => (showModal = false)}
76
+ >
77
+ <CarbonClose class="text-xl" />
78
+ </button>
79
+ {#if file.type === "hash"}
80
+ {#await fetch(urlNotTrailing + "/output/" + file.value).then((res) => res.text())}
81
+ <div class="flex h-full w-full items-center justify-center">
82
+ <EosIconsLoading class="text-xl" />
83
+ </div>
84
+ {:then result}
85
+ <pre
86
+ class="w-full whitespace-pre-wrap break-words pt-0 text-sm"
87
+ class:font-sans={file.mime === "text/plain" ||
88
+ file.mime === "application/vnd.chatui.clipboard"}
89
+ class:font-mono={file.mime !== "text/plain" &&
90
+ file.mime !== "application/vnd.chatui.clipboard"}>{result}</pre>
91
+ {/await}
92
+ {:else}
93
+ <pre
94
+ class="w-full whitespace-pre-wrap break-words pt-0 text-sm"
95
+ class:font-sans={file.mime === "text/plain" ||
96
+ file.mime === "application/vnd.chatui.clipboard"}
97
+ class:font-mono={file.mime !== "text/plain" &&
98
+ file.mime !== "application/vnd.chatui.clipboard"}>{atob(file.value)}</pre>
99
+ {/if}
100
+ </div>
101
  {/if}
102
  </Modal>
103
  {/if}
104
 
105
+ <button on:click={() => (showModal = true)} disabled={!isClickable} class:clickable={isClickable}>
106
  <div class="group relative flex items-center rounded-xl shadow-sm">
107
  {#if isImage(file.mime)}
108
+ <div class="size-48 overflow-hidden rounded-xl">
109
  <img
110
  src={file.type === "base64"
111
  ? `data:${file.mime};base64,${file.value}`
 
133
  controls
134
  />
135
  </div>
136
+ {:else if isPlainText(file.mime)}
137
+ <div
138
+ 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"
139
+ class:hoverable={isClickable}
140
+ >
141
+ <div
142
+ class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
143
+ >
144
+ <CarbonDocument class="text-base text-gray-700 dark:text-gray-300" />
145
+ </div>
146
+ <dl class="flex flex-col items-start truncate leading-tight">
147
+ <dd class="text-sm">
148
+ {truncateMiddle(file.name, 28)}
149
+ </dd>
150
+ {#if file.mime === "application/vnd.chatui.clipboard"}
151
+ <dt class="text-xs text-gray-400">Clipboard source</dt>
152
+ {:else}
153
+ <dt class="text-xs text-gray-400">{file.mime}</dt>
154
+ {/if}
155
+ </dl>
156
+ </div>
157
  {:else if file.mime === "octet-stream"}
158
  <div
159
  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"
160
+ class:hoverable={isClickable}
161
  >
162
  <div
163
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
 
183
  {:else}
184
  <div
185
  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"
186
+ class:hoverable={isClickable}
187
  >
188
  <div
189
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
190
  >
191
  <CarbonDocumentBlank class="text-base text-gray-700 dark:text-gray-300" />
192
  </div>
193
+ <dl class="flex flex-col items-start truncate leading-tight">
194
  <dd class="text-sm">
195
  {truncateMiddle(file.name, 28)}
196
  </dd>
 
201
  <!-- add a button on top that removes the image -->
202
  {#if canClose}
203
  <button
204
+ class="absolute -right-2 -top-2 z-10 grid size-6 place-items-center rounded-full border bg-black group-hover:visible dark:border-gray-700"
205
+ class:invisible={navigator.maxTouchPoints === 0}
206
+ on:click|stopPropagation|preventDefault={() => dispatch("close")}
207
  >
208
  <CarbonClose class=" text-xs text-white" />
209
  </button>
210
  {/if}
211
  </div>
212
  </button>
213
+
214
+ <style lang="postcss">
215
+ .hoverable {
216
+ @apply hover:bg-gray-500/10;
217
+ }
218
+ </style>
src/lib/server/endpoints/preprocessMessages.ts CHANGED
@@ -11,7 +11,8 @@ export async function preprocessMessages(
11
  ): Promise<EndpointMessage[]> {
12
  return Promise.resolve(messages)
13
  .then((msgs) => addWebSearchContext(msgs, webSearch))
14
- .then((msgs) => downloadFiles(msgs, convId));
 
15
  }
16
 
17
  function addWebSearchContext(messages: Message[], webSearch: Message["webSearch"]) {
@@ -54,3 +55,21 @@ async function downloadFiles(messages: Message[], convId: ObjectId): Promise<End
54
  )
55
  );
56
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ): Promise<EndpointMessage[]> {
12
  return Promise.resolve(messages)
13
  .then((msgs) => addWebSearchContext(msgs, webSearch))
14
+ .then((msgs) => downloadFiles(msgs, convId))
15
+ .then((msgs) => injectClipboardFiles(msgs));
16
  }
17
 
18
  function addWebSearchContext(messages: Message[], webSearch: Message["webSearch"]) {
 
55
  )
56
  );
57
  }
58
+
59
+ async function injectClipboardFiles(messages: EndpointMessage[]) {
60
+ return Promise.all(
61
+ messages.map((message) => {
62
+ const plaintextFiles = message.files
63
+ ?.filter((file) => file.mime === "application/vnd.chatui.clipboard")
64
+ .map((file) => Buffer.from(file.value, "base64").toString("utf-8"));
65
+
66
+ if (!plaintextFiles || plaintextFiles.length === 0) return message;
67
+
68
+ return {
69
+ ...message,
70
+ content: `${plaintextFiles.join("\n\n")}\n\n${message.content}`,
71
+ files: message.files?.filter((file) => file.mime !== "application/vnd.chatui.clipboard"),
72
+ };
73
+ })
74
+ );
75
+ }