From 97afe3bc58dc83b7726da09b2b0822b589242ca1 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:21:57 +0100 Subject: [PATCH 01/74] enh: skip chat notifications on other devices if temporary chat (#21292) * Merge pull request #20581 from Classic298/fix/db-pool-memory-update (#150) Co-authored-by: Claude Fixes #21290 * Update +layout.svelte --------- Co-authored-by: Claude --- src/routes/+layout.svelte | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a1a7adfa8a..6c4d0e523c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -329,6 +329,14 @@ const chatEventHandler = async (event, cb) => { const chat = $page.url.pathname.includes(`/c/${event.chat_id}`); + // Skip events from temporary chats that are not the current chat. + // This prevents notifications from being sent to other tabs/devices + // for privacy, since temporary chats are not meant to be persisted or visible elsewhere. + const isTemporaryChat = event.chat_id?.startsWith('local:'); + if (isTemporaryChat && event.chat_id !== $chatId) { + return; + } + let isFocused = document.visibilityState !== 'visible'; if (window.electronAPI) { const res = await window.electronAPI.send({ @@ -346,6 +354,7 @@ if ((event.chat_id !== $chatId && !$temporaryChatEnabled) || isFocused) { if (type === 'chat:completion') { const { done, content, title } = data; + const displayTitle = title || $i18n.t('New Chat'); if (done) { if ($settings?.notificationSoundAlways ?? false) { @@ -360,7 +369,7 @@ if ($isLastActiveTab) { if ($settings?.notificationEnabled ?? false) { - new Notification(`${title} • Open WebUI`, { + new Notification(`${displayTitle} • Open WebUI`, { body: content, icon: `${WEBUI_BASE_URL}/static/favicon.png` }); @@ -373,7 +382,7 @@ goto(`/c/${event.chat_id}`); }, content: content, - title: title + title: displayTitle }, duration: 15000, unstyled: true From 8fd5c06e5bf7e0ccbda15d83338912ea17f66783 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 10 Feb 2026 12:23:08 -0600 Subject: [PATCH 02/74] refac --- backend/open_webui/utils/embeddings.py | 16 +++++++-------- backend/open_webui/utils/payload.py | 27 +++++++++++++++++++++++++ backend/open_webui/utils/response.py | 28 ++++++++++++++++++-------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/backend/open_webui/utils/embeddings.py b/backend/open_webui/utils/embeddings.py index 43cbc56e5f..f92be44578 100644 --- a/backend/open_webui/utils/embeddings.py +++ b/backend/open_webui/utils/embeddings.py @@ -10,12 +10,11 @@ from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL from open_webui.routers.openai import embeddings as openai_embeddings from open_webui.routers.ollama import ( - embeddings as ollama_embeddings, - GenerateEmbeddingsForm, + embed as ollama_embed, + GenerateEmbedForm, ) - -from open_webui.utils.payload import convert_embedding_payload_openai_to_ollama +from open_webui.utils.payload import convert_embed_payload_openai_to_ollama from open_webui.utils.response import convert_embedding_response_ollama_to_openai logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -71,12 +70,12 @@ async def generate_embeddings( if not bypass_filter and user.role == "user": check_model_access(user, model) - # Ollama backend + # Ollama backend — use /api/embed which supports batch input natively if model.get("owned_by") == "ollama": - ollama_payload = convert_embedding_payload_openai_to_ollama(form_data) - response = await ollama_embeddings( + ollama_payload = convert_embed_payload_openai_to_ollama(form_data) + response = await ollama_embed( request=request, - form_data=GenerateEmbeddingsForm(**ollama_payload), + form_data=GenerateEmbedForm(**ollama_payload), user=user, ) return convert_embedding_response_ollama_to_openai(response) @@ -87,3 +86,4 @@ async def generate_embeddings( form_data=form_data, user=user, ) + diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 5094c910ca..0e8010ca34 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -395,3 +395,30 @@ def convert_embedding_payload_openai_to_ollama(openai_payload: dict) -> dict: ollama_payload[optional_key] = openai_payload[optional_key] return ollama_payload + + +def convert_embed_payload_openai_to_ollama(openai_payload: dict) -> dict: + """ + Convert an embeddings request payload from OpenAI format to Ollama's + /api/embed format, which supports batch input natively. + + Args: + openai_payload (dict): The original payload designed for OpenAI API usage. + Expected keys: "model", "input" (str or list[str]). + + Returns: + dict: A payload compatible with the Ollama /api/embed endpoint. + """ + ollama_payload = {"model": openai_payload.get("model")} + input_value = openai_payload.get("input") + + # /api/embed accepts 'input' as a string or list of strings directly + ollama_payload["input"] = input_value + + # Optionally forward other fields if present + for optional_key in ("truncate", "options", "keep_alive"): + if optional_key in openai_payload: + ollama_payload[optional_key] = openai_payload[optional_key] + + return ollama_payload + diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index 52539860aa..8738993c46 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -192,17 +192,29 @@ def convert_embedding_response_ollama_to_openai(response) -> dict: "model": "...", } """ - # Ollama batch-style output + # Ollama batch-style output from /api/embed + # Response format: {"embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]], "model": "..."} if isinstance(response, dict) and "embeddings" in response: openai_data = [] for i, emb in enumerate(response["embeddings"]): - openai_data.append( - { - "object": "embedding", - "embedding": emb.get("embedding"), - "index": emb.get("index", i), - } - ) + # /api/embed returns embeddings as plain float lists + if isinstance(emb, list): + openai_data.append( + { + "object": "embedding", + "embedding": emb, + "index": i, + } + ) + # Also handle dict format for robustness + elif isinstance(emb, dict): + openai_data.append( + { + "object": "embedding", + "embedding": emb.get("embedding"), + "index": emb.get("index", i), + } + ) return { "object": "list", "data": openai_data, From cd31b8301b38bfa86872608cfbd022ff74e3ae52 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 10 Feb 2026 12:44:31 -0600 Subject: [PATCH 03/74] refac --- backend/open_webui/retrieval/utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index e2e1b85770..fe7049a100 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -601,7 +601,10 @@ async def agenerate_openai_batch_embeddings( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: async with session.post( - f"{url}/embeddings", headers=headers, json=form_data + f"{url}/embeddings", + headers=headers, + json=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() data = await r.json() @@ -691,7 +694,10 @@ async def agenerate_azure_openai_batch_embeddings( async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: - async with session.post(full_url, headers=headers, json=form_data) as r: + async with session.post( + full_url, headers=headers, json=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: r.raise_for_status() data = await r.json() if "data" in data: From 0044902c082f8475336cc7d5c57fe3f35ab0555d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 10 Feb 2026 15:15:17 -0600 Subject: [PATCH 04/74] refac --- src/lib/components/workspace/Models/ModelEditor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 9aee1a37cd..6fd7d1ce2d 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -328,7 +328,7 @@ From c259c878060af1b03b702c943e8813d7b4fc3199 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 10 Feb 2026 15:30:16 -0600 Subject: [PATCH 05/74] refac --- backend/open_webui/models/prompts.py | 8 +++++++- backend/open_webui/routers/prompts.py | 3 ++- src/lib/apis/prompts/index.ts | 5 +++-- .../workspace/Prompts/PromptEditor.svelte | 14 +++++--------- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 544aea767b..9562d957d7 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -502,9 +502,10 @@ class PromptsTable: name: str, command: str, tags: Optional[list[str]] = None, + access_grants: Optional[list[dict]] = None, db: Optional[Session] = None, ) -> Optional[PromptModel]: - """Update only name and command (no history created).""" + """Update only name, command, tags, and access grants (no history created).""" try: with get_db_context(db) as db: prompt = db.query(Prompt).filter_by(id=prompt_id).first() @@ -516,6 +517,11 @@ class PromptsTable: if tags is not None: prompt.tags = tags + + if access_grants is not None: + AccessGrants.set_access_grants( + "prompt", prompt_id, access_grants, db=db + ) prompt.updated_at = int(time.time()) db.commit() diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 77c2e84fc4..f2580ef939 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -33,6 +33,7 @@ class PromptMetadataForm(BaseModel): name: str command: str tags: Optional[list[str]] = None + access_grants: Optional[list[dict]] = None router = APIRouter() @@ -372,7 +373,7 @@ async def update_prompt_metadata( ) updated_prompt = Prompts.update_prompt_metadata( - prompt.id, form_data.name, form_data.command, form_data.tags, db=db + prompt.id, form_data.name, form_data.command, form_data.tags, form_data.access_grants, 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 c227c9f713..7b2704cf03 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -331,7 +331,8 @@ export const updatePromptMetadata = async ( promptId: string, name: string, command: string, - tags: string[] = [] + tags: string[] = [], + accessGrants: any[] | null = null ) => { let error = null; @@ -342,7 +343,7 @@ export const updatePromptMetadata = async ( 'Content-Type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify({ name, command, tags }) + body: JSON.stringify({ name, command, tags, access_grants: accessGrants }) }) .then(async (res) => { if (!res.ok) throw await res.json(); diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte index 78f5c21b77..acd3c3fff1 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -213,14 +213,6 @@ } debounceTimer = setTimeout(async () => { - // Skip if nothing changed - if ( - name === originalName && - command === originalCommand && - JSON.stringify(tags) === JSON.stringify(originalTags) - ) - return; - if (!validateCommandString(command)) { toast.error( $i18n.t('Only alphanumeric characters and hyphens are allowed in the command string.') @@ -235,7 +227,8 @@ prompt?.id, name, command, - tags.map((tag) => tag.name) + tags.map((tag) => tag.name), + accessGrants ); // Update originals on success originalName = name; @@ -290,6 +283,9 @@ accessRoles={['read', 'write']} share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin' || edit} + onChange={() => { + debouncedSaveMetadata(); + }} /> From a73cdf42883779a83eedd3807a196e8b37b7b8eb Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 10 Feb 2026 15:38:21 -0600 Subject: [PATCH 06/74] refac: dmr fallback for ollama --- backend/open_webui/config.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index e8338a65aa..f2c65ccef8 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2,7 +2,9 @@ import json import logging import os import shutil +import socket import base64 +from concurrent.futures import ThreadPoolExecutor import redis from datetime import datetime @@ -1015,6 +1017,39 @@ if ENV == "prod": OLLAMA_BASE_URL = "http://ollama-service.open-webui.svc.cluster.local:11434" +def _resolve_ollama_base_url(url: str) -> str: + """If the default Ollama port (11434) is unreachable, try the fallback port (12434).""" + + def reachable(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=1.0): + return True + except (OSError, TimeoutError): + return False + + host = urlparse(url).hostname or "localhost" + + with ThreadPoolExecutor(max_workers=2) as pool: + default = pool.submit(reachable, host, 11434) + fallback = pool.submit(reachable, host, 12434) + + if not default.result() and fallback.result(): + url = url.replace(":11434", ":12434") + log.info(f"Ollama port 11434 unreachable on {host}, falling back to 12434") + elif not default.result(): + log.info(f"Ollama ports 11434 and 12434 both unreachable on {host}") + + return url + + +# Auto-resolve Ollama port when no explicit URL was provided by the user. +# The Dockerfile default is "/ollama" which the block above rewrites to :11434. +if os.environ.get("OLLAMA_BASE_URL", "") in ("", "/ollama") and not os.environ.get( + "OLLAMA_BASE_URLS", "" +): + OLLAMA_BASE_URL = _resolve_ollama_base_url(OLLAMA_BASE_URL) + + OLLAMA_BASE_URLS = os.environ.get("OLLAMA_BASE_URLS", "") OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != "" else OLLAMA_BASE_URL From e3a825769063cee486650cc2eb9a032676e630c5 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 10 Feb 2026 15:41:11 -0600 Subject: [PATCH 07/74] refac --- backend/open_webui/routers/models.py | 46 +++++++++++++++++++ backend/open_webui/routers/tools.py | 46 +++++++++++++++++++ src/lib/apis/models/index.ts | 33 +++++++++++++ src/lib/apis/tools/index.ts | 33 +++++++++++++ .../workspace/Models/ModelEditor.svelte | 11 +++++ .../workspace/Tools/ToolkitEditor.svelte | 12 +++++ 6 files changed, 181 insertions(+) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index b2bccc1958..eff83a54d9 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -481,6 +481,52 @@ async def update_model_by_id( return model +############################ +# UpdateModelAccessById +############################ + + +class ModelAccessGrantsForm(BaseModel): + id: str + access_grants: list[dict] + + +@router.post("/model/access/update", response_model=Optional[ModelModel]) +async def update_model_access_by_id( + form_data: ModelAccessGrantsForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + model = Models.get_model_by_id(form_data.id, db=db) + if not model: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + model.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="model", + resource_id=model.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + AccessGrants.set_access_grants( + "model", form_data.id, form_data.access_grants, db=db + ) + + return Models.get_model_by_id(form_data.id, db=db) + + ############################ # DeleteModelById ############################ diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 015bde232a..0d86272a23 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -510,6 +510,52 @@ async def update_tools_by_id( ) +############################ +# UpdateToolAccessById +############################ + + +class ToolAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post("/id/{id}/access/update", response_model=Optional[ToolModel]) +async def update_tool_access_by_id( + id: str, + form_data: ToolAccessGrantsForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + tools = Tools.get_tool_by_id(id, db=db) + if not tools: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="tool", + resource_id=tools.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + AccessGrants.set_access_grants( + "tool", id, form_data.access_grants, db=db + ) + + return Tools.get_tool_by_id(id, db=db) + + ############################ # DeleteToolsById ############################ diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index d03a83e9ca..0151a55c19 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -281,6 +281,39 @@ export const updateModelById = async (token: string, id: string, model: object) return res; }; +export const updateModelAccessGrants = async ( + token: string, + id: string, + accessGrants: any[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ id, access_grants: accessGrants }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteModelById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/tools/index.ts b/src/lib/apis/tools/index.ts index 2038e46ac6..103e05f390 100644 --- a/src/lib/apis/tools/index.ts +++ b/src/lib/apis/tools/index.ts @@ -225,6 +225,39 @@ export const updateToolById = async (token: string, id: string, tool: object) => return res; }; +export const updateToolAccessGrants = async ( + token: string, + id: string, + accessGrants: any[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/id/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ access_grants: accessGrants }) + }) + .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 deleteToolById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 6fd7d1ce2d..a47d738f79 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -25,6 +25,7 @@ import PromptSuggestions from './PromptSuggestions.svelte'; import AccessControlModal from '../common/AccessControlModal.svelte'; import LockClosed from '$lib/components/icons/LockClosed.svelte'; + import { updateModelAccessGrants } from '$lib/apis/models'; const i18n = getContext('i18n'); @@ -331,6 +332,16 @@ accessRoles={preset ? ['read', 'write'] : ['read']} share={$user?.permissions?.sharing?.models || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin' || edit} + onChange={async () => { + if (edit && model?.id) { + try { + await updateModelAccessGrants(localStorage.token, model.id, accessGrants); + toast.success($i18n.t('Saved')); + } catch (error) { + toast.error(`${error}`); + } + } + }} /> {#if onBack} diff --git a/src/lib/components/workspace/Tools/ToolkitEditor.svelte b/src/lib/components/workspace/Tools/ToolkitEditor.svelte index 70533099cf..4822ebbf8c 100644 --- a/src/lib/components/workspace/Tools/ToolkitEditor.svelte +++ b/src/lib/components/workspace/Tools/ToolkitEditor.svelte @@ -1,10 +1,12 @@ -
- {#each filteredItems as item, index} - { - value = item.value; - selectedModelIdx = index; - - show = false; - }} - /> - {:else} +
+ {#if filteredItems.length === 0}
{$i18n.t('No results found')}
- {/each} + {:else} + +
{ + listScrollTop = listContainer.scrollTop; + }} + > +
+ {#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)} + {@const index = visibleStart + i} + { + value = item.value; + selectedModelIdx = index; + + show = false; + }} + /> + {/each} +
+
+ {/if} {#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'} Date: Wed, 11 Feb 2026 02:26:01 -0600 Subject: [PATCH 12/74] refac --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 057a856e2a..0a85159d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.7.2", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.7.2", + "version": "0.8.0", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index bc71db855e..673bad49cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.7.2", + "version": "0.8.0", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", From 2f584c9f88aeb34ece07b10d05794020d1d656b8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 11 Feb 2026 02:31:17 -0600 Subject: [PATCH 13/74] refac --- src/lib/components/admin/Settings/Models.svelte | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index e0e7dff029..6e38d00d14 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -44,6 +44,7 @@ import { flyAndScale } from '$lib/utils/transitions'; import Dropdown from '$lib/components/common/Dropdown.svelte'; import AdminViewSelector from './Models/AdminViewSelector.svelte'; + import Pagination from '$lib/components/common/Pagination.svelte'; let shiftKey = false; @@ -64,6 +65,9 @@ let viewOption = ''; // '' = All, 'enabled', 'disabled', 'visible', 'hidden' + const perPage = 30; + let currentPage = 1; + $: if (models) { filteredModels = models .filter((m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase())) @@ -81,6 +85,10 @@ let searchValue = ''; + $: if (searchValue || viewOption !== undefined) { + currentPage = 1; + } + const enableAllHandler = async () => { const modelsToEnable = filteredModels.filter((m) => !(m.is_active ?? true)); // Optimistic UI update @@ -420,7 +428,7 @@
{#if filteredModels.length > 0} - {#each filteredModels as model, modelIdx (`${model.id}-${modelIdx}`)} + {#each filteredModels.slice((currentPage - 1) * perPage, currentPage * perPage) as model, modelIdx (`${model.id}-${modelIdx}`)}
None: + existing_tables = set(get_existing_tables()) + + if "skill" not in existing_tables: + op.create_table( + "skill", + sa.Column("id", sa.String(), nullable=False, primary_key=True), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("name", sa.Text(), nullable=False, unique=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.BigInteger(), nullable=False), + ) + op.create_index("idx_skill_user_id", "skill", ["user_id"]) + op.create_index("idx_skill_updated_at", "skill", ["updated_at"]) + + +def downgrade() -> None: + op.drop_index("idx_skill_updated_at", table_name="skill") + op.drop_index("idx_skill_user_id", table_name="skill") + op.drop_table("skill") From c2207887b3241c0d1c74af842d1822137936250d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 11 Feb 2026 14:00:34 -0600 Subject: [PATCH 15/74] feat: skills backend --- backend/open_webui/main.py | 2 + backend/open_webui/models/skills.py | 234 ++++++++++++++++ backend/open_webui/routers/skills.py | 363 +++++++++++++++++++++++++ backend/open_webui/tools/builtin.py | 58 ++++ backend/open_webui/utils/middleware.py | 23 ++ backend/open_webui/utils/tools.py | 5 + 6 files changed, 685 insertions(+) create mode 100644 backend/open_webui/models/skills.py create mode 100644 backend/open_webui/routers/skills.py diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 72cb31fcfe..4d838d251c 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -90,6 +90,7 @@ from open_webui.routers import ( knowledge, prompts, evaluations, + skills, tools, users, utils, @@ -1467,6 +1468,7 @@ app.include_router(models.router, prefix="/api/v1/models", tags=["models"]) app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"]) app.include_router(prompts.router, prefix="/api/v1/prompts", tags=["prompts"]) app.include_router(tools.router, prefix="/api/v1/tools", tags=["tools"]) +app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"]) app.include_router(memories.router, prefix="/api/v1/memories", tags=["memories"]) app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"]) diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py new file mode 100644 index 0000000000..d492e07a69 --- /dev/null +++ b/backend/open_webui/models/skills.py @@ -0,0 +1,234 @@ +import logging +import time +from typing import Optional + +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.models.users import Users, UserResponse +from open_webui.models.groups import Groups +from open_webui.models.access_grants import AccessGrantModel, AccessGrants + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import BigInteger, Boolean, Column, String, Text + + +log = logging.getLogger(__name__) + +#################### +# Skills DB Schema +#################### + + +class Skill(Base): + __tablename__ = "skill" + + id = Column(String, primary_key=True, unique=True) + user_id = Column(String) + name = Column(Text, unique=True) + description = Column(Text, nullable=True) + content = Column(Text) + meta = Column(JSONField) + is_active = Column(Boolean, default=True) + + updated_at = Column(BigInteger) + created_at = Column(BigInteger) + + +class SkillMeta(BaseModel): + tags: Optional[list[str]] = [] + + +class SkillModel(BaseModel): + id: str + user_id: str + name: str + description: Optional[str] = None + content: str + meta: SkillMeta + is_active: bool = True + access_grants: list[AccessGrantModel] = Field(default_factory=list) + + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + +#################### +# Forms +#################### + + +class SkillUserModel(SkillModel): + user: Optional[UserResponse] = None + + +class SkillResponse(BaseModel): + id: str + user_id: str + name: str + description: Optional[str] = None + meta: SkillMeta + is_active: bool = True + access_grants: list[AccessGrantModel] = Field(default_factory=list) + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + +class SkillUserResponse(SkillResponse): + user: Optional[UserResponse] = None + + model_config = ConfigDict(extra="allow") + + +class SkillAccessResponse(SkillUserResponse): + write_access: Optional[bool] = False + + +class SkillForm(BaseModel): + id: str + name: str + description: Optional[str] = None + content: str + meta: SkillMeta = SkillMeta() + is_active: bool = True + access_grants: Optional[list[dict]] = None + + +class SkillsTable: + def _get_access_grants( + self, skill_id: str, db: Optional[Session] = None + ) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource("skill", skill_id, db=db) + + def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel: + skill_data = SkillModel.model_validate(skill).model_dump(exclude={"access_grants"}) + skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db) + return SkillModel.model_validate(skill_data) + + def insert_new_skill( + self, + user_id: str, + form_data: SkillForm, + db: Optional[Session] = None, + ) -> Optional[SkillModel]: + with get_db_context(db) as db: + try: + result = Skill( + **{ + **form_data.model_dump(exclude={"access_grants"}), + "user_id": user_id, + "updated_at": int(time.time()), + "created_at": int(time.time()), + } + ) + db.add(result) + db.commit() + db.refresh(result) + AccessGrants.set_access_grants( + "skill", result.id, form_data.access_grants, db=db + ) + if result: + return self._to_skill_model(result, db=db) + else: + return None + except Exception as e: + log.exception(f"Error creating a new skill: {e}") + return None + + def get_skill_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + skill = db.get(Skill, id) + return self._to_skill_model(skill, db=db) if skill else None + except Exception: + return None + + def get_skill_by_name( + self, name: str, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + skill = db.query(Skill).filter_by(name=name).first() + return self._to_skill_model(skill, db=db) if skill else None + except Exception: + return None + + def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: + with get_db_context(db) as db: + all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all() + + user_ids = list(set(skill.user_id for skill in all_skills)) + + users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users_dict = {user.id: user for user in users} + + skills = [] + for skill in all_skills: + user = users_dict.get(skill.user_id) + skills.append( + SkillUserModel.model_validate( + { + **self._to_skill_model(skill, db=db).model_dump(), + "user": user.model_dump() if user else None, + } + ) + ) + return skills + + def get_skills_by_user_id( + self, user_id: str, permission: str = "write", db: Optional[Session] = None + ) -> list[SkillUserModel]: + skills = self.get_skills(db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + } + + return [ + skill + for skill in skills + if skill.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type="skill", + resource_id=skill.id, + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + ] + + def update_skill_by_id( + self, id: str, updated: dict, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + access_grants = updated.pop("access_grants", None) + db.query(Skill).filter_by(id=id).update( + {**updated, "updated_at": int(time.time())} + ) + db.commit() + if access_grants is not None: + AccessGrants.set_access_grants("skill", id, access_grants, db=db) + + skill = db.query(Skill).get(id) + db.refresh(skill) + return self._to_skill_model(skill, db=db) + except Exception: + return None + + def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool: + try: + with get_db_context(db) as db: + AccessGrants.revoke_all_access("skill", id, db=db) + db.query(Skill).filter_by(id=id).delete() + db.commit() + + return True + except Exception: + return False + + +Skills = SkillsTable() diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py new file mode 100644 index 0000000000..6739c634b9 --- /dev/null +++ b/backend/open_webui/routers/skills.py @@ -0,0 +1,363 @@ +import logging +from typing import Optional + +from open_webui.models.groups import Groups +from pydantic import BaseModel + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from open_webui.internal.db import get_session +from open_webui.models.skills import ( + SkillForm, + SkillModel, + SkillResponse, + SkillUserResponse, + SkillAccessResponse, + Skills, +) +from open_webui.models.access_grants import AccessGrants +from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_access, has_permission + +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.constants import ERROR_MESSAGES + + +log = logging.getLogger(__name__) + + +router = APIRouter() + + +############################ +# GetSkills +############################ + + +@router.get("/", response_model=list[SkillUserResponse]) +async def get_skills( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + skills = Skills.get_skills(db=db) + else: + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } + all_skills = Skills.get_skills(db=db) + skills = [ + skill + for skill in all_skills + if skill.user_id == user.id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="read", + user_group_ids=user_group_ids, + db=db, + ) + ] + + return skills + + +############################ +# GetSkillList +############################ + + +@router.get("/list", response_model=list[SkillAccessResponse]) +async def get_skill_list( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + skills = Skills.get_skills(db=db) + else: + skills = Skills.get_skills_by_user_id(user.id, "read", db=db) + + return [ + SkillAccessResponse( + **skill.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == skill.user_id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + ), + ) + for skill in skills + ] + + +############################ +# ExportSkills +############################ + + +@router.get("/export", response_model=list[SkillModel]) +async def export_skills( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role != "admin" and not has_permission( + user.id, + "workspace.skills", + request.app.state.config.USER_PERMISSIONS, + db=db, + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + return Skills.get_skills(db=db) + else: + return Skills.get_skills_by_user_id(user.id, "read", db=db) + + +############################ +# CreateNewSkill +############################ + + +@router.post("/create", response_model=Optional[SkillResponse]) +async def create_new_skill( + request: Request, + form_data: SkillForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role != "admin" and not has_permission( + user.id, "workspace.skills", request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + form_data.id = form_data.id.lower().replace(" ", "-") + + existing = Skills.get_skill_by_id(form_data.id, db=db) + if existing is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.ID_TAKEN, + ) + + try: + skill = Skills.insert_new_skill(user.id, form_data, db=db) + if skill: + return skill + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error creating skill"), + ) + except Exception as e: + log.exception(f"Failed to create skill: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + + +############################ +# GetSkillById +############################ + + +@router.get("/id/{id}", response_model=Optional[SkillAccessResponse]) +async def get_skill_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + skill = Skills.get_skill_by_id(id, db=db) + + if skill: + if ( + user.role == "admin" + or skill.user_id == user.id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="read", + db=db, + ) + ): + return SkillAccessResponse( + **skill.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == skill.user_id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + ), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# UpdateSkillById +############################ + + +@router.post("/id/{id}/update", response_model=Optional[SkillModel]) +async def update_skill_by_id( + request: Request, + id: str, + form_data: SkillForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + try: + updated = { + **form_data.model_dump(exclude={"id"}), + } + + skill = Skills.update_skill_by_id(id, updated, db=db) + + if skill: + return skill + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error updating skill"), + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + + +############################ +# UpdateSkillAccessById +############################ + + +class SkillAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post("/id/{id}/access/update", response_model=Optional[SkillModel]) +async def update_skill_access_by_id( + id: str, + form_data: SkillAccessGrantsForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + AccessGrants.set_access_grants( + "skill", id, form_data.access_grants, db=db + ) + + return Skills.get_skill_by_id(id, db=db) + + +############################ +# DeleteSkillById +############################ + + +@router.delete("/id/{id}/delete", response_model=bool) +async def delete_skill_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + result = Skills.delete_skill_by_id(id, db=db) + return result diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 9dd3ea1bc7..6014bd7bd6 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -1881,3 +1881,61 @@ async def query_knowledge_bases( except Exception as e: log.exception(f"query_knowledge_bases error: {e}") return json.dumps({"error": str(e)}) + + +# ============================================================================= +# SKILLS TOOLS +# ============================================================================= + + +async def view_skill( + name: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Load the full instructions of a skill by its name from the available skills manifest. + Use this when you need detailed instructions for a skill listed in . + + :param name: The name of the skill to load (as shown in the manifest) + :return: The full skill instructions as markdown content + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.skills import Skills + from open_webui.models.access_grants import AccessGrants + + user_id = __user__.get("id") + + # Direct DB lookup by unique name + skill = Skills.get_skill_by_name(name) + + if not skill or not skill.is_active: + return json.dumps({"error": f"Skill '{name}' not found"}) + + # Check user access + user_role = __user__.get("role", "user") + if user_role != "admin" and skill.user_id != user_id: + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + if not AccessGrants.has_access( + user_id=user_id, + resource_type="skill", + resource_id=skill.id, + permission="read", + user_group_ids=set(user_group_ids), + ): + return json.dumps({"error": "Access denied"}) + + return json.dumps({ + "name": skill.name, + "content": skill.content, + }, ensure_ascii=False) + except Exception as e: + log.exception(f"view_skill error: {e}") + return json.dumps({"error": str(e)}) + diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 58ea3f249f..586a261103 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2097,6 +2097,29 @@ async def process_chat_payload(request, form_data, user, metadata, model): tool_ids = form_data.pop("tool_ids", None) files = form_data.pop("files", None) + # Skills: inject manifest only — model uses view_skill tool to load full content on-demand + user_skill_ids = form_data.pop("skill_ids", None) or [] + model_skill_ids = model.get("info", {}).get("meta", {}).get("skills", []) + + all_skill_ids = list(set(user_skill_ids + model_skill_ids)) + if all_skill_ids: + from open_webui.models.skills import Skills as SkillsModel + + accessible_skill_ids = {s.id for s in SkillsModel.get_skills_by_user_id(user.id, "read")} + available_skills = [ + s for sid in all_skill_ids + if sid in accessible_skill_ids and (s := SkillsModel.get_skill_by_id(sid)) and s.is_active + ] + + if available_skills: + manifest = "\n" + for skill in available_skills: + manifest += f"\n{skill.name}\n{skill.description or ''}\n\n" + manifest += "" + form_data["messages"] = add_or_update_system_message( + manifest, form_data["messages"], append=True + ) + prompt = get_last_user_message(form_data["messages"]) # TODO: re-enable URL extraction from prompt # urls = [] diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 88479f40d8..bce57dc266 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -77,6 +77,7 @@ from open_webui.tools.builtin import ( search_knowledge_files, query_knowledge_files, view_knowledge_file, + view_skill, ) import copy @@ -506,6 +507,10 @@ def get_builtin_tools( ] ) + # Skills tools - view_skill allows model to load full skill instructions on demand + if is_builtin_tool_enabled("skills"): + builtin_functions.append(view_skill) + for func in builtin_functions: callable = get_async_tool_function_and_apply_extra_params( func, From a38ad8fc42926890853491b182956938244a1a03 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 11 Feb 2026 14:09:55 -0600 Subject: [PATCH 16/74] refac --- backend/open_webui/models/skills.py | 18 ++++++++++++ backend/open_webui/routers/skills.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index d492e07a69..dc91f6dc59 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -219,6 +219,24 @@ class SkillsTable: except Exception: return None + def toggle_skill_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[SkillModel]: + with get_db_context(db) as db: + try: + skill = db.query(Skill).filter_by(id=id).first() + if not skill: + return None + + skill.is_active = not skill.is_active + skill.updated_at = int(time.time()) + db.commit() + db.refresh(skill) + + return self._to_skill_model(skill, db=db) + except Exception: + return None + def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 6739c634b9..92e560c736 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -324,6 +324,49 @@ async def update_skill_access_by_id( return Skills.get_skill_by_id(id, db=db) +############################ +# ToggleSkillById +############################ + + +@router.post("/id/{id}/toggle", response_model=Optional[SkillModel]) +async def toggle_skill_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + skill = Skills.get_skill_by_id(id, db=db) + if skill: + if ( + user.role == "admin" + or skill.user_id == user.id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + ): + skill = Skills.toggle_skill_by_id(id, db=db) + + if skill: + return skill + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error toggling skill"), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + ############################ # DeleteSkillById ############################ From 1973115678b8d2cde4aff8bb8d334548536132ad Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 11 Feb 2026 14:22:26 -0600 Subject: [PATCH 17/74] feat: skills frontend --- src/lib/apis/skills/index.ts | 284 +++++++++++ src/lib/components/workspace/Skills.svelte | 440 ++++++++++++++++++ .../workspace/Skills/SkillEditor.svelte | 225 +++++++++ .../workspace/Skills/SkillMenu.svelte | 105 +++++ src/lib/stores/index.ts | 1 + src/routes/(app)/workspace/+layout.svelte | 11 + .../(app)/workspace/skills/+page.svelte | 5 + .../workspace/skills/create/+page.svelte | 56 +++ .../(app)/workspace/skills/edit/+page.svelte | 71 +++ 9 files changed, 1198 insertions(+) create mode 100644 src/lib/apis/skills/index.ts create mode 100644 src/lib/components/workspace/Skills.svelte create mode 100644 src/lib/components/workspace/Skills/SkillEditor.svelte create mode 100644 src/lib/components/workspace/Skills/SkillMenu.svelte create mode 100644 src/routes/(app)/workspace/skills/+page.svelte create mode 100644 src/routes/(app)/workspace/skills/create/+page.svelte create mode 100644 src/routes/(app)/workspace/skills/edit/+page.svelte diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts new file mode 100644 index 0000000000..52d14cbbd3 --- /dev/null +++ b/src/lib/apis/skills/index.ts @@ -0,0 +1,284 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const createNewSkill = async (token: string, skill: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...skill + }) + }) + .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 getSkills = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/`, { + 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.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSkillList = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/list`, { + 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.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const exportSkills = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/export`, { + 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.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}`, { + 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.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateSkillById = async (token: string, id: string, skill: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...skill + }) + }) + .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 updateSkillAccessGrants = async ( + token: string, + id: string, + accessGrants: any[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + access_grants: accessGrants + }) + }) + .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 toggleSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/toggle`, { + method: 'POST', + 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.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/delete`, { + method: 'DELETE', + 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.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/workspace/Skills.svelte b/src/lib/components/workspace/Skills.svelte new file mode 100644 index 0000000000..33cfc86c43 --- /dev/null +++ b/src/lib/components/workspace/Skills.svelte @@ -0,0 +1,440 @@ + + + + + {$i18n.t('Skills')} • {$WEBUI_NAME} + + + +{#if loaded} +
+
+
+
+ {$i18n.t('Skills')} +
+ +
+ {filteredItems.length} +
+
+ +
+ {#if skills.length && ($user?.role === 'admin' || $user?.permissions?.workspace?.skills)} + + {/if} + + {#if $user?.role === 'admin' || $user?.permissions?.workspace?.skills} + + + + + + {/if} +
+
+
+ +
+
+
+
+ +
+ + {#if query} +
+ +
+ {/if} +
+
+ +
{ + if (e.deltaY !== 0) { + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + } + }} + > +
+ { + localStorage.workspaceViewOption = value; + await tick(); + }} + /> +
+
+ + {#if (filteredItems ?? []).length !== 0} +
+ {#each filteredItems as skill} + +
+ {#if skill.write_access} + +
+
+ +
+
+ {skill.name} +
+ {#if !skill.is_active} + + {/if} +
+
+
+
+ + {$i18n.t('By {{name}}', { + name: capitalizeFirstLetter( + skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User') + ) + })} + +
+
+
+
+
+ {:else} +
+
+
+
+ +
+
+ {skill.name} +
+ {#if !skill.is_active} + + {/if} +
+
+ +
+
+
+ + {$i18n.t('By {{name}}', { + name: capitalizeFirstLetter( + skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User') + ) + })} + +
+
+
+
+
+ {/if} + {#if skill.write_access} +
+ {#if shiftKey} + + + + {:else} + { + goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`); + }} + cloneHandler={() => { + cloneHandler(skill); + }} + exportHandler={() => { + exportHandler(skill); + }} + deleteHandler={async () => { + selectedSkill = skill; + showDeleteConfirm = true; + }} + onClose={() => {}} + > + + + {/if} + + +
+ {/if} +
+
+ {/each} +
+ {:else} +
+
+
📝
+
{$i18n.t('No skills found')}
+
+ {$i18n.t('Try adjusting your search or filter to find what you are looking for.')} +
+
+
+ {/if} +
+ + { + deleteHandler(selectedSkill); + }} + > +
+ {$i18n.t('This will delete')} {selectedSkill.name}. +
+
+{:else} +
+ +
+{/if} diff --git a/src/lib/components/workspace/Skills/SkillEditor.svelte b/src/lib/components/workspace/Skills/SkillEditor.svelte new file mode 100644 index 0000000000..f5a9da51c6 --- /dev/null +++ b/src/lib/components/workspace/Skills/SkillEditor.svelte @@ -0,0 +1,225 @@ + + + { + if (edit && skill?.id) { + try { + await updateSkillAccessGrants(localStorage.token, skill.id, accessGrants); + toast.success($i18n.t('Saved')); + } catch (error) { + toast.error(`${error}`); + } + } + }} +/> + +
+
+
+