diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 14e07841f6..ff6ce5159b 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -176,6 +176,8 @@ async def export_skills( raise HTTPException(403, 'Export permission required') if version_id and (not ids or len(ids) != 1): raise HTTPException(400, 'Select one skill to export a historical version') + if format == 'skill' and (not ids or len(ids) != 1): + raise HTTPException(400, 'Select one skill to export as .skill') skills = ( [await authorized_skill(id, user, db=db) for id in ids] if ids is not None @@ -195,14 +197,15 @@ async def export_skills( } ) try: - if format == 'zip': + if format in ('zip', 'skill'): + filename = f'{skills[0].id}.skill' if format == 'skill' else 'skills.zip' return Response( zip_export(packages), media_type='application/zip', - headers={'Content-Disposition': 'attachment; filename="skills.zip"'}, + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, ) if format != 'json': - raise ValueError('Export format must be json or zip') + raise ValueError('Export format must be json, zip, or skill') return packages[0] if ids and len(ids) == 1 else packages except ValueError as error: raise HTTPException(400, str(error)) diff --git a/backend/open_webui/utils/skill_files.py b/backend/open_webui/utils/skill_files.py index 719b15138f..f84637f894 100644 --- a/backend/open_webui/utils/skill_files.py +++ b/backend/open_webui/utils/skill_files.py @@ -363,7 +363,7 @@ def parse_import( if filename.lower().endswith('.json'): value = json.loads(data) packages = value if isinstance(value, list) else [value] - elif filename.lower().endswith('.zip'): + elif filename.lower().endswith(('.zip', '.skill')): entries = {} links = [] total = 0 @@ -388,7 +388,12 @@ def parse_import( total += info.file_size if info.file_size > MAX_FILE_BYTES or total > MAX_IMPORT_BYTES or len(entries) >= 10000: raise ValueError('Archive exceeds import limits') - entries[path] = archive.read(info) + if info.flag_bits & 1: + raise ValueError('Encrypted skill archives are not supported') + try: + entries[path] = archive.read(info) + except NotImplementedError as error: + raise ValueError('Skill archive uses an unsupported compression method') from error roots = ( [''] if 'SKILL.md' in entries else sorted(p[: -len('SKILL.md')] for p in entries if p.endswith('/SKILL.md')) ) @@ -406,7 +411,7 @@ def parse_import( elif filename.lower().endswith('.md'): packages = [{'files': [encode_file('SKILL.md', data)]}] else: - raise ValueError('Use JSON, ZIP, or Markdown') + raise ValueError('Use JSON, ZIP, .skill, or Markdown') total = 0 result = [] for package in packages: @@ -500,7 +505,7 @@ async def load_skill_from_url(url: str) -> list[dict]: 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.') + raise ValueError('The URL returned a web page. Use a raw skill file or a ZIP/.skill download link.') data = bytearray() async for chunk in response.content.iter_chunked(64 * 1024): data.extend(chunk) diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts index d4ae83e064..7fbad04cef 100644 --- a/src/lib/apis/skills/index.ts +++ b/src/lib/apis/skills/index.ts @@ -399,7 +399,7 @@ export const cloneSkill = async ( ).json(); export const exportSkillBundle = async ( token: string, - format: 'json' | 'zip', + format: 'json' | 'zip' | 'skill', ids: string[] = [], versionId?: string ) => { diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index fc0ec51058..8b275cbbb3 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -14,6 +14,8 @@