diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py
index 8e85b05dab..79525198a1 100644
--- a/backend/open_webui/routers/skills.py
+++ b/backend/open_webui/routers/skills.py
@@ -34,10 +34,11 @@ from open_webui.utils.skill_files import (
MAX_IMPORT_BYTES,
file_bytes,
file_summaries,
+ load_skill_from_url,
parse_import,
zip_export,
)
-from pydantic import BaseModel
+from pydantic import BaseModel, HttpUrl
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -794,6 +795,23 @@ async def require_import(user, db):
raise HTTPException(403, 'Import permission required')
+class LoadUrlForm(BaseModel):
+ url: HttpUrl
+
+
+@router.post('/load/url')
+async def load_skill_by_url(
+ form_data: LoadUrlForm,
+ user=Depends(get_verified_user),
+ db: AsyncSession = Depends(get_async_session),
+):
+ await require_import(user, db)
+ try:
+ return await load_skill_from_url(str(form_data.url))
+ except (ValueError, UnicodeError, zipfile.BadZipFile) as error:
+ raise HTTPException(400, str(error))
+
+
@router.post('/import/preview')
async def preview_skill_import(
files: list[UploadFile] = File(...), user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
diff --git a/backend/open_webui/utils/skill_files.py b/backend/open_webui/utils/skill_files.py
index f03c1c1766..9ec201dfbc 100644
--- a/backend/open_webui/utils/skill_files.py
+++ b/backend/open_webui/utils/skill_files.py
@@ -1,5 +1,6 @@
"""Portable skill snapshots. Paths are virtual; packages are never extracted to disk."""
+import asyncio
import base64
import io
import json
@@ -8,7 +9,9 @@ import stat
import zipfile
from pathlib import PurePosixPath
from typing import Literal
+from urllib.parse import quote, unquote, urljoin, urlsplit
+import aiohttp
import yaml
from pydantic import BaseModel, ConfigDict
@@ -168,7 +171,9 @@ def file_summaries(files: list[dict]) -> list[dict]:
return [{'path': f['path'], 'size': len(file_bytes(f)), 'encoding': f.get('encoding')} for f in files]
-def parse_import(data: bytes, filename: str) -> list[dict]:
+def parse_import(
+ data: bytes, filename: str, *, discover_skills: bool = False, directory: str | None = None
+) -> list[dict]:
if len(data) > MAX_IMPORT_BYTES:
raise ValueError('Import exceeds 200 MiB')
if filename.lower().endswith('.json'):
@@ -185,6 +190,10 @@ def parse_import(data: bytes, filename: str) -> list[dict]:
raise ValueError('Archives cannot contain links or special files')
if info.is_dir():
continue
+ if directory is not None:
+ path = path.partition('/')[2]
+ if not path.startswith(directory):
+ continue
if path in entries:
raise ValueError(f'Duplicate archive path: {path}')
total += info.file_size
@@ -197,7 +206,7 @@ def parse_import(data: bytes, filename: str) -> list[dict]:
if not roots:
raise ValueError('Archive contains no SKILL.md')
roots = [root for root in roots if not any(root != parent and root.startswith(parent) for parent in roots)]
- if any(not any(p.startswith(root) for root in roots) for p in entries):
+ if not discover_skills and any(not any(p.startswith(root) for root in roots) for p in entries):
raise ValueError('Archive contains files outside skill directories')
packages = [
{'files': [encode_file(p[len(root) :], value) for p, value in entries.items() if p.startswith(root)]}
@@ -249,3 +258,72 @@ def zip_export(packages: list[dict]) -> bytes:
for file in package['files']:
archive.writestr(root + '/' + validate_path(file['path']), file_bytes(file))
return output.getvalue()
+
+
+def skill_import_source(url: str) -> tuple[str, str | None]:
+ parsed = urlsplit(url.strip())
+ if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password:
+ raise ValueError('Use an HTTP(S) URL without embedded credentials')
+ if parsed.hostname.lower() != 'github.com':
+ return url.strip(), None
+ parts = parsed.path.strip('/').split('/')
+ if len(parts) < 2 or any(not re.fullmatch(r'[\w.-]+', part) or part in ('.', '..') for part in parts[:2]):
+ raise ValueError('Invalid GitHub repository URL')
+ owner, repo = parts[:2]
+ repo = repo.removesuffix('.git')
+ if not repo:
+ raise ValueError('Invalid GitHub repository URL')
+ ref, directory = 'HEAD', ''
+ if len(parts) > 2:
+ if parts[2] in ('archive', 'releases', 'raw'):
+ return url.strip(), None
+ if len(parts) < 4 or parts[2] not in ('tree', 'blob'):
+ raise ValueError('Use a GitHub repository, folder, or SKILL.md URL')
+ # shortcut: slash-containing refs must be URL-encoded; use a commit permalink otherwise.
+ ref = validate_path(unquote(parts[3]))
+ path = '/'.join(unquote(part) for part in parts[4:])
+ if parts[2] == 'blob':
+ if not path or path.split('/')[-1] != 'SKILL.md':
+ raise ValueError('Select a SKILL.md file or a skill folder')
+ path = path.removesuffix('SKILL.md').rstrip('/')
+ directory = validate_path(path) + '/' if path else ''
+ return f'https://codeload.github.com/{owner}/{repo}/zip/{quote(ref, safe="")}', directory
+
+
+async def load_skill_from_url(url: str) -> list[dict]:
+ from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
+
+ url, directory = skill_import_source(url)
+ try:
+ async with asyncio.timeout(60), get_ssrf_safe_session(trust_env=False, store_cookies=False) as session:
+ for _ in range(6):
+ target = urlsplit(url)
+ if target.username or target.password:
+ raise ValueError('Use a URL without embedded credentials')
+ await asyncio.to_thread(validate_url, url)
+ async with session.get(url, allow_redirects=False) as response:
+ if response.status in (301, 302, 303, 307, 308):
+ url = urljoin(url, response.headers.get('Location', ''))
+ continue
+ if response.status != 200:
+ raise ValueError('Could not download the skill. Check that the URL is accessible.')
+ content_type = response.content_type
+ if content_type == 'text/html':
+ raise ValueError('The URL returned a web page. Use a raw skill file or a ZIP download link.')
+ data = bytearray()
+ async for chunk in response.content.iter_chunked(64 * 1024):
+ data.extend(chunk)
+ if len(data) > MAX_IMPORT_BYTES:
+ raise ValueError('Import exceeds 200 MiB')
+ filename = {
+ 'application/json': 'skills.json',
+ 'text/markdown': 'SKILL.md',
+ }.get(content_type, unquote(urlsplit(url).path))
+ if data.startswith(b'PK'):
+ filename = 'skills.zip'
+ return await asyncio.to_thread(
+ parse_import, bytes(data), filename, discover_skills=True, directory=directory
+ )
+ raise ValueError('Too many redirects while downloading the skill')
+ except (TimeoutError, aiohttp.ClientError) as error:
+ raise ValueError('Could not download the skill. Please try again.') from error
diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts
index a5bea20233..d4ae83e064 100644
--- a/src/lib/apis/skills/index.ts
+++ b/src/lib/apis/skills/index.ts
@@ -416,6 +416,13 @@ export const importSkillBundles = async (token: string, files: File[], decisions
await skillRequest(token, decisions ? '/import' : '/import/preview', { method: 'POST', body })
).json();
};
+export const loadSkillByUrl = async (token: string, url: string) =>
+ (
+ await skillRequest(token, '/load/url', {
+ method: 'POST',
+ body: JSON.stringify({ url })
+ })
+ ).json();
export const skillError = (error: unknown): string =>
error instanceof Error
? error.message
diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte
index e0192e165d..5f743d1b4d 100644
--- a/src/lib/components/ImportModal.svelte
+++ b/src/lib/components/ImportModal.svelte
@@ -15,6 +15,16 @@
export let loadUrlHandler: Function = () => {};
export let successMessage: string = '';
+ export let transformResult = (res) => {
+ const func = { ...res, id: res.id || nameToId(res.name) };
+ const frontmatter = extractFrontmatter(res.content);
+ if (frontmatter?.title) func.name = frontmatter.title;
+ func.meta = {
+ ...(func.meta ?? {}),
+ description: frontmatter?.description ?? func.name
+ };
+ return func;
+ };
let loading = false;
let url = '';
@@ -41,23 +51,10 @@
toast.success(successMessage);
- let func = res;
- func.id = func.id || nameToId(func.name);
-
- const frontmatter = extractFrontmatter(res.content); // Ensure frontmatter is extracted
-
- if (frontmatter?.title) {
- func.name = frontmatter.title;
- }
-
- func.meta = {
- ...(func.meta ?? {}),
- description: frontmatter?.description ?? func.name
- };
-
- onImport(func);
+ onImport(transformResult(res));
show = false;
}
+ loading = false;
};
diff --git a/src/lib/components/admin/Settings/Models/ModelDefaultsPanel.svelte b/src/lib/components/admin/Settings/Models/ModelDefaultsPanel.svelte
index f7f8a51147..f0718c0347 100644
--- a/src/lib/components/admin/Settings/Models/ModelDefaultsPanel.svelte
+++ b/src/lib/components/admin/Settings/Models/ModelDefaultsPanel.svelte
@@ -13,8 +13,6 @@
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
- import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
- import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedPromptSuggestions from '$lib/components/workspace/Models/LocalizedPromptSuggestions.svelte';
@@ -43,9 +41,6 @@
([_, value]) => value !== null && value !== '' && value !== undefined
);
$: enabledCapabilities = Object.entries(defaultCapabilities ?? {}).filter(([_, value]) => value);
- $: availableFeatures = enabledCapabilities
- .filter(([key]) => ['web_search', 'code_interpreter', 'image_generation'].includes(key))
- .map(([key]) => key);
$: translatedPromptLocales = Object.entries(promptSuggestionsI18n ?? {})
.filter(([_, value]) => Array.isArray(value?.suggestion_prompts))
.map(([locale]) => locale);
@@ -204,19 +199,11 @@
on:click={updateDirty}
on:change={updateDirty}
>
-
+ {$i18n.t('Start enabled in new chats')} +
+