refac: prompts pagination

This commit is contained in:
Timothy Jaeryang Baek
2026-01-27 23:01:56 +04:00
parent 683438b418
commit 36766f157d
4 changed files with 298 additions and 51 deletions
+115 -1
View File
@@ -10,7 +10,8 @@ from open_webui.models.prompt_history import PromptHistories
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
@@ -85,7 +86,18 @@ class PromptAccessResponse(PromptUserResponse):
write_access: Optional[bool] = False write_access: Optional[bool] = False
class PromptListResponse(BaseModel):
items: list[PromptUserResponse]
total: int
class PromptAccessListResponse(BaseModel):
items: list[PromptAccessResponse]
total: int
class PromptForm(BaseModel): class PromptForm(BaseModel):
command: str command: str
name: str # Changed from title name: str # Changed from title
content: str content: str
@@ -227,7 +239,109 @@ class PromptsTable:
or has_access(user_id, permission, prompt.access_control, user_group_ids) or has_access(user_id, permission, prompt.access_control, user_group_ids)
] ]
def search_prompts(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> PromptListResponse:
with get_db_context(db) as db:
from open_webui.models.users import User, UserModel
# Join with User table for user filtering and sorting
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = query.filter(Prompt.is_active == True)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Prompt.name.ilike(f"%{query_key}%"),
Prompt.command.ilike(f"%{query_key}%"),
Prompt.content.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Prompt.user_id == user_id)
elif view_option == "shared":
query = query.filter(Prompt.user_id != user_id)
# Apply access control filtering
group_ids = filter.get("group_ids", [])
filter_user_id = filter.get("user_id")
if filter_user_id:
# User must have access: owner OR public OR explicit access
access_conditions = [
Prompt.user_id == filter_user_id, # Owner
Prompt.access_control == None, # Public
]
query = query.filter(or_(*access_conditions))
tag = filter.get("tag")
if tag:
# Search for tag in JSON array field
like_pattern = f'%"{tag.lower()}"%'
tags_text = func.lower(cast(Prompt.tags, String))
query = query.filter(tags_text.like(like_pattern))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Prompt.name.asc())
else:
query = query.order_by(Prompt.name.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Prompt.created_at.asc())
else:
query = query.order_by(Prompt.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Prompt.updated_at.asc())
else:
query = query.order_by(Prompt.updated_at.desc())
else:
query = query.order_by(Prompt.updated_at.desc())
else:
query = query.order_by(Prompt.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
prompts = []
for prompt, user in items:
prompts.append(
PromptUserResponse(
**PromptModel.model_validate(prompt).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return PromptListResponse(items=prompts, total=total)
def update_prompt_by_command( def update_prompt_by_command(
self, self,
command: str, command: str,
form_data: PromptForm, form_data: PromptForm,
+52 -17
View File
@@ -5,9 +5,11 @@ from open_webui.models.prompts import (
PromptForm, PromptForm,
PromptUserResponse, PromptUserResponse,
PromptAccessResponse, PromptAccessResponse,
PromptAccessListResponse,
PromptModel, PromptModel,
Prompts, Prompts,
) )
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import ( from open_webui.models.prompt_history import (
PromptHistories, PromptHistories,
PromptHistoryModel, PromptHistoryModel,
@@ -34,6 +36,8 @@ class PromptMetadataForm(BaseModel):
router = APIRouter() router = APIRouter()
PAGE_ITEM_COUNT = 30
############################ ############################
# GetPrompts # GetPrompts
@@ -67,26 +71,57 @@ async def get_prompt_tags(
return sorted(list(tags)) return sorted(list(tags))
@router.get("/list", response_model=list[PromptAccessResponse]) @router.get("/list", response_model=PromptAccessListResponse)
async def get_prompt_list( async def get_prompt_list(
user=Depends(get_verified_user), db: Session = Depends(get_session) query: Optional[str] = None,
view_option: Optional[str] = None,
tag: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: limit = PAGE_ITEM_COUNT
prompts = Prompts.get_prompts(db=db)
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
return [ page = max(1, page)
PromptAccessResponse( skip = (page - 1) * limit
**prompt.model_dump(),
write_access=( filter = {}
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) if query:
or user.id == prompt.user_id filter["query"] = query
or has_access(user.id, "write", prompt.access_control, db=db) if view_option:
), filter["view_option"] = view_option
) if tag:
for prompt in prompts filter["tag"] = tag
] if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Prompts.search_prompts(user.id, filter=filter, skip=skip, limit=limit, db=db)
return PromptAccessListResponse(
items=[
PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
for prompt in result.items
],
total=result.total,
)
############################ ############################
+60
View File
@@ -136,7 +136,67 @@ export const getPromptTags = async (token: string = '') => {
return res; return res;
}; };
export const getPromptItems = async (
token: string = '',
query: string | null,
viewOption: string | null,
selectedTag: string | null,
orderBy: string | null,
direction: string | null,
page: number
) => {
let error = null;
const searchParams = new URLSearchParams();
if (query) {
searchParams.append('query', query);
}
if (viewOption) {
searchParams.append('view_option', viewOption);
}
if (selectedTag) {
searchParams.append('tag', selectedTag);
}
if (orderBy) {
searchParams.append('order_by', orderBy);
}
if (direction) {
searchParams.append('direction', direction);
}
if (page) {
searchParams.append('page', page.toString());
}
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getPromptList = async (token: string = '') => { export const getPromptList = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list`, { const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list`, {
+71 -33
View File
@@ -11,7 +11,7 @@
createNewPrompt, createNewPrompt,
deletePromptById, deletePromptById,
getPrompts, getPrompts,
getPromptList, getPromptItems,
getPromptTags getPromptTags
} from '$lib/apis/prompts'; } from '$lib/apis/prompts';
import { capitalizeFirstLetter, slugify, copyToClipboard } from '$lib/utils'; import { capitalizeFirstLetter, slugify, copyToClipboard } from '$lib/utils';
@@ -31,6 +31,7 @@
import ViewSelector from './common/ViewSelector.svelte'; import ViewSelector from './common/ViewSelector.svelte';
import TagSelector from './common/TagSelector.svelte'; import TagSelector from './common/TagSelector.svelte';
import Badge from '$lib/components/common/Badge.svelte'; import Badge from '$lib/components/common/Badge.svelte';
import Pagination from '../common/Pagination.svelte';
let shiftKey = false; let shiftKey = false;
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -40,8 +41,10 @@
let importFiles = null; let importFiles = null;
let query = ''; let query = '';
let prompts = []; let prompts = null;
let tags = []; let tags = [];
let total = null;
let loading = false;
let showDeleteConfirm = false; let showDeleteConfirm = false;
let deletePrompt = null; let deletePrompt = null;
@@ -51,27 +54,54 @@
let selectedTag = ''; let selectedTag = '';
let copiedId: string | null = null; let copiedId: string | null = null;
let filteredItems = []; let page = 1;
let searchDebounceTimer;
$: if (prompts && query !== undefined && viewOption !== undefined && selectedTag !== undefined) { // Debounce only query changes
setFilteredItems(); $: if (query !== undefined) {
loading = true;
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
getPromptList();
}, 300);
} }
const setFilteredItems = () => { // Immediate response to page/filter changes
filteredItems = prompts.filter((p) => { $: if (page && selectedTag !== undefined && viewOption !== undefined) {
if (query === '' && viewOption === '' && selectedTag === '') return true; getPromptList();
const lowerQuery = query.toLowerCase(); }
return (
((p.title || '').toLowerCase().includes(lowerQuery) || const getPromptList = async () => {
(p.command || '').toLowerCase().includes(lowerQuery) || loading = true;
(p.user?.name || '').toLowerCase().includes(lowerQuery) || try {
(p.user?.email || '').toLowerCase().includes(lowerQuery)) && const res = await getPromptItems(
(viewOption === '' || localStorage.token,
(viewOption === 'created' && p.user_id === $user?.id) || query,
(viewOption === 'shared' && p.user_id !== $user?.id)) && viewOption,
(selectedTag === '' || (p.tags && p.tags.includes(selectedTag))) selectedTag,
); null,
}); null,
page
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
prompts = res.items;
total = res.total;
// get tags
tags = await getPromptTags(localStorage.token).catch((error) => {
toast.error(`${error}`);
return [];
});
}
} catch (err) {
console.error(err);
} finally {
loading = false;
}
}; };
const shareHandler = async (prompt) => { const shareHandler = async (prompt) => {
@@ -134,18 +164,14 @@
toast.success($i18n.t(`Deleted {{name}}`, { name: command })); toast.success($i18n.t(`Deleted {{name}}`, { name: command }));
} }
await init(); page = 1;
}; getPromptList();
const init = async () => {
prompts = await getPromptList(localStorage.token);
tags = await getPromptTags(localStorage.token);
await _prompts.set(await getPrompts(localStorage.token)); await _prompts.set(await getPrompts(localStorage.token));
}; };
onMount(async () => { onMount(async () => {
viewOption = localStorage?.workspaceViewOption || ''; viewOption = localStorage?.workspaceViewOption || '';
await init(); page = 1;
loaded = true; loaded = true;
const onKeyDown = (event) => { const onKeyDown = (event) => {
@@ -222,7 +248,9 @@
}); });
} }
prompts = await getPromptList(localStorage.token); prompts = null;
page = 1;
getPromptList();
await _prompts.set(await getPrompts(localStorage.token)); await _prompts.set(await getPrompts(localStorage.token));
importFiles = []; importFiles = [];
@@ -239,7 +267,7 @@
</div> </div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500"> <div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{filteredItems.length} {total ?? ''}
</div> </div>
</div> </div>
@@ -257,7 +285,7 @@
</button> </button>
{/if} {/if}
{#if prompts.length && ($user?.role === 'admin' || $user?.permissions?.workspace?.prompts_export)} {#if total && ($user?.role === 'admin' || $user?.permissions?.workspace?.prompts_export)}
<button <button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition" class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => { on:click={async () => {
@@ -330,7 +358,7 @@
bind:value={viewOption} bind:value={viewOption}
onChange={async (value) => { onChange={async (value) => {
localStorage.workspaceViewOption = value; localStorage.workspaceViewOption = value;
page = 1;
await tick(); await tick();
}} }}
/> />
@@ -344,10 +372,14 @@
</div> </div>
</div> </div>
{#if (filteredItems ?? []).length !== 0} {#if prompts === null || loading}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
{:else if (prompts ?? []).length !== 0}
<!-- Before they call, I will answer; while they are yet speaking, I will hear. --> <!-- Before they call, I will answer; while they are yet speaking, I will hear. -->
<div class="gap-2 grid my-2 px-3 lg:grid-cols-2"> <div class="gap-2 grid my-2 px-3 lg:grid-cols-2">
{#each filteredItems as prompt} {#each prompts as prompt}
<a <a
class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl" class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
href={`/workspace/prompts/${prompt.id}`} href={`/workspace/prompts/${prompt.id}`}
@@ -450,6 +482,12 @@
</a> </a>
{/each} {/each}
</div> </div>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
</div>
{/if}
{:else} {:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24"> <div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center"> <div class="max-w-md text-center">