nsarrazin HF staff victor HF staff commited on
Commit
e6addfc
1 Parent(s): 89ae59b

Conversation trees (#223) (#807)

Browse files

* work on branching

* branching

* work on input field

* wip

* wip

* helper stuff

* pass tests

* add type guards and clean up type requirements

* fixed shared conv type

* more helpers stuff

* clean up code

* addChildren helper

* addSibling

* add test for addSibling & refacto addChildren tests

* backend work pt. 1

* backend done

* add children property to messages for easier frontend rendering

* fix message id type rail

* front-end work on simple linear conversation

* fix title generation

* convert conversations on post

* server side retry works with branching

* clean up buttons

* fix retry feature backend

* Send new messages in any subtree

* make edit previous prompts feature work

* fix padding

* revert unneeded changes

* bring back pending message

* fix front-end streaming

* fix initial message

* Revert "fix initial message"

This reverts commit 6257fe8789c52a20e7506aa453efda55c16e95bb.

* Fix bug subtree state refresh

* fix continue feature on shared conversations

* fix websearch updates

* Fix first message streaming

* fix fornt-end websearch updates

* fix loading icon

* fix bottom padding

* move children nav to below the message

* Show current message in continue & retry

* children nav styling

* you can now edit the first message

* bottom padding on assistant message

* fix test

* lint

* use <form>

* tree navigation

* misc

* mobile: hide download link

* forgot to implem continue feature lol

* fix continue feature on llama 70b

* fix edit mode

* disable submit button & nav when loading

* fix bug when interrupting

* hide arrows in edit mode

* forgot to reset edit mode when submitting retry

* reset editing when switching conversations

---------

Co-authored-by: Victor Mustar <victor.mustar@gmail.com>

Files changed (36) hide show
  1. .env.template +1 -5
  2. src/lib/buildPrompt.ts +19 -82
  3. src/lib/components/chat/ChatMessage.svelte +196 -45
  4. src/lib/components/chat/ChatMessages.svelte +0 -106
  5. src/lib/components/chat/ChatWindow.svelte +115 -40
  6. src/lib/server/database.ts +6 -0
  7. src/lib/server/endpoints/aws/endpointAws.ts +4 -4
  8. src/lib/server/endpoints/endpoints.ts +4 -7
  9. src/lib/server/endpoints/llamacpp/endpointLlamacpp.ts +4 -4
  10. src/lib/server/endpoints/ollama/endpointOllama.ts +4 -4
  11. src/lib/server/endpoints/openai/endpointOai.ts +16 -43
  12. src/lib/server/endpoints/tgi/endpointTgi.ts +5 -16
  13. src/lib/server/generateFromDefaultEndpoint.ts +1 -1
  14. src/lib/server/preprocessMessages.ts +57 -0
  15. src/lib/server/websearch/runWebSearch.ts +2 -5
  16. src/lib/stores/convTree.ts +25 -0
  17. src/lib/types/Conversation.ts +1 -0
  18. src/lib/types/Message.ts +7 -1
  19. src/lib/types/SharedConversation.ts +14 -14
  20. src/lib/utils/tree/addChildren.spec.ts +102 -0
  21. src/lib/utils/tree/addChildren.ts +52 -0
  22. src/lib/utils/tree/addSibling.spec.ts +80 -0
  23. src/lib/utils/tree/addSibling.ts +45 -0
  24. src/lib/utils/tree/buildSubtree.spec.ts +110 -0
  25. src/lib/utils/tree/buildSubtree.ts +28 -0
  26. src/lib/utils/tree/convertLegacyConversation.spec.ts +31 -0
  27. src/lib/utils/tree/convertLegacyConversation.ts +35 -0
  28. src/lib/utils/tree/isMessageId.spec.ts +14 -0
  29. src/lib/utils/tree/isMessageId.ts +5 -0
  30. src/lib/utils/tree/treeHelpers.spec.ts +164 -0
  31. src/routes/conversation/+server.ts +15 -1
  32. src/routes/conversation/[id]/+page.server.ts +10 -6
  33. src/routes/conversation/[id]/+page.svelte +118 -67
  34. src/routes/conversation/[id]/+server.ts +154 -112
  35. src/routes/conversation/[id]/message/[messageId]/prompt/+server.ts +4 -3
  36. src/routes/conversation/[id]/share/+server.ts +3 -2
.env.template CHANGED
@@ -33,10 +33,6 @@ MODELS=`[
33
  "name": "meta-llama/Llama-2-70b-chat-hf",
34
  "description": "The latest and biggest model from Meta, fine-tuned for chat.",
35
  "websiteUrl": "https://ai.meta.com/llama/",
36
- "userMessageToken": "",
37
- "userMessageEndToken": " [/INST] ",
38
- "assistantMessageToken": "",
39
- "assistantMessageEndToken": " </s><s>[INST] ",
40
  "preprompt": " ",
41
  "chatPromptTemplate" : "<s>[INST] <<SYS>>\n{{preprompt}}\n<</SYS>>\n\n{{#each messages}}{{#ifUser}}{{content}} [/INST] {{/ifUser}}{{#ifAssistant}}{{content}} </s><s>[INST] {{/ifAssistant}}{{/each}}",
42
  "promptExamples": [
@@ -58,7 +54,7 @@ MODELS=`[
58
  "top_k": 50,
59
  "truncate": 3072,
60
  "max_new_tokens": 1024,
61
- "stop" : ["</s>", " </s><s>[INST] "]
62
  }
63
  },
64
  {
 
33
  "name": "meta-llama/Llama-2-70b-chat-hf",
34
  "description": "The latest and biggest model from Meta, fine-tuned for chat.",
35
  "websiteUrl": "https://ai.meta.com/llama/",
 
 
 
 
36
  "preprompt": " ",
37
  "chatPromptTemplate" : "<s>[INST] <<SYS>>\n{{preprompt}}\n<</SYS>>\n\n{{#each messages}}{{#ifUser}}{{content}} [/INST] {{/ifUser}}{{#ifAssistant}}{{content}} </s><s>[INST] {{/ifAssistant}}{{/each}}",
38
  "promptExamples": [
 
54
  "top_k": 50,
55
  "truncate": 3072,
56
  "max_new_tokens": 1024,
57
+ "stop" : ["</s>", "</s><s>[INST]"]
58
  }
59
  },
