Adrien Denat coyotte508 HF staff commited on
Commit
3aa8136
1 Parent(s): 7b62aec

add loading icon + pending state when assistant message is pending (#48)

Browse files

* add loading icon + pending state when assistant message is pending

* remove dead code

Co-authored-by: Eliott C. <coyotte508@gmail.com>

* add loading to messages if a token takes a while to come

* add dom utils

---------

Co-authored-by: Eliott C. <coyotte508@gmail.com>

src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -1,15 +1,22 @@
1
  <script lang="ts">
2
  import { marked } from 'marked';
3
  import type { Message } from '$lib/types/Message';
 
 
4
 
5
  import CodeBlock from '../CodeBlock.svelte';
 
6
 
7
  function sanitizeMd(md: string) {
8
  return md.replaceAll('<', '&lt;');
9
  }
10
 
11
  export let message: Message;
12
- let el: HTMLElement;
 
 
 
 
13
 
14
  const options: marked.MarkedOptions = {
15
  ...marked.getDefaults(),
@@ -17,6 +24,23 @@
17
  };
18
 
19
  $: tokens = marked.lexer(sanitizeMd(message.content));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  </script>
21
 
22
  {#if message.from === 'assistant'}
@@ -27,16 +51,23 @@
27
  class="mt-5 w-3 h-3 flex-none rounded-full shadow-lg"
28
  />
29
  <div
30
- class="prose dark:prose-invert :prose-pre:bg-gray-100 dark:prose-pre:bg-gray-950 relative rounded-2xl px-5 py-3.5 border border-gray-100 bg-gradient-to-br from-gray-50 dark:from-gray-800/40 dark:border-gray-800 text-gray-600 dark:text-gray-300"
31
- bind:this={el}
32
  >
33
- {#each tokens as token}
34
- {#if token.type === 'code'}
35
- <CodeBlock lang={token.lang} code={token.text} />
36
- {:else}
37
- {@html marked.parser([token], options)}
38
- {/if}
39
- {/each}
 
 
 
 
 
 
 
 
40
  </div>
41
  </div>
42
  {/if}
 
1
  <script lang="ts">
2
  import { marked } from 'marked';
3
  import type { Message } from '$lib/types/Message';
4
+ import { afterUpdate } from 'svelte';
5
+ import { deepestChild } from '$lib/utils/dom';
6
 
7
  import CodeBlock from '../CodeBlock.svelte';
8
+ import IconLoading from '../icons/IconLoading.svelte';
9
 
10
  function sanitizeMd(md: string) {
11
  return md.replaceAll('<', '&lt;');
12
  }
13
 
14
  export let message: Message;
15
+ export let loading: boolean = false;
16
+
17
+ let contentEl: HTMLElement;
18
+ let loadingEl: any;
19
+ let pendingTimeout: NodeJS.Timeout;
20
 
21
  const options: marked.MarkedOptions = {
22
  ...marked.getDefaults(),
 
24
  };
25
 
26
  $: tokens = marked.lexer(sanitizeMd(message.content));
27
+
28
+ afterUpdate(() => {
29
+ loadingEl?.$destroy();
30
+ clearTimeout(pendingTimeout);
31
+
32
+ // Add loading animation to the last message if update takes more than 600ms
33
+ if (loading) {
34
+ pendingTimeout = setTimeout(() => {
35
+ if (contentEl) {
36
+ loadingEl = new IconLoading({
37
+ target: deepestChild(contentEl),
38
+ props: { classNames: 'loading inline ml-2' }
39
+ });
40
+ }
41
+ }, 600);
42
+ }
43
+ });
44
  </script>
45
 
46
  {#if message.from === 'assistant'}
 
51
  class="mt-5 w-3 h-3 flex-none rounded-full shadow-lg"
52
  />
53
  <div
54
+ class="relative rounded-2xl px-5 py-3.5 border border-gray-100 bg-gradient-to-br from-gray-50 dark:from-gray-800/40 dark:border-gray-800 text-gray-600 dark:text-gray-300 min-h-[calc(2rem+theme(spacing[3.5])*2)] min-w-[100px]"
 
55
  >
56
+ {#if !message.content}
57
+ <IconLoading classNames="absolute inset-0 m-auto" />
58
+ {/if}
59
+ <div
60
+ class="prose dark:prose-invert :prose-pre:bg-gray-100 dark:prose-pre:bg-gray-950"
61
+ bind:this={contentEl}
62
+ >
63
+ {#each tokens as token}
64
+ {#if token.type === 'code'}
65
+ <CodeBlock lang={token.lang} code={token.text} />
66
+ {:else}
67
+ {@html marked.parser([token], options)}
68
+ {/if}
69
+ {/each}
70
+ </div>
71
  </div>
72
  </div>
73
  {/if}
src/lib/components/chat/ChatMessages.svelte CHANGED
@@ -6,17 +6,22 @@
6
  import ChatMessage from './ChatMessage.svelte';
7
 
8
  export let messages: Message[];
 
 
9
 
10
  let chatContainer: HTMLElement;
11
  </script>
12
 
13
  <div class="overflow-y-auto h-full" use:snapScrollToBottom={messages} bind:this={chatContainer}>
14
  <div class="max-w-3xl xl:max-w-4xl mx-auto px-5 pt-6 flex flex-col gap-8 h-full">
15
- {#each messages as message}
16
- <ChatMessage {message} />
17
  {:else}
18
  <ChatIntroduction on:message />
19
  {/each}
 
 
 
20
  <div class="h-32 flex-none" />
21
  </div>
22
  <ScrollToBottomBtn class="bottom-10 right-12" scrollNode={chatContainer} />
 
6
  import ChatMessage from './ChatMessage.svelte';
7
 
8
  export let messages: Message[];
9
+ export let loading: boolean;
10
+ export let pending: boolean;
11
 
12
  let chatContainer: HTMLElement;
13
  </script>
14
 
15
  <div class="overflow-y-auto h-full" use:snapScrollToBottom={messages} bind:this={chatContainer}>
16
  <div class="max-w-3xl xl:max-w-4xl mx-auto px-5 pt-6 flex flex-col gap-8 h-full">
17
+ {#each messages as message, i}
18
+ <ChatMessage loading={loading && i === messages.length - 1} {message} />
19
  {:else}
20
  <ChatIntroduction on:message />
21
  {/each}
22
+ {#if pending}
23
+ <ChatMessage message={{ from: 'assistant', content: '' }} />
24
+ {/if}
25
  <div class="h-32 flex-none" />
26
  </div>
27
  <ScrollToBottomBtn class="bottom-10 right-12" scrollNode={chatContainer} />
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -8,7 +8,9 @@
8
  import ChatInput from './ChatInput.svelte';
9
 
10
  export let messages: Message[] = [];
11
- export let disabled: boolean;
 
 
12
 
13
  let message: string;
14
 
@@ -21,28 +23,23 @@
21
  <button>New Chat</button>
22
  <button>+</button>
23
  </nav>
24
- <ChatMessages {messages} on:message />
25
  <div
26
  class="flex max-md:border-t dark:border-gray-800 items-center max-md:dark:bg-gray-900 max-md:bg-white bg-gradient-to-t from-white dark:from-gray-900 to-transparent justify-center absolute inset-x-0 max-w-3xl xl:max-w-4xl mx-auto px-5 bottom-0 py-4 md:py-8 w-full"
27
  >
28
  <form
29
  on:submit|preventDefault={() => {
 
30
  dispatch('message', message);
31
  message = '';
32
  }}
33
  class="w-full relative flex items-center rounded-xl flex-1 max-w-4xl border bg-gray-100 focus-within:border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:focus-within:border-gray-500 transition-all"
34
  >
35
  <div class="w-full flex flex-1 border-none bg-transparent">
36
- <ChatInput
37
- placeholder="Ask anything"
38
- bind:value={message}
39
- {disabled}
40
- autofocus
41
- maxRows={10}
42
- />
43
  <button
44
  class="p-1 px-[0.7rem] group self-end my-1 h-[2.4rem] rounded-lg hover:bg-gray-100 enabled:dark:hover:text-gray-400 dark:hover:bg-gray-900 disabled:hover:bg-transparent dark:disabled:hover:bg-transparent disabled:opacity-60 dark:disabled:opacity-40 flex-shrink-0 transition-all mx-1"
45
- disabled={!message || disabled}
46
  type="submit"
47
  >
48
  <CarbonSendAltFilled
 
8
  import ChatInput from './ChatInput.svelte';
9
 
10
  export let messages: Message[] = [];
11
+ export let disabled: boolean = false;
12
+ export let loading: boolean = false;
13
+ export let pending: boolean = false;
14
 
15
  let message: string;
16
 
 
23
  <button>New Chat</button>
24
  <button>+</button>
25
  </nav>
26
+ <ChatMessages {loading} {pending} {messages} on:message />
27
  <div
28
  class="flex max-md:border-t dark:border-gray-800 items-center max-md:dark:bg-gray-900 max-md:bg-white bg-gradient-to-t from-white dark:from-gray-900 to-transparent justify-center absolute inset-x-0 max-w-3xl xl:max-w-4xl mx-auto px-5 bottom-0 py-4 md:py-8 w-full"
29
  >
30
  <form
31
  on:submit|preventDefault={() => {
32
+ if (loading) return;
33
  dispatch('message', message);
34
  message = '';
35
  }}
36
  class="w-full relative flex items-center rounded-xl flex-1 max-w-4xl border bg-gray-100 focus-within:border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:focus-within:border-gray-500 transition-all"
37
  >
38
  <div class="w-full flex flex-1 border-none bg-transparent">
39
+ <ChatInput placeholder="Ask anything" bind:value={message} autofocus maxRows={10} />
 
 
 
 
 
 
40
  <button
41
  class="p-1 px-[0.7rem] group self-end my-1 h-[2.4rem] rounded-lg hover:bg-gray-100 enabled:dark:hover:text-gray-400 dark:hover:bg-gray-900 disabled:hover:bg-transparent dark:disabled:hover:bg-transparent disabled:opacity-60 dark:disabled:opacity-40 flex-shrink-0 transition-all mx-1"
42
+ disabled={!message || loading || disabled}
43
  type="submit"
44
  >
45
  <CarbonSendAltFilled
src/lib/components/icons/IconLoading.svelte ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ export let classNames: string = '';
3
+ </script>
4
+
5
+ <svg
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ width="40px"
8
+ height="25px"
9
+ viewBox="0 0 60 40"
10
+ preserveAspectRatio="xMidYMid"
11
+ class={classNames}
12
+ >
13
+ {#each Array(3) as _, index}
14
+ <g transform={`translate(${20 * index + 10} 20)`}>
15
+ {index}
16
+ <circle cx="0" cy="0" r="6" fill="currentColor">
17
+ <animateTransform
18
+ attributeName="transform"
19
+ type="scale"
20
+ begin={`${-0.375 + 0.15 * index}s`}
21
+ calcMode="spline"
22
+ keySplines="0.3 0 0.7 1;0.3 0 0.7 1"
23
+ values="0.5;1;0.5"
24
+ keyTimes="0;0.5;1"
25
+ dur="1s"
26
+ repeatCount="indefinite"
27
+ />
28
+ </circle>
29
+ </g>
30
+ {/each}
31
+ </svg>
src/lib/utils/dom.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export function deepestChild(el: HTMLElement) {
2
+ let newEl = el;
3
+ while (newEl.hasChildNodes()) {
4
+ newEl = newEl.lastElementChild as HTMLElement;
5
+ }
6
+ return newEl;
7
+ }
src/routes/+page.svelte CHANGED
@@ -34,4 +34,4 @@
34
  }
35
  </script>
36
 
37
- <ChatWindow on:message={(ev) => createConversation(ev.detail)} disabled={loading} />
 
34
  }
35
  </script>
36
 
37
+ <ChatWindow on:message={(ev) => createConversation(ev.detail)} {loading} />
src/routes/conversation/[id]/+page.svelte CHANGED
@@ -14,6 +14,7 @@
14
  const hf = new HfInference();
15
 
16
  let loading = false;
 
17
 
18
  async function getTextGenerationStream(inputs: string) {
19
  const response = hf.endpoint($page.url.href).textGenerationStream(
@@ -39,6 +40,8 @@
39
  );
40
 
41
  for await (const data of response) {
 
 
42
  if (!data) break;
43
 
44
  if (!data.token.special) {
@@ -60,6 +63,7 @@
60
 
61
  try {
62
  loading = true;
 
63
 
64
  messages = [...messages, { from: 'user', content: message }];
65
 
@@ -84,4 +88,4 @@
84
  });
85
  </script>
86
 
87
- <ChatWindow disabled={loading} {messages} on:message={(message) => writeMessage(message.detail)} />
 
14
  const hf = new HfInference();
15
 
16
  let loading = false;
17
+ let pending = false;
18
 
19
  async function getTextGenerationStream(inputs: string) {
20
  const response = hf.endpoint($page.url.href).textGenerationStream(
 
40
  );
41
 
42
  for await (const data of response) {
43
+ pending = false;
44
+
45
  if (!data) break;
46
 
47
  if (!data.token.special) {
 
63
 
64
  try {
65
  loading = true;
66
+ pending = true;
67
 
68
  messages = [...messages, { from: 'user', content: message }];
69
 
 
88
  });
89
  </script>
90
 
91
+ <ChatWindow {loading} {pending} {messages} on:message={(message) => writeMessage(message.detail)} />