Hong An Tran krramos nsarrazin commited on
Commit
c6ccfa4
·
unverified ·
1 Parent(s): d251d9a

[WIP] - Add the pop-up search (Ctrl/Command + K) to search entire conversation history (#1823)

Browse files

* UI for crtl/command + k for conversation search

* UI for ctrl/command+k

* Added fetch conversations and filter conversations functions

* WIP - finish with the first draft of popup-search

* WIP - Finish the first version pop up search

* WIP - Finish the first version pop up search

* Remove unnecessary codes

* Fixed formatting errors

* Fixed formatting errors

* refactor: search functionality

* fix: revert those changes

* fix: lint

* chores: clean up unused comment

---------

Co-authored-by: kenneth.ramos95 <kenneth.ramos95@gmail.com>
Co-authored-by: Nathan Sarrazin <sarrazin.nathan@gmail.com>

package-lock.json CHANGED
@@ -93,12 +93,12 @@
93
  "minimist": "^1.2.8",
94
  "mongodb-memory-server": "^10.1.2",
95
  "node-llama-cpp": "^3.6.0",
96
- "prettier": "^3.1.0",
97
  "prettier-plugin-svelte": "^3.2.6",
98
  "prettier-plugin-tailwindcss": "^0.6.11",
99
  "prom-client": "^15.1.2",
100
  "sade": "^1.8.1",
101
- "svelte": "^5.27.0",
102
  "svelte-check": "^4.0.0",
103
  "svelte-gestures": "^5.1.3",
104
  "ts-node": "^10.9.1",
@@ -12911,9 +12911,9 @@
12911
  }
12912
  },
12913
  "node_modules/prettier": {
12914
- "version": "3.4.2",
12915
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
12916
- "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
12917
  "dev": true,
12918
  "license": "MIT",
12919
  "bin": {
@@ -14519,9 +14519,9 @@
14519
  }
14520
  },
14521
  "node_modules/svelte": {
14522
- "version": "5.27.0",
14523
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.27.0.tgz",
14524
- "integrity": "sha512-Uai13Ydt1ZE+bUHme6b9U38PCYVNCqBRoBMkUKbFbKiD7kHWjdUUrklYAQZJxyKK81qII4mrBwe/YmvEMSlC9w==",
14525
  "license": "MIT",
14526
  "dependencies": {
14527
  "@ampproject/remapping": "^2.3.0",
 
93
  "minimist": "^1.2.8",
94
  "mongodb-memory-server": "^10.1.2",
95
  "node-llama-cpp": "^3.6.0",
96
+ "prettier": "^3.5.3",
97
  "prettier-plugin-svelte": "^3.2.6",
98
  "prettier-plugin-tailwindcss": "^0.6.11",
99
  "prom-client": "^15.1.2",
100
  "sade": "^1.8.1",
101
+ "svelte": "^5.27.2",
102
  "svelte-check": "^4.0.0",
103
  "svelte-gestures": "^5.1.3",
104
  "ts-node": "^10.9.1",
 
12911
  }
12912
  },
12913
  "node_modules/prettier": {
12914
+ "version": "3.5.3",
12915
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
12916
+ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
12917
  "dev": true,
12918
  "license": "MIT",
12919
  "bin": {
 
14519
  }
14520
  },
