nsarrazin HF Staff commited on
Commit
c58d011
·
unverified ·
1 Parent(s): d91a8c9

feat(media): support audio & video files (#1408)

Browse files
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -1,7 +1,7 @@
1
  <script lang="ts">
2
  import { marked, type MarkedOptions } from "marked";
3
  import markedKatex from "marked-katex-extension";
4
- import type { Message, MessageFile } from "$lib/types/Message";
5
  import { afterUpdate, createEventDispatcher, tick } from "svelte";
6
  import { deepestChild } from "$lib/utils/deepestChild";
7
  import { page } from "$app/stores";
@@ -30,7 +30,6 @@
30
  } from "$lib/types/MessageUpdate";
31
  import { base } from "$app/paths";
32
  import { useConvTreeStore } from "$lib/stores/convTree";
33
- import Modal from "../Modal.svelte";
34
  import ToolUpdate from "./ToolUpdate.svelte";
35
  import { useSettingsStore } from "$lib/stores/settings";
36
  import DOMPurify from "isomorphic-dompurify";
@@ -209,30 +208,8 @@
209
  const convTreeStore = useConvTreeStore();
210
 
211
  $: if (message.children?.length === 0) $convTreeStore.leaf = message.id;
212
-
213
- $: modalImageToShow = null as MessageFile | null;
214
  </script>
215
 
