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.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags 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 open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -617,6 +618,17 @@ class ChatTable:
if word.startswith("tag:") 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 is_pinned = None
if "pinned:true" in search_text_words: if "pinned:true" in search_text_words:
is_pinned = True is_pinned = True
@ -666,6 +678,9 @@ class ChatTable:
else: else:
query = query.filter(Chat.share_id.is_(None)) 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()) query = query.order_by(Chat.updated_at.desc())
# Check if the database dialect is either 'sqlite' or 'postgresql' # Check if the database dialect is either 'sqlite' or 'postgresql'

View File

@ -2,14 +2,14 @@ import logging
import time import time
import uuid import uuid
from typing import Optional 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.internal.db import Base, get_db
from open_webui.models.chats import Chats
from open_webui.env import SRC_LOG_LEVELS 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__) log = logging.getLogger(__name__)
@ -106,7 +106,7 @@ class FolderTable:
def get_children_folders_by_id_and_user_id( def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str
) -> Optional[FolderModel]: ) -> Optional[list[FolderModel]]:
try: try:
with get_db() as db: with get_db() as db:
folders = [] folders = []
@ -283,5 +283,57 @@ class FolderTable:
log.error(f"delete_folder: {e}") log.error(f"delete_folder: {e}")
return [] 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() Folders = FolderTable()

View File

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

View File

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

View File

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