Sfoglia il codice sorgente

refac/feat: note/knowledge/chat select input menu

Timothy Jaeryang Baek 3 settimane fa
parent
commit
c03ca7270e

+ 15 - 4
backend/open_webui/models/notes.py

@@ -97,15 +97,26 @@ class NoteTable:
             db.commit()
             return note
 
-    def get_notes(self) -> list[NoteModel]:
+    def get_notes(
+        self, skip: Optional[int] = None, limit: Optional[int] = None
+    ) -> list[NoteModel]:
         with get_db() as db:
-            notes = db.query(Note).order_by(Note.updated_at.desc()).all()
+            query = db.query(Note).order_by(Note.updated_at.desc())
+            if skip is not None:
+                query = query.offset(skip)
+            if limit is not None:
+                query = query.limit(limit)
+            notes = query.all()
             return [NoteModel.model_validate(note) for note in notes]
 
     def get_notes_by_user_id(
-        self, user_id: str, permission: str = "write"
+        self,
+        user_id: str,
+        permission: str = "write",
+        skip: Optional[int] = None,
+        limit: Optional[int] = None,
     ) -> list[NoteModel]:
-        notes = self.get_notes()
+        notes = self.get_notes(skip=skip, limit=limit)
         user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
         return [
             note

+ 10 - 3
backend/open_webui/routers/notes.py

@@ -62,8 +62,9 @@ class NoteTitleIdResponse(BaseModel):
 
 
 @router.get("/list", response_model=list[NoteTitleIdResponse])
-async def get_note_list(request: Request, user=Depends(get_verified_user)):
-
+async def get_note_list(
+    request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
+):
     if user.role != "admin" and not has_permission(
         user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
     ):
@@ -72,9 +73,15 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)):
             detail=ERROR_MESSAGES.UNAUTHORIZED,
         )
 
+    limit = None
+    skip = None
+    if page is not None:
+        limit = 60
+        skip = (page - 1) * limit
+
     notes = [
         NoteTitleIdResponse(**note.model_dump())
-        for note in Notes.get_notes_by_user_id(user.id, "write")
+        for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit)
     ]
 
     return notes

+ 7 - 2
src/lib/apis/notes/index.ts

@@ -91,10 +91,15 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
 	return grouped;
 };
 
