nsarrazin's picture
nsarrazin HF staff
Conversation trees (#223) (#807)
e6addfc unverified
raw
history blame
13.2 kB
<script lang="ts">
import ChatWindow from "$lib/components/chat/ChatWindow.svelte";
import { pendingMessage } from "$lib/stores/pendingMessage";
import { isAborted } from "$lib/stores/isAborted";
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto, invalidate } from "$app/navigation";
import { base } from "$app/paths";
import { shareConversation } from "$lib/shareConversation";
import { UrlDependency } from "$lib/types/UrlDependency";
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
import { findCurrentModel } from "$lib/utils/models";
import { webSearchParameters } from "$lib/stores/webSearchParameters";
import type { Message } from "$lib/types/Message";
import type { MessageUpdate } from "$lib/types/MessageUpdate";
import titleUpdate from "$lib/stores/titleUpdate";
import file2base64 from "$lib/utils/file2base64";
import { addChildren } from "$lib/utils/tree/addChildren";
import { addSibling } from "$lib/utils/tree/addSibling";
import { createConvTreeStore } from "$lib/stores/convTree";
export let data;
let messages = data.messages;
let lastLoadedMessages = data.messages;
// Since we modify the messages array locally, we don't want to reset it if an old version is passed
$: if (data.messages !== lastLoadedMessages) {
messages = data.messages;
lastLoadedMessages = data.messages;
}
let loading = false;
let pending = false;
let files: File[] = [];
async function convFromShared() {
try {
loading = true;
const res = await fetch(`${base}/conversation`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
fromShare: $page.params.id,
model: data.model,
}),
});
if (!res.ok) {
error.set("Error while creating conversation, try again.");
console.error("Error while creating conversation: " + (await res.text()));
return;
}
const { conversationId } = await res.json();
return conversationId;
} catch (err) {
error.set(ERROR_MESSAGES.default);
console.error(String(err));
throw err;
}
}
// this function is used to send new message to the backends
async function writeMessage({
prompt,
messageId = $convTreeStore.leaf ?? undefined,
isRetry = false,
isContinue = false,
}: {
prompt?: string;
messageId?: ReturnType<typeof crypto.randomUUID>;
isRetry?: boolean;
isContinue?: boolean;
}): Promise<void> {
try {
$isAborted = false;
loading = true;
pending = true;
const module = await import("browser-image-resizer");
// currently, only IDEFICS is supported by TGI
// the size of images is hardcoded to 224x224 in TGI
// this will need to be configurable when support for more models is added
const resizedImages = await Promise.all(
files.map(async (file) => {
return await module
.readAndCompressImage(file, {
maxHeight: 224,
maxWidth: 224,
quality: 1,
})
.then(async (el) => await file2base64(el as File));
})
);
let messageToWriteToId: Message["id"] | undefined = undefined;
// used for building the prompt, subtree of the conversation that goes from the latest message to the root
if (isContinue && messageId) {
if ((messages.find((msg) => msg.id === messageId)?.children?.length ?? 0) > 0) {
$error = "Can only continue the last message";
} else {
messageToWriteToId = messageId;
}
} else if (isRetry && messageId) {
// two cases, if we're retrying a user message with a newPrompt set,
// it means we're editing a user message
// if we're retrying on an assistant message, newPrompt cannot be set
// it means we're retrying the last assistant message for a new answer
const messageToRetry = messages.find((message) => message.id === messageId);
if (!messageToRetry) {
$error = "Message not found";
}
if (messageToRetry?.from === "user" && prompt) {
// add a sibling to this message from the user, with the alternative prompt
// add a children to that sibling, where we can write to
const newUserMessageId = addSibling(
{
messages,
rootMessageId: data.rootMessageId,
},
{ from: "user", content: prompt },
messageId
);
messageToWriteToId = addChildren(
{
messages,
rootMessageId: data.rootMessageId,
},
{ from: "assistant", content: "", files: resizedImages },
newUserMessageId
);
} else if (messageToRetry?.from === "assistant") {
// we're retrying an assistant message, to generate a new answer
// just add a sibling to the assistant answer where we can write to
messageToWriteToId = addSibling(
{
messages,
rootMessageId: data.rootMessageId,
},
{ from: "assistant", content: "" },
messageId
);
}
} else {
// just a normal linear conversation, so we add the user message
// and the blank assistant message back to back
const newUserMessageId = addChildren(
{
messages,
rootMessageId: data.rootMessageId,
},
{
from: "user",
content: prompt ?? "",
files: resizedImages,
createdAt: new Date(),
updatedAt: new Date(),
},
messageId
);
if (!data.rootMessageId) {
data.rootMessageId = newUserMessageId;
}
messageToWriteToId = addChildren(
{
messages,
rootMessageId: data.rootMessageId,
},
{
from: "assistant",
content: "",
createdAt: new Date(),
updatedAt: new Date(),
},
newUserMessageId
);
}
messages = [...messages];
const messageToWriteTo = messages.find((message) => message.id === messageToWriteToId);
if (!messageToWriteTo) {
throw new Error("Message to write to not found");
}
// disable websearch if assistant is present
const hasAssistant = !!$page.data.assistant;
const response = await fetch(`${base}/conversation/${$page.params.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inputs: prompt,
id: messageId,
is_retry: isRetry,
is_continue: isContinue,
web_search: !hasAssistant && $webSearchParameters.useSearch,
files: isRetry ? undefined : resizedImages,
}),
});
files = [];
if (!response.body) {
throw new Error("Body not defined");
}
if (!response.ok) {
error.set((await response.json())?.message);
return;
}
// eslint-disable-next-line no-undef
const encoder = new TextDecoderStream();
const reader = response?.body?.pipeThrough(encoder).getReader();
let finalAnswer = "";
const messageUpdates: MessageUpdate[] = [];
// set str queue
// ex) if the last response is => {"type": "stream", "token":
// It should be => {"type": "stream", "token": "Hello"} = prev_input_chunk + "Hello"}
let prev_input_chunk = [""];
// this is a bit ugly
// we read the stream until we get the final answer
while (finalAnswer === "") {
// check for abort
if ($isAborted) {
reader?.cancel();
break;
}
// if there is something to read
await reader?.read().then(async ({ done, value }) => {
// we read, if it's done we cancel
if (done) {
reader.cancel();
return;
}
if (!value) {
return;
}
value = prev_input_chunk.pop() + value;
// if it's not done we parse the value, which contains all messages
const inputs = value.split("\n");
inputs.forEach(async (el: string) => {
try {
const update = JSON.parse(el) as MessageUpdate;
if (update.type !== "stream") {
messageUpdates.push(update);
}
if (update.type === "finalAnswer") {
finalAnswer = update.text;
reader.cancel();
loading = false;
pending = false;
invalidate(UrlDependency.Conversation);
} else if (update.type === "stream") {
pending = false;
messageToWriteTo.content += update.token;
messages = [...messages];
} else if (update.type === "webSearch") {
messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
messages = [...messages];
} else if (update.type === "status") {
if (update.status === "title" && update.message) {
const convInData = data.conversations.find(({ id }) => id === $page.params.id);
if (convInData) {
convInData.title = update.message;
$titleUpdate = {
title: update.message,
convId: $page.params.id,
};
}
} else if (update.status === "error") {
$error = update.message ?? "An error has occurred";
}
} else if (update.type === "error") {
error.set(update.message);
reader.cancel();
}
} catch (parseError) {
// in case of parsing error we wait for the next message
if (el === inputs[inputs.length - 1]) {
prev_input_chunk.push(el);
}
return;
}
});
});
}
messageToWriteTo.updates = messageUpdates;
await invalidate(UrlDependency.ConversationList);
} catch (err) {
if (err instanceof Error && err.message.includes("overloaded")) {
$error = "Too much traffic, please try again.";
} else if (err instanceof Error && err.message.includes("429")) {
$error = ERROR_MESSAGES.rateLimited;
} else if (err instanceof Error) {
$error = err.message;
} else {
$error = ERROR_MESSAGES.default;
}
console.error(err);
} finally {
loading = false;
pending = false;
await invalidate(UrlDependency.Conversation);
}
}
async function voteMessage(score: Message["score"], messageId: string) {
let conversationId = $page.params.id;
let oldScore: Message["score"] | undefined;
// optimistic update to avoid waiting for the server
messages = messages.map((message) => {
if (message.id === messageId) {
oldScore = message.score;
return { ...message, score };
}
return message;
});
try {
await fetch(`${base}/conversation/${conversationId}/message/${messageId}/vote`, {
method: "POST",
body: JSON.stringify({ score }),
});
} catch {
// revert score on any error
messages = messages.map((message) => {
return message.id !== messageId ? message : { ...message, score: oldScore };
});
}
}
onMount(async () => {
// only used in case of creating new conversations (from the parent POST endpoint)
if ($pendingMessage) {
files = $pendingMessage.files;
await writeMessage({ prompt: $pendingMessage.content });
$pendingMessage = undefined;
}
});
async function onMessage(event: CustomEvent<string>) {
if (!data.shared) {
await writeMessage({ prompt: event.detail });
} else {
await convFromShared()
.then(async (convId) => {
await goto(`${base}/conversation/${convId}`, { invalidateAll: true });
})
.then(async () => await writeMessage({ prompt: event.detail }))
.finally(() => (loading = false));
}
}
async function onRetry(event: CustomEvent<{ id: Message["id"]; content?: string }>) {
if (!data.shared) {
await writeMessage({
prompt: event.detail.content,
messageId: event.detail.id,
isRetry: true,
});
} else {
await convFromShared()
.then(async (convId) => {
await goto(`${base}/conversation/${convId}`, { invalidateAll: true });
})
.then(
async () =>
await writeMessage({
prompt: event.detail.content,
messageId: event.detail.id,
isRetry: true,
})
)
.finally(() => (loading = false));
}
}
async function onContinue(event: CustomEvent<{ id: Message["id"] }>) {
if (!data.shared) {
writeMessage({ messageId: event.detail.id, isContinue: true });
} else {
await convFromShared()
.then(async (convId) => {
await goto(`${base}/conversation/${convId}`, { invalidateAll: true });
})
.then(
async () =>
await writeMessage({
messageId: event.detail.id,
isContinue: true,
})
)
.finally(() => (loading = false));
}
}
$: $page.params.id, (($isAborted = true), (loading = false), ($convTreeStore.editing = null));
$: title = data.conversations.find((conv) => conv.id === $page.params.id)?.title ?? data.title;
const convTreeStore = createConvTreeStore();
</script>
<svelte:head>
<title>{title}</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css"
integrity="sha384-GvrOXuhMATgEsSwCs4smul74iXGOixntILdUW9XmUC6+HX0sLNAK3q71HotJqlAn"
crossorigin="anonymous"
/>
</svelte:head>
<ChatWindow
{loading}
{pending}
{messages}
shared={data.shared}
preprompt={data.preprompt}
bind:files
on:message={onMessage}
on:retry={onRetry}
on:continue={onContinue}
on:vote={(event) => voteMessage(event.detail.score, event.detail.id)}
on:share={() => shareConversation($page.params.id, data.title)}
on:stop={() => (($isAborted = true), (loading = false))}
models={data.models}
currentModel={findCurrentModel([...data.models, ...data.oldModels], data.model)}
assistant={data.assistant}
/>