enh: folder filter

This commit is contained in:
Timothy Jaeryang Baek 2025-08-10 02:10:18 +04:00
parent 6497b46a78
commit 3f7d3def02
5 changed files with 122 additions and 10 deletions

View File

@ -6,6 +6,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
@ -617,6 +618,17 @@ class ChatTable:
if word.startswith("tag:")
]
# Extract folder names - handle spaces and case insensitivity
folders = Folders.search_folders_by_names(
user_id,
[
word.replace("folder:", "")
for word in search_text_words
if word.startswith("folder:")
],
)
folder_ids = [folder.id for folder in folders]
is_pinned = None
if "pinned:true" in search_text_words:
is_pinned = True
@ -666,6 +678,9 @@ class ChatTable:
else:
query = query.filter(Chat.share_id.is_(None))
if folder_ids:
query = query.filter(Chat.folder_id.in_(folder_ids))
query = query.order_by(Chat.updated_at.desc())
# Check if the database dialect is either 'sqlite' or 'postgresql'

View File

@ -2,14 +2,14 @@ import logging
import time
import uuid
from typing import Optional
import re
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from open_webui.internal.db import Base, get_db
from open_webui.models.chats import Chats
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import get_permissions
log = logging.getLogger(__name__)
@ -106,7 +106,7 @@ class FolderTable:
def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[FolderModel]:
) -> Optional[list[FolderModel]]:
try:
with get_db() as db:
folders = []
@ -283,5 +283,57 @@ class FolderTable:
log.error(f"delete_folder: {e}")
return []
def normalize_folder_name(self, name: str) -> str:
# Replace _ and space with a single space, lower case, collapse multiple spaces
name = re.sub(r"[\s_]+", " ", name)
return name.strip().lower()
def search_folders_by_names(
self, user_id: str, queries: list[str]
) -> list[FolderModel]:
"""
Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
"""
normalized_queries = [self.normalize_folder_name(q) for q in queries]
if not normalized_queries:
return []
results = {}
with get_db() as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
if self.normalize_folder_name(folder.name) in normalized_queries:
results[folder.id] = FolderModel.model_validate(folder)
# get children folders
children = self.get_children_folders_by_id_and_user_id(
folder.id, user_id
)
for child in children:
results[child.id] = child
# Return the results as a list
if not results:
return []
else:
results = list(results.values())
return results
def search_folders_by_name_contains(
self, user_id: str, query: str
) -> list[FolderModel]:
"""
Partial match: normalized name contains (as substring) the normalized query.
"""
normalized_query = self.normalize_folder_name(query)
results = []
with get_db() as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
norm_name = self.normalize_folder_name(folder.name)
if normalized_query in norm_name:
results.append(FolderModel.model_validate(folder))
return results
Folders = FolderTable()

View File

@ -29,8 +29,7 @@
let searchDebounceTimeout;
let selectedIdx = 0;
let selectedIdx = null;
let selectedChat = null;
let selectedModels = [''];
@ -42,7 +41,12 @@
}
const loadChatPreview = async (selectedIdx) => {
if (!chatList || chatList.length === 0 || chatList[selectedIdx] === undefined) {
if (
!chatList ||
chatList.length === 0 ||
selectedIdx === null ||
chatList[selectedIdx] === undefined
) {
selectedChat = null;
messages = null;
history = null;
@ -217,6 +221,10 @@
on:input={searchHandler}
placeholder={$i18n.t('Search')}
showClearButton={true}
onFocus={() => {
selectedIdx = null;
messages = null;
}}
onKeydown={(e) => {
console.log('e', e);

View File

@ -10,6 +10,7 @@
showSettings,
chatId,
tags,
folders as _folders,
showSidebar,
showSearch,
mobile,
@ -85,6 +86,7 @@
toast.error(`${error}`);
return [];
});
_folders.set(folderList);
folders = {};

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { getAllTags } from '$lib/apis/chats';
import { tags } from '$lib/stores';
import { folders, tags } from '$lib/stores';
import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte';
import { fade } from 'svelte/transition';
import Search from '$lib/components/icons/Search.svelte';
@ -12,6 +12,8 @@
export let placeholder = '';
export let value = '';
export let showClearButton = false;
export let onFocus = () => {};
export let onKeydown = (e) => {};
let selectedIdx = 0;
@ -25,6 +27,10 @@
name: 'tag:',
description: $i18n.t('search for tags')
},
{
name: 'folder:',
description: $i18n.t('search for folders')
},
{
name: 'pinned:',
description: $i18n.t('search for pinned chats')
@ -88,6 +94,30 @@
type: 'tag'
};
});
} else if (lastWord.startsWith('folder:')) {
filteredItems = [...$folders]
.filter((folder) => {
const folderName = lastWord.slice(7);
if (folderName) {
const id = folder.name.replace(' ', '_').toLowerCase();
const folderId = folderName.replace(' ', '_').toLowerCase();
if (id !== folderId) {
return id.startsWith(folderId);
} else {
return false;
}
} else {
return true;
}
})
.map((folder) => {
return {
id: folder.name.replace(' ', '_').toLowerCase(),
name: folder.name,
type: 'folder'
};
});
} else if (lastWord.startsWith('pinned:')) {
filteredItems = [
{
@ -163,6 +193,7 @@
dispatch('input');
}}
on:focus={() => {
onFocus();
hovering = false;
focused = true;
initTags();
@ -211,6 +242,9 @@
selectedIdx = 0;
}
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
if (!document.getElementById('search-options-container')) {
onKeydown(e);
}
@ -257,6 +291,7 @@
itemIdx
? 'bg-gray-100 dark:bg-gray-900'
: ''}"
data-selected={selectedIdx === itemIdx}
id="search-item-{itemIdx}"
on:click|stopPropagation={async () => {
const words = value.split(' ');