216
- {#if modalImageToShow}
217
- <!-- show the image file full screen, click outside to exit -->
218
- <Modal width="sm:max-w-[500px]" on:close={() => (modalImageToShow = null)}>
219
- {#if modalImageToShow.type === "hash"}
220
- <img
221
- src={urlNotTrailing + "/output/" + modalImageToShow.value}
222
- alt="input from user"
223
- class="aspect-auto"
224
- />
225
- {:else}
226
- <!-- handle the case where this is a base64 encoded image -->
227
- <img
228
- src={`data:${modalImageToShow.mime};base64,${modalImageToShow.value}`}
229
- alt="input from user"
230
- class="aspect-auto"
231
- />
232
- {/if}
233
- </Modal>
234
- {/if}
235
-
236
  {#if message.from === "assistant"}
237
  <div
238
  class="group relative -mb-4 flex items-start justify-start gap-4 pb-4 leading-relaxed"
@@ -259,23 +236,7 @@
259
  {#if message.files?.length}
260
  <div class="flex h-fit flex-wrap gap-x-5 gap-y-2">
261
  {#each message.files as file}
262
- <!-- handle the case where this is a hash that points to an image in the db, hash is always 64 char long -->
263
- <button on:click={() => (modalImageToShow = file)}>
264
- {#if file.type === "hash"}
265
- <img
266
- src={urlNotTrailing + "/output/" + file.value}
267
- alt="output from assistant"
268
- class="my-2 aspect-auto max-h-48 cursor-pointer rounded-lg shadow-lg xl:max-h-56"
269
- />
270
- {:else}
271
- <!-- handle the case where this is a base64 encoded image -->
272
- <img
273
- src={`data:${file.mime};base64,${file.value}`}
274
- alt="output from assistant"
275
- class="my-2 aspect-auto max-h-48 cursor-pointer rounded-lg shadow-lg xl:max-h-56"
276
- />
277
- {/if}
278
- </button>
279
  {/each}
280
  </div>
281
  {/if}
@@ -400,13 +361,7 @@
400
  {#if message.files?.length}
401
  <div class="flex w-fit gap-4 px-5">
402
  {#each message.files as file}
403
- {#if file.mime.startsWith("image/")}
404
- <button on:click={() => (modalImageToShow = file)}>
405
- <UploadedFile {file} canClose={false} />
406
- </button>
407
- {:else}
408
- <UploadedFile {file} canClose={false} />
409
- {/if}
410
  {/each}
411
  </div>
412
  {/if}
 
1
  <script lang="ts">
2
  import { marked, type MarkedOptions } from "marked";
3
  import markedKatex from "marked-katex-extension";
4
+ import type { Message } from "$lib/types/Message";
5
  import { afterUpdate, createEventDispatcher, tick } from "svelte";
6
  import { deepestChild } from "$lib/utils/deepestChild";
7
  import { page } from "$app/stores";
 
30
  } from "$lib/types/MessageUpdate";
31
  import { base } from "$app/paths";
32
  import { useConvTreeStore } from "$lib/stores/convTree";
 
33
  import ToolUpdate from "./ToolUpdate.svelte";
34
  import { useSettingsStore } from "$lib/stores/settings";
35
  import DOMPurify from "isomorphic-dompurify";
 
208
  const convTreeStore = useConvTreeStore();
209
 
210
  $: if (message.children?.length === 0) $convTreeStore.leaf = message.id;
 
 
211
  </script>
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  {#if message.from === "assistant"}
214
  <div
215
  class="group relative -mb-4 flex items-start justify-start gap-4 pb-4 leading-relaxed"
 
236
  {#if message.files?.length}
237
  <div class="flex h-fit flex-wrap gap-x-5 gap-y-2">
238
  {#each message.files as file}
239
+ <UploadedFile {file} canClose={false} isPreview={false} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  {/each}
241
  </div>
242
  {/if}
 
361
  {#if message.files?.length}
362
  <div class="flex w-fit gap-4 px-5">
363
  {#each message.files as file}
364
+ <UploadedFile {file} canClose={false} isPreview={false} />
 
 
 
 
 
 
365
  {/each}
366
  </div>
367
  {/if}
src/lib/components/chat/UploadedFile.svelte CHANGED
@@ -5,8 +5,16 @@
5
  import CarbonClose from "~icons/carbon/close";
6
  import CarbonDocumentBlank from "~icons/carbon/document-blank";
7
 
 
 
 
8
  export let file: MessageFile;
9
  export let canClose = true;
 
 
 
 
 
10
  const dispatch = createEventDispatcher<{ close: void }>();
11
 
12
  function truncateMiddle(text: string, maxLength: number): string {
@@ -20,47 +28,92 @@
20
 
21
  return `${start}…${end}`;
22
  }
 
 
 
 
 
 
 
 
23
  </script>
24
 
25
- <div
26
- class="group relative flex items-center rounded-xl shadow-sm"
27
- class:w-24={file.mime.startsWith("image/")}
28
- class:w-72={!file.mime.startsWith("image/")}
29
- >
30
- {#if file.mime.startsWith("image/")}
31
- <div class="size-24 overflow-hidden rounded-xl">
 
 
 
 
32
  <img
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  src={file.type === "base64"
34
  ? `data:${file.mime};base64,${file.value}`
35
- : $page.url.pathname + "/output/" + file.value}
36
- alt={file.name}
37
- class="h-full w-full bg-gray-200 object-cover dark:bg-gray-800"
38
  />
39
- </div>
40
- {:else}
41
- <div
42
- 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"
43
- >
44
  <div
45
- class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
46
  >
47
- <CarbonDocumentBlank class="text-base text-gray-700 dark:text-gray-300" />
 
 
 
 
 
 
48
  </div>
49
- <dl class="flex flex-col truncate leading-tight">
50
- <dd class="text-sm">
51
- {truncateMiddle(file.name, 28)}
52
- </dd>
53
- <dt class="text-xs text-gray-400">{file.mime.split("/")[1].toUpperCase()}</dt>
54
- </dl>
55
- </div>
56
- {/if}
57
- <!-- add a button on top that removes the image -->
58
- {#if canClose}
59
- <button
60
- 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"
61
- on:click={() => dispatch("close")}
62
- >
63
- <CarbonClose class=" text-xs text-white" />
64
- </button>
65
- {/if}
66
- </div>
 
 
 
 
 
 
 
 
 
 
 
5
  import CarbonClose from "~icons/carbon/close";
6
  import CarbonDocumentBlank from "~icons/carbon/document-blank";
7
 
8
+ import Modal from "../Modal.svelte";
9
+ import AudioPlayer from "../players/AudioPlayer.svelte";
10
+
11
  export let file: MessageFile;
12
  export let canClose = true;
13
+ export let isPreview = false;
14
+
15
+ $: showModal = false;
16
+ $: urlNotTrailing = $page.url.pathname.replace(/\/$/, "");
17
+
18
  const dispatch = createEventDispatcher<{ close: void }>();
19
 
20
  function truncateMiddle(text: string, maxLength: number): string {
 
28
 
29
  return `${start}…${end}`;
30
  }
31
+
32
+ const isImage = (mime: string) =>
33
+ mime.startsWith("image/") || mime === "webp" || mime === "jpeg" || mime === "png";
34
+
35
+ const isAudio = (mime: string) => mime.startsWith("audio/");
36
+ const isVideo = (mime: string) => mime.startsWith("video/");
37
+
38
+ $: isClickable = isImage(file.mime) && !isPreview;
39
  </script>
40
 
41
+ {#if showModal && isClickable}
42
+ <!-- show the image file full screen, click outside to exit -->
43
+ <Modal width="sm:max-w-[500px]" on:close={() => (showModal = false)}>
44
+ {#if file.type === "hash"}
45
+ <img
46
+ src={urlNotTrailing + "/output/" + file.value}
47
+ alt="input from user"
48
+ class="aspect-auto"
49
+ />
50
+ {:else}
51
+ <!-- handle the case where this is a base64 encoded image -->
52
  <img
53
+ src={`data:${file.mime};base64,${file.value}`}
54
+ alt="input from user"
55
+ class="aspect-auto"
56
+ />
57
+ {/if}
58
+ </Modal>
59
+ {/if}
60
+
61
+ <button on:click={() => (showModal = true)} disabled={!isClickable}>
62
+ <div class="group relative flex items-center rounded-xl shadow-sm">
63
+ {#if isImage(file.mime)}
64
+ <div class=" overflow-hidden rounded-xl" class:size-24={isPreview} class:size-48={!isPreview}>
65
+ <img
66
+ src={file.type === "base64"
67
+ ? `data:${file.mime};base64,${file.value}`
68
+ : urlNotTrailing + "/output/" + file.value}
69
+ alt={file.name}
70
+ class="h-full w-full bg-gray-200 object-cover dark:bg-gray-800"
71
+ />
72
+ </div>
73
+ {:else if isAudio(file.mime)}
74
+ <AudioPlayer
75
  src={file.type === "base64"
76
  ? `data:${file.mime};base64,${file.value}`
77
+ : urlNotTrailing + "/output/" + file.value}
78
+ name={truncateMiddle(file.name, 28)}
 
79
  />
80
+ {:else if isVideo(file.mime)}
 
 
 
 
81
  <div
82
+ class="border-1 w-72 overflow-clip rounded-xl border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
83
  >
84
+ <!-- svelte-ignore a11y-media-has-caption -->
85
+ <video
86
+ src={file.type === "base64"
87
+ ? `data:${file.mime};base64,${file.value}`
88
+ : urlNotTrailing + "/output/" + file.value}
89
+ controls
90
+ />
91
  </div>
92
+ {:else}
93
+ <div
94
+ 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"
95
+ >
96
+ <div
97
+ class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
98
+ >
99
+ <CarbonDocumentBlank class="text-base text-gray-700 dark:text-gray-300" />
100
+ </div>
101
+ <dl class="flex flex-col truncate leading-tight">
102
+ <dd class="text-sm">
103
+ {truncateMiddle(file.name, 28)}
104
+ </dd>
105
+ <dt class="text-xs text-gray-400">{file.mime.split("/")[1].toUpperCase()}</dt>
106
+ </dl>
107
+ </div>
108
+ {/if}
109
+ <!-- add a button on top that removes the image -->
110
+ {#if canClose}
111
+ <button
112
+ 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"
113
+ on:click={() => dispatch("close")}
114
+ >
115
+ <CarbonClose class=" text-xs text-white" />
116
+ </button>
117
+ {/if}
118
+ </div>
119
+ </button>
src/lib/components/players/AudioPlayer.svelte ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ export let src: string;
3
+ export let name: string;
4
+
5
+ import CarbonPause from "~icons/carbon/pause";
6
+ import CarbonPlay from "~icons/carbon/play";
7
+
8
+ let time = 0;
9
+ let duration = 0;
10
+ let paused = true;
11
+
12
+ function format(time: number) {
13
+ if (isNaN(time)) return "...";
14
+
15
+ const minutes = Math.floor(time / 60);
16
+ const seconds = Math.floor(time % 60);
17
+
18
+ return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`;
19
+ }
20
+
21
+ function seek(e: PointerEvent) {
22
+ if (!e.currentTarget) return;
23
+ const { left, width } = (e.currentTarget as HTMLElement).getBoundingClientRect();
24
+
25
+ let p = (e.clientX - left) / width;
26
+ if (p < 0) p = 0;
27
+ if (p > 1) p = 1;
28
+
29
+ time = p * duration;
30
+ }
31
+ </script>
32
+
33
+ <div
34
+ class="flex h-14 w-72 items-center gap-4 rounded-2xl border border-gray-200 bg-white p-2.5 text-gray-600 shadow-sm transition-all dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"
35
+ >
36
+ <audio
37
+ {src}
38
+ bind:currentTime={time}
39
+ bind:duration
40
+ bind:paused
41
+ preload="metadata"
42
+ on:ended={() => {
43
+ time = 0;
44
+ }}
45
+ />
46
+
47
+ <button
48
+ class="mx-auto my-auto aspect-square size-8 rounded-full border border-gray-400 bg-gray-100 dark:border-gray-800 dark:bg-gray-700"
49
+ aria-label={paused ? "play" : "pause"}
50
+ on:click={() => (paused = !paused)}
51
+ >
52
+ {#if paused}
53
+ <CarbonPlay class="mx-auto my-auto text-gray-600 dark:text-gray-300" />
54
+ {:else}
55
+ <CarbonPause class="mx-auto my-auto text-gray-600 dark:text-gray-300" />
56
+ {/if}
57
+ </button>
58
+
59
+ <div class="overflow-hidden">
60
+ <div class="truncate font-medium">{name}</div>
61
+
62
+ <div class="flex items-center gap-2">
63
+ <span class="text-xs">{format(time)}</span>
64
+ <div
65
+ class="relative h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-700"
66
+ on:pointerdown={() => {
67
+ paused = true;
68
+ }}
69
+ on:pointerup={seek}
70
+ >
71
+ <div
72
+ class="absolute inset-0 h-full bg-gray-400 dark:bg-gray-600"
73
+ style="width: {(time / duration) * 100}%"
74
+ />
75
+ </div>
76
+ <span class="text-xs">{duration ? format(duration) : "--:--"}</span>
77
+ </div>
78
+ </div>
79
+ </div>