enh: folder filter
This commit is contained in:
parent
6497b46a78
commit
3f7d3def02
|
@ -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'
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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 = {};
|
||||||
|
|
||||||
|
|
|
@ -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(' ');
|
||||||
|
|
Loading…
Reference in New Issue