-export const getNoteList = async (token: string = '') => {
+export const getNoteList = async (token: string = '', page: number | null = null) => {
 	let error = null;
+	const searchParams = new URLSearchParams();
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, {
+	if (page !== null) {
+		searchParams.append('page', `${page}`);
+	}
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, {
 		method: 'GET',
 		headers: {
 			Accept: 'application/json',

+ 0 - 42
src/lib/components/chat/MessageInput/Commands/Knowledge.svelte

@@ -226,48 +226,6 @@
 				</div>
 			</button>
 		{/if}
-
-		<!-- <div slot="content" class=" pl-2 pt-1 flex flex-col gap-0.5">
-								{#if !item.legacy && (item?.files ?? []).length > 0}
-									{#each item?.files ?? [] as file, fileIdx}
-										<button
-											class=" px-3 py-1.5 rounded-xl w-full text-left flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-850 dark:hover:text-gray-100 selected-command-option-button"
-											type="button"
-											on:click={() => {
-												console.log(file);
-											}}
-											on:mousemove={() => {
-												selectedIdx = idx;
-											}}
-										>
-											<div>
-												<div
-													class=" font-medium text-black dark:text-gray-100 flex items-center gap-1"
-												>
-													<div
-														class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
-													>
-														File
-													</div>
-
-													<div class="line-clamp-1">
-														{file?.meta?.name}
-													</div>
-												</div>
-
-												<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
-													{$i18n.t('Updated')}
-													{dayjs(file.updated_at * 1000).fromNow()}
-												</div>
-											</div>
-										</button>
-									{/each}
-								{:else}
-									<div class=" text-gray-500 text-xs mt-1 mb-2">
-										{$i18n.t('File not found.')}
-									</div>
-								{/if}
-							</div> -->
 	{/each}
 
 	{#if query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')}

+ 2 - 2
src/lib/components/chat/MessageInput/InputMenu.svelte

@@ -87,7 +87,7 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[240px] rounded-2xl px-1 py-1  border border-gray-100  dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin transition"
+			class="w-full max-w-[260px] rounded-2xl px-1 py-1  border border-gray-100  dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin transition"
 			sideOffset={4}
 			alignOffset={-6}
 			side="bottom"
@@ -422,7 +422,7 @@
 						</div>
 					</button>
 
-					<Knowledge />
+					<Knowledge knowledge={$knowledge ?? []} />
 				</div>
 			{:else if tab === 'notes'}
 				<div in:fly={{ x: 20, duration: 150 }}>

+ 122 - 0
src/lib/components/chat/MessageInput/InputMenu/Chats.svelte

@@ -0,0 +1,122 @@
+<script lang="ts">
+	import dayjs from 'dayjs';
+	import { onMount, tick, getContext } from 'svelte';
+
+	import { decodeString } from '$lib/utils';
+	import { getChatList } 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';
+
+	const i18n = getContext('i18n');
+
+	export let onSelect = (e) => {};
+
+	let loaded = false;
+
+	let items = [];
+	let selectedIdx = 0;
+
+	let page = 1;
+	let itemsLoading = false;
+	let allItemsLoaded = false;
+
+	const loadMoreItems = async () => {
+		if (allItemsLoaded) return;
+		page += 1;
+		await getItemsPage();
+	};
+
+	const getItemsPage = async () => {
+		itemsLoading = true;
+		let res = await getChatList(localStorage.token, page).catch(() => {
+			return [];
+		});
+
+		if ((res ?? []).length === 0) {
+			allItemsLoaded = true;
+		} else {
+			allItemsLoaded = false;
+		}
+
+		items = [
+			...items,
+			...res.map((item) => {
+				return {
+					...item,
+					type: 'chat',
+					name: item.title,
+					description: dayjs(item.updated_at * 1000).fromNow()
+				};
+			})
+		];
+
+		itemsLoading = false;
+		return res;
+	};
+
+	onMount(async () => {
+		await getItemsPage();
+		await tick();
+
+		loaded = true;
+	});
+</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=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
+					selectedIdx
+						? ' bg-gray-50 dark:bg-gray-800 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}
+{:else}
+	<div class="py-5">
+		<Spinner />
+	</div>
+{/if}

+ 146 - 0
src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte

@@ -0,0 +1,146 @@
+<script lang="ts">
+	import { onMount, tick, getContext } from 'svelte';
+
+	import { decodeString } from '$lib/utils';
+
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Database from '$lib/components/icons/Database.svelte';
+	import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
+
+	const i18n = getContext('i18n');
+
+	export let knowledge = [];
+	export let onSelect = (e) => {};
+
+	let items = [];
+	let selectedIdx = 0;
+
+	onMount(async () => {
+		let legacy_documents = knowledge
+			.filter((item) => item?.meta?.document)
+			.map((item) => ({
+				...item,
+				type: 'file'
+			}));
+
+		let legacy_collections =
+			legacy_documents.length > 0
+				? [
+						{
+							name: 'All Documents',
+							legacy: true,
+							type: 'collection',
+							description: 'Deprecated (legacy collection), please create a new knowledge base.',
+							title: $i18n.t('All Documents'),
+							collection_names: legacy_documents.map((item) => item.id)
+						},
+
+						...legacy_documents
+							.reduce((a, item) => {
+								return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
+							}, [])
+							.map((tag) => ({
+								name: tag,
+								legacy: true,
+								type: 'collection',
+								description: 'Deprecated (legacy collection), please create a new knowledge base.',
+								collection_names: legacy_documents
+									.filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
+									.map((item) => item.id)
+							}))
+					]
+				: [];
+
+		let collections = knowledge
+			.filter((item) => !item?.meta?.document)
+			.map((item) => ({
+				...item,
+				type: 'collection'
+			}));
+		let collection_files =
+			knowledge.length > 0
+				? [
+						...knowledge
+							.reduce((a, item) => {
+								return [
+									...new Set([
+										...a,
+										...(item?.files ?? []).map((file) => ({
+											...file,
+											collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
+										}))
+									])
+								];
+							}, [])
+							.map((file) => ({
+								...file,
+								name: file?.meta?.name,
+								description: `${file?.collection?.name} - ${file?.collection?.description}`,
+								knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
+								type: 'file'
+							}))
+					]
+				: [];
+
+		items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
+			(item) => {
+				return {
+					...item,
+					...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
+				};
+			}
+		);
+
+		await tick();
+	});
+</script>
+
+<div class="flex flex-col gap-0.5">
+	{#each items as item, idx}
+		<button
+			class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
+			selectedIdx
+				? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
+				: ''}"
+			type="button"
+			on:click={() => {
+				console.log(item);
+				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">
+				<Tooltip
+					content={item?.legacy
+						? $i18n.t('Legacy')
+						: item?.type === 'file'
+							? $i18n.t('File')
+							: item?.type === 'collection'
+								? $i18n.t('Collection')
+								: ''}
+					placement="top"
+				>
+					{#if item?.type === 'collection'}
+						<Database className="size-4" />
+					{:else}
+						<DocumentPage className="size-4" />
+					{/if}
+				</Tooltip>
+
+				<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>

+ 128 - 0
src/lib/components/chat/MessageInput/InputMenu/Notes.svelte

@@ -0,0 +1,128 @@
+<script lang="ts">
+	import dayjs from 'dayjs';
+	import { onMount, tick, getContext } from 'svelte';
+
+	import { decodeString } from '$lib/utils';
+	import { getNoteList } 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';
+
+	const i18n = getContext('i18n');
+
+	export let onSelect = (e) => {};
+
+	let loaded = false;
+
+	let items = [];
+	let selectedIdx = 0;
+
+	let page = 1;
+	let itemsLoading = false;
+	let allItemsLoaded = false;
+
+	const loadMoreItems = async () => {
+		if (allItemsLoaded) return;
+		page += 1;
+		await getItemsPage();
+	};
+
+	const getItemsPage = async () => {
+		itemsLoading = true;
+		let res = await getNoteList(localStorage.token, page).catch(() => {
+			return [];
+		});
+
+		if ((res ?? []).length === 0) {
+			allItemsLoaded = true;
+		} else {
+			allItemsLoaded = false;
+		}
+
+		items = [
+			...items,
+			...res.map((note) => {
+				return {
+					...note,
+					type: 'note',
+					name: note.title,
+					description: dayjs(note.updated_at / 1000000).fromNow()
+				};
+			})
+		];
+
+		itemsLoading = false;
+
+		return res;
+	};
+
+	onMount(async () => {
+		await getItemsPage();
+		await tick();
+
+		loaded = true;
+	});
+</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=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
+					selectedIdx
+						? ' bg-gray-50 dark:bg-gray-800 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-4" />
+						</Tooltip>
+
+						<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}
+{:else}
+	<div class="py-5">
+		<Spinner />
+	</div>
+{/if}

+ 1 - 1
src/lib/components/chat/MessageInput/IntegrationsMenu.svelte

@@ -83,7 +83,7 @@
 	</Tooltip>
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[240px] rounded-2xl px-1 py-1  border border-gray-100  dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
+			class="w-full max-w-[260px] rounded-2xl px-1 py-1  border border-gray-100  dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
 			sideOffset={4}
 			alignOffset={-6}
 			side="bottom"

+ 8 - 0
src/lib/utils/index.ts

@@ -1546,3 +1546,11 @@ export const convertHeicToJpeg = async (file: File) => {
 		throw err;
 	}
 };
+
+export const decodeString = (str: string) => {
+	try {
+		return decodeURIComponent(str);
+	} catch (e) {
+		return str;
+	}
+};