This commit is contained in:
Timothy Jaeryang Baek committed 2026-07-16 21:25:35 -04:00
1 parent 975c9f9b50
commit 668f9fe390
6 files changed
+529 -327

No files matched your search

@@ -122,11 +122,12 @@
</Tooltip> </Tooltip>
<div slot="content"> <div slot="content">
<DropdownMenu <DropdownMenu className="w-70 max-h-72 overflow-hidden transition">
className="w-70 max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin transition"
>
{#if tab === ''} {#if tab === ''}
<div in:fly={{ x: -20, duration: 150 }}> <div
class="max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
in:fly={{ x: -20, duration: 150 }}
>
<Tooltip <Tooltip
content={fileUploadCapableModels.length !== selectedModels.length content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload') ? $i18n.t('Model(s) do not support file upload')
@@ -486,9 +487,9 @@
{/if} {/if}
</div> </div>
{:else if tab === 'knowledge'} {:else if tab === 'knowledge'}
<div in:fly={{ x: 20, duration: 150 }}> <div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button <button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40" class="flex w-full shrink-0 justify-between gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => { on:click={() => {
tab = ''; tab = '';
}} }}
@@ -505,9 +506,9 @@
<Knowledge {onSelect} /> <Knowledge {onSelect} />
</div> </div>
{:else if tab === 'notes'} {:else if tab === 'notes'}
<div in:fly={{ x: 20, duration: 150 }}> <div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button <button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40" class="flex w-full shrink-0 justify-between gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => { on:click={() => {
tab = ''; tab = '';
}} }}
@@ -524,9 +525,9 @@
<Notes {onSelect} /> <Notes {onSelect} />
</div> </div>
{:else if tab === 'files'} {:else if tab === 'files'}
<div in:fly={{ x: 20, duration: 150 }}> <div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button <button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40" class="flex w-full shrink-0 justify-between gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => { on:click={() => {
tab = ''; tab = '';
}} }}
@@ -543,9 +544,9 @@
<Files {onSelect} /> <Files {onSelect} />
</div> </div>
{:else if tab === 'chats'} {:else if tab === 'chats'}
<div in:fly={{ x: 20, duration: 150 }}> <div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button <button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40" class="flex w-full shrink-0 justify-between gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => { on:click={() => {
tab = ''; tab = '';
}} }}
@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { onMount, tick, getContext } from 'svelte'; import { onDestroy, onMount, tick, getContext } from 'svelte';
import { decodeString } from '$lib/utils'; import { decodeString } from '$lib/utils';
import { getChatList } from '$lib/apis/chats'; import { getChatList, getChatListBySearchText } from '$lib/apis/chats';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte'; import Loader from '$lib/components/common/Loader.svelte';
import { chatId } from '$lib/stores'; import { chatId } from '$lib/stores';
import SearchInput from './SearchInput.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -18,22 +19,52 @@
let items = []; let items = [];
let selectedIdx = 0; let selectedIdx = 0;
let query = '';
let page = 1; let page = 1;
let itemsLoading = false; let itemsLoading = false;
let allItemsLoaded = false; let allItemsLoaded = false;
let initialized = false;
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let requestId = 0;
$: if (initialized) {
query;
scheduleSearch();
}
const scheduleSearch = () => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(init, 200);
};
const init = async () => {
requestId += 1;
page = 1;
items = [];
selectedIdx = 0;
allItemsLoaded = false;
itemsLoading = false;
await tick();
await getItemsPage(requestId);
};
const loadMoreItems = async () => { const loadMoreItems = async () => {
if (allItemsLoaded) return; if (allItemsLoaded) return;
page += 1; page += 1;
await getItemsPage(); await getItemsPage(requestId);
}; };
const getItemsPage = async () => { const getItemsPage = async (activeRequestId = requestId) => {
itemsLoading = true; itemsLoading = true;
let res = await getChatList(localStorage.token, page, true, true).catch(() => { const res = query.trim()
return []; ? await getChatListBySearchText(localStorage.token, query.trim(), page).catch(() => {
}); return [];
})
: await getChatList(localStorage.token, page, true, true).catch(() => {
return [];
});
if (activeRequestId !== requestId) return res;
if ((res ?? []).length === 0) { if ((res ?? []).length === 0) {
allItemsLoaded = true; allItemsLoaded = true;
@@ -50,7 +81,7 @@
...item, ...item,
type: 'chat', type: 'chat',
name: item.title, name: item.title,
description: dayjs(item.updated_at * 1000).fromNow() description: item.snippet || dayjs(item.updated_at * 1000).fromNow()
}; };
}) })
]; ];
@@ -64,60 +95,78 @@
await tick(); await tick();
loaded = true; loaded = true;
initialized = true;
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
}); });
</script> </script>
{#if loaded} {#if loaded}
{#if items.length === 0} <div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden">
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No chats found')}</div> <SearchInput bind:value={query} placeholder={$i18n.t('Search Chats')} />
{:else}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal {idx ===
selectedIdx
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class="text-black dark:text-gray-100 flex items-center gap-1.5">
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
{#if !allItemsLoaded} <div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
<Loader {#if items.length === 0 && itemsLoading}
on:visible={(e) => { <div class="py-4.5">
if (!itemsLoading) { <Spinner />
loadMoreItems(); </div>
} {:else if items.length === 0}
}} <div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No chats found')}</div>
> {:else}
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"> <div class="flex flex-col gap-0.5">
<Spinner className=" size-4" /> {#each items as item, idx}
<div class=" ">{$i18n.t('Loading...')}</div> <button
</div> class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal {idx ===
</Loader> selectedIdx
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class="text-black dark:text-gray-100 flex items-center gap-1.5">
<Tooltip
content={item.description || decodeString(item?.name)}
placement="top-start"
>
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
</div>
{/if} {/if}
</div> </div>
{/if} </div>
{:else} {:else}
<div class="py-4.5"> <div class="py-4.5">
<Spinner /> <Spinner />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick, getContext } from 'svelte'; import { onDestroy, onMount, tick, getContext } from 'svelte';
import { searchFiles } from '$lib/apis/files'; import { searchFiles } from '$lib/apis/files';
@@ -7,6 +7,7 @@
import DocumentPage from '$lib/components/icons/DocumentPage.svelte'; import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte'; import Loader from '$lib/components/common/Loader.svelte';
import SearchInput from './SearchInput.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -15,21 +16,49 @@
let loaded = false; let loaded = false;
let items = []; let items = [];
let selectedIdx = 0; let selectedIdx = 0;
let query = '';
let page = 0; let page = 0;
let limit = 50; let limit = 50;
let itemsLoading = false; let itemsLoading = false;
let allItemsLoaded = false; let allItemsLoaded = false;
let initialized = false;
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let requestId = 0;
$: if (initialized) {
query;
scheduleSearch();
}
const scheduleSearch = () => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(init, 200);
};
const init = async () => {
requestId += 1;
page = 0;
items = [];
selectedIdx = 0;
allItemsLoaded = false;
itemsLoading = false;
await tick();
await getItemsPage(requestId);
};
const loadMoreItems = async () => { const loadMoreItems = async () => {
if (allItemsLoaded) return; if (allItemsLoaded) return;
page += 1; page += 1;
await getItemsPage(); await getItemsPage(requestId);
}; };
const getItemsPage = async () => { const getItemsPage = async (activeRequestId = requestId) => {
itemsLoading = true; itemsLoading = true;
let res = await searchFiles(localStorage.token, '*', page * limit, limit).catch(() => []); const value = query.trim();
const filename = value ? (/[?*]/.test(value) ? value : `*${value}*`) : '*';
let res = await searchFiles(localStorage.token, filename, page * limit, limit).catch(() => []);
if (activeRequestId !== requestId) return res;
if ((res ?? []).length < limit) { if ((res ?? []).length < limit) {
allItemsLoaded = true; allItemsLoaded = true;
@@ -55,64 +84,79 @@
await getItemsPage(); await getItemsPage();
await tick(); await tick();
loaded = true; loaded = true;
initialized = true;
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
}); });
</script> </script>
{#if loaded} {#if loaded}
{#if items.length === 0} <div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden">
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No files found')}</div> <SearchInput bind:value={query} placeholder={$i18n.t('Search Files')} />
{:else}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal {idx ===
selectedIdx
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class="text-black dark:text-gray-100 flex items-center gap-1.5 overflow-hidden">
<Tooltip content={$i18n.t('File')} placement="top">
<DocumentPage className="size-3.5 shrink-0" />
</Tooltip>
<Tooltip content={item?.name} placement="top-start"> <div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
<div class="line-clamp-1 flex-1"> {#if items.length === 0 && itemsLoading}
{item?.name} <div class="py-4.5">
<Spinner />
</div>
{:else if items.length === 0}
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No files found')}</div>
{:else}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal {idx ===
selectedIdx
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class="text-black dark:text-gray-100 flex items-center gap-1.5 overflow-hidden">
<Tooltip content={$i18n.t('File')} placement="top">
<DocumentPage className="size-3.5 shrink-0" />
</Tooltip>
<Tooltip content={item?.name} placement="top-start">
<div class="line-clamp-1 flex-1">
{item?.name}
</div>
</Tooltip>
</div> </div>
</Tooltip> </button>
</div> {/each}
</button>
{/each}
{#if !allItemsLoaded} {#if !allItemsLoaded}
<Loader <Loader
on:visible={(e) => { on:visible={(e) => {
if (!itemsLoading) { if (!itemsLoading) {
loadMoreItems(); loadMoreItems();
} }
}} }}
> >
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"> <div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" /> <Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div> <div class=" ">{$i18n.t('Loading...')}</div>
</div> </div>
</Loader> </Loader>
{/if}
</div>
{/if} {/if}
</div> </div>
{/if} </div>
{:else} {:else}
<div class="py-4.5"> <div class="py-4.5">
<Spinner /> <Spinner />
@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick, getContext } from 'svelte'; import { onDestroy, onMount, tick, getContext } from 'svelte';
import { decodeString } from '$lib/utils'; import { decodeString } from '$lib/utils';
import { knowledge } from '$lib/stores'; import { searchKnowledgeBases, searchKnowledgeFilesById } from '$lib/apis/knowledge';
import { getKnowledgeBases, searchKnowledgeFilesById } from '$lib/apis/knowledge';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Database from '$lib/components/icons/Database.svelte'; import Database from '$lib/components/icons/Database.svelte';
@@ -13,6 +11,7 @@
import Loader from '$lib/components/common/Loader.svelte'; import Loader from '$lib/components/common/Loader.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import SearchInput from './SearchInput.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -20,6 +19,7 @@
let loaded = false; let loaded = false;
let selectedIdx = 0; let selectedIdx = 0;
let query = '';
let selectedItem = null; let selectedItem = null;
@@ -30,35 +30,37 @@
let selectedFileItemsLoading = false; let selectedFileItemsLoading = false;
let selectedFileAllItemsLoaded = false; let selectedFileAllItemsLoaded = false;
let selectedFileRequestId = 0;
$: if (selectedItem) { $: if (selectedItem) {
initSelectedFileItems(); initSelectedFileItems();
} }
const initSelectedFileItems = async () => { const initSelectedFileItems = async () => {
selectedFileRequestId += 1;
selectedFileItemsPage = 1; selectedFileItemsPage = 1;
selectedFileItems = null; selectedFileItems = null;
selectedFileItemsTotal = null; selectedFileItemsTotal = null;
selectedFileAllItemsLoaded = false; selectedFileAllItemsLoaded = false;
selectedFileItemsLoading = false; selectedFileItemsLoading = false;
await tick(); await tick();
await getSelectedFileItemsPage(); await getSelectedFileItemsPage(selectedFileRequestId);
}; };
const loadMoreSelectedFileItems = async () => { const loadMoreSelectedFileItems = async () => {
if (selectedFileAllItemsLoaded) return; if (selectedFileAllItemsLoaded) return;
selectedFileItemsPage += 1; selectedFileItemsPage += 1;
await getSelectedFileItemsPage(); await getSelectedFileItemsPage(selectedFileRequestId);
}; };
const getSelectedFileItemsPage = async () => { const getSelectedFileItemsPage = async (activeRequestId = selectedFileRequestId) => {
if (!selectedItem) return; if (!selectedItem) return;
selectedFileItemsLoading = true; selectedFileItemsLoading = true;
const res = await searchKnowledgeFilesById( const res = await searchKnowledgeFilesById(
localStorage.token, localStorage.token,
selectedItem.id, selectedItem.id,
null, query.trim() || null,
null, null,
null, null,
null, null,
@@ -66,6 +68,7 @@
).catch(() => { ).catch(() => {
return null; return null;
}); });
if (activeRequestId !== selectedFileRequestId) return res;
if (res) { if (res) {
selectedFileItemsTotal = res.total; selectedFileItemsTotal = res.total;
@@ -91,25 +94,36 @@
}; };
let page = 1; let page = 1;
let items = null; let items = [];
let total = null; let total = null;
let itemsLoading = false; let itemsLoading = false;
let allItemsLoaded = false; let allItemsLoaded = false;
let initialized = false;
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let requestId = 0;
$: if (loaded) { $: if (initialized) {
init(); query;
scheduleSearch();
} }
const scheduleSearch = () => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(init, 200);
};
const init = async () => { const init = async () => {
requestId += 1;
reset(); reset();
selectedItem = null;
await tick(); await tick();
await getItemsPage(); await getItemsPage(requestId);
}; };
const reset = () => { const reset = () => {
page = 1; page = 1;
items = null; items = [];
total = null; total = null;
allItemsLoaded = false; allItemsLoaded = false;
itemsLoading = false; itemsLoading = false;
@@ -118,17 +132,22 @@
const loadMoreItems = async () => { const loadMoreItems = async () => {
if (allItemsLoaded) return; if (allItemsLoaded) return;
page += 1; page += 1;
await getItemsPage(); await getItemsPage(requestId);
}; };
const getItemsPage = async () => { const getItemsPage = async (activeRequestId = requestId) => {
itemsLoading = true; itemsLoading = true;
const res = await getKnowledgeBases(localStorage.token, page).catch(() => { const res = await searchKnowledgeBases(
localStorage.token,
query.trim() || null,
null,
page
).catch(() => {
return null; return null;
}); });
if (activeRequestId !== requestId) return res;
if (res) { if (res) {
console.log(res);
total = res.total; total = res.total;
const pageItems = res.items; const pageItems = res.items;
@@ -152,156 +171,170 @@
}; };
onMount(async () => { onMount(async () => {
await init();
await tick(); await tick();
loaded = true; loaded = true;
initialized = true;
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
}); });
</script> </script>
{#if loaded && items !== null} {#if loaded}
<div class="flex flex-col gap-0.5"> <div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden">
{#if items.length === 0} <SearchInput bind:value={query} placeholder={$i18n.t('Search Knowledge')} />
<div class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('No knowledge bases found.')} <div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
</div> {#if items.length === 0 && itemsLoading}
{:else} <div class="py-4.5">
{#each items as item, idx (item.id)} <Spinner />
<div </div>
class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {idx === {:else if items.length === 0}
selectedIdx <div class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button' {$i18n.t('No knowledge bases found.')}
: ''}" </div>
> {:else}
<button {#each items as item, idx (item.id)}
class="w-full flex-1" <div
type="button" class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {idx ===
on:click={() => { selectedIdx
onSelect({ ? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
type: 'collection', : ''}"
...item
});
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
> >
<div class="w-full text-left text-black dark:text-gray-100 flex items-center gap-1">
<Tooltip content={$i18n.t('Collection')} placement="top">
<Database className="size-3.5" />
</Tooltip>
<Tooltip
content={item.description || decodeString(item?.name)}
placement="top-start"
className="flex flex-1 min-w-0"
>
<div class="line-clamp-1 flex-1 text-[13px]">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
<Tooltip content={$i18n.t('Show Files')} placement="top">
<button <button
class="w-full flex-1"
type="button" type="button"
class=" ml-2 opacity-50 hover:opacity-100 transition"
on:click={() => { on:click={() => {
if (selectedItem && selectedItem.id === item.id) { onSelect({
selectedItem = null; type: 'collection',
} else { ...item
selectedItem = item; });
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
} }
}} }}
data-selected={idx === selectedIdx}
> >
{#if selectedItem && selectedItem.id === item.id} <div class="w-full text-left text-black dark:text-gray-100 flex items-center gap-1">
<ChevronDown className="size-3" /> <Tooltip content={$i18n.t('Collection')} placement="top">
{:else} <Database className="size-3.5" />
<ChevronRight className="size-3" /> </Tooltip>
{/if}
<Tooltip
content={item.description || decodeString(item?.name)}
placement="top-start"
className="flex flex-1 min-w-0"
>
<div class="line-clamp-1 flex-1 text-[13px]">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button> </button>
</Tooltip>
</div>
{#if selectedItem && selectedItem.id === item.id} <Tooltip content={$i18n.t('Show Files')} placement="top">
<div class="pl-3 mb-1 flex flex-col gap-0.5"> <button
{#if selectedFileItems === null && selectedFileItemsTotal === null} type="button"
<div class=" py-1 flex justify-center"> class=" ml-2 opacity-50 hover:opacity-100 transition"
<Spinner className="size-3" /> on:click={() => {
</div> if (selectedItem && selectedItem.id === item.id) {
{:else if selectedFileItemsTotal === 0} selectedItem = null;
<div class=" text-xs text-gray-500 dark:text-gray-400 italic py-0.5 px-2"> } else {
{$i18n.t('No files in this knowledge base.')} selectedItem = item;
</div> }
{:else} }}
{#each selectedFileItems as file, fileIdx (file.id)} >
<button {#if selectedItem && selectedItem.id === item.id}
class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100" <ChevronDown className="size-3" />
type="button" {:else}
on:click={() => { <ChevronRight className="size-3" />
console.log(file); {/if}
onSelect({ </button>
type: 'file', </Tooltip>
name: file?.meta?.name, </div>
...file
});
}}
>
<div class=" flex items-center gap-1.5">
<Tooltip content={$i18n.t('Collection')} placement="top">
<DocumentPage className="size-3.5" />
</Tooltip>
<Tooltip content={decodeString(file?.meta?.name)} placement="top-start"> {#if selectedItem && selectedItem.id === item.id}
<div class="line-clamp-1 flex-1 text-[13px]"> <div class="pl-3 mb-1 flex flex-col gap-0.5">
{decodeString(file?.meta?.name)} {#if selectedFileItems === null && selectedFileItemsTotal === null}
</div> <div class=" py-1 flex justify-center">
</Tooltip> <Spinner className="size-3" />
</div> </div>
</button> {:else if selectedFileItemsTotal === 0}
{/each} <div class=" text-xs text-gray-500 dark:text-gray-400 italic py-0.5 px-2">
{$i18n.t('No files in this knowledge base.')}
{#if !selectedFileAllItemsLoaded && !selectedFileItemsLoading} </div>
<Loader {:else}
on:visible={async (e) => { {#each selectedFileItems as file, fileIdx (file.id)}
if (!selectedFileItemsLoading) { <button
await loadMoreSelectedFileItems(); class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100"
} type="button"
}} on:click={() => {
> console.log(file);
<div onSelect({
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2" type: 'file',
name: file?.meta?.name,
...file
});
}}
> >
<Spinner className=" size-3" /> <div class=" flex items-center gap-1.5">
<div class=" ">{$i18n.t('Loading...')}</div> <Tooltip content={$i18n.t('Collection')} placement="top">
</div> <DocumentPage className="size-3.5" />
</Loader> </Tooltip>
{/if}
{/if}
</div>
{/if}
{/each}
{#if !allItemsLoaded} <Tooltip content={decodeString(file?.meta?.name)} placement="top-start">
<Loader <div class="line-clamp-1 flex-1 text-[13px]">
on:visible={(e) => { {decodeString(file?.meta?.name)}
if (!itemsLoading) { </div>
loadMoreItems(); </Tooltip>
} </div>
}} </button>
> {/each}
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" /> {#if !selectedFileAllItemsLoaded && !selectedFileItemsLoading}
<div class=" ">{$i18n.t('Loading...')}</div> <Loader
</div> on:visible={async (e) => {
</Loader> if (!selectedFileItemsLoading) {
await loadMoreSelectedFileItems();
}
}}
>
<div
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-3" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/if}
</div>
{/if}
{/each}
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/if} {/if}
{/if} </div>
</div> </div>
{:else} {:else}
<div class="py-4.5"> <div class="py-4.5">
@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { onMount, tick, getContext } from 'svelte'; import { onDestroy, onMount, tick, getContext } from 'svelte';
import { decodeString } from '$lib/utils'; import { decodeString } from '$lib/utils';
import { getNoteList } from '$lib/apis/notes'; import { getNoteList, searchNotes } from '$lib/apis/notes';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import PageEdit from '$lib/components/icons/PageEdit.svelte'; import PageEdit from '$lib/components/icons/PageEdit.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte'; import Loader from '$lib/components/common/Loader.svelte';
import SearchInput from './SearchInput.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -18,24 +19,56 @@
let items = []; let items = [];
let selectedIdx = 0; let selectedIdx = 0;
let query = '';
let page = 1; let page = 1;
let itemsLoading = false; let itemsLoading = false;
let allItemsLoaded = false; let allItemsLoaded = false;
let initialized = false;
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let requestId = 0;
$: if (initialized) {
query;
scheduleSearch();
}
const scheduleSearch = () => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(init, 200);
};
const init = async () => {
requestId += 1;
page = 1;
items = [];
selectedIdx = 0;
allItemsLoaded = false;
itemsLoading = false;
await tick();
await getItemsPage(requestId);
};
const loadMoreItems = async () => { const loadMoreItems = async () => {
if (allItemsLoaded) return; if (allItemsLoaded) return;
page += 1; page += 1;
await getItemsPage(); await getItemsPage(requestId);
}; };
const getItemsPage = async () => { const getItemsPage = async (activeRequestId = requestId) => {
itemsLoading = true; itemsLoading = true;
let res = await getNoteList(localStorage.token, page).catch(() => { const res = query.trim()
return []; ? await searchNotes(localStorage.token, query.trim(), null, null, null, page).catch(
}); () => null
)
: await getNoteList(localStorage.token, page).catch(() => {
return [];
});
if (activeRequestId !== requestId) return res;
if ((res ?? []).length === 0) { const pageItems = query.trim() ? (res?.items ?? []) : (res ?? []);
if ((pageItems ?? []).length === 0) {
allItemsLoaded = true; allItemsLoaded = true;
} else { } else {
allItemsLoaded = false; allItemsLoaded = false;
@@ -43,7 +76,7 @@
items = [ items = [
...items, ...items,
...res.map((note) => { ...pageItems.map((note) => {
return { return {
...note, ...note,
type: 'note', type: 'note',
@@ -63,64 +96,82 @@
await tick(); await tick();
loaded = true; loaded = true;
initialized = true;
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
}); });
</script> </script>
{#if loaded} {#if loaded}
{#if items.length === 0} <div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden">
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No notes found')}</div> <SearchInput bind:value={query} placeholder={$i18n.t('Search Notes')} />
{:else}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal {idx ===
selectedIdx
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class="text-black dark:text-gray-100 flex items-center gap-1.5">
<Tooltip content={$i18n.t('Note')} placement="top">
<PageEdit className="size-3.5" />
</Tooltip>
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start"> <div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
<div class="line-clamp-1 flex-1"> {#if items.length === 0 && itemsLoading}
{decodeString(item?.name)} <div class="py-4.5">
<Spinner />
</div>
{:else if items.length === 0}
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No notes found')}</div>
{:else}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" h-[1.6875rem] px-2 rounded-xl w-full text-left flex justify-between items-center text-[13px] font-normal {idx ===
selectedIdx
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class="text-black dark:text-gray-100 flex items-center gap-1.5">
<Tooltip content={$i18n.t('Note')} placement="top">
<PageEdit className="size-3.5" />
</Tooltip>
<Tooltip
content={item.description || decodeString(item?.name)}
placement="top-start"
>
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
</div>
</Tooltip>
</div> </div>
</Tooltip> </button>
</div> {/each}
</button>
{/each}
{#if !allItemsLoaded} {#if !allItemsLoaded}
<Loader <Loader
on:visible={(e) => { on:visible={(e) => {
if (!itemsLoading) { if (!itemsLoading) {
loadMoreItems(); loadMoreItems();
} }
}} }}
> >
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"> <div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" /> <Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div> <div class=" ">{$i18n.t('Loading...')}</div>
</div> </div>
</Loader> </Loader>
{/if}
</div>
{/if} {/if}
</div> </div>
{/if} </div>
{:else} {:else}
<div class="py-4.5"> <div class="py-4.5">
<Spinner /> <Spinner />
@@ -0,0 +1,24 @@
<script lang="ts">
import { getContext } from 'svelte';
import Search from '$lib/components/icons/Search.svelte';
const i18n = getContext('i18n');
export let value = '';
export let placeholder = $i18n.t('Search');
</script>
<div class="my-0.5 flex ml-2 mr-0.5 h-[1.6875rem] shrink-0 items-center gap-2">
<Search className="size-3.5 shrink-0" strokeWidth="2" />
<input
bind:value
class="w-full bg-transparent text-[13px] font-normal outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-500"
{placeholder}
autocomplete="off"
aria-label={placeholder}
on:click|stopPropagation
on:keydown|stopPropagation
/>
</div>