14521
  "node_modules/svelte": {
14522
+ "version": "5.27.2",
14523
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.27.2.tgz",
14524
+ "integrity": "sha512-WQigWAJSONxMfIYFBnnKXf5s+TcXbktLsZ+HMLfjhjSHOW9C0OV9TmPPTxVdbxs5tuiS8iQecCxLTHpdgWwgiA==",
14525
  "license": "MIT",
14526
  "dependencies": {
14527
  "@ampproject/remapping": "^2.3.0",
package.json CHANGED
@@ -51,12 +51,12 @@
51
  "minimist": "^1.2.8",
52
  "mongodb-memory-server": "^10.1.2",
53
  "node-llama-cpp": "^3.6.0",
54
- "prettier": "^3.1.0",
55
  "prettier-plugin-svelte": "^3.2.6",
56
  "prettier-plugin-tailwindcss": "^0.6.11",
57
  "prom-client": "^15.1.2",
58
  "sade": "^1.8.1",
59
- "svelte": "^5.27.0",
60
  "svelte-check": "^4.0.0",
61
  "svelte-gestures": "^5.1.3",
62
  "ts-node": "^10.9.1",
 
51
  "minimist": "^1.2.8",
52
  "mongodb-memory-server": "^10.1.2",
53
  "node-llama-cpp": "^3.6.0",
54
+ "prettier": "^3.5.3",
55
  "prettier-plugin-svelte": "^3.2.6",
56
  "prettier-plugin-tailwindcss": "^0.6.11",
57
  "prom-client": "^15.1.2",
58
  "sade": "^1.8.1",
59
+ "svelte": "^5.27.2",
60
  "svelte-check": "^4.0.0",
61
  "svelte-gestures": "^5.1.3",
62
  "ts-node": "^10.9.1",
src/lib/components/MobileNav.svelte CHANGED
@@ -1,3 +1,11 @@
 
 
 
 
 
 
 
 
1
  <script lang="ts">
2
  import { browser } from "$app/environment";
3
  import { beforeNavigate } from "$app/navigation";
@@ -18,7 +26,6 @@
18
  let closeEl: HTMLButtonElement | undefined = $state();
19
  let openEl: HTMLButtonElement | undefined = $state();
20
 
21
- let isOpen = $state(false);
22
  let panX: number | undefined = $state(undefined);
23
  let panStart: number | undefined = $state(undefined);
24
  let panStartTime: number | undefined = undefined;
 
1
+ <script lang="ts" module>
2
+ let isOpen = $state(false);
3
+
4
+ export function closeMobileNav() {
5
+ isOpen = false;
6
+ }
7
+ </script>
8
+
9
  <script lang="ts">
10
  import { browser } from "$app/environment";
11
  import { beforeNavigate } from "$app/navigation";
 
26
  let closeEl: HTMLButtonElement | undefined = $state();
27
  let openEl: HTMLButtonElement | undefined = $state();
28
 
 
29
  let panX: number | undefined = $state(undefined);
30
  let panStart: number | undefined = $state(undefined);
31
  let panStartTime: number | undefined = undefined;
src/lib/components/NavConversationItem.svelte CHANGED
@@ -11,9 +11,10 @@
11
 
12
  interface Props {
13
  conv: ConvSidebar;
 
14
  }
15
 
16
- let { conv }: Props = $props();
17
 
18
  let confirmDelete = $state(false);
19
 
@@ -55,59 +56,65 @@
55
  {/if}
56
  </div>
57
 
58
- {#if confirmDelete}
59
- <button
60
- type="button"
61
- class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
62
- title="Cancel delete action"
63
- onclick={(e) => {
64
- e.preventDefault();
65
- confirmDelete = false;
66
- }}
67
- >
68
- <CarbonClose class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
69
- </button>
70
- <button
71
- type="button"
72
- class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
73
- title="Confirm delete action"
74
- onclick={(e) => {
75
- e.preventDefault();
76
- confirmDelete = false;
77
- dispatch("deleteConversation", conv.id);
78
- }}
79
- >
80
- <CarbonCheckmark class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
81
- </button>
82
- {:else}
83
- <button
84
- type="button"
85
- class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
86
- title="Edit conversation title"
87
- onclick={(e) => {
88
- e.preventDefault();
89
- const newTitle = prompt("Edit this conversation title:", conv.title);
90
- if (!newTitle) return;
91
- dispatch("editConversationTitle", { id: conv.id, title: newTitle });
92
- }}
93
- >
94
- <CarbonEdit class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
95
- </button>
96
-
97
- <button
98
- type="button"
99
- class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
100
- title="Delete conversation"
101
- onclick={(event) => {
102
- event.preventDefault();
103
- if (event.shiftKey) {
104
  dispatch("deleteConversation", conv.id);
105
- } else {
106
- confirmDelete = true;
107
- }
108
- }}
109
- >
110
- <CarbonTrashCan class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
111
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  {/if}
113
  </a>
 
11
 
12
  interface Props {
13
  conv: ConvSidebar;
14
+ readOnly?: true;
15
  }
16
 
17
+ let { conv, readOnly }: Props = $props();
18
 
19
  let confirmDelete = $state(false);
20
 
 
56
  {/if}
57
  </div>
58
 
59
+ {#if !readOnly}
60
+ {#if confirmDelete}
61
+ <button
62
+ type="button"
63
+ class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
64
+ title="Cancel delete action"
65
+ onclick={(e) => {
66
+ e.preventDefault();
67
+ confirmDelete = false;
68
+ }}
69
+ >
70
+ <CarbonClose class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
71
+ </button>
72
+ <button
73
+ type="button"
74
+ class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
75
+ title="Confirm delete action"
76
+ onclick={(e) => {
77
+ e.preventDefault();
78
+ confirmDelete = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  dispatch("deleteConversation", conv.id);
80
+ }}
81
+ >
82
+ <CarbonCheckmark
83
+ class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
84
+ />
85
+ </button>
86
+ {:else}
87
+ <button
88
+ type="button"
89
+ class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
90
+ title="Edit conversation title"
91
+ onclick={(e) => {
92
+ e.preventDefault();
93
+ const newTitle = prompt("Edit this conversation title:", conv.title);
94
+ if (!newTitle) return;
95
+ dispatch("editConversationTitle", { id: conv.id, title: newTitle });
96
+ }}
97
+ >
98
+ <CarbonEdit class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
99
+ </button>
100
+
101
+ <button
102
+ type="button"
103
+ class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
104
+ title="Delete conversation"
105
+ onclick={(event) => {
106
+ event.preventDefault();
107
+ if (event.shiftKey) {
108
+ dispatch("deleteConversation", conv.id);
109
+ } else {
110
+ confirmDelete = true;
111
+ }
112
+ }}
113
+ >
114
+ <CarbonTrashCan
115
+ class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
116
+ />
117
+ </button>
118
+ {/if}
119
  {/if}
120
  </a>
src/lib/components/NavMenu.svelte CHANGED
@@ -1,3 +1,12 @@
 
 
 
 
 
 
 
 
 
1
  <script lang="ts">
2
  import { base } from "$app/paths";
3
 
@@ -15,6 +24,9 @@
15
  import type { Conversation } from "$lib/types/Conversation";
16
  import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
17
  import { browser } from "$app/environment";
 
 
 
18
 
19
  interface Props {
20
  conversations: ConvSidebar[];
@@ -48,13 +60,6 @@
48
  older: conversations.filter(({ updatedAt }) => updatedAt.getTime() < dateRanges[2]),
49
  });
50
 
51
- const titles: { [key: string]: string } = {
52
- today: "Today",
53
- week: "This week",
54
- month: "This month",
55
- older: "Older",
56
- } as const;
57
-
58
  const nModels: number = $page.data.models.filter((el: Model) => !el.unlisted).length;
59
 
60
  async function handleVisible() {
@@ -112,6 +117,16 @@
112
  <div
113
  class="scrollbar-custom flex touch-pan-y 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"
114
  >
 
 
 
 
 
 
 
 
 
 
115
  {#await groupedConversations}
116
  {#if $page.data.nConversations > 0}
117
  <div class="overflow-y-hidden">
@@ -220,7 +235,7 @@
220
  theme = localStorage.theme;
221
  }}
222
  aria-label="Toggle theme"
223
- class="flex h-9 min-w-[1.5em] flex-none items-center rounded-lg p-2 pr-0 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
224
  >
225
  {#if browser}
226
  {#if theme === "dark"}
 
1
+ <script lang="ts" module>
2
+ export const titles: { [key: string]: string } = {
3
+ today: "Today",
4
+ week: "This week",
5
+ month: "This month",
6
+ older: "Older",
7
+ } as const;
8
+ </script>
9
+
10
  <script lang="ts">
11
  import { base } from "$app/paths";
12
 
 
24
  import type { Conversation } from "$lib/types/Conversation";
25
  import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
26
  import { browser } from "$app/environment";
27
+ import { toggleSearch } from "./chat/Search.svelte";
28
+ import CarbonSearch from "~icons/carbon/search";
29
+ import { closeMobileNav } from "./MobileNav.svelte";
30
 
31
  interface Props {
32
  conversations: ConvSidebar[];
 
60
  older: conversations.filter(({ updatedAt }) => updatedAt.getTime() < dateRanges[2]),
61
  });
62
 
 
 
 
 
 
 
 
63
  const nModels: number = $page.data.models.filter((el: Model) => !el.unlisted).length;
64
 
65
  async function handleVisible() {
 
117
  <div
118
  class="scrollbar-custom flex touch-pan-y 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"
119
  >
120
+ <button
121
+ class="mx-auto flex w-full flex-row items-center justify-stretch gap-x-2 rounded-xl px-2 py-1 pl-0 align-middle text-gray-600 hover:bg-gray-500/20 dark:text-gray-400"
122
+ onclick={() => {
123
+ closeMobileNav();
124
+ toggleSearch();
125
+ }}
126
+ >
127
+ <CarbonSearch class="text-xs" />
128
+ <span class="block">Search chats</span></button
129
+ >
130
  {#await groupedConversations}
131
  {#if $page.data.nConversations > 0}
132
  <div class="overflow-y-hidden">
 
235
  theme = localStorage.theme;
236
  }}
237
  aria-label="Toggle theme"
238
+ class="flex h-9 min-w-[1.5em] flex-none items-center rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
239
  >
240
  {#if browser}
241
  {#if theme === "dark"}
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -247,7 +247,7 @@
247
  }}
248
  />
249
 
250
- <div class="relative min-h-0 min-w-0">
251
  <div
252
  class="scrollbar-custom h-full overflow-y-auto"
253
  use:snapScrollToBottom={messages.map((message) => message.content)}
 
247
  }}
248
  />
249
 
250
+ <div class="relative z-[-1] min-h-0 min-w-0">
251
  <div
252
  class="scrollbar-custom h-full overflow-y-auto"
253
  use:snapScrollToBottom={messages.map((message) => message.content)}
src/lib/components/chat/Search.svelte ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts" module>
2
+ export function toggleSearch() {
3
+ searchOpen = !searchOpen;
4
+ }
5
+
6
+ let searchOpen: boolean = $state(false);
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import { base } from "$app/paths";
11
+
12
+ import { debounce } from "$lib/utils/debounce";
13
+ import { onDestroy, onMount } from "svelte";
14
+
15
+ import type { GETSearchEndpointReturn } from "../../../routes/api/conversations/search/+server";
16
+ import NavConversationItem from "../NavConversationItem.svelte";
17
+ import { titles } from "../NavMenu.svelte";
18
+ import { beforeNavigate } from "$app/navigation";
19
+ import { browser } from "$app/environment";
20
+
21
+ import CarbonClose from "~icons/carbon/close";
22
+ import { fly } from "svelte/transition";
23
+
24
+ let inputElement: HTMLInputElement | undefined = $state(undefined);
25
+
26
+ let searchInput: string = $state("");
27
+ let debouncedInput: string = $state("");
28
+
29
+ let pending: boolean = $state(false);
30
+
31
+ let conversations: GETSearchEndpointReturn = $state([]);
32
+
33
+ const dateRanges = [
34
+ new Date().setDate(new Date().getDate() - 1),
35
+ new Date().setDate(new Date().getDate() - 7),
36
+ new Date().setMonth(new Date().getMonth() - 1),
37
+ ];
38
+
39
+ let groupedConversations = $derived({
40
+ today: conversations.filter(({ updatedAt }) => updatedAt.getTime() > dateRanges[0]),
41
+ week: conversations.filter(
42
+ ({ updatedAt }) => updatedAt.getTime() > dateRanges[1] && updatedAt.getTime() < dateRanges[0]
43
+ ),
44
+ month: conversations.filter(
45
+ ({ updatedAt }) => updatedAt.getTime() > dateRanges[2] && updatedAt.getTime() < dateRanges[1]
46
+ ),
47
+ older: conversations.filter(({ updatedAt }) => updatedAt.getTime() < dateRanges[2]),
48
+ });
49
+
50
+ const update = debounce(async (v: string) => {
51
+ debouncedInput = v;
52
+ pending = true;
53
+ await fetch(`${base}/api/conversations/search?q=${v}`)
54
+ .then(async (r) => {
55
+ if (r.ok) {
56
+ conversations = await r.json().then((conversations) =>
57
+ conversations.map((conv: GETSearchEndpointReturn[number]) => ({
58
+ ...conv,
59
+ updatedAt: new Date(conv.updatedAt),
60
+ }))
61
+ );
62
+ } else {
63
+ conversations = [];
64
+ }
65
+ })
66
+ .finally(() => {
67
+ pending = false;
68
+ });
69
+ }, 300);
70
+
71
+ $effect(() => update(searchInput));
72
+
73
+ async function openSearchListener(ev: KeyboardEvent) {
74
+ if (ev.ctrlKey && ev.key === "k") {
75
+ searchOpen = true;
76
+ ev.stopPropagation();
77
+ ev.preventDefault();
78
+ }
79
+ }
80
+
81
+ onMount(() => {
82
+ if (!browser) return;
83
+ window.addEventListener("keydown", openSearchListener);
84
+ });
85
+
86
+ beforeNavigate(() => {
87
+ searchOpen = false;
88
+ searchInput = "";
89
+ });
90
+
91
+ onDestroy(() => {
92
+ if (!browser) return;
93
+ window.removeEventListener("keydown", openSearchListener);
94
+ });
95
+
96
+ $effect(() => {
97
+ if (searchOpen) {
98
+ inputElement?.focus();
99
+ }
100
+ });
101
+
102
+ $effect(() => {
103
+ if (!searchOpen) {
104
+ searchInput = "";
105
+ }
106
+ });
107
+ </script>
108
+
109
+ {#if searchOpen}
110
+ <div
111
+ class="fixed bottom-0 left-[5%] right-[5%] top-[10%] z-50
112
+ m-4 mx-auto h-fit max-w-2xl
113
+ overflow-hidden rounded-xl
114
+ border border-gray-500/50 bg-gray-200 text-gray-800
115
+ shadow-[0_10px_40px_rgba(100,100,100,0.2)]
116
+ dark:bg-gray-800
117
+ dark:text-gray-200 dark:shadow-[0_10px_40px_rgba(255,255,255,0.1)] lg:top-[20%]"
118
+ in:fly={{ y: 100 }}
119
+ >
120
+ <button
121
+ class="absolute right-1 top-2.5 rounded-full p-1 hover:bg-gray-500/50"
122
+ onclick={toggleSearch}
123
+ >
124
+ <CarbonClose class="text-lg text-gray-400/80" />
125
+ </button>
126
+ <input
127
+ bind:value={searchInput}
128
+ bind:this={inputElement}
129
+ type="text"
130
+ name="searchbar"
131
+ placeholder="Search for chats..."
132
+ class={{
133
+ "h-12 w-full p-4 text-lg dark:bg-gray-800 dark:text-gray-200": true,
134
+ "border-b border-b-gray-500/50": searchInput && searchInput.length >= 3,
135
+ }}
136
+ />
137
+
138
+ <div class="max-h-[50dvh] overflow-y-scroll">
139
+ {#if debouncedInput && debouncedInput.length >= 3}
140
+ {#if pending}
141
+ {#each Array(5) as _}
142
+ <div
143
+ class="m-2 h-6 w-full animate-pulse gap-5 rounded bg-gray-300 first:mt-4 dark:bg-gray-700"
144
+ ></div>
145
+ {/each}
146
+ {:else if conversations.length === 0}
147
+ <p class="bg-gray-200 p-2 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
148
+ No conversations found matching that query
149
+ </p>
150
+ {:else}
151
+ {#each Object.entries(groupedConversations) as [group, convs]}
152
+ {#if convs.length}
153
+ <h4 class="mb-1.5 mt-4 pl-1.5 text-sm text-gray-400 dark:text-gray-500">
154
+ {titles[group]}
155
+ </h4>
156
+ {#each convs as conv}
157
+ <NavConversationItem {conv} readOnly={true} />
158
+ {/each}
159
+ {/if}
160
+ {/each}
161
+ {/if}
162
+ {/if}
163
+ </div>
164
+ </div>
165
+ {/if}
src/lib/server/database.ts CHANGED
@@ -198,6 +198,22 @@ export class Database {
198
  conversations
199
  .createIndex({ "messages.createdAt": 1 }, { sparse: true })
200
  .catch((e) => logger.error(e));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  // Unique index for stats
202
  conversationStats
203
  .createIndex(
 
198
  conversations
199
  .createIndex({ "messages.createdAt": 1 }, { sparse: true })
200
  .catch((e) => logger.error(e));
201
+
202
+ // Text index for searching conversation titles and message content
203
+ conversations
204
+ .createIndex(
205
+ {
206
+ userId: 1,
207
+ sessionId: 1,
208
+ title: "text",
209
+ "messages.content": "text",
210
+ },
211
+ {
212
+ default_language: "en",
213
+ }
214
+ )
215
+ .catch((e) => logger.error(e));
216
+
217
  // Unique index for stats
218
  conversationStats
219
  .createIndex(
src/routes/+layout.svelte CHANGED
@@ -22,6 +22,7 @@
22
  import { loginModalOpen } from "$lib/stores/loginModal";
23
  import LoginModal from "$lib/components/LoginModal.svelte";
24
  import OverloadedModal from "$lib/components/OverloadedModal.svelte";
 
25
 
26
  let { data = $bindable(), children } = $props();
27
 
@@ -261,6 +262,8 @@
261
  <OverloadedModal onClose={() => (overloadedModalOpen = false)} />
262
  {/if}
263
 
 
 
264
  <div
265
  class="fixed grid h-full w-screen grid-cols-1 grid-rows-[auto,1fr] overflow-hidden text-smd {!isNavCollapsed
266
  ? 'md:grid-cols-[290px,1fr]'
 
22
  import { loginModalOpen } from "$lib/stores/loginModal";
23
  import LoginModal from "$lib/components/LoginModal.svelte";
24
  import OverloadedModal from "$lib/components/OverloadedModal.svelte";
25
+ import Search from "$lib/components/chat/Search.svelte";
26
 
27
  let { data = $bindable(), children } = $props();
28
 
 
262
  <OverloadedModal onClose={() => (overloadedModalOpen = false)} />
263
  {/if}
264
 
265
+ <Search />
266
+
267
  <div
268
  class="fixed grid h-full w-screen grid-cols-1 grid-rows-[auto,1fr] overflow-hidden text-smd {!isNavCollapsed
269
  ? 'md:grid-cols-[290px,1fr]'
src/routes/api/conversations/+server.ts CHANGED
@@ -6,7 +6,6 @@ import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
6
 
7
  export async function GET({ locals, url }) {
8
  const p = parseInt(url.searchParams.get("p") ?? "0");
9
-
10
  if (locals.user?._id || locals.sessionId) {
11
  const convs = await collections.conversations
12
  .find({
@@ -26,7 +25,6 @@ export async function GET({ locals, url }) {
26
  if (convs.length === 0) {
27
  return Response.json([]);
28
  }
29
-
30
  const res = convs.map((conv) => ({
31
  _id: conv._id,
32
  id: conv._id, // legacy param iOS
 
6
 
7
  export async function GET({ locals, url }) {
8
  const p = parseInt(url.searchParams.get("p") ?? "0");
 
9
  if (locals.user?._id || locals.sessionId) {
10
  const convs = await collections.conversations
11
  .find({
 
25
  if (convs.length === 0) {
26
  return Response.json([]);
27
  }
 
28
  const res = convs.map((conv) => ({
29
  _id: conv._id,
30
  id: conv._id, // legacy param iOS
src/routes/api/conversations/search/+server.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
2
+ import { authCondition } from "$lib/server/auth";
3
+ import { collections } from "$lib/server/database";
4
+ import { models } from "$lib/server/models";
5
+ import type { RequestHandler } from "@sveltejs/kit";
6
+
7
+ export type GETSearchEndpointReturn = Array<{
8
+ id: string;
9
+ title: string;
10
+ updatedAt: Date;
11
+ model: string;
12
+ assistantId?: string;
13
+ mdoelTools?: boolean;
14
+ }>;
15
+
16
+ export const GET: RequestHandler = async ({ locals, url }) => {
17
+ const searchQuery = url.searchParams.get("q");
18
+ const p = parseInt(url.searchParams.get("p") ?? "0");
19
+
20
+ if (!searchQuery || searchQuery.length < 3) {
21
+ return Response.json([]);
22
+ }
23
+
24
+ if (locals.user?._id || locals.sessionId) {
25
+ const convs = await collections.conversations
26
+ .find({
27
+ ...authCondition(locals),
28
+ $text: { $search: searchQuery },
29
+ })
30
+ .sort({ score: { $meta: "textScore" } })
31
+ .project({
32
+ title: 1,
33
+ updatedAt: 1,
34
+ model: 1,
35
+ assistantId: 1,
36
+ messages: 1,
37
+ userId: 1,
38
+ })
39
+ .skip(p * CONV_NUM_PER_PAGE)
40
+ .limit(CONV_NUM_PER_PAGE)
41
+ .toArray()
42
+ .then((convs) =>
43
+ convs.map((conv) => {
44
+ return {
45
+ _id: conv._id,
46
+ id: conv._id, // legacy param iOS
47
+ title: conv.title,
48
+ updatedAt: conv.updatedAt,
49
+ model: conv.model,
50
+ assistantId: conv.assistantId,
51
+ modelTools: models.find((m) => m.id == conv.model)?.tools ?? false,
52
+ };
53
+ })
54
+ );
55
+
56
+ return Response.json(convs as GETSearchEndpointReturn);
57
+ }
58
+ return Response.json([]);
59
+ };