This commit is contained in:
Timothy Jaeryang Baek
2026-07-16 21:25:35 -04:00
parent 975c9f9b50
commit 668f9fe390
6 changed files with 529 additions and 327 deletions
@@ -122,11 +122,12 @@
</Tooltip>
<div slot="content">
<DropdownMenu
className="w-70 max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin transition"
>
<DropdownMenu className="w-70 max-h-72 overflow-hidden transition">
{#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
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
@@ -486,9 +487,9 @@
{/if}
</div>
{: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
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={() => {
tab = '';
}}
@@ -505,9 +506,9 @@
<Knowledge {onSelect} />
</div>
{: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
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={() => {
tab = '';
}}
@@ -524,9 +525,9 @@
<Notes {onSelect} />
</div>
{: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
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={() => {
tab = '';
}}
@@ -543,9 +544,9 @@
<Files {onSelect} />
</div>
{: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
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={() => {
tab = '';
}}
@@ -1,14 +1,15 @@
<script lang="ts">
import dayjs from 'dayjs';
import { onMount, tick, getContext } from 'svelte';
import { onDestroy, onMount, tick, getContext } from 'svelte';
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 Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import { chatId } from '$lib/stores';
import SearchInput from './SearchInput.svelte';
const i18n = getContext('i18n');
@@ -18,22 +19,52 @@
let items = [];
let selectedIdx = 0;
let query = '';
let page = 1;
let itemsLoading = 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 () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
await getItemsPage(requestId);
};
const getItemsPage = async () => {
const getItemsPage = async (activeRequestId = requestId) => {
itemsLoading = true;
let res = await getChatList(localStorage.token, page, true, true).catch(() => {
return [];
});
const res = query.trim()
? 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) {
allItemsLoaded = true;
@@ -50,7 +81,7 @@
...item,
type: 'chat',
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();
loaded = true;
initialized = true;
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
{#if loaded}
{#if items.length === 0}
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No chats 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={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
<div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden">
<SearchInput bind:value={query} placeholder={$i18n.t('Search Chats')} />
{#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>
<div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
{#if items.length === 0 && itemsLoading}
<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 chats 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={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}
</div>
{/if}
</div>
{:else}
<div class="py-4.5">
<Spinner />
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
import { onDestroy, onMount, tick, getContext } from 'svelte';
import { searchFiles } from '$lib/apis/files';
@@ -7,6 +7,7 @@
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import SearchInput from './SearchInput.svelte';
const i18n = getContext('i18n');
@@ -15,21 +16,49 @@
let loaded = false;
let items = [];
let selectedIdx = 0;
let query = '';
let page = 0;
let limit = 50;
let itemsLoading = 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 () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
await getItemsPage(requestId);
};
const getItemsPage = async () => {
const getItemsPage = async (activeRequestId = requestId) => {
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) {
allItemsLoaded = true;
@@ -55,64 +84,79 @@
await getItemsPage();
await tick();
loaded = true;
initialized = true;
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
{#if loaded}
{#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>
<div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden">
<SearchInput bind:value={query} placeholder={$i18n.t('Search Files')} />
<Tooltip content={item?.name} placement="top-start">
<div class="line-clamp-1 flex-1">
{item?.name}
<div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
{#if items.length === 0 && itemsLoading}
<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>
</Tooltip>
</div>
</button>
{/each}
</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 !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}
</div>
{/if}
</div>
{:else}
<div class="py-4.5">
<Spinner />
@@ -1,10 +1,8 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
import { onDestroy, onMount, tick, getContext } from 'svelte';
import { decodeString } from '$lib/utils';
import { knowledge } from '$lib/stores';
import { getKnowledgeBases, searchKnowledgeFilesById } from '$lib/apis/knowledge';
import { searchKnowledgeBases, searchKnowledgeFilesById } from '$lib/apis/knowledge';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Database from '$lib/components/icons/Database.svelte';
@@ -13,6 +11,7 @@
import Loader from '$lib/components/common/Loader.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import SearchInput from './SearchInput.svelte';
const i18n = getContext('i18n');
@@ -20,6 +19,7 @@
let loaded = false;
let selectedIdx = 0;
let query = '';
let selectedItem = null;
@@ -30,35 +30,37 @@
let selectedFileItemsLoading = false;
let selectedFileAllItemsLoaded = false;
let selectedFileRequestId = 0;
$: if (selectedItem) {
initSelectedFileItems();
}
const initSelectedFileItems = async () => {
selectedFileRequestId += 1;
selectedFileItemsPage = 1;
selectedFileItems = null;
selectedFileItemsTotal = null;
selectedFileAllItemsLoaded = false;
selectedFileItemsLoading = false;
await tick();
await getSelectedFileItemsPage();
await getSelectedFileItemsPage(selectedFileRequestId);
};
const loadMoreSelectedFileItems = async () => {
if (selectedFileAllItemsLoaded) return;
selectedFileItemsPage += 1;
await getSelectedFileItemsPage();
await getSelectedFileItemsPage(selectedFileRequestId);
};
const getSelectedFileItemsPage = async () => {
const getSelectedFileItemsPage = async (activeRequestId = selectedFileRequestId) => {
if (!selectedItem) return;
selectedFileItemsLoading = true;
const res = await searchKnowledgeFilesById(
localStorage.token,
selectedItem.id,
null,
query.trim() || null,
null,
null,
null,
@@ -66,6 +68,7 @@
).catch(() => {
return null;
});
if (activeRequestId !== selectedFileRequestId) return res;
if (res) {
selectedFileItemsTotal = res.total;
@@ -91,25 +94,36 @@
};
let page = 1;
let items = null;
let items = [];
let total = null;
let itemsLoading = false;
let allItemsLoaded = false;
let initialized = false;
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let requestId = 0;
$: if (loaded) {
init();
$: if (initialized) {
query;
scheduleSearch();
}
const scheduleSearch = () => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(init, 200);
};
const init = async () => {
requestId += 1;
reset();
selectedItem = null;
await tick();
await getItemsPage();
await getItemsPage(requestId);
};
const reset = () => {
page = 1;
items = null;
items = [];
total = null;
allItemsLoaded = false;
itemsLoading = false;
@@ -118,17 +132,22 @@
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
await getItemsPage(requestId);
};
const getItemsPage = async () => {
const getItemsPage = async (activeRequestId = requestId) => {
itemsLoading = true;
const res = await getKnowledgeBases(localStorage.token, page).catch(() => {
const res = await searchKnowledgeBases(
localStorage.token,
query.trim() || null,
null,
page
).catch(() => {
return null;
});
if (activeRequestId !== requestId) return res;
if (res) {
console.log(res);
total = res.total;
const pageItems = res.items;
@@ -152,156 +171,170 @@
};
onMount(async () => {
await init();
await tick();
loaded = true;
initialized = true;
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
{#if loaded && items !== null}
<div class="flex flex-col gap-0.5">
{#if items.length === 0}
<div class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('No knowledge bases found.')}
</div>
{:else}
{#each items as item, idx (item.id)}
<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 ===
selectedIdx
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
: ''}"
>
<button
class="w-full flex-1"
type="button"
on:click={() => {
onSelect({
type: 'collection',
...item
});
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
{#if loaded}
<div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden">
<SearchInput bind:value={query} placeholder={$i18n.t('Search Knowledge')} />
<div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
{#if items.length === 0 && itemsLoading}
<div class="py-4.5">
<Spinner />
</div>
{:else if items.length === 0}
<div class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('No knowledge bases found.')}
</div>
{:else}
{#each items as item, idx (item.id)}
<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 ===
selectedIdx
? ' bg-gray-50/40 dark:bg-gray-800/40 dark:text-gray-100 selected-command-option-button'
: ''}"
>
<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
class="w-full flex-1"
type="button"
class=" ml-2 opacity-50 hover:opacity-100 transition"
on:click={() => {
if (selectedItem && selectedItem.id === item.id) {
selectedItem = null;
} else {
selectedItem = item;
onSelect({
type: 'collection',
...item
});
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
{#if selectedItem && selectedItem.id === item.id}
<ChevronDown className="size-3" />
{:else}
<ChevronRight className="size-3" />
{/if}
<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>
</div>
{#if selectedItem && selectedItem.id === item.id}
<div class="pl-3 mb-1 flex flex-col gap-0.5">
{#if selectedFileItems === null && selectedFileItemsTotal === null}
<div class=" py-1 flex justify-center">
<Spinner className="size-3" />
</div>
{:else if selectedFileItemsTotal === 0}
<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.')}
</div>
{:else}
{#each selectedFileItems as file, fileIdx (file.id)}
<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"
type="button"
on:click={() => {
console.log(file);
onSelect({
type: 'file',
name: file?.meta?.name,
...file
});
}}
>
<div class=" flex items-center gap-1.5">
<Tooltip content={$i18n.t('Collection')} placement="top">
<DocumentPage className="size-3.5" />
</Tooltip>
<Tooltip content={$i18n.t('Show Files')} placement="top">
<button
type="button"
class=" ml-2 opacity-50 hover:opacity-100 transition"
on:click={() => {
if (selectedItem && selectedItem.id === item.id) {
selectedItem = null;
} else {
selectedItem = item;
}
}}
>
{#if selectedItem && selectedItem.id === item.id}
<ChevronDown className="size-3" />
{:else}
<ChevronRight className="size-3" />
{/if}
</button>
</Tooltip>
</div>
<Tooltip content={decodeString(file?.meta?.name)} placement="top-start">
<div class="line-clamp-1 flex-1 text-[13px]">
{decodeString(file?.meta?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
{#if !selectedFileAllItemsLoaded && !selectedFileItemsLoading}
<Loader
on:visible={async (e) => {
if (!selectedFileItemsLoading) {
await loadMoreSelectedFileItems();
}
}}
>
<div
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
{#if selectedItem && selectedItem.id === item.id}
<div class="pl-3 mb-1 flex flex-col gap-0.5">
{#if selectedFileItems === null && selectedFileItemsTotal === null}
<div class=" py-1 flex justify-center">
<Spinner className="size-3" />
</div>
{:else if selectedFileItemsTotal === 0}
<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.')}
</div>
{:else}
{#each selectedFileItems as file, fileIdx (file.id)}
<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"
type="button"
on:click={() => {
console.log(file);
onSelect({
type: 'file',
name: file?.meta?.name,
...file
});
}}
>
<Spinner className=" size-3" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/if}
</div>
{/if}
{/each}
<div class=" flex items-center gap-1.5">
<Tooltip content={$i18n.t('Collection')} placement="top">
<DocumentPage className="size-3.5" />
</Tooltip>
{#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>
<Tooltip content={decodeString(file?.meta?.name)} placement="top-start">
<div class="line-clamp-1 flex-1 text-[13px]">
{decodeString(file?.meta?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
{#if !selectedFileAllItemsLoaded && !selectedFileItemsLoading}
<Loader
on:visible={async (e) => {
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}
</div>
</div>
{:else}
<div class="py-4.5">
@@ -1,14 +1,15 @@
<script lang="ts">
import dayjs from 'dayjs';
import { onMount, tick, getContext } from 'svelte';
import { onDestroy, onMount, tick, getContext } from 'svelte';
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 PageEdit from '$lib/components/icons/PageEdit.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import SearchInput from './SearchInput.svelte';
const i18n = getContext('i18n');
@@ -18,24 +19,56 @@
let items = [];
let selectedIdx = 0;
let query = '';
let page = 1;
let itemsLoading = 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 () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
await getItemsPage(requestId);
};
const getItemsPage = async () => {
const getItemsPage = async (activeRequestId = requestId) => {
itemsLoading = true;
let res = await getNoteList(localStorage.token, page).catch(() => {
return [];
});
const res = query.trim()
? 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;
} else {
allItemsLoaded = false;
@@ -43,7 +76,7 @@
items = [
...items,
...res.map((note) => {
...pageItems.map((note) => {
return {
...note,
type: 'note',
@@ -63,64 +96,82 @@
await tick();
loaded = true;
initialized = true;
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
{#if loaded}
{#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>
<div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden">
<SearchInput bind:value={query} placeholder={$i18n.t('Search Notes')} />
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
<div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
{#if items.length === 0 && itemsLoading}
<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>
</Tooltip>
</div>
</button>
{/each}
</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 !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}
</div>
{/if}
</div>
{:else}
<div class="py-4.5">
<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>