Adrien Denat coyotte508 HF staff commited on
Commit
5b779a6
1 Parent(s): 26ccb67

✨ add thumb up/down voting system for messages (#152)

Browse files

* add thumb up/down voting system for messages

* make like/dislike buttons toggle + bind to server

* refactor vote API to better endpoint structure

* set score to undefined rather than 0 when toggled

* throw if message is not found + refactor retry dispatch

* fix undefined class

* Only make the buttons invisible if there's no score

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

* only allow thumb up/down if user is the author of the messages

* always show thumbs up/down when voted

* use MongoDB instead of mutating messages array in code

* fix typings

* fix linting issue

* refactor code to throw before ifs

* add auth logic to vote API endpoint

* lint fix after merge conflict

* on mobile only show thumbs on top + increase spacing between messages

* fix thumbs always showing on mobile

---------

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

src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -9,6 +9,8 @@
9
  import IconLoading from "../icons/IconLoading.svelte";
10
  import CarbonRotate360 from "~icons/carbon/rotate-360";
11
  import CarbonDownload from "~icons/carbon/download";
 
 
12
  import { PUBLIC_SEP_TOKEN } from "$lib/constants/publicSepToken";
13
  import type { Model } from "$lib/types/Model";
14
 
@@ -38,9 +40,14 @@
38
  export let model: Model;
39
  export let message: Message;
40
  export let loading = false;
 
41
  export let readOnly = false;
 
42
 
43
- const dispatch = createEventDispatcher<{ retry: void }>();
 
 
 
44
 
45
  let contentEl: HTMLElement;
46
  let loadingEl: IconLoading;
@@ -85,7 +92,11 @@
85
  </script>
86
 
87
  {#if message.from === "assistant"}
88
- <div class="flex items-start justify-start gap-4 leading-relaxed">
 
 
 
 
89
  <img
90
  alt=""
91
  src="https://huggingface.co/avatars/2edb18bd0206c16b433841a47f53fa8e.svg"
@@ -111,6 +122,38 @@
111
  {/each}
112
  </div>
113
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  </div>
115
  {/if}
116
  {#if message.from === "user"}
 
9
  import IconLoading from "../icons/IconLoading.svelte";
10
  import CarbonRotate360 from "~icons/carbon/rotate-360";
11
  import CarbonDownload from "~icons/carbon/download";
12
+ import CarbonThumbsUp from "~icons/carbon/thumbs-up";
13
+ import CarbonThumbsDown from "~icons/carbon/thumbs-down";
14
  import { PUBLIC_SEP_TOKEN } from "$lib/constants/publicSepToken";
15
  import type { Model } from "$lib/types/Model";
16
 
 
40
  export let model: Model;
41
  export let message: Message;
42
  export let loading = false;
43
+ export let isAuthor = true;
44
  export let readOnly = false;
45
+ export let isTapped = false;
46
 
47
+ const dispatch = createEventDispatcher<{
48
+ retry: { content: string; id: Message["id"] };
49
+ vote: { score: Message["score"]; id: Message["id"] };
50
+ }>();
51
 
52
  let contentEl: HTMLElement;
53
  let loadingEl: IconLoading;
 
92
  </script>
93
 
94
  {#if message.from === "assistant"}
95
+ <div
96
+ class="group relative -mb-8 flex items-start justify-start gap-4 pb-8 leading-relaxed"
97
+ on:click={() => (isTapped = !isTapped)}
98
+ on:keypress={() => (isTapped = !isTapped)}
99
+ >
100
  <img
101
  alt=""
102
  src="https://huggingface.co/avatars/2edb18bd0206c16b433841a47f53fa8e.svg"
 
122
  {/each}
123
  </div>
124
  </div>
125
+ {#if isAuthor && !loading && message.content}
126
+ <div
127
+ class="absolute bottom-1 right-0 flex max-md:transition-all md:bottom-0 md:group-hover:visible md:group-hover:opacity-100
128
+ {message.score ? 'visible opacity-100' : 'invisible max-md:-translate-y-4 max-md:opacity-0'}
129
+ {isTapped ? 'max-md:visible max-md:translate-y-0 max-md:opacity-100' : ''}
130
+ "
131
+ >
132
+ <button
133
+ class="btn rounded-sm p-1 text-sm text-gray-400 focus:ring-0 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300
134
+ {message.score && message.score > 0
135
+ ? 'text-green-500 hover:text-green-500 dark:text-green-400 hover:dark:text-green-400'
136
+ : ''}"
137
+ title={message.score === 1 ? "Remove +1" : "+1"}
138
+ type="button"
139
+ on:click={() => dispatch("vote", { score: message.score === 1 ? 0 : 1, id: message.id })}
140
+ >
141
+ <CarbonThumbsUp class="h-[1.14em] w-[1.14em]" />
142
+ </button>
143
+ <button
144
+ class="btn rounded-sm p-1 text-sm text-gray-400 focus:ring-0 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300
145
+ {message.score && message.score < 0
146
+ ? 'text-red-500 hover:text-red-500 dark:text-red-400 hover:dark:text-red-400'
147
+ : ''}"
148
+ title={message.score === -1 ? "Remove -1" : "-1"}
149
+ type="button"
150
+ on:click={() =>
151
+ dispatch("vote", { score: message.score === -1 ? 0 : -1, id: message.id })}
152
+ >
153
+ <CarbonThumbsDown class="h-[1.14em] w-[1.14em]" />
154
+ </button>
155
+ </div>
156
+ {/if}
157
  </div>
158
  {/if}
159
  {#if message.from === "user"}
src/lib/components/chat/ChatMessages.svelte CHANGED
@@ -2,19 +2,17 @@
2
  import type { Message } from "$lib/types/Message";
3
  import { snapScrollToBottom } from "$lib/actions/snapScrollToBottom";
4
  import ScrollToBottomBtn from "$lib/components/ScrollToBottomBtn.svelte";
5
- import { createEventDispatcher, tick } from "svelte";
6
-
7
- import ChatIntroduction from "./ChatIntroduction.svelte";
8
- import ChatMessage from "./ChatMessage.svelte";
9
  import { randomUUID } from "$lib/utils/randomUuid";
10
  import type { Model } from "$lib/types/Model";
11
  import type { LayoutData } from "../../../routes/$types";
12
-
13
- const dispatch = createEventDispatcher<{ retry: { id: Message["id"]; content: string } }>();
14
 
15
  export let messages: Message[];
16
  export let loading: boolean;
17
  export let pending: boolean;
 
18
  export let currentModel: Model;
19
  export let settings: LayoutData["settings"];
20
  export let models: Model[];
@@ -38,14 +36,16 @@
38
  use:snapScrollToBottom={messages.length ? messages : false}
39
  bind:this={chatContainer}
40
  >
41
- <div class="mx-auto flex h-full max-w-3xl flex-col gap-5 px-5 pt-6 sm:gap-8 xl:max-w-4xl">
42
  {#each messages as message, i}
43
  <ChatMessage
44
  loading={loading && i === messages.length - 1}
45
  {message}
46
- model={currentModel}
47
  {readOnly}
48
- on:retry={() => dispatch("retry", { id: message.id, content: message.content })}
 
 
49
  />
50
  {:else}
51
  <ChatIntroduction {settings} {models} {currentModel} on:message />
@@ -56,7 +56,7 @@
56
  model={currentModel}
57
  />
58
  {/if}
59
- <div class="h-32 flex-none" />
60
  </div>
61
  <ScrollToBottomBtn
62
  class="bottom-36 right-4 max-md:hidden lg:right-10"
 
2
  import type { Message } from "$lib/types/Message";
3
  import { snapScrollToBottom } from "$lib/actions/snapScrollToBottom";
4
  import ScrollToBottomBtn from "$lib/components/ScrollToBottomBtn.svelte";
5
+ import { tick } from "svelte";
 
 
 
6
  import { randomUUID } from "$lib/utils/randomUuid";
7
  import type { Model } from "$lib/types/Model";
8
  import type { LayoutData } from "../../../routes/$types";
9
+ import ChatIntroduction from "./ChatIntroduction.svelte";
10
+ import ChatMessage from "./ChatMessage.svelte";
11
 
12
  export let messages: Message[];
13
  export let loading: boolean;
14
  export let pending: boolean;
15
+ export let isAuthor: boolean;
16
  export let currentModel: Model;
17
  export let settings: LayoutData["settings"];
18
  export let models: Model[];
 
36
  use:snapScrollToBottom={messages.length ? messages : false}
37
  bind:this={chatContainer}
38
  >
39
+ <div class="mx-auto flex h-full max-w-3xl flex-col gap-6 px-5 pt-6 sm:gap-8 xl:max-w-4xl">
40
  {#each messages as message, i}
41
  <ChatMessage
42
  loading={loading && i === messages.length - 1}
43
  {message}
44
+ {isAuthor}
45
  {readOnly}
46
+ model={currentModel}
47
+ on:retry
48
+ on:vote
49
  />
50
  {:else}
51
  <ChatIntroduction {settings} {models} {currentModel} on:message />
 
56
  model={currentModel}
57
  />
58
  {/if}
59
+ <div class="h-36 flex-none" />
60
  </div>
61
  <ScrollToBottomBtn
62
  class="bottom-36 right-4 max-md:hidden lg:right-10"
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -14,6 +14,7 @@
14
  export let messages: Message[] = [];
15
  export let loading = false;
16
  export let pending = false;
 
17
  export let currentModel: Model;
18
  export let models: Model[];
19
  export let settings: LayoutData["settings"];
@@ -45,7 +46,9 @@
45
  {models}
46
  {messages}
47
  readOnly={isReadOnly}
 
48
  on:message
 
49
  on:retry={(ev) => {
50
  if (!loading) dispatch("retry", ev.detail);
51
  }}
 
14
  export let messages: Message[] = [];
15
  export let loading = false;
16
  export let pending = false;
17
+ export let shared = false;
18
  export let currentModel: Model;
19
  export let models: Model[];
20
  export let settings: LayoutData["settings"];
 
46
  {models}
47
  {messages}
48
  readOnly={isReadOnly}
49
+ isAuthor={!shared}
50
  on:message
51
+ on:vote
52
  on:retry={(ev) => {
53
  if (!loading) dispatch("retry", ev.detail);
54
  }}
src/lib/types/Message.ts CHANGED
@@ -2,4 +2,5 @@ export interface Message {
2
  from: "user" | "assistant";
3
  id: ReturnType<typeof crypto.randomUUID>;
4
  content: string;
 
5
  }
 
2
  from: "user" | "assistant";
3
  id: ReturnType<typeof crypto.randomUUID>;
4
  content: string;
5
+ score?: -1 | 0 | 1;
6
  }
src/routes/conversation/[id]/+page.svelte CHANGED
@@ -12,6 +12,7 @@
12
  import { ERROR_MESSAGES, error } from "$lib/stores/errors";
13
  import { randomUUID } from "$lib/utils/randomUuid";
14
  import { findCurrentModel } from "$lib/utils/models";
 
15
 
16
  export let data;
17
 
@@ -29,7 +30,7 @@
29
  let pending = false;
30
 
31
  async function getTextGenerationStream(inputs: string, messageId: string, isRetry = false) {
32
- let conversationId = $page.params.id;
33
 
34
  const response = textGenerationStream(
35
  {
@@ -147,6 +148,32 @@
147
  }
148
  }
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  onMount(async () => {
151
  if ($pendingMessage) {
152
  const val = $pendingMessage;
@@ -169,8 +196,9 @@
169
  {loading}
170
  {pending}
171
  {messages}
172
- on:message={(message) => writeMessage(message.detail)}
173
- on:retry={(message) => writeMessage(message.detail.content, message.detail.id)}
 
174
  on:share={() => shareConversation($page.params.id, data.title)}
175
  on:stop={() => (isAborted = true)}
176
  models={data.models}
 
12
  import { ERROR_MESSAGES, error } from "$lib/stores/errors";
13
  import { randomUUID } from "$lib/utils/randomUuid";
14
  import { findCurrentModel } from "$lib/utils/models";
15
+ import type { Message } from "$lib/types/Message";
16
 
17
  export let data;
18
 
 
30
  let pending = false;
31
 
32
  async function getTextGenerationStream(inputs: string, messageId: string, isRetry = false) {
33
+ const conversationId = $page.params.id;
34
 
35
  const response = textGenerationStream(
36
  {
 
148
  }
149
  }
150
 
151
+ async function voteMessage(score: Message["score"], messageId: string) {
152
+ let conversationId = $page.params.id;
153
+ let oldScore: Message["score"] | undefined;
154
+
155
+ // optimistic update to avoid waiting for the server
156
+ messages = messages.map((message) => {
157
+ if (message.id === messageId) {
158
+ oldScore = message.score;
159
+ return { ...message, score: score };
160
+ }
161
+ return message;
162
+ });
163
+
164
+ try {
165
+ await fetch(`${base}/conversation/${conversationId}/message/${messageId}/vote`, {
166
+ method: "POST",
167
+ body: JSON.stringify({ score }),
168
+ });
169
+ } catch {
170
+ // revert score on any error
171
+ messages = messages.map((message) => {
172
+ return message.id !== messageId ? message : { ...message, score: oldScore };
173
+ });
174
+ }
175
+ }
176
+
177
  onMount(async () => {
178
  if ($pendingMessage) {
179
  const val = $pendingMessage;
 
196
  {loading}
197
  {pending}
198
  {messages}
199
+ on:message={(event) => writeMessage(event.detail)}
200
+ on:retry={(event) => writeMessage(event.detail.content, event.detail.id)}
201
+ on:vote={(event) => voteMessage(event.detail.score, event.detail.id)}
202
  on:share={() => shareConversation($page.params.id, data.title)}
203
  on:stop={() => (isAborted = true)}
204
  models={data.models}
src/routes/conversation/[id]/message/[messageId]/vote/+server.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { authCondition } from "$lib/server/auth";
2
+ import { collections } from "$lib/server/database";
3
+ import { error } from "@sveltejs/kit";
4
+ import { ObjectId } from "mongodb";
5
+ import { z } from "zod";
6
+
7
+ export async function POST({ params, request, locals }) {
8
+ const { score } = z
9
+ .object({
10
+ score: z.number().int().min(-1).max(1),
11
+ })
12
+ .parse(await request.json());
13
+ const conversationId = new ObjectId(params.id);
14
+ const messageId = params.messageId;
15
+
16
+ const document = await collections.conversations.updateOne(
17
+ {
18
+ _id: conversationId,
19
+ ...authCondition(locals),
20
+ "messages.id": messageId,
21
+ },
22
+ {
23
+ ...(score !== 0
24
+ ? {
25
+ $set: {
26
+ "messages.$.score": score,
27
+ },
28
+ }
29
+ : { $unset: { "messages.$.score": "" } }),
30
+ }
31
+ );
32
+
33
+ if (!document.matchedCount) {
34
+ throw error(404, "Message not found");
35
+ }
36
+
37
+ return new Response();
38
+ }
src/routes/r/[id]/+page.svelte CHANGED
@@ -55,6 +55,9 @@
55
  </svelte:head>
56
 
57
  <ChatWindow
 
 
 
58
  on:message={(ev) =>
59
  createConversation()
60
  .then((convId) => {
@@ -71,9 +74,7 @@
71
  return goto(`${base}/conversation/${convId}`, { invalidateAll: true });
72
  })
73
  .finally(() => (loading = false))}
74
- messages={data.messages}
75
  models={data.models}
76
  currentModel={findCurrentModel(data.models, data.model)}
77
  settings={data.settings}
78
- {loading}
79
  />
 
55
  </svelte:head>
56
 
57
  <ChatWindow
58
+ {loading}
59
+ shared={true}
60
+ messages={data.messages}
61
  on:message={(ev) =>
62
  createConversation()
63
  .then((convId) => {
 
74
  return goto(`${base}/conversation/${convId}`, { invalidateAll: true });
75
  })
76
  .finally(() => (loading = false))}
 
77
  models={data.models}
78
  currentModel={findCurrentModel(data.models, data.model)}
79
  settings={data.settings}
 
80
  />