✨ 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. <[email protected]>
* 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 <[email protected]>
- src/lib/components/chat/ChatMessage.svelte +45 -2
- src/lib/components/chat/ChatMessages.svelte +10 -10
- src/lib/components/chat/ChatWindow.svelte +3 -0
- src/lib/types/Message.ts +1 -0
- src/routes/conversation/[id]/+page.svelte +31 -3
- src/routes/conversation/[id]/message/[messageId]/vote/+server.ts +38 -0
- src/routes/r/[id]/+page.svelte +3 -2
|
@@ -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<{
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
let contentEl: HTMLElement;
|
| 46 |
let loadingEl: IconLoading;
|
|
@@ -85,7 +92,11 @@
|
|
| 85 |
</script>
|
| 86 |
|
| 87 |
{#if message.from === "assistant"}
|
| 88 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"}
|
|
@@ -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 {
|
| 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 |
-
|
| 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-
|
| 42 |
{#each messages as message, i}
|
| 43 |
<ChatMessage
|
| 44 |
loading={loading && i === messages.length - 1}
|
| 45 |
{message}
|
| 46 |
-
|
| 47 |
{readOnly}
|
| 48 |
-
|
|
|
|
|
|
|
| 49 |
/>
|
| 50 |
{:else}
|
| 51 |
<ChatIntroduction {settings} {models} {currentModel} on:message />
|
|
@@ -56,7 +56,7 @@
|
|
| 56 |
model={currentModel}
|
| 57 |
/>
|
| 58 |
{/if}
|
| 59 |
-
<div class="h-
|
| 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"
|
|
@@ -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 |
}}
|
|
@@ -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 |
}
|
|
@@ -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 |
-
|
| 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={(
|
| 173 |
-
on:retry={(
|
|
|
|
| 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}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
/>
|