Spaces:
Running
Running
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}
|
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}
|
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
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
</script>
|
43 |
|
44 |
{#if showModal && isClickable}
|
45 |
<!-- show the image file full screen, click outside to exit -->
|
46 |
-
<Modal width="sm:max-w-[
|
47 |
-
{#if file.
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
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"
|
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="
|
141 |
-
|
|
|
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 |
+
}
|