Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
feat: lazy stream conversations load function (#1553)
Browse files* feat: lazy stream conversations load function
* fix: lint
* feat: add animation
* feat: skip db call if no convs
- src/lib/components/MobileNav.svelte +6 -2
- src/lib/components/NavConversationItem.svelte +2 -2
- src/lib/components/NavMenu.svelte +32 -16
- src/lib/types/ConvSidebar.ts +1 -1
- src/routes/+layout.server.ts +83 -50
- src/routes/+layout.svelte +10 -8
- src/routes/conversation/[id]/+page.svelte +12 -5
- src/routes/settings/(nav)/assistants/[assistantId]/+page.svelte +1 -1
- src/routes/settings/+layout.server.ts +1 -1
src/lib/components/MobileNav.svelte
CHANGED
@@ -10,7 +10,7 @@
|
|
10 |
import IconNew from "$lib/components/icons/IconNew.svelte";
|
11 |
|
12 |
export let isOpen = false;
|
13 |
-
export let title: string | undefined;
|
14 |
|
15 |
$: title = title ?? "New Chat";
|
16 |
|
@@ -40,7 +40,11 @@
|
|
40 |
aria-label="Open menu"
|
41 |
bind:this={openEl}><CarbonTextAlignJustify /></button
|
42 |
>
|
43 |
-
|
|
|
|
|
|
|
|
|
44 |
<a
|
45 |
class:invisible={!$page.params.id}
|
46 |
href="{base}/"
|
|
|
10 |
import IconNew from "$lib/components/icons/IconNew.svelte";
|
11 |
|
12 |
export let isOpen = false;
|
13 |
+
export let title: Promise<string | undefined> | string;
|
14 |
|
15 |
$: title = title ?? "New Chat";
|
16 |
|
|
|
40 |
aria-label="Open menu"
|
41 |
bind:this={openEl}><CarbonTextAlignJustify /></button
|
42 |
>
|
43 |
+
{#await title}
|
44 |
+
<div class="flex h-full items-center justify-center" />
|
45 |
+
{:then title}
|
46 |
+
<span class="truncate px-4">{title ?? ""}</span>
|
47 |
+
{/await}
|
48 |
<a
|
49 |
class:invisible={!$page.params.id}
|
50 |
href="{base}/"
|
src/lib/components/NavConversationItem.svelte
CHANGED
@@ -34,9 +34,9 @@
|
|
34 |
{#if confirmDelete}
|
35 |
<span class="mr-1 font-semibold"> Delete </span>
|
36 |
{/if}
|
37 |
-
{#if conv.
|
38 |
<img
|
39 |
-
src="{base}
|
40 |
alt="Assistant avatar"
|
41 |
class="mr-1.5 inline size-4 flex-none rounded-full object-cover"
|
42 |
/>
|
|
|
34 |
{#if confirmDelete}
|
35 |
<span class="mr-1 font-semibold"> Delete </span>
|
36 |
{/if}
|
37 |
+
{#if conv.avatarUrl}
|
38 |
<img
|
39 |
+
src="{base}{conv.avatarUrl}"
|
40 |
alt="Assistant avatar"
|
41 |
class="mr-1.5 inline size-4 flex-none rounded-full object-cover"
|
42 |
/>
|
src/lib/components/NavMenu.svelte
CHANGED
@@ -11,7 +11,8 @@
|
|
11 |
import type { Model } from "$lib/types/Model";
|
12 |
import { page } from "$app/stores";
|
13 |
|
14 |
-
|
|
|
15 |
export let canLogin: boolean;
|
16 |
export let user: LayoutData["user"];
|
17 |
|
@@ -25,16 +26,16 @@
|
|
25 |
new Date().setMonth(new Date().getMonth() - 1),
|
26 |
];
|
27 |
|
28 |
-
$: groupedConversations = {
|
29 |
-
today:
|
30 |
-
week:
|
31 |
({ updatedAt }) => updatedAt.getTime() > dateRanges[1] && updatedAt.getTime() < dateRanges[0]
|
32 |
),
|
33 |
-
month:
|
34 |
({ updatedAt }) => updatedAt.getTime() > dateRanges[2] && updatedAt.getTime() < dateRanges[1]
|
35 |
),
|
36 |
-
older:
|
37 |
-
};
|
38 |
|
39 |
const titles: { [key: string]: string } = {
|
40 |
today: "Today",
|
@@ -65,16 +66,31 @@
|
|
65 |
<div
|
66 |
class="scrollbar-custom flex flex-col gap-1 overflow-y-auto rounded-r-xl from-gray-50 px-3 pb-3 pt-2 text-[.9rem] dark:from-gray-800/30 max-sm:bg-gradient-to-t md:bg-gradient-to-l"
|
67 |
>
|
68 |
-
{#
|
69 |
-
{#if
|
70 |
-
<
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
|
|
|
|
76 |
{/if}
|
77 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
78 |
</div>
|
79 |
<div
|
80 |
class="mt-0.5 flex flex-col gap-1 rounded-r-xl p-3 text-sm md:bg-gradient-to-l md:from-gray-50 md:dark:from-gray-800/30"
|
|
|
11 |
import type { Model } from "$lib/types/Model";
|
12 |
import { page } from "$app/stores";
|
13 |
|
14 |
+
import { fade } from "svelte/transition";
|
15 |
+
export let conversations: Promise<ConvSidebar[]>;
|
16 |
export let canLogin: boolean;
|
17 |
export let user: LayoutData["user"];
|
18 |
|
|
|
26 |
new Date().setMonth(new Date().getMonth() - 1),
|
27 |
];
|
28 |
|
29 |
+
$: groupedConversations = conversations.then((convs) => ({
|
30 |
+
today: convs.filter(({ updatedAt }) => updatedAt.getTime() > dateRanges[0]),
|
31 |
+
week: convs.filter(
|
32 |
({ updatedAt }) => updatedAt.getTime() > dateRanges[1] && updatedAt.getTime() < dateRanges[0]
|
33 |
),
|
34 |
+
month: convs.filter(
|
35 |
({ updatedAt }) => updatedAt.getTime() > dateRanges[2] && updatedAt.getTime() < dateRanges[1]
|
36 |
),
|
37 |
+
older: convs.filter(({ updatedAt }) => updatedAt.getTime() < dateRanges[2]),
|
38 |
+
}));
|
39 |
|
40 |
const titles: { [key: string]: string } = {
|
41 |
today: "Today",
|
|
|
66 |
<div
|
67 |
class="scrollbar-custom flex flex-col gap-1 overflow-y-auto rounded-r-xl from-gray-50 px-3 pb-3 pt-2 text-[.9rem] dark:from-gray-800/30 max-sm:bg-gradient-to-t md:bg-gradient-to-l"
|
68 |
>
|
69 |
+
{#await groupedConversations}
|
70 |
+
{#if $page.data.nConversations > 0}
|
71 |
+
<div class="overflow-y-hidden">
|
72 |
+
<div class="flex animate-pulse flex-col gap-4">
|
73 |
+
<div class="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
|
74 |
+
{#each Array(100) as _}
|
75 |
+
<div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700" />
|
76 |
+
{/each}
|
77 |
+
</div>
|
78 |
+
</div>
|
79 |
{/if}
|
80 |
+
{:then groupedConversations}
|
81 |
+
<div transition:fade class="flex flex-col gap-1">
|
82 |
+
{#each Object.entries(groupedConversations) as [group, convs]}
|
83 |
+
{#if convs.length}
|
84 |
+
<h4 class="mb-1.5 mt-4 pl-0.5 text-sm text-gray-400 first:mt-0 dark:text-gray-500">
|
85 |
+
{titles[group]}
|
86 |
+
</h4>
|
87 |
+
{#each convs as conv}
|
88 |
+
<NavConversationItem on:editConversationTitle on:deleteConversation {conv} />
|
89 |
+
{/each}
|
90 |
+
{/if}
|
91 |
+
{/each}
|
92 |
+
</div>
|
93 |
+
{/await}
|
94 |
</div>
|
95 |
<div
|
96 |
class="mt-0.5 flex flex-col gap-1 rounded-r-xl p-3 text-sm md:bg-gradient-to-l md:from-gray-50 md:dark:from-gray-800/30"
|
src/lib/types/ConvSidebar.ts
CHANGED
@@ -4,5 +4,5 @@ export interface ConvSidebar {
|
|
4 |
updatedAt: Date;
|
5 |
model?: string;
|
6 |
assistantId?: string;
|
7 |
-
|
8 |
}
|
|
|
4 |
updatedAt: Date;
|
5 |
model?: string;
|
6 |
assistantId?: string;
|
7 |
+
avatarUrl?: string;
|
8 |
}
|
src/routes/+layout.server.ts
CHANGED
@@ -51,38 +51,52 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => {
|
|
51 |
})
|
52 |
: null;
|
53 |
|
54 |
-
const
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
|
70 |
const userAssistants = settings?.assistants?.map((assistantId) => assistantId.toString()) ?? [];
|
71 |
const userAssistantsSet = new Set(userAssistants);
|
72 |
|
73 |
-
const
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
79 |
|
80 |
const messagesBeforeLogin = env.MESSAGES_BEFORE_LOGIN ? parseInt(env.MESSAGES_BEFORE_LOGIN) : 0;
|
81 |
|
82 |
let loginRequired = false;
|
83 |
|
84 |
if (requiresUser && !locals.user && messagesBeforeLogin) {
|
85 |
-
if (
|
86 |
loginRequired = true;
|
87 |
} else {
|
88 |
// get the number of messages where `from === "assistant"` across all conversations.
|
@@ -129,25 +143,42 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => {
|
|
129 |
);
|
130 |
|
131 |
return {
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
151 |
settings: {
|
152 |
searchEnabled: !!(
|
153 |
env.SERPAPI_KEY ||
|
@@ -223,15 +254,17 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => {
|
|
223 |
type: "community",
|
224 |
review: ReviewStatus.APPROVED,
|
225 |
}),
|
226 |
-
assistants: assistants
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
|
|
|
|
235 |
user: locals.user && {
|
236 |
id: locals.user._id.toString(),
|
237 |
username: locals.user.username,
|
|
|
51 |
})
|
52 |
: null;
|
53 |
|
54 |
+
const nConversations = await collections.conversations.countDocuments(authCondition(locals));
|
55 |
+
|
56 |
+
const conversations =
|
57 |
+
nConversations === 0
|
58 |
+
? Promise.resolve([])
|
59 |
+
: collections.conversations
|
60 |
+
.find(authCondition(locals))
|
61 |
+
.sort({ updatedAt: -1 })
|
62 |
+
.project<
|
63 |
+
Pick<
|
64 |
+
Conversation,
|
65 |
+
"title" | "model" | "_id" | "updatedAt" | "createdAt" | "assistantId"
|
66 |
+
>
|
67 |
+
>({
|
68 |
+
title: 1,
|
69 |
+
model: 1,
|
70 |
+
_id: 1,
|
71 |
+
updatedAt: 1,
|
72 |
+
createdAt: 1,
|
73 |
+
assistantId: 1,
|
74 |
+
})
|
75 |
+
.limit(300)
|
76 |
+
.toArray();
|
77 |
|
78 |
const userAssistants = settings?.assistants?.map((assistantId) => assistantId.toString()) ?? [];
|
79 |
const userAssistantsSet = new Set(userAssistants);
|
80 |
|
81 |
+
const assistants = conversations.then((conversations) =>
|
82 |
+
collections.assistants
|
83 |
+
.find({
|
84 |
+
_id: {
|
85 |
+
$in: [
|
86 |
+
...userAssistants.map((el) => new ObjectId(el)),
|
87 |
+
...(conversations.map((conv) => conv.assistantId).filter((el) => !!el) as ObjectId[]),
|
88 |
+
],
|
89 |
+
},
|
90 |
+
})
|
91 |
+
.toArray()
|
92 |
+
);
|
93 |
|
94 |
const messagesBeforeLogin = env.MESSAGES_BEFORE_LOGIN ? parseInt(env.MESSAGES_BEFORE_LOGIN) : 0;
|
95 |
|
96 |
let loginRequired = false;
|
97 |
|
98 |
if (requiresUser && !locals.user && messagesBeforeLogin) {
|
99 |
+
if (nConversations > messagesBeforeLogin) {
|
100 |
loginRequired = true;
|
101 |
} else {
|
102 |
// get the number of messages where `from === "assistant"` across all conversations.
|
|
|
143 |
);
|
144 |
|
145 |
return {
|
146 |
+
nConversations,
|
147 |
+
conversations: conversations.then(
|
148 |
+
async (convs) =>
|
149 |
+
await Promise.all(
|
150 |
+
convs.map(async (conv) => {
|
151 |
+
if (settings?.hideEmojiOnSidebar) {
|
152 |
+
conv.title = conv.title.replace(/\p{Emoji}/gu, "");
|
153 |
+
}
|
154 |
+
|
155 |
+
// remove invalid unicode and trim whitespaces
|
156 |
+
conv.title = conv.title.replace(/\uFFFD/gu, "").trimStart();
|
157 |
+
|
158 |
+
let avatarUrl: string | undefined = undefined;
|
159 |
+
|
160 |
+
if (conv.assistantId) {
|
161 |
+
const hash = (
|
162 |
+
await collections.assistants.findOne({
|
163 |
+
_id: new ObjectId(conv.assistantId),
|
164 |
+
})
|
165 |
+
)?.avatar;
|
166 |
+
if (hash) {
|
167 |
+
avatarUrl = `/settings/assistants/${conv.assistantId}/avatar.jpg?hash=${hash}`;
|
168 |
+
}
|
169 |
+
}
|
170 |
+
|
171 |
+
return {
|
172 |
+
id: conv._id.toString(),
|
173 |
+
title: conv.title,
|
174 |
+
model: conv.model ?? defaultModel,
|
175 |
+
updatedAt: conv.updatedAt,
|
176 |
+
assistantId: conv.assistantId?.toString(),
|
177 |
+
avatarUrl,
|
178 |
+
} satisfies ConvSidebar;
|
179 |
+
})
|
180 |
+
)
|
181 |
+
),
|
182 |
settings: {
|
183 |
searchEnabled: !!(
|
184 |
env.SERPAPI_KEY ||
|
|
|
254 |
type: "community",
|
255 |
review: ReviewStatus.APPROVED,
|
256 |
}),
|
257 |
+
assistants: assistants.then((assistants) =>
|
258 |
+
assistants
|
259 |
+
.filter((el) => userAssistantsSet.has(el._id.toString()))
|
260 |
+
.map((el) => ({
|
261 |
+
...el,
|
262 |
+
_id: el._id.toString(),
|
263 |
+
createdById: undefined,
|
264 |
+
createdByMe:
|
265 |
+
el.createdById.toString() === (locals.user?._id ?? locals.sessionId).toString(),
|
266 |
+
}))
|
267 |
+
),
|
268 |
user: locals.user && {
|
269 |
id: locals.user._id.toString(),
|
270 |
username: locals.user.username,
|
src/routes/+layout.svelte
CHANGED
@@ -100,15 +100,17 @@
|
|
100 |
$: if ($error) onError();
|
101 |
|
102 |
$: if ($titleUpdate) {
|
103 |
-
|
|
|
104 |
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
|
111 |
-
|
|
|
112 |
}
|
113 |
|
114 |
const settings = createSettingsStore(data.settings);
|
@@ -147,7 +149,7 @@
|
|
147 |
|
148 |
$: mobileNavTitle = ["/models", "/assistants", "/privacy"].includes($page.route.id ?? "")
|
149 |
? ""
|
150 |
-
: data.conversations.find((conv) => conv.id === $page.params.id)?.title;
|
151 |
</script>
|
152 |
|
153 |
<svelte:head>
|
|
|
100 |
$: if ($error) onError();
|
101 |
|
102 |
$: if ($titleUpdate) {
|
103 |
+
data.conversations.then((convs) => {
|
104 |
+
const convIdx = convs.findIndex(({ id }) => id === $titleUpdate?.convId);
|
105 |
|
106 |
+
if (convIdx != -1) {
|
107 |
+
convs[convIdx].title = $titleUpdate?.title ?? convs[convIdx].title;
|
108 |
+
}
|
109 |
+
// update data.conversations
|
110 |
+
data.conversations = Promise.resolve([...convs]);
|
111 |
|
112 |
+
$titleUpdate = null;
|
113 |
+
});
|
114 |
}
|
115 |
|
116 |
const settings = createSettingsStore(data.settings);
|
|
|
149 |
|
150 |
$: mobileNavTitle = ["/models", "/assistants", "/privacy"].includes($page.route.id ?? "")
|
151 |
? ""
|
152 |
+
: data.conversations.then((convs) => convs.find((conv) => conv.id === $page.params.id)?.title);
|
153 |
</script>
|
154 |
|
155 |
<svelte:head>
|
src/routes/conversation/[id]/+page.svelte
CHANGED
@@ -4,7 +4,7 @@
|
|
4 |
import { isAborted } from "$lib/stores/isAborted";
|
5 |
import { onMount } from "svelte";
|
6 |
import { page } from "$app/stores";
|
7 |
-
import { goto,
|
8 |
import { base } from "$app/paths";
|
9 |
import { shareConversation } from "$lib/shareConversation";
|
10 |
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
|
@@ -24,6 +24,7 @@
|
|
24 |
import { createConvTreeStore } from "$lib/stores/convTree";
|
25 |
import type { v4 } from "uuid";
|
26 |
import { useSettingsStore } from "$lib/stores/settings.js";
|
|
|
27 |
|
28 |
export let data;
|
29 |
|
@@ -247,7 +248,9 @@
|
|
247 |
) {
|
248 |
$error = update.message ?? "An error has occurred";
|
249 |
} else if (update.type === MessageUpdateType.Title) {
|
250 |
-
const convInData = data.conversations.
|
|
|
|
|
251 |
if (convInData) {
|
252 |
convInData.title = update.title;
|
253 |
|
@@ -280,7 +283,7 @@
|
|
280 |
} finally {
|
281 |
loading = false;
|
282 |
pending = false;
|
283 |
-
await
|
284 |
}
|
285 |
}
|
286 |
|
@@ -376,14 +379,18 @@
|
|
376 |
}
|
377 |
|
378 |
$: $page.params.id, (($isAborted = true), (loading = false), ($convTreeStore.editing = null));
|
379 |
-
$: title = data.conversations.
|
|
|
|
|
380 |
|
381 |
const convTreeStore = createConvTreeStore();
|
382 |
const settings = useSettingsStore();
|
383 |
</script>
|
384 |
|
385 |
<svelte:head>
|
386 |
-
|
|
|
|
|
387 |
<link
|
388 |
rel="stylesheet"
|
389 |
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"
|
|
|
4 |
import { isAborted } from "$lib/stores/isAborted";
|
5 |
import { onMount } from "svelte";
|
6 |
import { page } from "$app/stores";
|
7 |
+
import { goto, invalidate } from "$app/navigation";
|
8 |
import { base } from "$app/paths";
|
9 |
import { shareConversation } from "$lib/shareConversation";
|
10 |
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
|
|
|
24 |
import { createConvTreeStore } from "$lib/stores/convTree";
|
25 |
import type { v4 } from "uuid";
|
26 |
import { useSettingsStore } from "$lib/stores/settings.js";
|
27 |
+
import { UrlDependency } from "$lib/types/UrlDependency.js";
|
28 |
|
29 |
export let data;
|
30 |
|
|
|
248 |
) {
|
249 |
$error = update.message ?? "An error has occurred";
|
250 |
} else if (update.type === MessageUpdateType.Title) {
|
251 |
+
const convInData = await data.conversations.then((convs) =>
|
252 |
+
convs.find(({ id }) => id === $page.params.id)
|
253 |
+
);
|
254 |
if (convInData) {
|
255 |
convInData.title = update.title;
|
256 |
|
|
|
283 |
} finally {
|
284 |
loading = false;
|
285 |
pending = false;
|
286 |
+
await invalidate(UrlDependency.Conversation);
|
287 |
}
|
288 |
}
|
289 |
|
|
|
379 |
}
|
380 |
|
381 |
$: $page.params.id, (($isAborted = true), (loading = false), ($convTreeStore.editing = null));
|
382 |
+
$: title = data.conversations.then(
|
383 |
+
(convs) => convs.find((conv) => conv.id === $page.params.id)?.title ?? data.title
|
384 |
+
);
|
385 |
|
386 |
const convTreeStore = createConvTreeStore();
|
387 |
const settings = useSettingsStore();
|
388 |
</script>
|
389 |
|
390 |
<svelte:head>
|
391 |
+
{#await title then title}
|
392 |
+
<title>{title}</title>
|
393 |
+
{/await}
|
394 |
<link
|
395 |
rel="stylesheet"
|
396 |
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"
|
src/routes/settings/(nav)/assistants/[assistantId]/+page.svelte
CHANGED
@@ -103,7 +103,7 @@
|
|
103 |
>
|
104 |
<div class="w-full sm:w-auto">
|
105 |
<button
|
106 |
-
class="mx-auto my-2 flex w-
|
107 |
name="Activate model"
|
108 |
on:click|stopPropagation={() => {
|
109 |
settings.instantSet({
|
|
|
103 |
>
|
104 |
<div class="w-full sm:w-auto">
|
105 |
<button
|
106 |
+
class="mx-auto my-2 flex w-min items-center justify-center rounded-full bg-black px-3 py-1 text-base !text-white"
|
107 |
name="Activate model"
|
108 |
on:click|stopPropagation={() => {
|
109 |
settings.instantSet({
|
src/routes/settings/+layout.server.ts
CHANGED
@@ -18,7 +18,7 @@ export const load = (async ({ locals, parent }) => {
|
|
18 |
}
|
19 |
|
20 |
return {
|
21 |
-
assistants: assistants.map((el) => ({
|
22 |
...el,
|
23 |
reported: reportsByUser.includes(el._id),
|
24 |
})),
|
|
|
18 |
}
|
19 |
|
20 |
return {
|
21 |
+
assistants: (await assistants).map((el) => ({
|
22 |
...el,
|
23 |
reported: reportsByUser.includes(el._id),
|
24 |
})),
|