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 +41 -10
- src/lib/components/chat/ChatMessages.svelte +7 -2
- src/lib/components/chat/ChatWindow.svelte +7 -10
- src/lib/components/icons/IconLoading.svelte +31 -0
- src/lib/utils/dom.ts +7 -0
- src/routes/+page.svelte +1 -1
- src/routes/conversation/[id]/+page.svelte +5 -1
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('<', '<');
|
9 |
}
|
10 |
|
11 |
export let message: Message;
|
12 |
-
let
|
|
|
|
|
|
|
|
|
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="
|
31 |
-
bind:this={el}
|
32 |
>
|
33 |
-
{#
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
{
|
39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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('<', '<');
|
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)}
|
|
|
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
|
|
|
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)} />
|