From 4c8d4e6dbd2bf900f634b978502b9a8733b2711f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 26 Jan 2026 16:11:10 +0400 Subject: [PATCH] enh: prompt tags --- .../374d2f66af06_add_prompt_history_table.py | 3 + backend/open_webui/models/prompts.py | 31 ++++++++ backend/open_webui/routers/prompts.py | 18 ++++- src/lib/apis/prompts/index.ts | 33 ++++++++- src/lib/components/workspace/Prompts.svelte | 26 ++++++- .../workspace/Prompts/PromptEditor.svelte | 74 ++++++++++++++++--- .../(app)/workspace/prompts/[id]/+page.svelte | 2 + .../workspace/prompts/create/+page.svelte | 2 + 8 files changed, 173 insertions(+), 16 deletions(-) diff --git a/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py index e56854a154..c61196fcb0 100644 --- a/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py +++ b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py @@ -65,6 +65,7 @@ def upgrade() -> None: sa.Column("access_control", sa.JSON(), nullable=True), sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), sa.Column("version_id", sa.Text(), nullable=True), + sa.Column("tags", sa.JSON(), nullable=True), sa.Column("created_at", sa.BigInteger(), nullable=False), sa.Column("updated_at", sa.BigInteger(), nullable=False), ) @@ -94,6 +95,7 @@ def upgrade() -> None: sa.column("access_control", sa.JSON()), sa.column("is_active", sa.Boolean()), sa.column("version_id", sa.Text()), + sa.column("tags", sa.JSON()), sa.column("created_at", sa.BigInteger()), sa.column("updated_at", sa.BigInteger()), ) @@ -134,6 +136,7 @@ def upgrade() -> None: access_control=access_control, is_active=True, version_id=history_uuid, + tags=[], created_at=timestamp, updated_at=timestamp, ) diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 2d79cf13fd..381d4109fd 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -30,6 +30,7 @@ class Prompt(Base): content = Column(Text) data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) + tags = Column(JSON, nullable=True) is_active = Column(Boolean, default=True) version_id = Column(Text, nullable=True) # Points to active history entry created_at = Column(BigInteger, nullable=True) @@ -61,6 +62,7 @@ class PromptModel(BaseModel): content: str data: Optional[dict] = None meta: Optional[dict] = None + tags: Optional[list[str]] = None is_active: Optional[bool] = True version_id: Optional[str] = None created_at: Optional[int] = None @@ -89,6 +91,7 @@ class PromptForm(BaseModel): content: str data: Optional[dict] = None meta: Optional[dict] = None + tags: Optional[list[str]] = None access_control: Optional[dict] = None version_id: Optional[str] = None # Active version commit_message: Optional[str] = None # For history tracking @@ -110,6 +113,7 @@ class PromptsTable: content=form_data.content, data=form_data.data or {}, meta=form_data.meta or {}, + tags=form_data.tags or [], access_control=form_data.access_control, is_active=True, created_at=now, @@ -130,6 +134,7 @@ class PromptsTable: "command": form_data.command, "data": form_data.data or {}, "meta": form_data.meta or {}, + "tags": form_data.tags or [], "access_control": form_data.access_control, } @@ -310,6 +315,7 @@ class PromptsTable: or prompt.command != form_data.command or prompt.content != form_data.content or prompt.access_control != form_data.access_control + or (form_data.tags is not None and prompt.tags != form_data.tags) ) # Update prompt fields @@ -319,6 +325,10 @@ class PromptsTable: prompt.data = form_data.data or prompt.data prompt.meta = form_data.meta or prompt.meta prompt.access_control = form_data.access_control + + if form_data.tags is not None: + prompt.tags = form_data.tags + prompt.updated_at = int(time.time()) db.commit() @@ -331,6 +341,7 @@ class PromptsTable: "command": prompt.command, "data": form_data.data or {}, "meta": form_data.meta or {}, + "tags": prompt.tags or [], "access_control": form_data.access_control, } @@ -357,6 +368,7 @@ class PromptsTable: prompt_id: str, name: str, command: str, + tags: Optional[list[str]] = None, db: Optional[Session] = None, ) -> Optional[PromptModel]: """Update only name and command (no history created).""" @@ -368,6 +380,10 @@ class PromptsTable: prompt.name = name prompt.command = command + + if tags is not None: + prompt.tags = tags + prompt.updated_at = int(time.time()) db.commit() @@ -402,6 +418,7 @@ class PromptsTable: prompt.content = snapshot.get("content", prompt.content) prompt.data = snapshot.get("data", prompt.data) prompt.meta = snapshot.get("meta", prompt.meta) + prompt.tags = snapshot.get("tags", prompt.tags) # Note: command and access_control are not restored from snapshot prompt.version_id = version_id @@ -464,5 +481,19 @@ class PromptsTable: except Exception: return False + def get_tags(self, db: Optional[Session] = None) -> list[str]: + try: + with get_db_context(db) as db: + prompts = db.query(Prompt).filter_by(is_active=True).all() + tags = set() + for prompt in prompts: + if prompt.tags: + for tag in prompt.tags: + if tag: + tags.add(tag) + return sorted(list(tags)) + except Exception: + return [] + Prompts = PromptsTable() diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 639658c594..3515009175 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -29,6 +29,7 @@ class PromptVersionUpdateForm(BaseModel): class PromptMetadataForm(BaseModel): name: str command: str + tags: Optional[list[str]] = None router = APIRouter() @@ -51,6 +52,21 @@ async def get_prompts( return prompts +@router.get("/tags", response_model=list[str]) +async def get_prompt_tags( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + return Prompts.get_tags(db=db) + else: + prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db) + tags = set() + for prompt in prompts: + if prompt.tags: + tags.update(prompt.tags) + return sorted(list(tags)) + + @router.get("/list", response_model=list[PromptAccessResponse]) async def get_prompt_list( user=Depends(get_verified_user), db: Session = Depends(get_session) @@ -278,7 +294,7 @@ async def update_prompt_metadata( ) updated_prompt = Prompts.update_prompt_metadata( - prompt.id, form_data.name, form_data.command, db=db + prompt.id, form_data.name, form_data.command, form_data.tags, db=db ) if updated_prompt: return updated_prompt diff --git a/src/lib/apis/prompts/index.ts b/src/lib/apis/prompts/index.ts index 791a98bb43..58ab889f80 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -108,6 +108,34 @@ export const getPrompts = async (token: string = '') => { return res; }; +export const getPromptTags = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/tags`, { + 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(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getPromptList = async (token: string = '') => { let error = null; @@ -242,7 +270,8 @@ export const updatePromptMetadata = async ( token: string, promptId: string, name: string, - command: string + command: string, + tags: string[] = [] ) => { let error = null; @@ -253,7 +282,7 @@ export const updatePromptMetadata = async ( 'Content-Type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify({ name, command }) + body: JSON.stringify({ name, command, tags }) }) .then(async (res) => { if (!res.ok) throw await res.json(); diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte index 7c8e20183f..3d9a9acdb4 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -7,7 +7,13 @@ import { onMount, getContext, tick } from 'svelte'; import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores'; - import { createNewPrompt, deletePromptById, getPrompts, getPromptList } from '$lib/apis/prompts'; + import { + createNewPrompt, + deletePromptById, + getPrompts, + getPromptList, + getPromptTags + } from '$lib/apis/prompts'; import { capitalizeFirstLetter, slugify, copyToClipboard } from '$lib/utils'; import PromptMenu from './Prompts/PromptMenu.svelte'; @@ -23,6 +29,7 @@ import XMark from '../icons/XMark.svelte'; import GarbageBin from '../icons/GarbageBin.svelte'; import ViewSelector from './common/ViewSelector.svelte'; + import TagSelector from './common/TagSelector.svelte'; import Badge from '$lib/components/common/Badge.svelte'; let shiftKey = false; @@ -34,23 +41,25 @@ let query = ''; let prompts = []; + let tags = []; let showDeleteConfirm = false; let deletePrompt = null; let tagsContainerElement: HTMLDivElement; let viewOption = ''; + let selectedTag = ''; let copiedId: string | null = null; let filteredItems = []; - $: if (prompts && query !== undefined && viewOption !== undefined) { + $: if (prompts && query !== undefined && viewOption !== undefined && selectedTag !== undefined) { setFilteredItems(); } const setFilteredItems = () => { filteredItems = prompts.filter((p) => { - if (query === '' && viewOption === '') return true; + if (query === '' && viewOption === '' && selectedTag === '') return true; const lowerQuery = query.toLowerCase(); return ( ((p.title || '').toLowerCase().includes(lowerQuery) || @@ -59,7 +68,8 @@ (p.user?.email || '').toLowerCase().includes(lowerQuery)) && (viewOption === '' || (viewOption === 'created' && p.user_id === $user?.id) || - (viewOption === 'shared' && p.user_id !== $user?.id)) + (viewOption === 'shared' && p.user_id !== $user?.id)) && + (selectedTag === '' || (p.tags && p.tags.includes(selectedTag))) ); }); }; @@ -129,6 +139,7 @@ const init = async () => { prompts = await getPromptList(localStorage.token); + tags = await getPromptTags(localStorage.token); await _prompts.set(await getPrompts(localStorage.token)); }; @@ -323,6 +334,13 @@ await tick(); }} /> + + {#if (tags ?? []).length > 0} + ({ value: tag, label: tag }))} + /> + {/if} diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte index 09bf91af2b..b89bc3a0a6 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -17,12 +17,14 @@ getPromptHistory, setProductionPromptVersion, deletePromptHistoryVersion, - updatePromptMetadata + updatePromptMetadata, + getPromptTags } from '$lib/apis/prompts'; import dayjs from 'dayjs'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import PromptHistoryMenu from './PromptHistoryMenu.svelte'; import Badge from '$lib/components/common/Badge.svelte'; + import Tags from '$lib/components/common/Tags.svelte'; dayjs.extend(localizedFormat); @@ -40,6 +42,7 @@ let name = ''; let command = ''; let content = ''; + let tags = []; let commitMessage = ''; let isProduction = true; @@ -57,8 +60,11 @@ // For debounced auto-save of name/command let originalName = ''; let originalCommand = ''; + let originalTags = []; let debounceTimer: ReturnType | null = null; + let suggestionTags = []; + $: if (!edit && !hasManualEdit) { command = name !== '' ? slugify(name) : ''; } @@ -80,6 +86,7 @@ name, command, content, + tags: tags.map((tag) => tag.name), access_control: accessControl, commit_message: commitMessage || undefined, is_production: isProduction @@ -207,7 +214,12 @@ debounceTimer = setTimeout(async () => { // Skip if nothing changed - if (name === originalName && command === originalCommand) return; + if ( + name === originalName && + command === originalCommand && + JSON.stringify(tags) === JSON.stringify(originalTags) + ) + return; if (!validateCommandString(command)) { toast.error( @@ -218,16 +230,24 @@ } try { - await updatePromptMetadata(localStorage.token, prompt?.id, name, command); + await updatePromptMetadata( + localStorage.token, + prompt?.id, + name, + command, + tags.map((tag) => tag.name) + ); // Update originals on success originalName = name; originalCommand = command; + originalTags = tags; toast.success($i18n.t('Saved')); } catch (error) { toast.error(`${error}`); // Revert on error (collision) name = originalName; command = originalCommand; + tags = originalTags; } }, 500); }; @@ -238,11 +258,13 @@ await tick(); command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command; content = prompt.content; + tags = (prompt.tags || []).map((tag) => ({ name: tag })); accessControl = prompt?.access_control === undefined ? {} : prompt?.access_control; // Store originals for revert on collision originalName = name; originalCommand = command; + originalTags = tags; if (edit) { await loadHistory(); @@ -254,6 +276,11 @@ } } } + + const res = await getPromptTags(localStorage.token); + if (res) { + suggestionTags = res.map((tag) => ({ name: tag })); + } }); @@ -361,9 +388,31 @@ {disabled} /> + +
+ { + tags = [...tags, { name: e.detail }]; + debouncedSaveMetadata(); + }} + on:delete={(e) => { + tags = tags.filter((tag) => tag.name !== e.detail); + debouncedSaveMetadata(); + }} + /> +
{#if !disabled} + + - {:else} {$i18n.t('Read Only')}
+ +
+ { + tags = [...tags, { name: e.detail }]; + }} + on:delete={(e) => { + tags = tags.filter((tag) => tag.name !== e.detail); + }} + /> +
diff --git a/src/routes/(app)/workspace/prompts/[id]/+page.svelte b/src/routes/(app)/workspace/prompts/[id]/+page.svelte index 5e75b00991..0cbf3d4e56 100644 --- a/src/routes/(app)/workspace/prompts/[id]/+page.svelte +++ b/src/routes/(app)/workspace/prompts/[id]/+page.svelte @@ -34,6 +34,7 @@ command: updatedPrompt.command, content: updatedPrompt.content, version_id: updatedPrompt.version_id, + tags: updatedPrompt.tags, access_control: updatedPrompt?.access_control === undefined ? {} : updatedPrompt?.access_control }; } @@ -57,6 +58,7 @@ command: _prompt.command, content: _prompt.content, version_id: _prompt.version_id, + tags: _prompt.tags, access_control: _prompt?.access_control === undefined ? {} : _prompt?.access_control }; } else { diff --git a/src/routes/(app)/workspace/prompts/create/+page.svelte b/src/routes/(app)/workspace/prompts/create/+page.svelte index d88d7a0e10..a80e1c28b9 100644 --- a/src/routes/(app)/workspace/prompts/create/+page.svelte +++ b/src/routes/(app)/workspace/prompts/create/+page.svelte @@ -49,6 +49,7 @@ name: _prompt.name || _prompt.title || 'Prompt', command: _prompt.command, content: _prompt.content, + tags: _prompt.tags || [], access_control: _prompt.access_control !== undefined ? _prompt.access_control : {} }; }); @@ -68,6 +69,7 @@ name: _prompt.name || _prompt.title || 'Prompt', command: _prompt.command, content: _prompt.content, + tags: _prompt.tags || [], access_control: _prompt.access_control !== undefined ? _prompt.access_control : {} }; }