60
  {
src/lib/buildPrompt.ts CHANGED
@@ -1,94 +1,31 @@
 
1
  import type { BackendModel } from "./server/models";
2
- import type { Message } from "./types/Message";
3
- import { format } from "date-fns";
4
- import type { WebSearch } from "./types/WebSearch";
5
- import { downloadFile } from "./server/files/downloadFile";
6
- import type { Conversation } from "./types/Conversation";
7
 
8
- interface buildPromptOptions {
9
- messages: Pick<Message, "from" | "content" | "files">[];
10
- id?: Conversation["_id"];
11
  model: BackendModel;
12
- locals?: App.Locals;
13
- webSearch?: WebSearch;
14
- preprompt?: string;
15
- files?: File[];
16
- continue?: boolean;
17
- }
18
 
19
  export async function buildPrompt({
20
  messages,
21
  model,
22
- webSearch,
23
  preprompt,
24
- id,
25
  }: buildPromptOptions): Promise<string> {
26
- let modifiedMessages = [...messages];
27
-
28
- if (webSearch && webSearch.context) {
29
- // find index of the last user message
30
- const lastUsrMsgIndex = modifiedMessages.map((el) => el.from).lastIndexOf("user");
31
-
32
- // combine all the other previous questions into one string
33
- const previousUserMessages = modifiedMessages.filter((el) => el.from === "user").slice(0, -1);
34
- const previousQuestions =
35
- previousUserMessages.length > 0
36
- ? `Previous questions: \n${previousUserMessages
37
- .map(({ content }) => `- ${content}`)
38
- .join("\n")}`
39
- : "";
40
-
41
- const currentDate = format(new Date(), "MMMM d, yyyy");
42
-
43
- // update the last user message directly (that way if the last message is an assistant partial answer, we keep the beginning of that answer)
44
- modifiedMessages[lastUsrMsgIndex] = {
45
- from: "user",
46
- content: `I searched the web using the query: ${webSearch.searchQuery}. Today is ${currentDate} and here are the results:
47
- =====================
48
- ${webSearch.context}
49
- =====================
50
- ${previousQuestions}
51
- Answer the question: ${messages[lastUsrMsgIndex].content}`,
52
- };
53
- }
54
- // section to handle potential files input
55
- if (model.multimodal) {
56
- modifiedMessages = await Promise.all(
57
- modifiedMessages.map(async (el) => {
58
- let content = el.content;
59
-
60
- if (el.from === "user") {
61
- if (el?.files && el.files.length > 0 && id) {
62
- const markdowns = await Promise.all(
63
- el.files.map(async (hash) => {
64
- try {
65
- const { content: image, mime } = await downloadFile(hash, id);
66
- const b64 = image.toString("base64");
67
- return `![](data:${mime};base64,${b64})})`;
68
- } catch (e) {
69
- console.error(e);
70
- }
71
- })
72
- );
73
- content += markdowns.join("\n ");
74
- } else {
75
- // if no image, append an empty white image
76
- content +=
77
- "\n![](data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAQABADAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD+/igAoAKACgD/2Q==)";
78
- }
79
- }
80
-
81
- return { ...el, content };
82
- })
83
- );
84
  }
85
 
86
- return (
87
- model
88
- .chatPromptRender({ messages: modifiedMessages, preprompt })
89
- // Not super precise, but it's truncated in the model's backend anyway
90
- .split(" ")
91
- .slice(-(model.parameters?.truncate ?? 0))
92
- .join(" ")
93
- );
94
  }
 
1
+ import type { EndpointParameters } from "./server/endpoints/endpoints";
2
  import type { BackendModel } from "./server/models";
 
 
 
 
 
3
 
4
+ type buildPromptOptions = Pick<EndpointParameters, "messages" | "preprompt" | "continueMessage"> & {
 
 
5
  model: BackendModel;
6
+ };
 
 
 
 
 
7
 
8
  export async function buildPrompt({
9
  messages,
10
  model,
 
11
  preprompt,
12
+ continueMessage,
13
  }: buildPromptOptions): Promise<string> {
14
+ let prompt = model
15
+ .chatPromptRender({ messages, preprompt })
16
+ // Not super precise, but it's truncated in the model's backend anyway
17
+ .split(" ")
18
+ .slice(-(model.parameters?.truncate ?? 0))
19
+ .join(" ");
20
+
21
+ if (continueMessage && model.parameters?.stop) {
22
+ prompt = model.parameters.stop.reduce((acc: string, curr: string) => {
23
+ if (acc.endsWith(curr)) {
24
+ return acc.slice(0, acc.length - curr.length);
25
+ }
26
+ return acc;
27
+ }, prompt.trimEnd());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
 
30
+ return prompt;
 
 
 
 
 
 
 
31
  }
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -2,7 +2,7 @@
2
  import { marked } from "marked";
3
  import markedKatex from "marked-katex-extension";
4
  import type { Message } from "$lib/types/Message";
5
- import { afterUpdate, createEventDispatcher } from "svelte";
6
  import { deepestChild } from "$lib/utils/deepestChild";
7
  import { page } from "$app/stores";
8
 
@@ -13,6 +13,9 @@
13
  import CarbonDownload from "~icons/carbon/download";
14
  import CarbonThumbsUp from "~icons/carbon/thumbs-up";
15
  import CarbonThumbsDown from "~icons/carbon/thumbs-down";
 
 
 
16
 
17
  import { PUBLIC_SEP_TOKEN } from "$lib/constants/publicSepToken";
18
  import type { Model } from "$lib/types/Model";
@@ -20,6 +23,7 @@
20
  import OpenWebSearchResults from "../OpenWebSearchResults.svelte";
21
  import type { WebSearchUpdate } from "$lib/types/MessageUpdate";
22
  import { base } from "$app/paths";
 
23
 
24
  function sanitizeMd(md: string) {
25
  let ret = md
@@ -45,16 +49,17 @@
45
  }
46
 
47
  export let model: Model;
48
- export let message: Message;
 
49
  export let loading = false;
50
  export let isAuthor = true;
51
  export let readOnly = false;
52
  export let isTapped = false;
53
 
54
- export let webSearchMessages: WebSearchUpdate[];
55
 
56
  const dispatch = createEventDispatcher<{
57
- retry: { content: string; id: Message["id"] };
58
  vote: { score: Message["score"]; id: Message["id"] };
59
  }>();
60
 
@@ -63,6 +68,8 @@
63
  let pendingTimeout: ReturnType<typeof setTimeout>;
64
  let isCopied = false;
65
 
 
 
66
  const renderer = new marked.Renderer();
67
  // For code blocks with simple backticks
68
  renderer.codespan = (code) => {
@@ -91,12 +98,15 @@
91
 
92
  $: tokens = marked.lexer(sanitizeMd(message.content));
93
 
 
 
 
94
  afterUpdate(() => {
95
  loadingEl?.$destroy();
96
  clearTimeout(pendingTimeout);
97
 
98
  // Add loading animation to the last message if update takes more than 600ms
99
- if (loading) {
100
  pendingTimeout = setTimeout(() => {
101
  if (contentEl) {
102
  loadingEl = new IconLoading({
@@ -108,11 +118,14 @@
108
  }
109
  });
110
 
111
- let searchUpdates: WebSearchUpdate[] = [];
 
 
 
 
112
 
113
- $: searchUpdates = ((webSearchMessages.length > 0
114
- ? webSearchMessages
115
- : message.updates?.filter(({ type }) => type === "webSearch")) ?? []) as WebSearchUpdate[];
116
 
117
  $: downloadLink =
118
  message.from === "user" ? `${$page.url.pathname}/message/${message.id}/prompt` : undefined;
@@ -131,11 +144,40 @@
131
  isCopied = false;
132
  }, 1000);
133
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  </script>
135
 
136
  {#if message.from === "assistant"}
137
  <div
138
- class="group relative -mb-8 flex items-start justify-start gap-4 pb-8 leading-relaxed"
139
  role="presentation"
140
  on:click={() => (isTapped = !isTapped)}
141
  on:keydown={() => (isTapped = !isTapped)}
@@ -162,9 +204,6 @@
162
  webSearchMessages={searchUpdates}
163
  />
164
  {/if}
165
- {#if !message.content && (webSearchIsDone || (webSearchMessages && webSearchMessages.length === 0))}
166
- <IconLoading />
167
- {/if}
168
 
169
  <div
170
  class="prose max-w-none max-sm:prose-sm dark:prose-invert prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
@@ -179,6 +218,7 @@
179
  {/if}
180
  {/each}
181
  </div>
 
182
  <!-- Web Search sources -->
183
  {#if webSearchSources?.length}
184
  <div class="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm">
@@ -202,7 +242,7 @@
202
  </div>
203
  {#if isAuthor && !loading && message.content}
204
  <div
205
- class="absolute bottom-1 right-0 flex max-md:transition-all md:bottom-0 md:group-hover:visible md:group-hover:opacity-100
206
  {message.score ? 'visible opacity-100' : 'invisible max-md:-translate-y-4 max-md:opacity-0'}
207
  {isTapped || isCopied ? 'max-md:visible max-md:translate-y-0 max-md:opacity-100' : ''}
208
  "
@@ -230,6 +270,14 @@
230
  >
231
  <CarbonThumbsDown class="h-[1.14em] w-[1.14em]" />
232
  </button>
 
 
 
 
 
 
 
 
233
  <CopyToClipBoardBtn
234
  on:click={() => {
235
  isCopied = true;
@@ -240,10 +288,16 @@
240
  </div>
241
  {/if}
242
  </div>
 
243
  {/if}
244
  {#if message.from === "user"}
245
- <div class="group relative flex items-start justify-start gap-4 max-sm:text-sm">
246
- <div class="flex flex-col">
 
 
 
 
 
247
  {#if message.files && message.files.length > 0}
248
  <div class="mx-auto grid w-fit grid-cols-2 gap-5 px-5">
249
  {#each message.files as file}
@@ -266,36 +320,133 @@
266
  </div>
267
  {/if}
268
 
269
- <div
270
- class="max-w-full whitespace-break-spaces break-words rounded-2xl px-5 py-3.5 text-gray-500 dark:text-gray-400"
271
- >
272
- {message.content.trim()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  </div>
274
- {#if !loading}
275
- <div class="absolute right-0 top-3.5 flex gap-2 lg:-right-2">
276
- {#if downloadLink}
277
- <a
278
- class="rounded-lg border border-gray-100 p-1 text-xs text-gray-400 group-hover:block hover:text-gray-500 md:hidden dark:border-gray-800 dark:text-gray-400 dark:hover:text-gray-300"
279
- title="Download prompt and parameters"
280
- type="button"
281
- target="_blank"
282
- href={downloadLink}
283
- >
284
- <CarbonDownload />
285
- </a>
286
- {/if}
287
- {#if !readOnly}
288
- <button
289
- class="cursor-pointer rounded-lg border border-gray-100 p-1 text-xs text-gray-400 group-hover:block hover:text-gray-500 md:hidden lg:-right-2 dark:border-gray-800 dark:text-gray-400 dark:hover:text-gray-300"
290
- title="Retry"
291
- type="button"
292
- on:click={() => dispatch("retry", { content: message.content, id: message.id })}
293
- >
294
- <CarbonRotate360 />
295
- </button>
296
- {/if}
297
- </div>
298
- {/if}
299
  </div>
300
  </div>
301
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import { marked } from "marked";
3
  import markedKatex from "marked-katex-extension";
4
  import type { Message } from "$lib/types/Message";
5
+ import { afterUpdate, createEventDispatcher, tick } from "svelte";
6
  import { deepestChild } from "$lib/utils/deepestChild";
7
  import { page } from "$app/stores";
8
 
 
13
  import CarbonDownload from "~icons/carbon/download";
14
  import CarbonThumbsUp from "~icons/carbon/thumbs-up";
15
  import CarbonThumbsDown from "~icons/carbon/thumbs-down";
16
+ import CarbonPen from "~icons/carbon/pen";
17
+ import CarbonChevronLeft from "~icons/carbon/chevron-left";
18
+ import CarbonChevronRight from "~icons/carbon/chevron-right";
19
 
20
  import { PUBLIC_SEP_TOKEN } from "$lib/constants/publicSepToken";
21
  import type { Model } from "$lib/types/Model";
 
23
  import OpenWebSearchResults from "../OpenWebSearchResults.svelte";
24
  import type { WebSearchUpdate } from "$lib/types/MessageUpdate";
25
  import { base } from "$app/paths";
26
+ import { useConvTreeStore } from "$lib/stores/convTree";
27
 
28
  function sanitizeMd(md: string) {
29
  let ret = md
 
49
  }
50
 
51
  export let model: Model;
52
+ export let id: Message["id"];
53
+ export let messages: Message[];
54
  export let loading = false;
55
  export let isAuthor = true;
56
  export let readOnly = false;
57
  export let isTapped = false;
58
 
59
+ $: message = messages.find((m) => m.id === id) ?? ({} as Message);
60
 
61
  const dispatch = createEventDispatcher<{
62
+ retry: { content?: string; id: Message["id"] };
63
  vote: { score: Message["score"]; id: Message["id"] };
64
  }>();
65
 
 
68
  let pendingTimeout: ReturnType<typeof setTimeout>;
69
  let isCopied = false;
70
 
71
+ let initialized = false;
72
+
73
  const renderer = new marked.Renderer();
74
  // For code blocks with simple backticks
75
  renderer.codespan = (code) => {
 
98
 
99
  $: tokens = marked.lexer(sanitizeMd(message.content));
100
 
101
+ $: emptyLoad =
102
+ !message.content && (webSearchIsDone || (searchUpdates && searchUpdates.length === 0));
103
+
104
  afterUpdate(() => {
105
  loadingEl?.$destroy();
106
  clearTimeout(pendingTimeout);
107
 
108
  // Add loading animation to the last message if update takes more than 600ms
109
+ if ((loading && isLast) || emptyLoad) {
110
  pendingTimeout = setTimeout(() => {
111
  if (contentEl) {
112
  loadingEl = new IconLoading({
 
118
  }
119
  });
120
 
121
+ function handleKeyDown(e: KeyboardEvent) {
122
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
123
+ editFormEl.requestSubmit();
124
+ }
125
+ }
126
 
127
+ $: searchUpdates = (message.updates?.filter(({ type }) => type === "webSearch") ??
128
+ []) as WebSearchUpdate[];
 
129
 
130
  $: downloadLink =
131
  message.from === "user" ? `${$page.url.pathname}/message/${message.id}/prompt` : undefined;
 
144
  isCopied = false;
145
  }, 1000);
146
  }
147
+
148
+ $: editMode = $convTreeStore.editing === message.id;
149
+ let editContentEl: HTMLTextAreaElement;
150
+ let editFormEl: HTMLFormElement;
151
+
152
+ $: if (editMode) {
153
+ tick();
154
+ if (editContentEl) {
155
+ editContentEl.value = message.content;
156
+ editContentEl?.focus();
157
+ }
158
+ }
159
+
160
+ $: isLast = (message && message.children?.length === 0) ?? false;
161
+
162
+ $: childrenToRender = 0;
163
+ $: nChildren = message?.children?.length ?? 0;
164
+
165
+ $: {
166
+ if (initialized) {
167
+ childrenToRender = Math.max(0, nChildren - 1);
168
+ } else {
169
+ childrenToRender = 0;
170
+ initialized = true;
171
+ }
172
+ }
173
+ const convTreeStore = useConvTreeStore();
174
+
175
+ $: if (message.children?.length === 0) $convTreeStore.leaf = message.id;
176
  </script>
177
 
178
  {#if message.from === "assistant"}
179
  <div
180
+ class="group relative -mb-6 flex items-start justify-start gap-4 pb-4 leading-relaxed"
181
  role="presentation"
182
  on:click={() => (isTapped = !isTapped)}
183
  on:keydown={() => (isTapped = !isTapped)}
 
204
  webSearchMessages={searchUpdates}
205
  />
206
  {/if}
 
 
 
207
 
208
  <div
209
  class="prose max-w-none max-sm:prose-sm dark:prose-invert prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
 
218
  {/if}
219
  {/each}
220
  </div>
221
+
222
  <!-- Web Search sources -->
223
  {#if webSearchSources?.length}
224
  <div class="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm">
 
242
  </div>
243
  {#if isAuthor && !loading && message.content}
244
  <div
245
+ class="absolute bottom-1 right-0 -mb-4 flex max-md:transition-all md:bottom-0 md:group-hover:visible md:group-hover:opacity-100
246
  {message.score ? 'visible opacity-100' : 'invisible max-md:-translate-y-4 max-md:opacity-0'}
247
  {isTapped || isCopied ? 'max-md:visible max-md:translate-y-0 max-md:opacity-100' : ''}
248
  "
 
270
  >
271
  <CarbonThumbsDown class="h-[1.14em] w-[1.14em]" />
272
  </button>
273
+ <button
274
+ 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"
275
+ title="Retry"
276
+ type="button"
277
+ on:click={() => dispatch("retry", { id: message.id })}
278
+ >
279
+ <CarbonRotate360 />
280
+ </button>
281
  <CopyToClipBoardBtn
282
  on:click={() => {
283
  isCopied = true;
 
288
  </div>
289
  {/if}
290
  </div>
291
+ <slot name="childrenNav" />
292
  {/if}
293
  {#if message.from === "user"}
294
+ <div
295
+ class="group relative w-full items-start justify-start gap-4 max-sm:text-sm"
296
+ role="presentation"
297
+ on:click={() => (isTapped = !isTapped)}
298
+ on:keydown={() => (isTapped = !isTapped)}
299
+ >
300
+ <div class="flex w-full flex-col">
301
  {#if message.files && message.files.length > 0}
302
  <div class="mx-auto grid w-fit grid-cols-2 gap-5 px-5">
303
  {#each message.files as file}
 
320
  </div>
321
  {/if}
322
 
323
+ <div class="flex w-full flex-row flex-nowrap">
324
+ {#if !editMode}
325
+ <p
326
+ class="disabled w-full appearance-none whitespace-break-spaces text-wrap break-words bg-inherit px-5 py-3.5 text-gray-500 dark:text-gray-400"
327
+ >
328
+ {message.content.trim()}
329
+ </p>
330
+ {:else}
331
+ <form
332
+ class="flex w-full flex-col"
333
+ bind:this={editFormEl}
334
+ on:submit|preventDefault={() => {
335
+ dispatch("retry", { content: editContentEl.value, id: message.id });
336
+ $convTreeStore.editing = null;
337
+ }}
338
+ >
339
+ <textarea
340
+ class="w-full whitespace-break-spaces break-words rounded-lg bg-gray-100 px-5 py-3.5 text-gray-500 *:h-max dark:bg-gray-800 dark:text-gray-400"
341
+ bind:this={editContentEl}
342
+ value={message.content.trim()}
343
+ on:keydown={handleKeyDown}
344
+ required
345
+ />
346
+ <div class="flex w-full flex-row flex-nowrap items-center justify-center gap-2 pt-2">
347
+ <button
348
+ type="submit"
349
+ class="btn rounded-lg px-3 py-1.5 text-sm
350
+ {loading
351
+ ? 'bg-gray-300 text-gray-400 dark:bg-gray-700 dark:text-gray-600'
352
+ : 'bg-gray-200 text-gray-600 focus:ring-0 hover:text-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:text-gray-200'}
353
+ "
354
+ disabled={loading}
355
+ >
356
+ Submit
357
+ </button>
358
+ <button
359
+ type="button"
360
+ class="btn rounded-sm p-2 text-sm text-gray-400 focus:ring-0 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300"
361
+ on:click={() => {
362
+ $convTreeStore.editing = null;
363
+ }}
364
+ >
365
+ Cancel
366
+ </button>
367
+ </div>
368
+ </form>
369
+ {/if}
370
+ {#if !loading && !editMode}
371
+ <div
372
+ class="
373
+ max-md:opacity-0' invisible absolute
374
+ right-0 top-3.5 z-10 h-max max-md:-translate-y-4 max-md:transition-all md:bottom-0 md:group-hover:visible md:group-hover:opacity-100 {isTapped ||
375
+ isCopied
376
+ ? 'max-md:visible max-md:translate-y-0 max-md:opacity-100'
377
+ : ''}"
378
+ >
379
+ <div class="mx-auto flex flex-row flex-nowrap gap-2">
380
+ {#if downloadLink}
381
+ <a
382
+ class="rounded-lg border border-gray-100 bg-gray-100 p-1 text-xs text-gray-400 group-hover:block hover:text-gray-500 max-sm:!hidden md:hidden dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400 dark:hover:text-gray-300"
383
+ title="Download prompt and parameters"
384
+ type="button"
385
+ target="_blank"
386
+ href={downloadLink}
387
+ >
388
+ <CarbonDownload />
389
+ </a>
390
+ {/if}
391
+ {#if !readOnly}
392
+ <button
393
+ class="cursor-pointer rounded-lg border border-gray-100 bg-gray-100 p-1 text-xs text-gray-400 group-hover:block hover:text-gray-500 md:hidden lg:-right-2 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400 dark:hover:text-gray-300"
394
+ title="Branch"
395
+ type="button"
396
+ on:click={() => ($convTreeStore.editing = message.id)}
397
+ >
398
+ <CarbonPen />
399
+ </button>
400
+ {/if}
401
+ </div>
402
+ </div>
403
+ {/if}
404
  </div>
405
+ <slot name="childrenNav" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  </div>
407
  </div>
408
  {/if}
409
+
410
+ {#if nChildren > 0}
411
+ <svelte:self
412
+ {loading}
413
+ {messages}
414
+ {isAuthor}
415
+ {readOnly}
416
+ {model}
417
+ id={messages.find((m) => m.id === id)?.children?.[childrenToRender]}
418
+ on:retry
419
+ on:vote
420
+ on:continue
421
+ >
422
+ <svelte:fragment slot="childrenNav">
423
+ {#if nChildren > 1 && $convTreeStore.editing === null}
424
+ <div
425
+ class="font-white z-10 -mt-1 ml-3.5 mr-auto flex h-6 w-fit select-none flex-row items-center justify-center gap-1 text-sm"
426
+ >
427
+ <button
428
+ class="inline text-lg font-thin text-gray-400 disabled:pointer-events-none disabled:opacity-25 hover:text-gray-800 dark:text-gray-500 dark:hover:text-gray-200"
429
+ on:click={() => (childrenToRender = Math.max(0, childrenToRender - 1))}
430
+ disabled={childrenToRender === 0 || loading}
431
+ >
432
+ <CarbonChevronLeft class="text-sm" />
433
+ </button>
434
+ <span class=" text-gray-400 dark:text-gray-500">
435
+ {childrenToRender + 1} / {nChildren}
436
+ </span>
437
+ <button
438
+ class="inline text-lg font-thin text-gray-400 disabled:pointer-events-none disabled:opacity-25 hover:text-gray-800 dark:text-gray-500 dark:hover:text-gray-200"
439
+ on:click={() =>
440
+ (childrenToRender = Math.min(
441
+ message?.children?.length ?? 1 - 1,
442
+ childrenToRender + 1
443
+ ))}
444
+ disabled={childrenToRender === nChildren - 1 || loading}
445
+ >
446
+ <CarbonChevronRight class="text-sm" />
447
+ </button>
448
+ </div>
449
+ {/if}
450
+ </svelte:fragment>
451
+ </svelte:self>
452
+ {/if}
src/lib/components/chat/ChatMessages.svelte DELETED
@@ -1,106 +0,0 @@
1
- <script lang="ts">
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 ChatIntroduction from "./ChatIntroduction.svelte";
9
- import ChatMessage from "./ChatMessage.svelte";
10
- import type { WebSearchUpdate } from "$lib/types/MessageUpdate";
11
- import { browser } from "$app/environment";
12
- import SystemPromptModal from "../SystemPromptModal.svelte";
13
- import type { Assistant } from "$lib/types/Assistant";
14
- import AssistantIntroduction from "./AssistantIntroduction.svelte";
15
- import { page } from "$app/stores";
16
- import { base } from "$app/paths";
17
-
18
- export let messages: Message[];
19
- export let loading: boolean;
20
- export let pending: boolean;
21
- export let isAuthor: boolean;
22
- export let currentModel: Model;
23
- export let assistant: Assistant | undefined;
24
- export let models: Model[];
25
- export let preprompt: string | undefined;
26
- export let readOnly: boolean;
27
-
28
- let chatContainer: HTMLElement;
29
-
30
- export let webSearchMessages: WebSearchUpdate[] = [];
31
-
32
- async function scrollToBottom() {
33
- await tick();
34
- chatContainer.scrollTop = chatContainer.scrollHeight;
35
- }
36
-
37
- // If last message is from user, scroll to bottom
38
- $: if (browser && messages[messages.length - 1]?.from === "user") {
39
- scrollToBottom();
40
- }
41
- </script>
42
-
43
- <div
44
- class="scrollbar-custom mr-1 h-full overflow-y-auto"
45
- use:snapScrollToBottom={messages.length ? [...messages, ...webSearchMessages] : false}
46
- bind:this={chatContainer}
47
- >
48
- <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">
49
- {#each messages as message, i}
50
- {#if i === 0 && $page.data?.assistant}
51
- <a
52
- class="mx-auto flex items-center gap-1.5 rounded-full border border-gray-100 bg-gray-50 py-1 pl-1 pr-3 text-sm text-gray-800 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
53
- href="{base}/settings/assistants/{$page.data.assistant._id}"
54
- >
55
- {#if $page.data?.assistant.avatar}
56
- <img
57
- src="{base}/settings/assistants/{$page.data?.assistant._id.toString()}/avatar.jpg?hash=${$page
58
- .data.assistant.avatar}"
59
- alt="Avatar"
60
- class="size-5 rounded-full object-cover"
61
- />
62
- {:else}
63
- <div
64
- class="flex size-6 items-center justify-center rounded-full bg-gray-300 font-bold uppercase text-gray-500"
65
- >
66
- {$page.data?.assistant.name[0]}
67
- </div>
68
- {/if}
69
-
70
- {$page.data.assistant.name}
71
- </a>
72
- {:else if i === 0 && preprompt && preprompt != currentModel.preprompt}
73
- <SystemPromptModal preprompt={preprompt ?? ""} />
74
- {/if}
75
- <ChatMessage
76
- loading={loading && i === messages.length - 1}
77
- {message}
78
- {isAuthor}
79
- {readOnly}
80
- model={currentModel}
81
- webSearchMessages={i === messages.length - 1 ? webSearchMessages : []}
82
- on:retry
83
- on:vote
84
- on:continue
85
- />
86
- {:else}
87
- {#if !assistant}
88
- <ChatIntroduction {models} {currentModel} on:message />
89
- {:else}
90
- <AssistantIntroduction {assistant} on:message />
91
- {/if}
92
- {/each}
93
- {#if pending && messages[messages.length - 1]?.from === "user"}
94
- <ChatMessage
95
- message={{ from: "assistant", content: "", id: randomUUID() }}
96
- model={currentModel}
97
- {webSearchMessages}
98
- />
99
- {/if}
100
- <div class="h-44 flex-none" />
101
- </div>
102
- <ScrollToBottomBtn
103
- class="bottom-36 right-4 max-md:hidden lg:right-10"
104
- scrollNode={chatContainer}
105
- />
106
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -1,6 +1,6 @@
1
  <script lang="ts">
2
  import type { Message } from "$lib/types/Message";
3
- import { createEventDispatcher, onDestroy } from "svelte";
4
 
5
  import CarbonSendAltFilled from "~icons/carbon/send-alt-filled";
6
  import CarbonExport from "~icons/carbon/export";
@@ -11,13 +11,11 @@
11
 
12
  import EosIconsLoading from "~icons/eos-icons/loading";
13
 
14
- import ChatMessages from "./ChatMessages.svelte";
15
  import ChatInput from "./ChatInput.svelte";
16
  import StopGeneratingBtn from "../StopGeneratingBtn.svelte";
17
  import type { Model } from "$lib/types/Model";
18
  import WebSearchToggle from "../WebSearchToggle.svelte";
19
  import LoginModal from "../LoginModal.svelte";
20
- import type { WebSearchUpdate } from "$lib/types/MessageUpdate";
21
  import { page } from "$app/stores";
22
  import FileDropzone from "./FileDropzone.svelte";
23
  import RetryBtn from "../RetryBtn.svelte";
@@ -26,15 +24,23 @@
26
  import type { Assistant } from "$lib/types/Assistant";
27
  import { base } from "$app/paths";
28
  import ContinueBtn from "../ContinueBtn.svelte";
 
 
 
 
 
 
 
 
29
 
30
  export let messages: Message[] = [];
31
  export let loading = false;
32
  export let pending = false;
 
33
  export let shared = false;
34
  export let currentModel: Model;
35
  export let models: Model[];
36
  export let assistant: Assistant | undefined = undefined;
37
- export let webSearchMessages: WebSearchUpdate[] = [];
38
  export let preprompt: string | undefined = undefined;
39
  export let files: File[] = [];
40
 
@@ -50,7 +56,7 @@
50
  message: string;
51
  share: void;
52
  stop: void;
53
- retry: { id: Message["id"]; content: string };
54
  continue: { id: Message["id"] };
55
  }>();
56
 
@@ -76,7 +82,11 @@
76
  const onDragOver = (e: DragEvent) => {
77
  e.preventDefault();
78
  };
79
- $: lastIsError = messages[messages.length - 1]?.from === "user" && !loading;
 
 
 
 
80
 
81
  $: sources = files.map((file) => file2base64(file));
82
 
@@ -96,6 +106,18 @@
96
  clearTimeout(timeout);
97
  }
98
  });
 
 
 
 
 
 
 
 
 
 
 
 
99
  </script>
100
 
101
  <div class="relative min-h-0 min-w-0">
@@ -106,31 +128,79 @@
106
  }}
107
  />
108
  {/if}
109
- <ChatMessages
110
- {loading}
111
- {pending}
112
- {currentModel}
113
- {models}
114
- {assistant}
115
- {messages}
116
- readOnly={isReadOnly}
117
- isAuthor={!shared}
118
- {webSearchMessages}
119
- {preprompt}
120
- on:message={(ev) => {
121
- if ($page.data.loginRequired) {
122
- loginModalOpen = true;
123
- } else {
124
- dispatch("message", ev.detail);
125
- }
126
- }}
127
- on:vote
128
- on:continue
129
- on:retry={(ev) => {
130
- if (!loading) dispatch("retry", ev.detail);
131
- }}
132
- />
 
 
 
 
 
 
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  <div
135
  class="dark:via-gray-80 pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full max-w-3xl flex-col items-center justify-center bg-gradient-to-t from-white via-white/80 to-white/0 px-3.5 py-4 max-md:border-t max-md:bg-white sm:px-5 md:py-8 xl:max-w-4xl dark:border-gray-800 dark:from-gray-900 dark:to-gray-900/0 max-md:dark:bg-gray-900 [&>*]:pointer-events-auto"
136
  >
@@ -169,23 +239,28 @@
169
  {:else if lastIsError}
170
  <RetryBtn
171
  classNames="ml-auto"
172
- on:click={() =>
173
- dispatch("retry", {
174
- id: messages[messages.length - 1].id,
175
- content: messages[messages.length - 1].content,
176
- })}
 
 
177
  />
178
  {:else}
179
  <div class="ml-auto gap-2">
180
  {#if currentModel.multimodal}
181
  <UploadBtn bind:files classNames="ml-auto" />
182
  {/if}
183
- {#if messages && messages[messages.length - 1]?.interrupted && !isReadOnly}
184
  <ContinueBtn
185
- on:click={() =>
186
- dispatch("continue", {
187
- id: messages[messages.length - 1].id,
188
- })}
 
 
 
189
  />
190
  {/if}
191
  </div>
 
1
  <script lang="ts">
2
  import type { Message } from "$lib/types/Message";
3
+ import { createEventDispatcher, onDestroy, tick } from "svelte";
4
 
5
  import CarbonSendAltFilled from "~icons/carbon/send-alt-filled";
6
  import CarbonExport from "~icons/carbon/export";
 
11
 
12
  import EosIconsLoading from "~icons/eos-icons/loading";
13
 
 
14
  import ChatInput from "./ChatInput.svelte";
15
  import StopGeneratingBtn from "../StopGeneratingBtn.svelte";
16
  import type { Model } from "$lib/types/Model";
17
  import WebSearchToggle from "../WebSearchToggle.svelte";
18
  import LoginModal from "../LoginModal.svelte";
 
19
  import { page } from "$app/stores";
20
  import FileDropzone from "./FileDropzone.svelte";
21
  import RetryBtn from "../RetryBtn.svelte";
 
24
  import type { Assistant } from "$lib/types/Assistant";
25
  import { base } from "$app/paths";
26
  import ContinueBtn from "../ContinueBtn.svelte";
27
+ import AssistantIntroduction from "./AssistantIntroduction.svelte";
28
+ import ChatMessage from "./ChatMessage.svelte";
29
+ import ScrollToBottomBtn from "../ScrollToBottomBtn.svelte";
30
+ import { browser } from "$app/environment";
31
+ import { snapScrollToBottom } from "$lib/actions/snapScrollToBottom";
32
+ import SystemPromptModal from "../SystemPromptModal.svelte";
33
+ import ChatIntroduction from "./ChatIntroduction.svelte";
34
+ import { useConvTreeStore } from "$lib/stores/convTree";
35
 
36
  export let messages: Message[] = [];
37
  export let loading = false;
38
  export let pending = false;
39
+
40
  export let shared = false;
41
  export let currentModel: Model;
42
  export let models: Model[];
43
  export let assistant: Assistant | undefined = undefined;
 
44
  export let preprompt: string | undefined = undefined;
45
  export let files: File[] = [];
46
 
 
56
  message: string;
57
  share: void;
58
  stop: void;
59
+ retry: { id: Message["id"]; content?: string };
60
  continue: { id: Message["id"] };
61
  }>();
62
 
 
82
  const onDragOver = (e: DragEvent) => {
83
  e.preventDefault();
84
  };
85
+
86
+ const convTreeStore = useConvTreeStore();
87
+
88
+ $: lastMessage = browser && (messages.find((m) => m.id == $convTreeStore.leaf) as Message);
89
+ $: lastIsError = lastMessage && lastMessage.from === "user" && !loading;
90
 
91
  $: sources = files.map((file) => file2base64(file));
92
 
 
106
  clearTimeout(timeout);
107
  }
108
  });
109
+
110
+ let chatContainer: HTMLElement;
111
+
112
+ async function scrollToBottom() {
113
+ await tick();
114
+ chatContainer.scrollTop = chatContainer.scrollHeight;
115
+ }
116
+
117
+ // If last message is from user, scroll to bottom
118
+ $: if (lastMessage && lastMessage.from === "user") {
119
+ scrollToBottom();
120
+ }
121
  </script>
122
 
123
  <div class="relative min-h-0 min-w-0">
 
128
  }}
129
  />
130
  {/if}
131
+ <div
132
+ class="scrollbar-custom mr-1 h-full overflow-y-auto"
133
+ use:snapScrollToBottom={messages.length ? [...messages] : false}
134
+ bind:this={chatContainer}
135
+ >
136
+ <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">
137
+ {#if $page.data?.assistant}
138
+ <a
139
+ class="mx-auto flex items-center gap-1.5 rounded-full border border-gray-100 bg-gray-50 py-1 pl-1 pr-3 text-sm text-gray-800 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
140
+ href="{base}/settings/assistants/{$page.data.assistant._id}"
141
+ >
142
+ {#if $page.data?.assistant.avatar}
143
+ <img
144
+ src="{base}/settings/assistants/{$page.data?.assistant._id.toString()}/avatar.jpg?hash=${$page
145
+ .data.assistant.avatar}"
146
+ alt="Avatar"
147
+ class="size-5 rounded-full object-cover"
148
+ />
149
+ {:else}
150
+ <div
151
+ class="flex size-6 items-center justify-center rounded-full bg-gray-300 font-bold uppercase text-gray-500"
152
+ >
153
+ {$page.data?.assistant.name[0]}
154
+ </div>
155
+ {/if}
156
+
157
+ {$page.data.assistant.name}
158
+ </a>
159
+ {:else if preprompt && preprompt != currentModel.preprompt}
160
+ <SystemPromptModal preprompt={preprompt ?? ""} />
161
+ {/if}
162
 
163
+ {#if messages.length > 0}
164
+ <div class="flex h-max flex-col gap-6 pb-52">
165
+ <ChatMessage
166
+ {loading}
167
+ {messages}
168
+ id={messages[0].id}
169
+ isAuthor={!shared}
170
+ readOnly={isReadOnly}
171
+ model={currentModel}
172
+ on:retry
173
+ on:vote
174
+ on:continue
175
+ />
176
+ </div>
177
+ {:else if pending}
178
+ <ChatMessage
179
+ loading={true}
180
+ messages={[
181
+ {
182
+ id: "0-0-0-0-0",
183
+ content: "",
184
+ from: "assistant",
185
+ children: [],
186
+ },
187
+ ]}
188
+ id={"0-0-0-0-0"}
189
+ isAuthor={!shared}
190
+ readOnly={isReadOnly}
191
+ model={currentModel}
192
+ />
193
+ {:else if !assistant}
194
+ <ChatIntroduction {models} {currentModel} on:message />
195
+ {:else}
196
+ <AssistantIntroduction {assistant} on:message />
197
+ {/if}
198
+ </div>
199
+ <ScrollToBottomBtn
200
+ class="bottom-36 right-4 max-md:hidden lg:right-10"
201
+ scrollNode={chatContainer}
202
+ />
203
+ </div>
204
  <div
205
  class="dark:via-gray-80 pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full max-w-3xl flex-col items-center justify-center bg-gradient-to-t from-white via-white/80 to-white/0 px-3.5 py-4 max-md:border-t max-md:bg-white sm:px-5 md:py-8 xl:max-w-4xl dark:border-gray-800 dark:from-gray-900 dark:to-gray-900/0 max-md:dark:bg-gray-900 [&>*]:pointer-events-auto"
206
  >
 
239
  {:else if lastIsError}
240
  <RetryBtn
241
  classNames="ml-auto"
242
+ on:click={() => {
243
+ if (lastMessage && lastMessage.ancestors) {
244
+ dispatch("retry", {
245
+ id: lastMessage.ancestors[lastMessage.ancestors.length - 1],
246
+ });
247
+ }
248
+ }}
249
  />
250
  {:else}
251
  <div class="ml-auto gap-2">
252
  {#if currentModel.multimodal}
253
  <UploadBtn bind:files classNames="ml-auto" />
254
  {/if}
255
+ {#if messages && lastMessage && lastMessage.interrupted && !isReadOnly}
256
  <ContinueBtn
257
+ on:click={() => {
258
+ if (lastMessage && lastMessage.ancestors) {
259
+ dispatch("continue", {
260
+ id: lastMessage?.id,
261
+ });
262
+ }
263
+ }}
264
  />
265
  {/if}
266
  </div>
src/lib/server/database.ts CHANGED
@@ -62,6 +62,12 @@ client.on("open", () => {
62
  { partialFilterExpression: { userId: { $exists: true } } }
63
  )
64
  .catch(console.error);
 
 
 
 
 
 
65
  abortedGenerations.createIndex({ updatedAt: 1 }, { expireAfterSeconds: 30 }).catch(console.error);
66
  abortedGenerations.createIndex({ conversationId: 1 }, { unique: true }).catch(console.error);
67
  sharedConversations.createIndex({ hash: 1 }, { unique: true }).catch(console.error);
 
62
  { partialFilterExpression: { userId: { $exists: true } } }
63
  )
64
  .catch(console.error);
65
+ conversations
66
+ .createIndex(
67
+ { "message.id": 1, "message.ancestors": 1 },
68
+ { partialFilterExpression: { userId: { $exists: true } } }
69
+ )
70
+ .catch(console.error);
71
  abortedGenerations.createIndex({ updatedAt: 1 }, { expireAfterSeconds: 30 }).catch(console.error);
72
  abortedGenerations.createIndex({ conversationId: 1 }, { unique: true }).catch(console.error);
73
  sharedConversations.createIndex({ hash: 1 }, { unique: true }).catch(console.error);
src/lib/server/endpoints/aws/endpointAws.ts CHANGED
@@ -36,11 +36,11 @@ export async function endpointAws(
36
  region,
37
  });
38
 
39
- return async ({ conversation }) => {
40
  const prompt = await buildPrompt({
41
- messages: conversation.messages,
42
- webSearch: conversation.messages[conversation.messages.length - 1].webSearch,
43
- preprompt: conversation.preprompt,
44
  model,
45
  });
46
 
 
36
  region,
37
  });
38
 
39
+ return async ({ messages, preprompt, continueMessage }) => {
40
  const prompt = await buildPrompt({
41
+ messages,
42
+ continueMessage,
43
+ preprompt,
44
  model,
45
  });
46
 
src/lib/server/endpoints/endpoints.ts CHANGED
@@ -8,13 +8,10 @@ import endpointLlamacpp, { endpointLlamacppParametersSchema } from "./llamacpp/e
8
  import endpointOllama, { endpointOllamaParametersSchema } from "./ollama/endpointOllama";
9
 
10
  // parameters passed when generating text
11
- interface EndpointParameters {
12
- conversation: {
13
- messages: Omit<Conversation["messages"][0], "id">[];
14
- preprompt?: Conversation["preprompt"];
15
- _id?: Conversation["_id"];
16
- };
17
- continue?: boolean;
18
  }
19
 
20
  interface CommonEndpoint {
 
8
  import endpointOllama, { endpointOllamaParametersSchema } from "./ollama/endpointOllama";
9
 
10
  // parameters passed when generating text
11
+ export interface EndpointParameters {
12
+ messages: Omit<Conversation["messages"][0], "id">[];
13
+ preprompt?: Conversation["preprompt"];
14
+ continueMessage?: boolean; // used to signal that the last message will be extended
 
 
 
15
  }
16
 
17
  interface CommonEndpoint {
src/lib/server/endpoints/llamacpp/endpointLlamacpp.ts CHANGED
@@ -19,11 +19,11 @@ export function endpointLlamacpp(
19
  input: z.input<typeof endpointLlamacppParametersSchema>
20
  ): Endpoint {
21
  const { url, model } = endpointLlamacppParametersSchema.parse(input);
22
- return async ({ conversation }) => {
23
  const prompt = await buildPrompt({
24
- messages: conversation.messages,
25
- webSearch: conversation.messages[conversation.messages.length - 1].webSearch,
26
- preprompt: conversation.preprompt,
27
  model,
28
  });
29
 
 
19
  input: z.input<typeof endpointLlamacppParametersSchema>
20
  ): Endpoint {
21
  const { url, model } = endpointLlamacppParametersSchema.parse(input);
22
+ return async ({ messages, preprompt, continueMessage }) => {
23
  const prompt = await buildPrompt({
24
+ messages,
25
+ continueMessage,
26
+ preprompt,
27
  model,
28
  });
29
 
src/lib/server/endpoints/ollama/endpointOllama.ts CHANGED
@@ -14,11 +14,11 @@ export const endpointOllamaParametersSchema = z.object({
14
  export function endpointOllama(input: z.input<typeof endpointOllamaParametersSchema>): Endpoint {
15
  const { url, model, ollamaName } = endpointOllamaParametersSchema.parse(input);
16
 
17
- return async ({ conversation }) => {
18
  const prompt = await buildPrompt({
19
- messages: conversation.messages,
20
- webSearch: conversation.messages[conversation.messages.length - 1].webSearch,
21
- preprompt: conversation.preprompt,
22
  model,
23
  });
24
 
 
14
  export function endpointOllama(input: z.input<typeof endpointOllamaParametersSchema>): Endpoint {
15
  const { url, model, ollamaName } = endpointOllamaParametersSchema.parse(input);
16
 
17
+ return async ({ messages, preprompt, continueMessage }) => {
18
  const prompt = await buildPrompt({
19
+ messages,
20
+ continueMessage,
21
+ preprompt,
22
  model,
23
  });
24
 
src/lib/server/endpoints/openai/endpointOai.ts CHANGED
@@ -4,7 +4,6 @@ import { openAIChatToTextGenerationStream } from "./openAIChatToTextGenerationSt
4
  import { buildPrompt } from "$lib/buildPrompt";
5
  import { OPENAI_API_KEY } from "$env/static/private";
6
  import type { Endpoint } from "../endpoints";
7
- import { format } from "date-fns";
8
 
9
  export const endpointOAIParametersSchema = z.object({
10
  weight: z.number().int().positive().default(1),
@@ -37,16 +36,18 @@ export async function endpointOai(
37
  });
38
 
39
  if (completion === "completions") {
40
- return async ({ conversation }) => {
 
 
 
 
 
 
 
41
  return openAICompletionToTextGenerationStream(
42
  await openai.completions.create({
43
  model: model.id ?? model.name,
44
- prompt: await buildPrompt({
45
- messages: conversation.messages,
46
- webSearch: conversation.messages[conversation.messages.length - 1].webSearch,
47
- preprompt: conversation.preprompt,
48
- model,
49
- }),
50
  stream: true,
51
  max_tokens: model.parameters?.max_new_tokens,
52
  stop: model.parameters?.stop,
@@ -57,48 +58,20 @@ export async function endpointOai(
57
  );
58
  };
59
  } else if (completion === "chat_completions") {
60
- return async ({ conversation }) => {
61
- let messages = conversation.messages;
62
- const webSearch = conversation.messages[conversation.messages.length - 1].webSearch;
63
-
64
- if (webSearch && webSearch.context) {
65
- const lastMsg = messages.slice(-1)[0];
66
- const messagesWithoutLastUsrMsg = messages.slice(0, -1);
67
- const previousUserMessages = messages.filter((el) => el.from === "user").slice(0, -1);
68
-
69
- const previousQuestions =
70
- previousUserMessages.length > 0
71
- ? `Previous questions: \n${previousUserMessages
72
- .map(({ content }) => `- ${content}`)
73
- .join("\n")}`
74
- : "";
75
- const currentDate = format(new Date(), "MMMM d, yyyy");
76
- messages = [
77
- ...messagesWithoutLastUsrMsg,
78
- {
79
- from: "user",
80
- content: `I searched the web using the query: ${webSearch.searchQuery}. Today is ${currentDate} and here are the results:
81
- =====================
82
- ${webSearch.context}
83
- =====================
84
- ${previousQuestions}
85
- Answer the question: ${lastMsg.content}
86
- `,
87
- },
88
- ];
89
- }
90
-
91
- const messagesOpenAI = messages.map((message) => ({
92
  role: message.from,
93
  content: message.content,
94
  }));
95
 
 
 
 
 
96
  return openAIChatToTextGenerationStream(
97
  await openai.chat.completions.create({
98
  model: model.id ?? model.name,
99
- messages: conversation.preprompt
100
- ? [{ role: "system", content: conversation.preprompt }, ...messagesOpenAI]
101
- : messagesOpenAI,
102
  stream: true,
103
  max_tokens: model.parameters?.max_new_tokens,
104
  stop: model.parameters?.stop,
 
4
  import { buildPrompt } from "$lib/buildPrompt";
5
  import { OPENAI_API_KEY } from "$env/static/private";
6
  import type { Endpoint } from "../endpoints";
 
7
 
8
  export const endpointOAIParametersSchema = z.object({
9
  weight: z.number().int().positive().default(1),
 
36
  });
37
 
38
  if (completion === "completions") {
39
+ return async ({ messages, preprompt, continueMessage }) => {
40
+ const prompt = await buildPrompt({
41
+ messages,
42
+ continueMessage,
43
+ preprompt,
44
+ model,
45
+ });
46
+
47
  return openAICompletionToTextGenerationStream(
48
  await openai.completions.create({
49
  model: model.id ?? model.name,
50
+ prompt,
 
 
 
 
 
51
  stream: true,
52
  max_tokens: model.parameters?.max_new_tokens,
53
  stop: model.parameters?.stop,
 
58
  );
59
  };
60
  } else if (completion === "chat_completions") {
61
+ return async ({ messages, preprompt }) => {
62
+ let messagesOpenAI = messages.map((message) => ({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  role: message.from,
64
  content: message.content,
65
  }));
66
 
67
+ if (messagesOpenAI?.[0]?.role !== "system") {
68
+ messagesOpenAI = [{ role: "system", content: preprompt ?? "" }, ...messagesOpenAI];
69
+ }
70
+
71
  return openAIChatToTextGenerationStream(
72
  await openai.chat.completions.create({
73
  model: model.id ?? model.name,
74
+ messages: messagesOpenAI,
 
 
75
  stream: true,
76
  max_tokens: model.parameters?.max_new_tokens,
77
  stop: model.parameters?.stop,
src/lib/server/endpoints/tgi/endpointTgi.ts CHANGED
@@ -16,25 +16,14 @@ export const endpointTgiParametersSchema = z.object({
16
  export function endpointTgi(input: z.input<typeof endpointTgiParametersSchema>): Endpoint {
17
  const { url, accessToken, model, authorization } = endpointTgiParametersSchema.parse(input);
18
 
19
- return async ({ conversation, continue: messageContinue }) => {
20
- let prompt = await buildPrompt({
21
- messages: conversation.messages,
22
- webSearch: conversation.messages[conversation.messages.length - 1].webSearch,
23
- preprompt: conversation.preprompt,
24
  model,
25
- id: conversation._id,
26
  });
27
 
28
- if (messageContinue) {
29
- // start with the full prompt, and for each stop token, try to remove it from the end of the prompt
30
- prompt = model.parameters.stop.reduce((acc: string, curr: string) => {
31
- if (acc.endsWith(curr)) {
32
- return acc.slice(0, acc.length - curr.length);
33
- }
34
- return acc;
35
- }, prompt.trimEnd());
36
- }
37
-
38
  return textGenerationStream(
39
  {
40
  parameters: { ...model.parameters, return_full_text: false },
 
16
  export function endpointTgi(input: z.input<typeof endpointTgiParametersSchema>): Endpoint {
17
  const { url, accessToken, model, authorization } = endpointTgiParametersSchema.parse(input);
18
 
19
+ return async ({ messages, preprompt, continueMessage }) => {
20
+ const prompt = await buildPrompt({
21
+ messages,
22
+ preprompt,
 
23
  model,
24
+ continueMessage,
25
  });
26
 
 
 
 
 
 
 
 
 
 
 
27
  return textGenerationStream(
28
  {
29
  parameters: { ...model.parameters, return_full_text: false },
src/lib/server/generateFromDefaultEndpoint.ts CHANGED
@@ -10,7 +10,7 @@ export async function generateFromDefaultEndpoint({
10
  }): Promise<string> {
11
  const endpoint = await smallModel.getEndpoint();
12
 
13
- const tokenStream = await endpoint({ conversation: { messages, preprompt } });
14
 
15
  for await (const output of tokenStream) {
16
  // if not generated_text is here it means the generation is not done
 
10
  }): Promise<string> {
11
  const endpoint = await smallModel.getEndpoint();
12
 
13
+ const tokenStream = await endpoint({ messages, preprompt });
14
 
15
  for await (const output of tokenStream) {
16
  // if not generated_text is here it means the generation is not done
src/lib/server/preprocessMessages.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Conversation } from "$lib/types/Conversation";
2
+ import type { Message } from "$lib/types/Message";
3
+ import { format } from "date-fns";
4
+ import { downloadFile } from "./files/downloadFile";
5
+
6
+ export async function preprocessMessages(
7
+ messages: Message[],
8
+ multimodal: boolean,
9
+ id: Conversation["_id"]
10
+ ): Promise<Message[]> {
11
+ return await Promise.all(
12
+ messages.map(async (message, idx) => {
13
+ // start by adding websearch to the last message
14
+ if (idx === messages.length - 1 && message.webSearch && message.webSearch.context) {
15
+ const lastUsrMsgIndex = messages.map((el) => el.from).lastIndexOf("user");
16
+ const previousUserMessages = messages.filter((el) => el.from === "user").slice(0, -1);
17
+ const previousQuestions =
18
+ previousUserMessages.length > 0
19
+ ? `Previous questions: \n${previousUserMessages
20
+ .map(({ content }) => `- ${content}`)
21
+ .join("\n")}`
22
+ : "";
23
+ const currentDate = format(new Date(), "MMMM d, yyyy");
24
+
25
+ message.content = `I searched the web using the query: ${message.webSearch.searchQuery}. Today is ${currentDate} and here are the results:
26
+ =====================
27
+ ${message.webSearch.context}
28
+ =====================
29
+ ${previousQuestions}
30
+ Answer the question: ${messages[lastUsrMsgIndex].content}`;
31
+ }
32
+ // handle files if model is multimodal
33
+ if (multimodal) {
34
+ if (message.files && message.files.length > 0) {
35
+ const markdowns = await Promise.all(
36
+ message.files.map(async (hash) => {
37
+ try {
38
+ const { content: image, mime } = await downloadFile(hash, id);
39
+ const b64 = image.toString("base64");
40
+ return `![](data:${mime};base64,${b64})})`;
41
+ } catch (e) {
42
+ console.error(e);
43
+ }
44
+ })
45
+ );
46
+ message.content += markdowns.join("\n ");
47
+ } else {
48
+ // if no image, append an empty white image
49
+ message.content +=
50
+ "\n![](data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAAQABADAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD+/igAoAKACgD/2Q==)";
51
+ }
52
+ }
53
+
54
+ return message;
55
+ })
56
+ );
57
+ }
src/lib/server/websearch/runWebSearch.ts CHANGED
@@ -17,13 +17,10 @@ const DOMAIN_BLOCKLIST = ["youtube.com", "twitter.com"];
17
 
18
  export async function runWebSearch(
19
  conv: Conversation,
20
- prompt: string,
21
  updatePad: (upd: MessageUpdate) => void
22
  ) {
23
- const messages = (() => {
24
- return [...conv.messages, { content: prompt, from: "user", id: crypto.randomUUID() }];
25
- })() satisfies Message[];
26
-
27
  const webSearch: WebSearch = {
28
  prompt,
29
  searchQuery: "",
 
17
 
18
  export async function runWebSearch(
19
  conv: Conversation,
20
+ messages: Message[],
21
  updatePad: (upd: MessageUpdate) => void
22
  ) {
23
+ const prompt = messages[messages.length - 1].content;
 
 
 
24
  const webSearch: WebSearch = {
25
  prompt,
26
  searchQuery: "",
src/lib/stores/convTree.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Message } from "$lib/types/Message";
2
+ import { getContext, setContext } from "svelte";
3
+ import { writable, type Writable } from "svelte/store";
4
+
5
+ // used to store the id of the message that is the currently displayed leaf of the conversation tree
6
+ // (that is the last message in the current branch of the conversation tree)
7
+
8
+ interface ConvTreeStore {
9
+ leaf: Message["id"] | null;
10
+ editing: Message["id"] | null;
11
+ }
12
+
13
+ export function useConvTreeStore() {
14
+ return getContext<Writable<ConvTreeStore>>("convTreeStore");
15
+ }
16
+
17
+ export function createConvTreeStore() {
18
+ const convTreeStore = writable<ConvTreeStore>({
19
+ leaf: null,
20
+ editing: null,
21
+ });
22
+ setContext("convTreeStore", convTreeStore);
23
+
24
+ return convTreeStore;
25
+ }
src/lib/types/Conversation.ts CHANGED
@@ -14,6 +14,7 @@ export interface Conversation extends Timestamps {
14
  embeddingModel: string;
15
 
16
  title: string;
 
17
  messages: Message[];
18
 
19
  meta?: {
 
14
  embeddingModel: string;
15
 
16
  title: string;
17
+ rootMessageId?: Message["id"];
18
  messages: Message[];
19
 
20
  meta?: {
src/lib/types/Message.ts CHANGED
@@ -3,7 +3,7 @@ import type { Timestamps } from "./Timestamps";
3
  import type { WebSearch } from "./WebSearch";
4
 
5
  export type Message = Partial<Timestamps> & {
6
- from: "user" | "assistant";
7
  id: ReturnType<typeof crypto.randomUUID>;
8
  content: string;
9
  updates?: MessageUpdate[];
@@ -12,4 +12,10 @@ export type Message = Partial<Timestamps> & {
12
  score?: -1 | 0 | 1;
13
  files?: string[]; // can contain either the hash of the file or the b64 encoded image data on the client side when uploading
14
  interrupted?: boolean;
 
 
 
 
 
 
15
  };
 
3
  import type { WebSearch } from "./WebSearch";
4
 
5
  export type Message = Partial<Timestamps> & {
6
+ from: "user" | "assistant" | "system";
7
  id: ReturnType<typeof crypto.randomUUID>;
8
  content: string;
9
  updates?: MessageUpdate[];
 
12
  score?: -1 | 0 | 1;
13
  files?: string[]; // can contain either the hash of the file or the b64 encoded image data on the client side when uploading
14
  interrupted?: boolean;
15
+
16
+ // needed for conversation trees
17
+ ancestors?: Message["id"][];
18
+
19
+ // goes one level deep
20
+ children?: Message["id"][];
21
  };
src/lib/types/SharedConversation.ts CHANGED
@@ -1,17 +1,17 @@
1
- import type { Assistant } from "./Assistant";
2
- import type { Message } from "./Message";
3
- import type { Timestamps } from "./Timestamps";
4
 
5
- export interface SharedConversation extends Timestamps {
 
 
 
 
 
 
 
 
 
 
 
6
  _id: string;
7
-
8
  hash: string;
9
-
10
- model: string;
11
- embeddingModel: string;
12
-
13
- title: string;
14
- messages: Message[];
15
- preprompt?: string;
16
- assistantId?: Assistant["_id"];
17
- }
 
1
+ import type { Conversation } from "./Conversation";
 
 
2
 
3
+ export type SharedConversation = Pick<
4
+ Conversation,
5
+ | "model"
6
+ | "embeddingModel"
7
+ | "title"
8
+ | "rootMessageId"
9
+ | "messages"
10
+ | "preprompt"
11
+ | "assistantId"
12
+ | "createdAt"
13
+ | "updatedAt"
14
+ > & {
15
  _id: string;
 
16
  hash: string;
17
+ };
 
 
 
 
 
 
 
 
src/lib/utils/tree/addChildren.spec.ts ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { collections } from "$lib/server/database";
2
+ import { ObjectId } from "mongodb";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { insertLegacyConversation, insertSideBranchesConversation } from "./treeHelpers.spec";
6
+ import { addChildren } from "./addChildren";
7
+ import type { Message } from "$lib/types/Message";
8
+
9
+ const newMessage: Omit<Message, "id"> = {
10
+ content: "new message",
11
+ from: "user",
12
+ };
13
+
14
+ Object.freeze(newMessage);
15
+
16
+ describe("addChildren", async () => {
17
+ it("should let you append on legacy conversations", async () => {
18
+ const convId = await insertLegacyConversation();
19
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
20
+ if (!conv) throw new Error("Conversation not found");
21
+
22
+ const convLength = conv.messages.length;
23
+
24
+ addChildren(conv, newMessage, conv.messages[conv.messages.length - 1].id);
25
+ expect(conv.messages.length).toEqual(convLength + 1);
26
+ });
27
+ it("should not let you create branches on legacy conversations", async () => {
28
+ const convId = await insertLegacyConversation();
29
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
30
+ if (!conv) throw new Error("Conversation not found");
31
+
32
+ expect(() => addChildren(conv, newMessage, conv.messages[0].id)).toThrow();
33
+ });
34
+ it("should not let you create a message that already exists", async () => {
35
+ const convId = await insertLegacyConversation();
36
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
37
+ if (!conv) throw new Error("Conversation not found");
38
+
39
+ const messageThatAlreadyExists: Message = {
40
+ id: conv.messages[0].id,
41
+ content: "new message",
42
+ from: "user",
43
+ };
44
+
45
+ expect(() => addChildren(conv, messageThatAlreadyExists, conv.messages[0].id)).toThrow();
46
+ });
47
+ it("should let you create branches on conversations with subtrees", async () => {
48
+ const convId = await insertSideBranchesConversation();
49
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
50
+ if (!conv) throw new Error("Conversation not found");
51
+
52
+ const nChildren = conv.messages[0].children?.length;
53
+ if (!nChildren) throw new Error("No children found");
54
+ addChildren(conv, newMessage, conv.messages[0].id);
55
+ expect(conv.messages[0].children?.length).toEqual(nChildren + 1);
56
+ });
57
+
58
+ it("should let you create a new leaf", async () => {
59
+ const convId = await insertSideBranchesConversation();
60
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
61
+ if (!conv) throw new Error("Conversation not found");
62
+
63
+ const parentId = conv.messages[conv.messages.length - 1].id;
64
+ const nChildren = conv.messages[conv.messages.length - 1].children?.length;
65
+
66
+ if (nChildren === undefined) throw new Error("No children found");
67
+ expect(nChildren).toEqual(0);
68
+
69
+ addChildren(conv, newMessage, parentId);
70
+ expect(conv.messages[conv.messages.length - 2].children?.length).toEqual(nChildren + 1);
71
+ });
72
+
73
+ it("should let you append to an empty conversation without specifying a parentId", async () => {
74
+ const conv = {
75
+ _id: new ObjectId(),
76
+ rootMessageId: undefined,
77
+ messages: [] as Message[],
78
+ };
79
+
80
+ addChildren(conv, newMessage);
81
+ expect(conv.messages.length).toEqual(1);
82
+ expect(conv.rootMessageId).toEqual(conv.messages[0].id);
83
+ });
84
+
85
+ it("should throw if you don't specify a parentId in a conversation with messages", async () => {
86
+ const convId = await insertLegacyConversation();
87
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
88
+ if (!conv) throw new Error("Conversation not found");
89
+
90
+ expect(() => addChildren(conv, newMessage)).toThrow();
91
+ });
92
+
93
+ it("should return the id of the new message", async () => {
94
+ const convId = await insertLegacyConversation();
95
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
96
+ if (!conv) throw new Error("Conversation not found");
97
+
98
+ expect(addChildren(conv, newMessage, conv.messages[conv.messages.length - 1].id)).toEqual(
99
+ conv.messages[conv.messages.length - 1].id
100
+ );
101
+ });
102
+ });
src/lib/utils/tree/addChildren.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Conversation } from "$lib/types/Conversation";
2
+ import type { Message } from "$lib/types/Message";
3
+
4
+ export function addChildren(
5
+ conv: Pick<Conversation, "messages" | "rootMessageId">,
6
+ message: Omit<Message, "id">,
7
+ parentId?: Message["id"]
8
+ ): Message["id"] {
9
+ // if this is the first message we just push it
10
+ if (conv.messages.length === 0) {
11
+ const messageId = crypto.randomUUID();
12
+ conv.rootMessageId = messageId;
13
+ conv.messages.push({
14
+ ...message,
15
+ ancestors: [],
16
+ id: messageId,
17
+ });
18
+ return messageId;
19
+ }
20
+
21
+ if (!parentId) {
22
+ throw new Error("You need to specify a parentId if this is not the first message");
23
+ }
24
+
25
+ const messageId = crypto.randomUUID();
26
+ if (!conv.rootMessageId) {
27
+ // if there is no parentId we just push the message
28
+ if (!!parentId && parentId !== conv.messages[conv.messages.length - 1].id) {
29
+ throw new Error("This is a legacy conversation, you can only append to the last message");
30
+ }
31
+ conv.messages.push({ ...message, id: messageId });
32
+ return messageId;
33
+ }
34
+
35
+ const ancestors = [...(conv.messages.find((m) => m.id === parentId)?.ancestors ?? []), parentId];
36
+ conv.messages.push({
37
+ ...message,
38
+ ancestors,
39
+ id: messageId,
40
+ children: [],
41
+ });
42
+
43
+ const parent = conv.messages.find((m) => m.id === parentId);
44
+
45
+ if (parent) {
46
+ if (parent.children) {
47
+ parent.children.push(messageId);
48
+ } else parent.children = [messageId];
49
+ }
50
+
51
+ return messageId;
52
+ }
src/lib/utils/tree/addSibling.spec.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { collections } from "$lib/server/database";
2
+ import { ObjectId } from "mongodb";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { insertLegacyConversation, insertSideBranchesConversation } from "./treeHelpers.spec";
6
+ import type { Message } from "$lib/types/Message";
7
+ import { addSibling } from "./addSibling";
8
+
9
+ const newMessage: Omit<Message, "id"> = {
10
+ content: "new message",
11
+ from: "user",
12
+ };
13
+
14
+ Object.freeze(newMessage);
15
+
16
+ describe("addSibling", async () => {
17
+ it("should fail on empty conversations", () => {
18
+ const conv = {
19
+ _id: new ObjectId(),
20
+ rootMessageId: undefined,
21
+ messages: [],
22
+ };
23
+
24
+ expect(() => addSibling(conv, newMessage, "not-a-real-id-test")).toThrow(
25
+ "Cannot add a sibling to an empty conversation"
26
+ );
27
+ });
28
+
29
+ it("should fail on legacy conversations", async () => {
30
+ const convId = await insertLegacyConversation();
31
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
32
+ if (!conv) throw new Error("Conversation not found");
33
+
34
+ expect(() => addSibling(conv, newMessage, conv.messages[0].id)).toThrow(
35
+ "Cannot add a sibling to a legacy conversation"
36
+ );
37
+ });
38
+
39
+ it("should fail if the sibling message doesn't exist", async () => {
40
+ const convId = await insertSideBranchesConversation();
41
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
42
+ if (!conv) throw new Error("Conversation not found");
43
+
44
+ expect(() => addSibling(conv, newMessage, "not-a-real-id-test")).toThrow(
45
+ "The sibling message doesn't exist"
46
+ );
47
+ });
48
+
49
+ // TODO: This behaviour should be fixed, we do not need to fail on the root message.
50
+ it("should fail if the sibling message is the root message", async () => {
51
+ const convId = await insertSideBranchesConversation();
52
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
53
+ if (!conv) throw new Error("Conversation not found");
54
+ if (!conv.rootMessageId) throw new Error("Root message not found");
55
+
56
+ expect(() => addSibling(conv, newMessage, conv.rootMessageId as Message["id"])).toThrow(
57
+ "The sibling message is the root message, therefore we can't add a sibling"
58
+ );
59
+ });
60
+
61
+ it("should add a sibling to a message", async () => {
62
+ const convId = await insertSideBranchesConversation();
63
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
64
+ if (!conv) throw new Error("Conversation not found");
65
+
66
+ // add sibling and check children count for parnets
67
+
68
+ const nChildren = conv.messages[1].children?.length;
69
+ const siblingId = addSibling(conv, newMessage, conv.messages[2].id);
70
+ const nChildrenNew = conv.messages[1].children?.length;
71
+
72
+ if (!nChildren) throw new Error("No children found");
73
+
74
+ expect(nChildrenNew).toBe(nChildren + 1);
75
+
76
+ // make sure siblings have the same ancestors
77
+ const sibling = conv.messages.find((m) => m.id === siblingId);
78
+ expect(sibling?.ancestors).toEqual(conv.messages[2].ancestors);
79
+ });
80
+ });
src/lib/utils/tree/addSibling.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Conversation } from "$lib/types/Conversation";
2
+ import type { Message } from "$lib/types/Message";
3
+
4
+ export function addSibling(
5
+ conv: Pick<Conversation, "messages" | "rootMessageId">,
6
+ message: Omit<Message, "id">,
7
+ siblingId: Message["id"]
8
+ ): Message["id"] {
9
+ if (conv.messages.length === 0) {
10
+ throw new Error("Cannot add a sibling to an empty conversation");
11
+ }
12
+ if (!conv.rootMessageId) {
13
+ throw new Error("Cannot add a sibling to a legacy conversation");
14
+ }
15
+
16
+ const sibling = conv.messages.find((m) => m.id === siblingId);
17
+
18
+ if (!sibling) {
19
+ throw new Error("The sibling message doesn't exist");
20
+ }
21
+
22
+ if (!sibling.ancestors || sibling.ancestors?.length === 0) {
23
+ throw new Error("The sibling message is the root message, therefore we can't add a sibling");
24
+ }
25
+
26
+ const messageId = crypto.randomUUID();
27
+
28
+ conv.messages.push({
29
+ ...message,
30
+ id: messageId,
31
+ ancestors: sibling.ancestors,
32
+ children: [],
33
+ });
34
+
35
+ const nearestAncestorId = sibling.ancestors[sibling.ancestors.length - 1];
36
+ const nearestAncestor = conv.messages.find((m) => m.id === nearestAncestorId);
37
+
38
+ if (nearestAncestor) {
39
+ if (nearestAncestor.children) {
40
+ nearestAncestor.children.push(messageId);
41
+ } else nearestAncestor.children = [messageId];
42
+ }
43
+
44
+ return messageId;
45
+ }
src/lib/utils/tree/buildSubtree.spec.ts ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { collections } from "$lib/server/database";
2
+ import { ObjectId } from "mongodb";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import {
6
+ insertLegacyConversation,
7
+ insertLinearBranchConversation,
8
+ insertSideBranchesConversation,
9
+ } from "./treeHelpers.spec";
10
+ import { buildSubtree } from "./buildSubtree";
11
+
12
+ describe("buildSubtree", () => {
13
+ it("a subtree in a legacy conversation should be just a slice", async () => {
14
+ const convId = await insertLegacyConversation();
15
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
16
+ if (!conv) throw new Error("Conversation not found");
17
+
18
+ // check middle
19
+ const id = conv.messages[2].id;
20
+ const subtree = buildSubtree(conv, id);
21
+ expect(subtree).toEqual(conv.messages.slice(0, 3));
22
+
23
+ // check zero
24
+ const id2 = conv.messages[0].id;
25
+ const subtree2 = buildSubtree(conv, id2);
26
+ expect(subtree2).toEqual(conv.messages.slice(0, 1));
27
+
28
+ //check full length
29
+ const id3 = conv.messages[conv.messages.length - 1].id;
30
+ const subtree3 = buildSubtree(conv, id3);
31
+ expect(subtree3).toEqual(conv.messages);
32
+ });
33
+
34
+ it("a subtree in a linear branch conversation should be the ancestors and the message", async () => {
35
+ const convId = await insertLinearBranchConversation();
36
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
37
+ if (!conv) throw new Error("Conversation not found");
38
+
39
+ // check middle
40
+ const id = conv.messages[1].id;
41
+ const subtree = buildSubtree(conv, id);
42
+ expect(subtree).toEqual([conv.messages[0], conv.messages[1]]);
43
+
44
+ // check zero
45
+ const id2 = conv.messages[0].id;
46
+ const subtree2 = buildSubtree(conv, id2);
47
+ expect(subtree2).toEqual([conv.messages[0]]);
48
+
49
+ //check full length
50
+ const id3 = conv.messages[conv.messages.length - 1].id;
51
+ const subtree3 = buildSubtree(conv, id3);
52
+ expect(subtree3).toEqual(conv.messages);
53
+ });
54
+
55
+ it("should throw an error if the message is not found", async () => {
56
+ const convId = await insertLinearBranchConversation();
57
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
58
+ if (!conv) throw new Error("Conversation not found");
59
+
60
+ const id = "not-a-real-id-test";
61
+
62
+ expect(() => buildSubtree(conv, id)).toThrow("Message not found");
63
+ });
64
+
65
+ it("should throw an error if the ancestor is not found", async () => {
66
+ const convId = await insertLinearBranchConversation();
67
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
68
+ if (!conv) throw new Error("Conversation not found");
69
+
70
+ const id = "1-1-1-1-2";
71
+
72
+ conv.messages[1].ancestors = ["not-a-real-id-test"];
73
+
74
+ expect(() => buildSubtree(conv, id)).toThrow("Ancestor not found");
75
+ });
76
+
77
+ it("should work on empty conversations", () => {
78
+ const conv = {
79
+ _id: new ObjectId(),
80
+ rootMessageId: undefined,
81
+ messages: [],
82
+ };
83
+
84
+ const subtree = buildSubtree(conv, "not-a-real-id-test");
85
+ expect(subtree).toEqual([]);
86
+ });
87
+
88
+ it("should work for conversation with subtrees", async () => {
89
+ const convId = await insertSideBranchesConversation();
90
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
91
+ if (!conv) throw new Error("Conversation not found");
92
+
93
+ const subtree = buildSubtree(conv, "1-1-1-1-2");
94
+ expect(subtree).toEqual([conv.messages[0], conv.messages[1]]);
95
+
96
+ const subtree2 = buildSubtree(conv, "1-1-1-1-4");
97
+ expect(subtree2).toEqual([
98
+ conv.messages[0],
99
+ conv.messages[1],
100
+ conv.messages[2],
101
+ conv.messages[3],
102
+ ]);
103
+
104
+ const subtree3 = buildSubtree(conv, "1-1-1-1-6");
105
+ expect(subtree3).toEqual([conv.messages[0], conv.messages[4], conv.messages[5]]);
106
+
107
+ const subtree4 = buildSubtree(conv, "1-1-1-1-7");
108
+ expect(subtree4).toEqual([conv.messages[0], conv.messages[4], conv.messages[6]]);
109
+ });
110
+ });
src/lib/utils/tree/buildSubtree.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Conversation } from "$lib/types/Conversation";
2
+ import type { Message } from "$lib/types/Message";
3
+
4
+ export function buildSubtree(
5
+ conv: Pick<Conversation, "messages" | "rootMessageId">,
6
+ id: Message["id"]
7
+ ): Message[] {
8
+ if (!conv.rootMessageId) {
9
+ if (conv.messages.length === 0) return [];
10
+ // legacy conversation slice up to id
11
+ const index = conv.messages.findIndex((m) => m.id === id);
12
+ if (index === -1) throw new Error("Message not found");
13
+ return conv.messages.slice(0, index + 1);
14
+ } else {
15
+ // find the message with the right id then create the ancestor tree
16
+ const message = conv.messages.find((m) => m.id === id);
17
+ if (!message) throw new Error("Message not found");
18
+
19
+ return [
20
+ ...(message.ancestors?.map((ancestorId) => {
21
+ const ancestor = conv.messages.find((m) => m.id === ancestorId);
22
+ if (!ancestor) throw new Error("Ancestor not found");
23
+ return ancestor;
24
+ }) ?? []),
25
+ message,
26
+ ];
27
+ }
28
+ }
src/lib/utils/tree/convertLegacyConversation.spec.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { collections } from "$lib/server/database";
2
+ import { ObjectId } from "mongodb";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { convertLegacyConversation } from "./convertLegacyConversation";
6
+ import { insertLegacyConversation } from "./treeHelpers.spec";
7
+
8
+ describe("convertLegacyConversation", () => {
9
+ it("should convert a legacy conversation", async () => {
10
+ const convId = await insertLegacyConversation();
11
+ const conv = await collections.conversations.findOne({ _id: new ObjectId(convId) });
12
+ if (!conv) throw new Error("Conversation not found");
13
+
14
+ const newConv = convertLegacyConversation(conv);
15
+
16
+ expect(newConv.rootMessageId).toBe(newConv.messages[0].id);
17
+ expect(newConv.messages[0].ancestors).toEqual([]);
18
+ expect(newConv.messages[1].ancestors).toEqual([newConv.messages[0].id]);
19
+ expect(newConv.messages[0].children).toEqual([newConv.messages[1].id]);
20
+ });
21
+ it("should work on empty conversations", async () => {
22
+ const conv = {
23
+ _id: new ObjectId(),
24
+ rootMessageId: undefined,
25
+ messages: [],
26
+ };
27
+ const newConv = convertLegacyConversation(conv);
28
+ expect(newConv.rootMessageId).toBe(undefined);
29
+ expect(newConv.messages).toEqual([]);
30
+ });
31
+ });
src/lib/utils/tree/convertLegacyConversation.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Conversation } from "$lib/types/Conversation";
2
+ import type { Message } from "$lib/types/Message";
3
+
4
+ export function convertLegacyConversation(
5
+ conv: Pick<Conversation, "messages" | "rootMessageId" | "preprompt">
6
+ ): Pick<Conversation, "messages" | "rootMessageId" | "preprompt"> {
7
+ if (conv.rootMessageId) return conv; // not a legacy conversation
8
+ if (conv.messages.length === 0) return conv; // empty conversation
9
+ const messages = [
10
+ {
11
+ from: "system",
12
+ content: conv.preprompt ?? "",
13
+ createdAt: new Date(),
14
+ updatedAt: new Date(),
15
+ id: crypto.randomUUID(),
16
+ } satisfies Message,
17
+ ...conv.messages,
18
+ ];
19
+
20
+ const rootMessageId = messages[0].id;
21
+
22
+ const newMessages = messages.map((message, index) => {
23
+ return {
24
+ ...message,
25
+ ancestors: messages.slice(0, index).map((m) => m.id),
26
+ children: index < messages.length - 1 ? [messages[index + 1].id] : [],
27
+ };
28
+ });
29
+
30
+ return {
31
+ ...conv,
32
+ rootMessageId,
33
+ messages: newMessages,
34
+ };
35
+ }
src/lib/utils/tree/isMessageId.spec.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { isMessageId } from "./isMessageId";
3
+
4
+ describe("isMessageId", () => {
5
+ it("should return true for a valid message id", () => {
6
+ expect(isMessageId(crypto.randomUUID())).toBe(true);
7
+ });
8
+ it("should return false for an invalid message id", () => {
9
+ expect(isMessageId("1-2-3-4")).toBe(false);
10
+ });
11
+ it("should return false for an empty string", () => {
12
+ expect(isMessageId("")).toBe(false);
13
+ });
14
+ });
src/lib/utils/tree/isMessageId.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import type { Message } from "$lib/types/Message";
2
+
3
+ export function isMessageId(id: string): id is Message["id"] {
4
+ return id.split("-").length === 5;
5
+ }
src/lib/utils/tree/treeHelpers.spec.ts ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { collections } from "$lib/server/database";
2
+ import { ObjectId } from "mongodb";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ // function used to insert conversations used for testing
6
+
7
+ export const insertLegacyConversation = async () => {
8
+ const res = await collections.conversations.insertOne({
9
+ _id: new ObjectId(),
10
+ createdAt: new Date(),
11
+ updatedAt: new Date(),
12
+ title: "legacy conversation",
13
+ model: "",
14
+ embeddingModel: "",
15
+ messages: [
16
+ {
17
+ id: "1-1-1-1-1",
18
+ from: "user",
19
+ content: "Hello, world! I am a user",
20
+ },
21
+ {
22
+ id: "1-1-1-1-2",
23
+ from: "assistant",
24
+ content: "Hello, world! I am an assistant.",
25
+ },
26
+ {
27
+ id: "1-1-1-1-3",
28
+ from: "user",
29
+ content: "Hello, world! I am a user.",
30
+ },
31
+ {
32
+ id: "1-1-1-1-4",
33
+ from: "assistant",
34
+ content: "Hello, world! I am an assistant.",
35
+ },
36
+ ],
37
+ });
38
+ return res.insertedId;
39
+ };
40
+
41
+ export const insertLinearBranchConversation = async () => {
42
+ const res = await collections.conversations.insertOne({
43
+ _id: new ObjectId(),
44
+ createdAt: new Date(),
45
+ updatedAt: new Date(),
46
+ title: "linear branch conversation",
47
+ model: "",
48
+ embeddingModel: "",
49
+
50
+ rootMessageId: "1-1-1-1-1",
51
+ messages: [
52
+ {
53
+ id: "1-1-1-1-1",
54
+ from: "user",
55
+ content: "Hello, world! I am a user",
56
+ ancestors: [],
57
+ children: ["1-1-1-1-2"],
58
+ },
59
+ {
60
+ id: "1-1-1-1-2",
61
+ from: "assistant",
62
+ content: "Hello, world! I am an assistant.",
63
+ ancestors: ["1-1-1-1-1"],
64
+ children: ["1-1-1-1-3"],
65
+ },
66
+ {
67
+ id: "1-1-1-1-3",
68
+ from: "user",
69
+ content: "Hello, world! I am a user.",
70
+ ancestors: ["1-1-1-1-1", "1-1-1-1-2"],
71
+ children: ["1-1-1-1-4"],
72
+ },
73
+ {
74
+ id: "1-1-1-1-4",
75
+ from: "assistant",
76
+ content: "Hello, world! I am an assistant.",
77
+ ancestors: ["1-1-1-1-1", "1-1-1-1-2", "1-1-1-1-3"],
78
+ children: [],
79
+ },
80
+ ],
81
+ });
82
+ return res.insertedId;
83
+ };
84
+
85
+ export const insertSideBranchesConversation = async () => {
86
+ const res = await collections.conversations.insertOne({
87
+ _id: new ObjectId(),
88
+ createdAt: new Date(),
89
+ updatedAt: new Date(),
90
+ title: "side branches conversation",
91
+ model: "",
92
+ embeddingModel: "",
93
+ rootMessageId: "1-1-1-1-1",
94
+ messages: [
95
+ {
96
+ id: "1-1-1-1-1",
97
+ from: "user",
98
+ content: "Hello, world, root message!",
99
+ ancestors: [],
100
+ children: ["1-1-1-1-2", "1-1-1-1-5"],
101
+ },
102
+ {
103
+ id: "1-1-1-1-2",
104
+ from: "assistant",
105
+ content: "Hello, response to root message!",
106
+ ancestors: ["1-1-1-1-1"],
107
+ children: ["1-1-1-1-3"],
108
+ },
109
+ {
110
+ id: "1-1-1-1-3",
111
+ from: "user",
112
+ content: "Hello, follow up question!",
113
+ ancestors: ["1-1-1-1-1", "1-1-1-1-2"],
114
+ children: ["1-1-1-1-4"],
115
+ },
116
+ {
117
+ id: "1-1-1-1-4",
118
+ from: "assistant",
119
+ content: "Hello, response from follow up question!",
120
+ ancestors: ["1-1-1-1-1", "1-1-1-1-2", "1-1-1-1-3"],
121
+ children: [],
122
+ },
123
+ {
124
+ id: "1-1-1-1-5",
125
+ from: "assistant",
126
+ content: "Hello, alternative assistant answer!",
127
+ ancestors: ["1-1-1-1-1"],
128
+ children: ["1-1-1-1-6", "1-1-1-1-7"],
129
+ },
130
+ {
131
+ id: "1-1-1-1-6",
132
+ from: "user",
133
+ content: "Hello, follow up question to alternative answer!",
134
+ ancestors: ["1-1-1-1-1", "1-1-1-1-5"],
135
+ children: [],
136
+ },
137
+ {
138
+ id: "1-1-1-1-7",
139
+ from: "user",
140
+ content: "Hello, alternative follow up question to alternative answer!",
141
+ ancestors: ["1-1-1-1-1", "1-1-1-1-5"],
142
+ children: [],
143
+ },
144
+ ],
145
+ });
146
+ return res.insertedId;
147
+ };
148
+
149
+ describe("inserting conversations", () => {
150
+ it("should insert a legacy conversation", async () => {
151
+ const id = await insertLegacyConversation();
152
+ expect(id).toBeDefined();
153
+ });
154
+
155
+ it("should insert a linear branch conversation", async () => {
156
+ const id = await insertLinearBranchConversation();
157
+ expect(id).toBeDefined();
158
+ });
159
+
160
+ it("should insert a side branches conversation", async () => {
161
+ const id = await insertSideBranchesConversation();
162
+ expect(id).toBeDefined();
163
+ });
164
+ });
src/routes/conversation/+server.ts CHANGED
@@ -12,7 +12,6 @@ export const POST: RequestHandler = async ({ locals, request }) => {
12
  const body = await request.text();
13
 
14
  let title = "";
15
- let messages: Message[] = [];
16
 
17
  const values = z
18
  .object({
@@ -23,6 +22,19 @@ export const POST: RequestHandler = async ({ locals, request }) => {
23
  })
24
  .parse(JSON.parse(body));
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  let embeddingModel: string;
27
 
28
  if (values.fromShare) {
@@ -36,6 +48,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
36
 
37
  title = conversation.title;
38
  messages = conversation.messages;
 
39
  values.model = conversation.model;
40
  values.preprompt = conversation.preprompt;
41
  values.assistantId = conversation.assistantId?.toString();
@@ -69,6 +82,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
69
  const res = await collections.conversations.insertOne({
70
  _id: new ObjectId(),
71
  title: title || "New Chat",
 
72
  messages,
73
  model: values.model,
74
  preprompt: preprompt === model?.preprompt ? model?.preprompt : preprompt,
 
12
  const body = await request.text();
13
 
14
  let title = "";
 
15
 
16
  const values = z
17
  .object({
 
22
  })
23
  .parse(JSON.parse(body));
24
 
25
+ let messages: Message[] = [
26
+ {
27
+ id: crypto.randomUUID(),
28
+ from: "system",
29
+ content: values.preprompt ?? "",
30
+ createdAt: new Date(),
31
+ updatedAt: new Date(),
32
+ children: [],
33
+ ancestors: [],
34
+ },
35
+ ];
36
+
37
+ let rootMessageId: Message["id"] = messages[0].id;
38
  let embeddingModel: string;
39
 
40
  if (values.fromShare) {
 
48
 
49
  title = conversation.title;
50
  messages = conversation.messages;
51
+ rootMessageId = conversation.rootMessageId ?? rootMessageId;
52
  values.model = conversation.model;
53
  values.preprompt = conversation.preprompt;
54
  values.assistantId = conversation.assistantId?.toString();
 
82
  const res = await collections.conversations.insertOne({
83
  _id: new ObjectId(),
84
  title: title || "New Chat",
85
+ rootMessageId,
86
  messages,
87
  model: values.model,
88
  preprompt: preprompt === model?.preprompt ? model?.preprompt : preprompt,
src/routes/conversation/[id]/+page.server.ts CHANGED
@@ -3,6 +3,7 @@ import { ObjectId } from "mongodb";
3
  import { error } from "@sveltejs/kit";
4
  import { authCondition } from "$lib/server/auth";
5
  import { UrlDependency } from "$lib/types/UrlDependency";
 
6
 
7
  export const load = async ({ params, depends, locals }) => {
8
  let conversation;
@@ -45,16 +46,19 @@ export const load = async ({ params, depends, locals }) => {
45
  }
46
  }
47
 
 
 
48
  return {
49
- messages: conversation.messages,
50
- title: conversation.title,
51
- model: conversation.model,
52
- preprompt: conversation.preprompt,
53
- assistant: conversation.assistantId
 
54
  ? JSON.parse(
55
  JSON.stringify(
56
  await collections.assistants.findOne({
57
- _id: new ObjectId(conversation.assistantId),
58
  })
59
  )
60
  )
 
3
  import { error } from "@sveltejs/kit";
4
  import { authCondition } from "$lib/server/auth";
5
  import { UrlDependency } from "$lib/types/UrlDependency";
6
+ import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversation.js";
7
 
8
  export const load = async ({ params, depends, locals }) => {
9
  let conversation;
 
46
  }
47
  }
48
 
49
+ const convertedConv = { ...conversation, ...convertLegacyConversation(conversation) };
50
+
51
  return {
52
+ messages: convertedConv.messages,
53
+ title: convertedConv.title,
54
+ model: convertedConv.model,
55
+ preprompt: convertedConv.preprompt,
56
+ rootMessageId: convertedConv.rootMessageId,
57
+ assistant: convertedConv.assistantId
58
  ? JSON.parse(
59
  JSON.stringify(
60
  await collections.assistants.findOne({
61
+ _id: new ObjectId(convertedConv.assistantId),
62
  })
63
  )
64
  )
src/routes/conversation/[id]/+page.svelte CHANGED
@@ -9,20 +9,21 @@
9
  import { shareConversation } from "$lib/shareConversation";
10
  import { UrlDependency } from "$lib/types/UrlDependency";
11
  import { ERROR_MESSAGES, error } from "$lib/stores/errors";
12
- import { randomUUID } from "$lib/utils/randomUuid";
13
  import { findCurrentModel } from "$lib/utils/models";
14
  import { webSearchParameters } from "$lib/stores/webSearchParameters";
15
  import type { Message } from "$lib/types/Message";
16
- import type { MessageUpdate, WebSearchUpdate } from "$lib/types/MessageUpdate";
17
  import titleUpdate from "$lib/stores/titleUpdate";
18
  import file2base64 from "$lib/utils/file2base64";
 
 
 
 
19
  export let data;
20
 
21
  let messages = data.messages;
22
  let lastLoadedMessages = data.messages;
23
 
24
- let webSearchMessages: WebSearchUpdate[] = [];
25
-
26
  // Since we modify the messages array locally, we don't want to reset it if an old version is passed
27
  $: if (data.messages !== lastLoadedMessages) {
28
  messages = data.messages;
@@ -66,12 +67,12 @@
66
  // this function is used to send new message to the backends
67
  async function writeMessage({
68
  prompt,
69
- messageId = randomUUID(),
70
  isRetry = false,
71
  isContinue = false,
72
  }: {
73
  prompt?: string;
74
- messageId?: ReturnType<typeof randomUUID>;
75
  isRetry?: boolean;
76
  isContinue?: boolean;
77
  }): Promise<void> {
@@ -80,25 +81,7 @@
80
  loading = true;
81
  pending = true;
82
 
83
- // first we check if the messageId already exists, indicating a retry
84
-
85
- let msgIndex = messages.findIndex((msg) => msg.id === messageId);
86
-
87
- if (msgIndex === -1) {
88
- msgIndex = messages.length - 1;
89
- }
90
- if (isRetry && messages[msgIndex].from === "assistant") {
91
- throw new Error("Trying to retry a message that is not from user");
92
- }
93
-
94
- if (isContinue && messages[msgIndex].from === "user") {
95
- throw new Error("Trying to continue a message that is not from assistant");
96
- }
97
-
98
- // const isNewMessage = !isRetry && !isContinue;
99
-
100
  const module = await import("browser-image-resizer");
101
-
102
  // currently, only IDEFICS is supported by TGI
103
  // the size of images is hardcoded to 224x224 in TGI
104
  // this will need to be configurable when support for more models is added
@@ -114,35 +97,101 @@
114
  })
115
  );
116
 
117
- // slice up to the point of the retry
118
- if (isRetry) {
119
- messages = [
120
- ...messages.slice(0, msgIndex),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  {
122
- from: "user",
123
- content: messages[msgIndex].content,
124
- id: messageId,
125
- files: messages[msgIndex].files,
126
  },
127
- ];
128
- } else if (!isContinue) {
129
- // or add a new message if its not a continue request
130
- if (!prompt) {
131
- throw new Error("Prompt is undefined");
132
- }
133
- messages = [
134
- ...messages,
135
  {
136
  from: "user",
137
  content: prompt ?? "",
138
- id: messageId,
139
  files: resizedImages,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  },
141
- ];
 
142
  }
143
 
144
- files = [];
 
145
 
 
 
 
146
  // disable websearch if assistant is present
147
  const hasAssistant = !!$page.data.assistant;
148
 
@@ -221,25 +270,16 @@
221
  invalidate(UrlDependency.Conversation);
222
  } else if (update.type === "stream") {
223
  pending = false;
224
-
225
- let lastMessage = messages[messages.length - 1];
226
-
227
- if (lastMessage.from !== "assistant") {
228
- messages = [
229
- ...messages,
230
- { from: "assistant", id: randomUUID(), content: update.token },
231
- ];
232
- } else {
233
- lastMessage.content += update.token;
234
- messages = [...messages];
235
- }
236
  } else if (update.type === "webSearch") {
237
- webSearchMessages = [...webSearchMessages, update];
 
238
  } else if (update.type === "status") {
239
  if (update.status === "title" && update.message) {
240
- const conv = data.conversations.find(({ id }) => id === $page.params.id);
241
- if (conv) {
242
- conv.title = update.message;
243
 
244
  $titleUpdate = {
245
  title: update.message,
@@ -265,11 +305,7 @@
265
  });
266
  }
267
 
268
- webSearchMessages = [];
269
-
270
- const lastMessage = messages[messages.length - 1];
271
- lastMessage.updates = messageUpdates;
272
-
273
  await invalidate(UrlDependency.ConversationList);
274
  } catch (err) {
275
  if (err instanceof Error && err.message.includes("overloaded")) {
@@ -285,6 +321,7 @@
285
  } finally {
286
  loading = false;
287
  pending = false;
 
288
  }
289
  }
290
 
@@ -336,7 +373,7 @@
336
  }
337
  }
338
 
339
- async function onRetry(event: CustomEvent<{ id: Message["id"]; content: string }>) {
340
  if (!data.shared) {
341
  await writeMessage({
342
  prompt: event.detail.content,
@@ -363,11 +400,26 @@
363
  async function onContinue(event: CustomEvent<{ id: Message["id"] }>) {
364
  if (!data.shared) {
365
  writeMessage({ messageId: event.detail.id, isContinue: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  }
367
  }
368
 
369
- $: $page.params.id, (($isAborted = true), (loading = false));
370
  $: title = data.conversations.find((conv) => conv.id === $page.params.id)?.title ?? data.title;
 
 
371
  </script>
372
 
373
  <svelte:head>
@@ -386,7 +438,6 @@
386
  {messages}
387
  shared={data.shared}
388
  preprompt={data.preprompt}
389
- bind:webSearchMessages
390
  bind:files
391
  on:message={onMessage}
392
  on:retry={onRetry}
 
9
  import { shareConversation } from "$lib/shareConversation";
10
  import { UrlDependency } from "$lib/types/UrlDependency";
11
  import { ERROR_MESSAGES, error } from "$lib/stores/errors";
 
12
  import { findCurrentModel } from "$lib/utils/models";
13
  import { webSearchParameters } from "$lib/stores/webSearchParameters";
14
  import type { Message } from "$lib/types/Message";
15
+ import type { MessageUpdate } from "$lib/types/MessageUpdate";
16
  import titleUpdate from "$lib/stores/titleUpdate";
17
  import file2base64 from "$lib/utils/file2base64";
18
+ import { addChildren } from "$lib/utils/tree/addChildren";
19
+ import { addSibling } from "$lib/utils/tree/addSibling";
20
+ import { createConvTreeStore } from "$lib/stores/convTree";
21
+
22
  export let data;
23
 
24
  let messages = data.messages;
25
  let lastLoadedMessages = data.messages;
26
 
 
 
27
  // Since we modify the messages array locally, we don't want to reset it if an old version is passed
28
  $: if (data.messages !== lastLoadedMessages) {
29
  messages = data.messages;
 
67
  // this function is used to send new message to the backends
68
  async function writeMessage({
69
  prompt,
70
+ messageId = $convTreeStore.leaf ?? undefined,
71
  isRetry = false,
72
  isContinue = false,
73
  }: {
74
  prompt?: string;
75
+ messageId?: ReturnType<typeof crypto.randomUUID>;
76
  isRetry?: boolean;
77
  isContinue?: boolean;
78
  }): Promise<void> {
 
81
  loading = true;
82
  pending = true;
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  const module = await import("browser-image-resizer");
 
85
  // currently, only IDEFICS is supported by TGI
86
  // the size of images is hardcoded to 224x224 in TGI
87
  // this will need to be configurable when support for more models is added
 
97
  })
98
  );
99
 
100
+ let messageToWriteToId: Message["id"] | undefined = undefined;
101
+ // used for building the prompt, subtree of the conversation that goes from the latest message to the root
102
+
103
+ if (isContinue && messageId) {
104
+ if ((messages.find((msg) => msg.id === messageId)?.children?.length ?? 0) > 0) {
105
+ $error = "Can only continue the last message";
106
+ } else {
107
+ messageToWriteToId = messageId;
108
+ }
109
+ } else if (isRetry && messageId) {
110
+ // two cases, if we're retrying a user message with a newPrompt set,
111
+ // it means we're editing a user message
112
+ // if we're retrying on an assistant message, newPrompt cannot be set
113
+ // it means we're retrying the last assistant message for a new answer
114
+
115
+ const messageToRetry = messages.find((message) => message.id === messageId);
116
+
117
+ if (!messageToRetry) {
118
+ $error = "Message not found";
119
+ }
120
+
121
+ if (messageToRetry?.from === "user" && prompt) {
122
+ // add a sibling to this message from the user, with the alternative prompt
123
+ // add a children to that sibling, where we can write to
124
+ const newUserMessageId = addSibling(
125
+ {
126
+ messages,
127
+ rootMessageId: data.rootMessageId,
128
+ },
129
+ { from: "user", content: prompt },
130
+ messageId
131
+ );
132
+ messageToWriteToId = addChildren(
133
+ {
134
+ messages,
135
+ rootMessageId: data.rootMessageId,
136
+ },
137
+ { from: "assistant", content: "", files: resizedImages },
138
+ newUserMessageId
139
+ );
140
+ } else if (messageToRetry?.from === "assistant") {
141
+ // we're retrying an assistant message, to generate a new answer
142
+ // just add a sibling to the assistant answer where we can write to
143
+ messageToWriteToId = addSibling(
144
+ {
145
+ messages,
146
+ rootMessageId: data.rootMessageId,
147
+ },
148
+ { from: "assistant", content: "" },
149
+ messageId
150
+ );
151
+ }
152
+ } else {
153
+ // just a normal linear conversation, so we add the user message
154
+ // and the blank assistant message back to back
155
+ const newUserMessageId = addChildren(
156
  {
157
+ messages,
158
+ rootMessageId: data.rootMessageId,
 
 
159
  },
 
 
 
 
 
 
 
 
160
  {
161
  from: "user",
162
  content: prompt ?? "",
 
163
  files: resizedImages,
164
+ createdAt: new Date(),
165
+ updatedAt: new Date(),
166
+ },
167
+ messageId
168
+ );
169
+
170
+ if (!data.rootMessageId) {
171
+ data.rootMessageId = newUserMessageId;
172
+ }
173
+
174
+ messageToWriteToId = addChildren(
175
+ {
176
+ messages,
177
+ rootMessageId: data.rootMessageId,
178
+ },
179
+ {
180
+ from: "assistant",
181
+ content: "",
182
+ createdAt: new Date(),
183
+ updatedAt: new Date(),
184
  },
185
+ newUserMessageId
186
+ );
187
  }
188
 
189
+ messages = [...messages];
190
+ const messageToWriteTo = messages.find((message) => message.id === messageToWriteToId);
191
 
192
+ if (!messageToWriteTo) {
193
+ throw new Error("Message to write to not found");
194
+ }
195
  // disable websearch if assistant is present
196
  const hasAssistant = !!$page.data.assistant;
197
 
 
270
  invalidate(UrlDependency.Conversation);
271
  } else if (update.type === "stream") {
272
  pending = false;
273
+ messageToWriteTo.content += update.token;
274
+ messages = [...messages];
 
 
 
 
 
 
 
 
 
 
275
  } else if (update.type === "webSearch") {
276
+ messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
277
+ messages = [...messages];
278
  } else if (update.type === "status") {
279
  if (update.status === "title" && update.message) {
280
+ const convInData = data.conversations.find(({ id }) => id === $page.params.id);
281
+ if (convInData) {
282
+ convInData.title = update.message;
283
 
284
  $titleUpdate = {
285
  title: update.message,
 
305
  });
306
  }
307
 
308
+ messageToWriteTo.updates = messageUpdates;
 
 
 
 
309
  await invalidate(UrlDependency.ConversationList);
310
  } catch (err) {
311
  if (err instanceof Error && err.message.includes("overloaded")) {
 
321
  } finally {
322
  loading = false;
323
  pending = false;
324
+ await invalidate(UrlDependency.Conversation);
325
  }
326
  }
327
 
 
373
  }
374
  }
375
 
376
+ async function onRetry(event: CustomEvent<{ id: Message["id"]; content?: string }>) {
377
  if (!data.shared) {
378
  await writeMessage({
379
  prompt: event.detail.content,
 
400
  async function onContinue(event: CustomEvent<{ id: Message["id"] }>) {
401
  if (!data.shared) {
402
  writeMessage({ messageId: event.detail.id, isContinue: true });
403
+ } else {
404
+ await convFromShared()
405
+ .then(async (convId) => {
406
+ await goto(`${base}/conversation/${convId}`, { invalidateAll: true });
407
+ })
408
+ .then(
409
+ async () =>
410
+ await writeMessage({
411
+ messageId: event.detail.id,
412
+ isContinue: true,
413
+ })
414
+ )
415
+ .finally(() => (loading = false));
416
  }
417
  }
418
 
419
+ $: $page.params.id, (($isAborted = true), (loading = false), ($convTreeStore.editing = null));
420
  $: title = data.conversations.find((conv) => conv.id === $page.params.id)?.title ?? data.title;
421
+
422
+ const convTreeStore = createConvTreeStore();
423
  </script>
424
 
425
  <svelte:head>
 
438
  {messages}
439
  shared={data.shared}
440
  preprompt={data.preprompt}
 
441
  bind:files
442
  on:message={onMessage}
443
  on:retry={onRetry}
src/routes/conversation/[id]/+server.ts CHANGED
@@ -9,11 +9,16 @@ import { ObjectId } from "mongodb";
9
  import { z } from "zod";
10
  import type { MessageUpdate } from "$lib/types/MessageUpdate";
11
  import { runWebSearch } from "$lib/server/websearch/runWebSearch";
12
- import type { WebSearch } from "$lib/types/WebSearch";
13
  import { abortedGenerations } from "$lib/server/abortedGenerations";
14
  import { summarize } from "$lib/server/summarize";
15
  import { uploadFile } from "$lib/server/files/uploadFile";
16
  import sizeof from "image-size";
 
 
 
 
 
 
17
 
18
  export async function POST({ request, locals, params, getClientAddress }) {
19
  const id = z.string().parse(params.id);
@@ -28,6 +33,29 @@ export async function POST({ request, locals, params, getClientAddress }) {
28
  }
29
 
30
  // check if the user has access to the conversation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  const conv = await collections.conversations.findOne({
32
  _id: convId,
33
  ...authCondition(locals),
@@ -97,8 +125,8 @@ export async function POST({ request, locals, params, getClientAddress }) {
97
  files: b64files,
98
  } = z
99
  .object({
 
100
  inputs: z.optional(z.string().trim().min(1)),
101
- id: z.optional(z.string().uuid()),
102
  is_retry: z.optional(z.boolean()),
103
  is_continue: z.optional(z.boolean()),
104
  web_search: z.optional(z.boolean()),
@@ -138,59 +166,93 @@ export async function POST({ request, locals, params, getClientAddress }) {
138
  hashes = await Promise.all(files.map(async (file) => await uploadFile(file, conv)));
139
  }
140
 
141
- // can only call isContinue on the last message id
142
- if (isContinue && conv.messages[conv.messages.length - 1].id !== messageId) {
143
- throw error(400, "Can only continue the last message");
144
- }
145
 
146
- // get the list of messages
147
- // while checking for retries
148
- let messages = (() => {
149
- // for retries we slice and rewrite the last user message
150
- if (isRetry && messageId) {
151
- // if the message is a retry, replace the message and remove the messages after it
152
- let retryMessageIdx = conv.messages.findIndex((message) => message.id === messageId);
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- if (retryMessageIdx === -1) {
155
- retryMessageIdx = conv.messages.length;
156
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
- return [
159
- ...conv.messages.slice(0, retryMessageIdx),
160
- {
161
- content: conv.messages[retryMessageIdx]?.content,
162
- from: "user",
163
- id: messageId as Message["id"],
164
- updatedAt: new Date(),
165
- files: conv.messages[retryMessageIdx]?.files,
166
- },
167
- ];
168
- } else if (isContinue && messageId) {
169
- // for continue we do nothing and expand the last assistant message
170
- return conv.messages;
171
- } else {
172
- // in normal conversation we add an extra user message
173
- return [
174
- ...conv.messages,
175
- {
176
- content: newPrompt ?? "",
177
- from: "user",
178
- id: (messageId as Message["id"]) || crypto.randomUUID(),
179
- createdAt: new Date(),
180
- updatedAt: new Date(),
181
- files: hashes,
182
- },
183
- ];
184
- } // else append the message at the bottom
185
- })() satisfies Message[];
186
 
 
 
 
 
 
 
 
 
 
187
  await collections.conversations.updateOne(
188
  {
189
  _id: convId,
190
  },
191
  {
192
  $set: {
193
- messages,
194
  title: conv.title,
195
  updatedAt: new Date(),
196
  },
@@ -202,13 +264,10 @@ export async function POST({ request, locals, params, getClientAddress }) {
202
  // we now build the stream
203
  const stream = new ReadableStream({
204
  async start(controller) {
205
- const updates: MessageUpdate[] = isContinue
206
- ? conv.messages[conv.messages.length - 1].updates ?? []
207
- : [];
208
-
209
  function update(newUpdate: MessageUpdate) {
210
  if (newUpdate.type !== "stream") {
211
- updates.push(newUpdate);
212
  }
213
 
214
  if (newUpdate.type === "stream" && newUpdate.token === "") {
@@ -225,10 +284,21 @@ export async function POST({ request, locals, params, getClientAddress }) {
225
  update({ type: "status", status: "started" });
226
 
227
  const summarizeIfNeeded = (async () => {
228
- if (conv.title === "New Chat" && messages.length === 1) {
229
  try {
230
- conv.title = (await summarize(messages[0].content)) ?? conv.title;
231
  update({ type: "status", status: "title", message: conv.title });
 
 
 
 
 
 
 
 
 
 
 
232
  } catch (e) {
233
  console.error(e);
234
  }
@@ -241,31 +311,33 @@ export async function POST({ request, locals, params, getClientAddress }) {
241
  },
242
  {
243
  $set: {
244
- messages,
245
  title: conv.title,
246
  updatedAt: new Date(),
247
  },
248
  }
249
  );
250
 
251
- let webSearchResults: WebSearch | undefined;
252
-
253
  if (webSearch && !isContinue && !conv.assistantId) {
254
- webSearchResults = await runWebSearch(conv, messages[messages.length - 1].content, update);
255
- messages[messages.length - 1].webSearch = webSearchResults;
256
- } else if (isContinue) {
257
- webSearchResults = messages[messages.length - 1].webSearch;
258
  }
259
 
260
- conv.messages = messages;
 
 
 
 
 
261
 
262
- const previousContent = isContinue
263
- ? conv.messages.find((message) => message.id === messageId)?.content ?? ""
264
- : "";
265
 
266
  try {
267
  const endpoint = await model.getEndpoint();
268
- for await (const output of await endpoint({ conversation: conv, continue: isContinue })) {
 
 
 
 
269
  // if not generated_text is here it means the generation is not done
270
  if (!output.generated_text) {
271
  // else we get the next token
@@ -274,63 +346,33 @@ export async function POST({ request, locals, params, getClientAddress }) {
274
  type: "stream",
275
  token: output.token.text,
276
  });
277
-
278
- // if the last message is not from assistant, it means this is the first token
279
- const lastMessage = messages[messages.length - 1];
280
-
281
- if (lastMessage?.from !== "assistant") {
282
- // so we create a new message
283
- messages = [
284
- ...messages,
285
- // id doesn't match the backend id but it's not important for assistant messages
286
- // First token has a space at the beginning, trim it
287
- {
288
- from: "assistant",
289
- content: output.token.text.trimStart(),
290
- webSearch: webSearchResults,
291
- updates,
292
- id: crypto.randomUUID(),
293
- createdAt: new Date(),
294
- updatedAt: new Date(),
295
- },
296
- ];
297
- } else {
298
- // abort check
299
- const date = abortedGenerations.get(convId.toString());
300
- if (date && date > promptedAt) {
301
- break;
302
- }
303
-
304
- if (!output) {
305
- break;
306
- }
307
-
308
- // otherwise we just concatenate tokens
309
- lastMessage.content += output.token.text;
310
  }
 
 
 
311
  }
312
  } else {
313
- let interrupted = !output.token.special;
314
  // add output.generated text to the last message
315
  // strip end tokens from the output.generated_text
316
  const text = (model.parameters.stop ?? []).reduce((acc: string, curr: string) => {
317
  if (acc.endsWith(curr)) {
318
- interrupted = false;
319
  return acc.slice(0, acc.length - curr.length);
320
  }
321
  return acc;
322
  }, output.generated_text.trimEnd());
323
 
324
- messages = [
325
- ...messages.slice(0, -1),
326
- {
327
- ...messages[messages.length - 1],
328
- content: previousContent + text,
329
- interrupted, // if its a special token it finished on its own, else it was interrupted
330
- updates,
331
- updatedAt: new Date(),
332
- },
333
- ];
334
  }
335
  }
336
  } catch (e) {
@@ -343,7 +385,7 @@ export async function POST({ request, locals, params, getClientAddress }) {
343
  },
344
  {
345
  $set: {
346
- messages,
347
  title: conv?.title,
348
  updatedAt: new Date(),
349
  },
@@ -355,7 +397,7 @@ export async function POST({ request, locals, params, getClientAddress }) {
355
 
356
  update({
357
  type: "finalAnswer",
358
- text: messages[messages.length - 1].content,
359
  });
360
 
361
  await summarizeIfNeeded;
@@ -370,7 +412,7 @@ export async function POST({ request, locals, params, getClientAddress }) {
370
  },
371
  {
372
  $set: {
373
- messages,
374
  title: conv.title,
375
  updatedAt: new Date(),
376
  },
 
9
  import { z } from "zod";
10
  import type { MessageUpdate } from "$lib/types/MessageUpdate";
11
  import { runWebSearch } from "$lib/server/websearch/runWebSearch";
 
12
  import { abortedGenerations } from "$lib/server/abortedGenerations";
13
  import { summarize } from "$lib/server/summarize";
14
  import { uploadFile } from "$lib/server/files/uploadFile";
15
  import sizeof from "image-size";
16
+ import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversation";
17
+ import { isMessageId } from "$lib/utils/tree/isMessageId";
18
+ import { buildSubtree } from "$lib/utils/tree/buildSubtree.js";
19
+ import { addChildren } from "$lib/utils/tree/addChildren.js";
20
+ import { addSibling } from "$lib/utils/tree/addSibling.js";
21
+ import { preprocessMessages } from "$lib/server/preprocessMessages.js";
22
 
23
  export async function POST({ request, locals, params, getClientAddress }) {
24
  const id = z.string().parse(params.id);
 
33
  }
34
 
35
  // check if the user has access to the conversation
36
+ const convBeforeCheck = await collections.conversations.findOne({
37
+ _id: convId,
38
+ ...authCondition(locals),
39
+ });
40
+
41
+ if (convBeforeCheck && !convBeforeCheck.rootMessageId) {
42
+ const res = await collections.conversations.updateOne(
43
+ {
44
+ _id: convId,
45
+ },
46
+ {
47
+ $set: {
48
+ ...convBeforeCheck,
49
+ ...convertLegacyConversation(convBeforeCheck),
50
+ },
51
+ }
52
+ );
53
+
54
+ if (!res.acknowledged) {
55
+ throw error(500, "Failed to convert conversation");
56
+ }
57
+ }
58
+
59
  const conv = await collections.conversations.findOne({
60
  _id: convId,
61
  ...authCondition(locals),
 
125
  files: b64files,
126
  } = z
127
  .object({
128
+ id: z.string().uuid().refine(isMessageId).optional(), // parent message id to append to for a normal message, or the message id for a retry/continue
129
  inputs: z.optional(z.string().trim().min(1)),
 
130
  is_retry: z.optional(z.boolean()),
131
  is_continue: z.optional(z.boolean()),
132
  web_search: z.optional(z.boolean()),
 
166
  hashes = await Promise.all(files.map(async (file) => await uploadFile(file, conv)));
167
  }
168
 
169
+ // we will append tokens to the content of this message
170
+ let messageToWriteToId: Message["id"] | undefined = undefined;
171
+ // used for building the prompt, subtree of the conversation that goes from the latest message to the root
172
+ let messagesForPrompt: Message[] = [];
173
 
174
+ if (isContinue && messageId) {
175
+ // if it's the last message and we continue then we build the prompt up to the last message
176
+ // we will strip the end tokens afterwards when the prompt is built
177
+ if ((conv.messages.find((msg) => msg.id === messageId)?.children?.length ?? 0) > 0) {
178
+ throw error(400, "Can only continue the last message");
179
+ }
180
+ messageToWriteToId = messageId;
181
+ messagesForPrompt = buildSubtree(conv, messageId);
182
+ } else if (isRetry && messageId) {
183
+ // two cases, if we're retrying a user message with a newPrompt set,
184
+ // it means we're editing a user message
185
+ // if we're retrying on an assistant message, newPrompt cannot be set
186
+ // it means we're retrying the last assistant message for a new answer
187
+
188
+ const messageToRetry = conv.messages.find((message) => message.id === messageId);
189
+
190
+ if (!messageToRetry) {
191
+ throw error(404, "Message not found");
192
+ }
193
 
194
+ if (messageToRetry.from === "user" && newPrompt) {
195
+ // add a sibling to this message from the user, with the alternative prompt
196
+ // add a children to that sibling, where we can write to
197
+ const newUserMessageId = addSibling(conv, { from: "user", content: newPrompt }, messageId);
198
+ messageToWriteToId = addChildren(
199
+ conv,
200
+ { from: "assistant", content: "", files: hashes },
201
+ newUserMessageId
202
+ );
203
+ messagesForPrompt = buildSubtree(conv, newUserMessageId);
204
+ } else if (messageToRetry.from === "assistant") {
205
+ // we're retrying an assistant message, to generate a new answer
206
+ // just add a sibling to the assistant answer where we can write to
207
+ messageToWriteToId = addSibling(conv, { from: "assistant", content: "" }, messageId);
208
+ messagesForPrompt = buildSubtree(conv, messageId);
209
+ messagesForPrompt.pop(); // don't need the latest assistant message in the prompt since we're retrying it
210
+ }
211
+ } else {
212
+ // just a normal linear conversation, so we add the user message
213
+ // and the blank assistant message back to back
214
+ const newUserMessageId = addChildren(
215
+ conv,
216
+ {
217
+ from: "user",
218
+ content: newPrompt ?? "",
219
+ files: hashes,
220
+ createdAt: new Date(),
221
+ updatedAt: new Date(),
222
+ },
223
+ messageId
224
+ );
225
 
226
+ messageToWriteToId = addChildren(
227
+ conv,
228
+ {
229
+ from: "assistant",
230
+ content: "",
231
+ createdAt: new Date(),
232
+ updatedAt: new Date(),
233
+ },
234
+ newUserMessageId
235
+ );
236
+ // build the prompt from the user message
237
+ messagesForPrompt = buildSubtree(conv, newUserMessageId);
238
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
+ const messageToWriteTo = conv.messages.find((message) => message.id === messageToWriteToId);
241
+ if (!messageToWriteTo) {
242
+ throw error(500, "Failed to create message");
243
+ }
244
+ if (messagesForPrompt.length === 0) {
245
+ throw error(500, "Failed to create prompt");
246
+ }
247
+
248
+ // update the conversation with the new messages
249
  await collections.conversations.updateOne(
250
  {
251
  _id: convId,
252
  },
253
  {
254
  $set: {
255
+ messages: conv.messages,
256
  title: conv.title,
257
  updatedAt: new Date(),
258
  },
 
264
  // we now build the stream
265
  const stream = new ReadableStream({
266
  async start(controller) {
267
+ messageToWriteTo.updates ??= [];
 
 
 
268
  function update(newUpdate: MessageUpdate) {
269
  if (newUpdate.type !== "stream") {
270
+ messageToWriteTo?.updates?.push(newUpdate);
271
  }
272
 
273
  if (newUpdate.type === "stream" && newUpdate.token === "") {
 
284
  update({ type: "status", status: "started" });
285
 
286
  const summarizeIfNeeded = (async () => {
287
+ if (conv.title === "New Chat" && conv.messages.length === 3) {
288
  try {
289
+ conv.title = (await summarize(conv.messages[1].content)) ?? conv.title;
290
  update({ type: "status", status: "title", message: conv.title });
291
+ await collections.conversations.updateOne(
292
+ {
293
+ _id: convId,
294
+ },
295
+ {
296
+ $set: {
297
+ title: conv?.title,
298
+ updatedAt: new Date(),
299
+ },
300
+ }
301
+ );
302
  } catch (e) {
303
  console.error(e);
304
  }
 
311
  },
312
  {
313
  $set: {
 
314
  title: conv.title,
315
  updatedAt: new Date(),
316
  },
317
  }
318
  );
319
 
320
+ // perform websearch if needed
 
321
  if (webSearch && !isContinue && !conv.assistantId) {
322
+ messageToWriteTo.webSearch = await runWebSearch(conv, messagesForPrompt, update);
 
 
 
323
  }
324
 
325
+ // inject websearch result & optionally images into the messages
326
+ const processedMessages = await preprocessMessages(
327
+ messagesForPrompt,
328
+ model.multimodal,
329
+ convId
330
+ );
331
 
332
+ const previousText = messageToWriteTo.content;
 
 
333
 
334
  try {
335
  const endpoint = await model.getEndpoint();
336
+ for await (const output of await endpoint({
337
+ messages: processedMessages,
338
+ preprompt: conv.preprompt,
339
+ continueMessage: isContinue,
340
+ })) {
341
  // if not generated_text is here it means the generation is not done
342
  if (!output.generated_text) {
343
  // else we get the next token
 
346
  type: "stream",
347
  token: output.token.text,
348
  });
349
+ // abort check
350
+ const date = abortedGenerations.get(convId.toString());
351
+ if (date && date > promptedAt) {
352
+ break;
353
+ }
354
+ // no output check
355
+ if (!output) {
356
+ break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  }
358
+
359
+ // otherwise we just concatenate tokens
360
+ messageToWriteTo.content += output.token.text;
361
  }
362
  } else {
363
+ messageToWriteTo.interrupted = !output.token.special;
364
  // add output.generated text to the last message
365
  // strip end tokens from the output.generated_text
366
  const text = (model.parameters.stop ?? []).reduce((acc: string, curr: string) => {
367
  if (acc.endsWith(curr)) {
368
+ messageToWriteTo.interrupted = false;
369
  return acc.slice(0, acc.length - curr.length);
370
  }
371
  return acc;
372
  }, output.generated_text.trimEnd());
373
 
374
+ messageToWriteTo.content = previousText + text;
375
+ messageToWriteTo.updatedAt = new Date();
 
 
 
 
 
 
 
 
376
  }
377
  }
378
  } catch (e) {
 
385
  },
386
  {
387
  $set: {
388
+ messages: conv.messages,
389
  title: conv?.title,
390
  updatedAt: new Date(),
391
  },
 
397
 
398
  update({
399
  type: "finalAnswer",
400
+ text: messageToWriteTo.content,
401
  });
402
 
403
  await summarizeIfNeeded;
 
412
  },
413
  {
414
  $set: {
415
+ messages: conv.messages,
416
  title: conv.title,
417
  updatedAt: new Date(),
418
  },
src/routes/conversation/[id]/message/[messageId]/prompt/+server.ts CHANGED
@@ -2,6 +2,8 @@ import { buildPrompt } from "$lib/buildPrompt";
2
  import { authCondition } from "$lib/server/auth";
3
  import { collections } from "$lib/server/database";
4
  import { models } from "$lib/server/models";
 
 
5
  import { error } from "@sveltejs/kit";
6
  import { ObjectId } from "mongodb";
7
 
@@ -24,7 +26,7 @@ export async function GET({ params, locals }) {
24
 
25
  const messageIndex = conv.messages.findIndex((msg) => msg.id === messageId);
26
 
27
- if (messageIndex === -1) {
28
  throw error(404, "Message not found");
29
  }
30
 
@@ -34,11 +36,10 @@ export async function GET({ params, locals }) {
34
  throw error(404, "Conversation model not found");
35
  }
36
 
37
- const messagesUpTo = conv.messages.slice(0, messageIndex + 1);
38
 
39
  const prompt = await buildPrompt({
40
  preprompt: conv.preprompt,
41
- webSearch: messagesUpTo[messagesUpTo.length - 1].webSearch,
42
  messages: messagesUpTo,
43
  model,
44
  });
 
2
  import { authCondition } from "$lib/server/auth";
3
  import { collections } from "$lib/server/database";
4
  import { models } from "$lib/server/models";
5
+ import { buildSubtree } from "$lib/utils/tree/buildSubtree";
6
+ import { isMessageId } from "$lib/utils/tree/isMessageId";
7
  import { error } from "@sveltejs/kit";
8
  import { ObjectId } from "mongodb";
9
 
 
26
 
27
  const messageIndex = conv.messages.findIndex((msg) => msg.id === messageId);
28
 
29
+ if (!isMessageId(messageId) || messageIndex === -1) {
30
  throw error(404, "Message not found");
31
  }
32
 
 
36
  throw error(404, "Conversation model not found");
37
  }
38
 
39
+ const messagesUpTo = buildSubtree(conv, messageId);
40
 
41
  const prompt = await buildPrompt({
42
  preprompt: conv.preprompt,
 
43
  messages: messagesUpTo,
44
  model,
45
  });
src/routes/conversation/[id]/share/+server.ts CHANGED
@@ -32,10 +32,11 @@ export async function POST({ params, url, locals }) {
32
 
33
  const shared: SharedConversation = {
34
  _id: nanoid(7),
35
- createdAt: new Date(),
36
- messages: conversation.messages,
37
  hash,
 
38
  updatedAt: new Date(),
 
 
39
  title: conversation.title,
40
  model: conversation.model,
41
  embeddingModel: conversation.embeddingModel,
 
32
 
33
  const shared: SharedConversation = {
34
  _id: nanoid(7),
 
 
35
  hash,
36
+ createdAt: new Date(),
37
  updatedAt: new Date(),
38
+ rootMessageId: conversation.rootMessageId,
39
+ messages: conversation.messages,
40
  title: conversation.title,
41
  model: conversation.model,
42
  embeddingModel: conversation.embeddingModel,