浏览代码

refac: tag search

Timothy J. Baek 6 月之前
父节点
当前提交
1db1ef7c26

+ 52 - 0
backend/open_webui/apps/webui/models/chats.py

@@ -389,9 +389,22 @@ class ChatTable:
         Filters chats based on a search query using Python, allowing pagination using skip and limit.
         """
         search_text = search_text.lower().strip()
+
         if not search_text:
             return self.get_chat_list_by_user_id(user_id, include_archived, skip, limit)
 
+        search_text_words = search_text.split(" ")
+
+        # search_text might contain 'tag:tag_name' format so we need to extract the tag_name, split the search_text and remove the tags
+        tag_ids = []
+        for word in search_text_words:
+            if word.startswith("tag:"):
+                tag_id = word[4:]
+                tag_ids.append(tag_id)
+                search_text_words.remove(word)
+
+        search_text = " ".join(search_text_words)
+
         with get_db() as db:
             query = db.query(Chat).filter(Chat.user_id == user_id)
 
@@ -420,6 +433,26 @@ class ChatTable:
                         )
                     ).params(search_text=search_text)
                 )
+
+                # Check if there are any tags to filter, it should have all the tags
+                if tag_ids:
+                    query = query.filter(
+                        and_(
+                            *[
+                                text(
+                                    f"""
+                                    EXISTS (
+                                        SELECT 1
+                                        FROM json_each(Chat.meta, '$.tags') AS tag
+                                        WHERE tag.value = :tag_id
+                                    )
+                                    """
+                                ).params(tag_id=tag_id)
+                                for tag_id in tag_ids
+                            ]
+                        )
+                    )
+
             elif dialect_name == "postgresql":
                 # PostgreSQL relies on proper JSON query for search
                 query = query.filter(
@@ -438,6 +471,25 @@ class ChatTable:
                         )
                     ).params(search_text=search_text)
                 )
+
+                # Check if there are any tags to filter, it should have all the tags
+                if tag_ids:
+                    query = query.filter(
+                        and_(
+                            *[
+                                text(
+                                    f"""
+                                    EXISTS (
+                                        SELECT 1
+                                        FROM json_array_elements_text(Chat.meta->'tags') AS tag
+                                        WHERE tag = :tag_id
+                                    )
+                                    """
+                                ).params(tag_id=tag_id)
+                                for tag_id in tag_ids
+                            ]
+                        )
+                    )
             else:
                 raise NotImplementedError(
                     f"Unsupported dialect: {db.bind.dialect.name}"

+ 9 - 30
src/lib/components/layout/Sidebar.svelte

@@ -48,6 +48,7 @@
 	import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
 	import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte';
 	import { select } from 'd3-selection';
+	import SearchInput from './Sidebar/SearchInput.svelte';
 
 	const BREAKPOINT = 768;
 
@@ -93,7 +94,7 @@
 
 		// once the bottom of the list has been reached (no results) there is no need to continue querying
 		allChatsLoaded = newChatList.length === 0;
-		await chats.set([...$chats, ...newChatList]);
+		await chats.set([...($chats ? $chats : []), ...newChatList]);
 
 		chatListLoading = false;
 	};
@@ -484,35 +485,13 @@
 				<div class="absolute z-40 w-full h-full flex justify-center"></div>
 			{/if}
 
-			<div class="px-2 mt-0.5 mb-2 flex justify-center space-x-2">
-				<div class="flex w-full rounded-xl" id="chat-search">
-					<div class="self-center pl-3 py-2 rounded-l-xl bg-transparent">
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 20 20"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								fill-rule="evenodd"
-								d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
-								clip-rule="evenodd"
-							/>
-						</svg>
-					</div>
+			<SearchInput
+				bind:value={search}
+				on:input={searchDebounceHandler}
+				placeholder={$i18n.t('Search')}
+			/>
 
-					<input
-						class="w-full rounded-r-xl py-1.5 pl-2.5 pr-4 text-sm bg-transparent dark:text-gray-300 outline-none"
-						placeholder={$i18n.t('Search')}
-						bind:value={search}
-						on:input={() => {
-							searchDebounceHandler();
-						}}
-					/>
-				</div>
-			</div>
-
-			{#if $tags.length > 0}
+			<!-- {#if $tags.length > 0}
 				<div class="px-3.5 mb-2.5 flex gap-0.5 flex-wrap">
 					<button
 						class="px-2.5 py-[1px] text-xs transition {selectedTagName === null
@@ -549,7 +528,7 @@
 						</button>
 					{/each}
 				</div>
-			{/if}
+			{/if} -->
 
 			{#if !search && $pinnedChats.length > 0}
 				<div class="pl-2 pb-2 flex flex-col space-y-1">

+ 213 - 0
src/lib/components/layout/Sidebar/SearchInput.svelte

@@ -0,0 +1,213 @@
+<script lang="ts">
+	import { tags } from '$lib/stores';
+	import { stringify } from 'postcss';
+	import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte';
+	import { fade } from 'svelte/transition';
+
+	const dispatch = createEventDispatcher();
+	const i18n = getContext('i18n');
+
+	export let placeholder = '';
+	export let value = '';
+
+	let selectedIdx = 0;
+
+	let lastWord = '';
+	$: lastWord = value ? value.split(' ').at(-1) : value;
+
+	let focused = false;
+	let options = [
+		{
+			name: 'tag:',
+			description: $i18n.t('search for tags')
+		}
+	];
+
+	let filteredOptions = options;
+	$: filteredOptions = options.filter((option) => {
+		return option.name.startsWith(lastWord);
+	});
+
+	let filteredTags = [];
+	$: filteredTags = lastWord.startsWith('tag:')
+		? $tags.filter((tag) => {
+				const tagName = lastWord.slice(4);
+				if (tagName) {
+					const tagId = tagName.replace(' ', '_').toLowerCase();
+
+					if (tag.id !== tagId) {
+						return tag.id.startsWith(tagId);
+					} else {
+						return false;
+					}
+				} else {
+					return true;
+				}
+			})
+		: [];
+
+	const documentClickHandler = (e) => {
+		const searchContainer = document.getElementById('search-container');
+		const chatSearch = document.getElementById('chat-search');
+
+		if (!searchContainer.contains(e.target) && !chatSearch.contains(e.target)) {
+			console.log(
+				e.target.id,
+				e.target.id.startsWith('search-tag-') || e.target.id.startsWith('search-option-')
+			);
+			if (e.target.id.startsWith('search-tag-') || e.target.id.startsWith('search-option-')) {
+				return;
+			}
+			focused = false;
+		}
+	};
+
+	onMount(() => {
+		document.addEventListener('click', documentClickHandler);
+	});
+
+	onDestroy(() => {
+		document.removeEventListener('click', documentClickHandler);
+	});
+</script>
+
+<div class="px-2 mt-0.5 mb-2 flex justify-center space-x-2 relative" id="search-container">
+	<div class="flex w-full rounded-xl" id="chat-search">
+		<div class="self-center pl-3 py-2 rounded-l-xl bg-transparent">
+			<svg
+				xmlns="http://www.w3.org/2000/svg"
+				viewBox="0 0 20 20"
+				fill="currentColor"
+				class="w-4 h-4"
+			>
+				<path
+					fill-rule="evenodd"
+					d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
+					clip-rule="evenodd"
+				/>
+			</svg>
+		</div>
+
+		<input
+			id="search-input"
+			class="w-full rounded-r-xl py-1.5 pl-2.5 pr-4 text-sm bg-transparent dark:text-gray-300 outline-none"
+			placeholder={placeholder ? placeholder : $i18n.t('Search')}
+			bind:value
+			on:input={() => {
+				dispatch('input');
+			}}
+			on:focus={() => {
+				focused = true;
+			}}
+			on:keydown={(e) => {
+				if (e.key === 'Enter') {
+					if (filteredTags.length > 0) {
+						const tagElement = document.getElementById(`search-tag-${selectedIdx}`);
+						tagElement.click();
+						return;
+					}
+
+					if (filteredOptions.length > 0) {
+						const optionElement = document.getElementById(`search-option-${selectedIdx}`);
+						optionElement.click();
+						return;
+					}
+				}
+
+				if (e.key === 'ArrowUp') {
+					e.preventDefault();
+					selectedIdx = Math.max(0, selectedIdx - 1);
+				} else if (e.key === 'ArrowDown') {
+					e.preventDefault();
+
+					if (filteredTags.length > 0) {
+						selectedIdx = Math.min(selectedIdx + 1, filteredTags.length - 1);
+					} else {
+						selectedIdx = Math.min(selectedIdx + 1, filteredOptions.length - 1);
+					}
+				} else {
+					// if the user types something, reset to the top selection.
+					selectedIdx = 0;
+				}
+			}}
+		/>
+	</div>
+
+	{#if focused && (filteredOptions.length > 0 || filteredTags.length > 0)}
+		<!-- svelte-ignore a11y-no-static-element-interactions -->
+		<div
+			class="absolute top-0 mt-8 left-0 right-1 border dark:border-gray-900 bg-gray-50 dark:bg-gray-950 rounded-lg z-10 shadow-lg"
+			in:fade={{ duration: 50 }}
+			on:mouseenter={() => {
+				selectedIdx = null;
+			}}
+			on:mouseleave={() => {
+				selectedIdx = 0;
+			}}
+		>
+			<div class="px-2 py-2 text-xs group">
+				{#if filteredTags.length > 0}
+					<div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1">Tags</div>
+
+					<div class="">
+						{#each filteredTags as tag, tagIdx}
+							<button
+								class=" px-1.5 py-0.5 flex gap-1 hover:bg-gray-100 dark:hover:bg-gray-900 w-full rounded {selectedIdx ===
+								tagIdx
+									? 'bg-gray-100 dark:bg-gray-900'
+									: ''}"
+								id="search-tag-{tagIdx}"
+								on:click={async () => {
+									const words = value.split(' ');
+
+									words.pop();
+									words.push(`tag:${tag.id} `);
+
+									value = words.join(' ');
+
+									dispatch('input');
+								}}
+							>
+								<div class="dark:text-gray-300 text-gray-700 font-medium">{tag.name}</div>
+
+								<div class=" text-gray-500 line-clamp-1">
+									{tag.id}
+								</div>
+							</button>
+						{/each}
+					</div>
+				{:else if filteredOptions.length > 0}
+					<div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1">Search options</div>
+
+					<div class="">
+						{#each filteredOptions as option, optionIdx}
+							<button
+								class=" px-1.5 py-0.5 flex gap-1 hover:bg-gray-100 dark:hover:bg-gray-900 w-full rounded {selectedIdx ===
+								optionIdx
+									? 'bg-gray-100 dark:bg-gray-900'
+									: ''}"
+								id="search-option-{optionIdx}"
+								on:click={async () => {
+									const words = value.split(' ');
+
+									words.pop();
+									words.push('tag:');
+
+									value = words.join(' ');
+
+									dispatch('input');
+								}}
+							>
+								<div class="dark:text-gray-300 text-gray-700 font-medium">{option.name}</div>
+
+								<div class=" text-gray-500 line-clamp-1">
+									{option.description}
+								</div>
+							</button>
+						{/each}
+					</div>
+				{/if}
+			</div>
+		</div>
+	{/if}
+</div>