From 9bbb95048b841d5f6a1fedf606ae93ec28969521 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 9 Oct 2026 15:07:43 +0400 Subject: [PATCH] refac --- .../d6a8c3f912ab_add_skill_history.py | 58 ++ backend/open_webui/models/access_grants.py | 51 +- backend/open_webui/models/skill_history.py | 88 ++ backend/open_webui/models/skills.py | 181 ++++- backend/open_webui/routers/skills.py | 403 +++++++++- backend/open_webui/tools/builtin.py | 235 +++++- backend/open_webui/utils/middleware.py | 37 +- backend/open_webui/utils/skill_files.py | 216 +++++ backend/open_webui/utils/skills.py | 18 +- backend/open_webui/utils/tools.py | 11 +- src/lib/apis/skills/index.ts | 102 +++ .../chat/FileNav/FileCodeEditor.svelte | 13 +- .../chat/FileNav/FileEntryRow.svelte | 94 ++- .../chat/FileNav/FileNavToolbar.svelte | 16 +- .../chat/FileNav/FilePreview.svelte | 4 + .../Commands/SlashCommands.svelte | 5 +- src/lib/components/workspace/Skills.svelte | 160 ++-- .../workspace/Skills/SkillEditor.svelte | 571 ++++++++++--- .../workspace/Skills/SkillFiles.svelte | 754 ++++++++++++++++++ .../workspace/Skills/SkillImport.svelte | 122 +++ .../workspace/Skills/SkillMenu.svelte | 11 +- .../workspace/skills/create/+page.svelte | 14 +- .../(app)/workspace/skills/edit/+page.svelte | 36 +- 23 files changed, 2783 insertions(+), 417 deletions(-) create mode 100644 backend/open_webui/migrations/versions/d6a8c3f912ab_add_skill_history.py create mode 100644 backend/open_webui/models/skill_history.py create mode 100644 backend/open_webui/utils/skill_files.py create mode 100644 src/lib/components/workspace/Skills/SkillFiles.svelte create mode 100644 src/lib/components/workspace/Skills/SkillImport.svelte diff --git a/backend/open_webui/migrations/versions/d6a8c3f912ab_add_skill_history.py b/backend/open_webui/migrations/versions/d6a8c3f912ab_add_skill_history.py new file mode 100644 index 0000000000..a5cefeab31 --- /dev/null +++ b/backend/open_webui/migrations/versions/d6a8c3f912ab_add_skill_history.py @@ -0,0 +1,58 @@ +"""Add immutable multi-file skill snapshots, preserving existing instruction bytes.""" + +import uuid + +import sqlalchemy as sa +from alembic import op + +revision = 'd6a8c3f912ab' +down_revision = 'b8e4f0a3c752' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('skill', sa.Column('version_id', sa.Text(), nullable=True)) + op.add_column('skill', sa.Column('data', sa.JSON(), nullable=True)) + history = op.create_table( + 'skill_history', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('skill_id', sa.Text(), nullable=False), + sa.Column('parent_id', sa.Text(), nullable=True), + sa.Column('snapshot', sa.JSON(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('commit_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + ) + op.create_index('ix_skill_history_skill_id', 'skill_history', ['skill_id']) + connection = op.get_bind() + skill = sa.Table('skill', sa.MetaData(), autoload_with=connection) + for row in connection.execute(sa.select(skill)).mappings(): + version_id = str(uuid.uuid4()) + data = {'files': [{'path': 'SKILL.md', 'content': row['content']}]} + connection.execute( + history.insert().values( + id=version_id, + skill_id=row['id'], + parent_id=None, + user_id=row['user_id'], + commit_message=None, + created_at=row['updated_at'], + snapshot={ + 'name': row['name'], + 'description': row['description'], + 'meta': row['meta'] or {}, + 'content': row['content'], + 'data': data, + }, + ) + ) + connection.execute(skill.update().where(skill.c.id == row['id']).values(version_id=version_id, data=data)) + + +def downgrade(): + op.drop_index('ix_skill_history_skill_id', table_name='skill_history') + op.drop_table('skill_history') + with op.batch_alter_table('skill') as batch: + batch.drop_column('version_id') + batch.drop_column('data') diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 8055d8b49e..b9c2221f7a 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -451,32 +451,37 @@ class AccessGrantsTable: Replace all grants for a resource from a direct access_grants list. """ async with get_async_db_context(db) as db: - await db.execute( - delete(AccessGrant).filter_by( - resource_type=resource_type, - resource_id=resource_id, - ) - ) - - normalized_grants = normalize_access_grants(access_grants) - - results = [] - for grant_dict in normalized_grants: - grant = AccessGrant( - id=str(uuid.uuid4()), - resource_type=resource_type, - resource_id=resource_id, - principal_type=grant_dict['principal_type'], - principal_id=grant_dict['principal_id'], - permission=grant_dict['permission'], - created_at=int(time.time()), - ) - db.add(grant) - results.append(grant) - + results = await self.replace_access_grants(db, resource_type, resource_id, access_grants) await db.commit() return [AccessGrantModel.model_validate(g) for g in results] + async def replace_access_grants(self, db, resource_type, resource_id, access_grants): + """Replace grants in the caller's transaction without committing.""" + await db.execute( + delete(AccessGrant).filter_by( + resource_type=resource_type, + resource_id=resource_id, + ) + ) + + normalized_grants = normalize_access_grants(access_grants) + + results = [] + for grant_dict in normalized_grants: + grant = AccessGrant( + id=str(uuid.uuid4()), + resource_type=resource_type, + resource_id=resource_id, + principal_type=grant_dict['principal_type'], + principal_id=grant_dict['principal_id'], + permission=grant_dict['permission'], + created_at=int(time.time()), + ) + db.add(grant) + results.append(grant) + + return results + async def get_access_control( self, resource_type: str, diff --git a/backend/open_webui/models/skill_history.py b/backend/open_webui/models/skill_history.py new file mode 100644 index 0000000000..44a63b1f91 --- /dev/null +++ b/backend/open_webui/models/skill_history.py @@ -0,0 +1,88 @@ +import time +import uuid + +from open_webui.internal.db import Base, get_async_db_context +from pydantic import BaseModel, ConfigDict +from sqlalchemy import JSON, BigInteger, Column, Text, select + + +class SkillHistory(Base): + __tablename__ = 'skill_history' + id = Column(Text, primary_key=True) + skill_id = Column(Text, nullable=False, index=True) + parent_id = Column(Text, nullable=True) + snapshot = Column(JSON, nullable=False) + user_id = Column(Text, nullable=False) + commit_message = Column(Text, nullable=True) + created_at = Column(BigInteger, nullable=False) + + +class SkillHistoryModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + skill_id: str + parent_id: str | None = None + snapshot: dict + user_id: str + commit_message: str | None = None + created_at: int + + +class SkillHistoryResponse(BaseModel): + id: str + skill_id: str + parent_id: str | None = None + user_id: str + commit_message: str | None = None + created_at: int + user: dict | None = None + + +class SkillHistoryTable: + def new_entry(self, skill_id, snapshot, user_id, parent_id=None, commit_message=None): + return SkillHistory( + id=str(uuid.uuid4()), + skill_id=skill_id, + snapshot=snapshot, + user_id=user_id, + parent_id=parent_id, + commit_message=commit_message, + created_at=int(time.time()), + ) + + async def get_history_by_id(self, skill_id, history_id, db=None): + async with get_async_db_context(db) as session: + entry = ( + await session.execute(select(SkillHistory).filter_by(id=history_id, skill_id=skill_id)) + ).scalar_one_or_none() + return SkillHistoryModel.model_validate(entry) if entry else None + + async def get_history_by_skill_id(self, skill_id, page=1, db=None): + from open_webui.models.users import User + + async with get_async_db_context(db) as session: + columns = [getattr(SkillHistory, key) for key in SkillHistoryResponse.model_fields if key != 'user'] + rows = ( + ( + await session.execute( + select(*columns, User.name.label('author_name')) + .outerjoin(User, User.id == SkillHistory.user_id) + .where(SkillHistory.skill_id == skill_id) + .order_by(SkillHistory.created_at.desc(), SkillHistory.id.desc()) + .offset((max(1, page) - 1) * 20) + .limit(20) + ) + ) + .mappings() + .all() + ) + return [ + SkillHistoryResponse( + **{k: v for k, v in row.items() if k != 'author_name'}, + user={'name': row['author_name']} if row['author_name'] else None, + ) + for row in rows + ] + + +SkillHistories = SkillHistoryTable() diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 4d43434a73..27542bed93 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -2,10 +2,13 @@ import logging import time from typing import Optional +from fastapi import HTTPException from open_webui.internal.db import Base, get_async_db_context -from open_webui.models.access_grants import AccessGrantModel, AccessGrants +from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants from open_webui.models.groups import Groups +from open_webui.models.skill_history import SkillHistories, SkillHistory from open_webui.models.users import User, UserModel, UserResponse, Users +from open_webui.utils.skill_files import SkillFile, SkillFileOperation, apply_operations, validate_files from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -25,6 +28,8 @@ class Skill(Base): name = Column(Text, unique=True) description = Column(Text, nullable=True) content = Column(Text) + data = Column(JSON, nullable=True) + version_id = Column(Text, nullable=True) meta = Column(JSON) is_active = Column(Boolean, default=True) @@ -43,6 +48,8 @@ class SkillModel(BaseModel): name: str description: Optional[str] = None content: str + data: dict + version_id: str | None = None meta: SkillMeta is_active: bool = True access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -63,6 +70,7 @@ class SkillUserModel(SkillModel): class SkillResponse(BaseModel): + version_id: str | None = None id: str user_id: str name: str @@ -77,19 +85,33 @@ class SkillResponse(BaseModel): class SkillUserResponse(SkillResponse): user: Optional[UserResponse] = None - model_config = ConfigDict(extra='allow') + model_config = ConfigDict(extra='ignore') class SkillAccessResponse(SkillUserResponse): write_access: Optional[bool] = False +class SkillDetailResponse(SkillAccessResponse): + content: str + + +class SkillData(BaseModel): + model_config = ConfigDict(extra='forbid') + files: list[SkillFile] + + class SkillForm(BaseModel): id: str name: str description: Optional[str] = None - content: str - meta: SkillMeta = SkillMeta() + content: str | None = None + files: list[SkillFile] | None = None + data: SkillData | None = None + operations: list[SkillFileOperation] | None = None + expected_version_id: str | None = None + commit_message: str | None = None + meta: SkillMeta = Field(default_factory=SkillMeta) is_active: bool = True access_grants: Optional[list[dict]] = None @@ -104,6 +126,25 @@ class SkillAccessListResponse(BaseModel): total: int = 0 +def skill_snapshot(skill) -> dict: + return { + 'name': skill.name, + 'description': skill.description, + 'content': skill.content, + 'data': skill.data, + 'meta': SkillMeta.model_validate(skill.meta or {}).model_dump(), + } + + +async def get_skill_snapshot(skill, version_id=None, db=None) -> dict: + if not version_id or version_id == skill.version_id: + return skill_snapshot(skill) + entry = await SkillHistories.get_history_by_id(skill.id, version_id, db=db) + if not entry: + raise HTTPException(404, 'Skill version not found') + return entry.snapshot + + class SkillsTable: async def _get_access_grants(self, skill_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: return await AccessGrants.get_grants_by_resource('skill', skill_id, db=db) @@ -126,26 +167,45 @@ class SkillsTable: form_data: SkillForm, db: Optional[AsyncSession] = None, ) -> Optional[SkillModel]: - async with get_async_db_context(db) as db: + data = form_data.model_dump(exclude_none=True) + if data.get('data') is not None and data.get('files') is not None: + raise ValueError('Provide data or files, not both') + files = ( + data['data']['files'] + if data.get('data') is not None + else data.get('files', [{'path': 'SKILL.md', 'content': data.get('content', '')}]) + ) + files = validate_files(files) + snapshot = { + 'name': form_data.name, + 'description': form_data.description, + 'meta': form_data.meta.model_dump(), + 'content': next(f['content'] for f in files if f['path'] == 'SKILL.md'), + 'data': {'files': files}, + } + entry = SkillHistories.new_entry(form_data.id, snapshot, user_id, commit_message=form_data.commit_message) + async with get_async_db_context(db) as session: try: result = Skill( - **{ - **form_data.model_dump(exclude={'access_grants'}), - 'user_id': user_id, - 'updated_at': int(time.time()), - 'created_at': int(time.time()), - } + id=form_data.id, + user_id=user_id, + name=form_data.name, + description=form_data.description, + meta=snapshot['meta'], + content=snapshot['content'], + data=snapshot['data'], + is_active=form_data.is_active, + version_id=entry.id, + created_at=int(time.time()), + updated_at=int(time.time()), ) - db.add(result) - await db.commit() - await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db) - if result: - return await 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 + session.add_all([result, entry]) + grants = await AccessGrants.replace_access_grants(session, 'skill', result.id, form_data.access_grants) + await session.commit() + return await self._to_skill_model(result, [AccessGrantModel.model_validate(g) for g in grants]) + except Exception: + await session.rollback() + raise async def get_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]: try: @@ -316,22 +376,66 @@ class SkillsTable: log.exception(f'Error searching skills: {e}') return SkillListResponse(items=[], total=0) - async def update_skill_by_id( - self, id: str, updated: dict, db: Optional[AsyncSession] = None - ) -> Optional[SkillModel]: - try: - async with get_async_db_context(db) as db: - access_grants = updated.pop('access_grants', None) - await db.execute(update(Skill).filter_by(id=id).values(**updated, updated_at=int(time.time()))) - await db.commit() - if access_grants is not None: - await AccessGrants.set_access_grants('skill', id, access_grants, db=db) - - # populate_existing: the Core update above bypasses any identity-map copy - skill = await db.get(Skill, id, populate_existing=True) - return await self._to_skill_model(skill, db=db) - except Exception: - return None + async def update_skill_by_id(self, id: str, updated: dict, db=None, user_id=None) -> Optional[SkillModel]: + async with get_async_db_context(db) as session: + try: + skill = await session.get(Skill, id, populate_existing=True) + if not skill: + return None + expected = updated.get('expected_version_id') + if expected is not None and expected != skill.version_id: + raise HTTPException(409, {'code': 'version_conflict', 'current_version_id': skill.version_id}) + if any(updated.get(key) is not None for key in ('files', 'data', 'operations')) and expected is None: + raise HTTPException(400, 'expected_version_id is required for file updates') + old = skill_snapshot(skill) + if sum(updated.get(key) is not None for key in ('files', 'data', 'operations')) > 1: + raise ValueError('Provide data, files, or operations, not more than one') + files = ( + SkillData.model_validate(updated['data']).model_dump(exclude_none=True)['files'] + if updated.get('data') is not None + else updated.get('files') + ) + files = files if files is not None else skill.data['files'] + if updated.get('operations') is not None: + files = apply_operations(files, updated['operations']) + if updated.get('content') is not None: + files = [f for f in files if f['path'] != 'SKILL.md'] + [ + {'path': 'SKILL.md', 'content': updated['content']} + ] + files = validate_files(files, skill.data['files']) + snapshot = {key: updated.get(key, old.get(key)) for key in ('name', 'description', 'meta')} + snapshot['meta'] = SkillMeta.model_validate(snapshot['meta'] or {}).model_dump() + snapshot['data'] = {'files': files} + snapshot['content'] = next(f['content'] for f in files if f['path'] == 'SKILL.md') + values = {'is_active': updated.get('is_active', skill.is_active)} + if snapshot != old or updated.get('_restore'): + entry = SkillHistories.new_entry( + id, snapshot, user_id or skill.user_id, skill.version_id, updated.get('commit_message') + ) + session.add(entry) + values.update(snapshot, version_id=entry.id) + values['updated_at'] = int(time.time()) + result = await session.execute( + update(Skill) + .where(Skill.id == id, Skill.version_id == skill.version_id) + .values(**values) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + raise HTTPException(409, {'code': 'version_conflict'}) + if updated.get('access_grants') is not None: + await AccessGrants.replace_access_grants(session, 'skill', id, updated['access_grants']) + await session.commit() + await session.refresh(skill) + grants = ( + (await session.execute(select(AccessGrant).filter_by(resource_type='skill', resource_id=id))) + .scalars() + .all() + ) + return await self._to_skill_model(skill, [AccessGrantModel.model_validate(g) for g in grants]) + except Exception: + await session.rollback() + raise async def toggle_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]: async with get_async_db_context(db) as db: @@ -352,7 +456,8 @@ class SkillsTable: async def delete_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: async with get_async_db_context(db) as db: - await AccessGrants.revoke_all_access('skill', id, db=db) + await db.execute(delete(AccessGrant).filter_by(resource_type='skill', resource_id=id)) + await db.execute(delete(SkillHistory).filter_by(skill_id=id)) await db.execute(delete(Skill).filter_by(id=id)) await db.commit() diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index a828bac0e9..033b2bd5af 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -1,8 +1,14 @@ +import difflib +import json import logging +import mimetypes import re +import zipfile from typing import Optional +from urllib.parse import quote -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status +from fastapi.responses import Response from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event @@ -10,17 +16,27 @@ from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config from open_webui.models.groups import Groups +from open_webui.models.skill_history import SkillHistories from open_webui.models.skills import ( SkillAccessListResponse, SkillAccessResponse, + SkillDetailResponse, SkillForm, SkillModel, SkillResponse, Skills, SkillUserResponse, + get_skill_snapshot, ) from open_webui.utils.access_control import filter_allowed_access_grants, has_permission -from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.auth import get_verified_user +from open_webui.utils.skill_files import ( + MAX_IMPORT_BYTES, + file_bytes, + file_summaries, + parse_import, + zip_export, +) from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -122,27 +138,73 @@ async def get_skill_list( ############################ -@router.get('/export', response_model=list[SkillModel]) +async def authorized_skill(id, user, permission='read', db=None): + skill = await Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException(404, 'Skill not found') + if not ( + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) + or skill.user_id == user.id + or await AccessGrants.has_access( + user_id=user.id, resource_type='skill', resource_id=id, permission=permission, db=db + ) + ): + raise HTTPException(403, 'Access denied') + return skill + + +async def selected_history(skill, version_id=None, db=None): + entry = await SkillHistories.get_history_by_id(skill.id, version_id or skill.version_id, db=db) + if not entry: + raise HTTPException(404, 'Skill version not found') + return entry + + +@router.get('/export') async def export_skills( request: Request, + format: str = 'json', + ids: list[str] | None = Query(None), + version_id: str | None = None, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not await has_permission( - user.id, - 'workspace.skills_export', - await Config.get('user.permissions'), - db=db, + user.id, 'workspace.skills_export', await Config.get('user.permissions'), db=db ): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.UNAUTHORIZED, + 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') + skills = ( + [await authorized_skill(id, user, db=db) for id in ids] + if ids is not None + else await Skills.get_skills( + user_id=None if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL else user.id, db=db ) - - if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - return await Skills.get_skills(db=db) - else: - return await Skills.get_skills(db=db, user_id=user.id) + ) + packages = [] + for skill in skills: + snapshot = await get_skill_snapshot(skill, version_id, db) + packages.append( + { + 'id': skill.id, + **{key: snapshot[key] for key in ('name', 'description', 'meta')}, + 'files': snapshot['data']['files'], + 'is_active': skill.is_active, + } + ) + try: + if format == 'zip': + return Response( + zip_export(packages), + media_type='application/zip', + headers={'Content-Disposition': 'attachment; filename="skills.zip"'}, + ) + if format != 'json': + raise ValueError('Export format must be json or zip') + return packages[0] if ids and len(ids) == 1 else packages + except ValueError as error: + raise HTTPException(400, str(error)) ############################ @@ -182,6 +244,9 @@ async def create_new_skill( detail=ERROR_MESSAGES.ID_TAKEN, ) + if await Skills.get_skill_by_name(form_data.name, db=db): + raise HTTPException(409, 'A skill with this name already exists') + # Strip public/user grants the requesting user is not permitted to assign # (matches the channel/notes/calendar pattern). Without this, a user with # workspace.skills permission could attach principal_id='*' read/write @@ -226,13 +291,13 @@ async def create_new_skill( ############################ -@router.get('/id/{id}', response_model=Optional[SkillAccessResponse]) +@router.get('/id/{id}', response_model=Optional[SkillDetailResponse]) async def get_skill_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): skill = await Skills.get_skill_by_id(id, db=db) if skill: if ( - user.role == 'admin' + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or skill.user_id == user.id or await AccessGrants.has_access( user_id=user.id, @@ -242,7 +307,7 @@ async def get_skill_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes db=db, ) ): - return SkillAccessResponse( + return SkillDetailResponse( **skill.model_dump(), write_access=( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) @@ -297,7 +362,7 @@ async def update_skill_by_id( permission='write', db=db, ) - and user.role != 'admin' + and not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -319,10 +384,10 @@ async def update_skill_by_id( try: updated = { - **form_data.model_dump(exclude={'id'}), + **form_data.model_dump(exclude={'id'}, exclude_unset=True), } - skill = await Skills.update_skill_by_id(id, updated, db=db) + skill = await Skills.update_skill_by_id(id, updated, db=db, user_id=user.id) if skill: await publish_event( @@ -380,7 +445,7 @@ async def update_skill_access_by_id( permission='write', db=db, ) - and user.role != 'admin' + and not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -423,7 +488,7 @@ async def toggle_skill_by_id( skill = await Skills.get_skill_by_id(id, db=db) if skill: if ( - user.role == 'admin' + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or skill.user_id == user.id or await AccessGrants.has_access( user_id=user.id, @@ -489,7 +554,7 @@ async def delete_skill_by_id( permission='write', db=db, ) - and user.role != 'admin' + and not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -506,3 +571,293 @@ async def delete_skill_by_id( data={'name': skill.name}, ) return result + + +@router.get('/id/{id}/files') +async def get_skill_files( + id: str, + version_id: str | None = None, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + skill = await authorized_skill(id, user, db=db) + snapshot = await get_skill_snapshot(skill, version_id, db) + return { + 'version_id': version_id or skill.version_id, + 'files': file_summaries(snapshot['data']['files']), + } + + +@router.get('/id/{id}/files/content') +async def get_skill_file( + id: str, path: str, version_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + skill = await authorized_skill(id, user, db=db) + snapshot = await get_skill_snapshot(skill, version_id, db) + file = next((f for f in snapshot['data']['files'] if f['path'] == path), None) + if not file: + raise HTTPException(404, 'File not found') + return Response( + file_bytes(file), + media_type=mimetypes.guess_type(path)[0] or 'application/octet-stream', + headers={ + 'Content-Disposition': "attachment; filename*=UTF-8''" + quote(path.rsplit('/', 1)[-1], safe=''), + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + 'Cache-Control': 'private, no-store', + }, + ) + + +@router.get('/id/{id}/history') +async def get_skill_history( + id: str, page: int = 1, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + await authorized_skill(id, user, db=db) + return await SkillHistories.get_history_by_skill_id(id, page, db) + + +@router.get('/id/{id}/history/diff') +async def diff_skill_history( + id: str, from_id: str, to_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + skill = await authorized_skill(id, user, db=db) + before, after = ( + (await selected_history(skill, from_id, db)).snapshot, + (await selected_history(skill, to_id, db)).snapshot, + ) + a, b = ({f['path']: f for f in snapshot['data']['files']} for snapshot in (before, after)) + return { + 'metadata': { + k: {'before': before.get(k), 'after': after.get(k)} + for k in ('name', 'description', 'meta') + if before.get(k) != after.get(k) + }, + 'files': [ + { + 'path': p, + 'status': 'added' if p not in a else 'deleted' if p not in b else 'modified', + 'binary': bool(a.get(p, {}).get('encoding') or b.get(p, {}).get('encoding')), + } + for p in sorted(a.keys() | b.keys()) + if a.get(p) != b.get(p) + ], + } + + +@router.get('/id/{id}/history/diff/file') +async def diff_skill_file( + id: str, + from_id: str, + to_id: str, + path: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + skill = await authorized_skill(id, user, db=db) + entries = [await selected_history(skill, version, db) for version in (from_id, to_id)] + files = [next((f for f in entry.snapshot['data']['files'] if f['path'] == path), None) for entry in entries] + if not any(files): + raise HTTPException(404, 'File not found') + if any(f and f.get('encoding') for f in files): + return {'binary': True} + return { + 'binary': False, + 'diff': ''.join( + difflib.unified_diff( + (files[0] or {}).get('content', '').splitlines(True), + (files[1] or {}).get('content', '').splitlines(True), + fromfile=f'{from_id[:7]}/{path}', + tofile=f'{to_id[:7]}/{path}', + ) + ), + } + + +@router.get('/id/{id}/history/{history_id}') +async def get_skill_history_entry( + id: str, history_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + skill = await authorized_skill(id, user, db=db) + entry = await selected_history(skill, history_id, db) + snapshot = entry.snapshot + return { + **entry.model_dump(exclude={'snapshot'}), + 'snapshot': { + **{k: v for k, v in snapshot.items() if k != 'data'}, + 'data': {'files': file_summaries(snapshot['data']['files'])}, + }, + } + + +class RestoreSkillForm(BaseModel): + expected_version_id: str + commit_message: str | None = None + + +@router.post('/id/{id}/history/{history_id}/restore') +async def restore_skill( + id: str, + history_id: str, + request: Request, + form_data: RestoreSkillForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + skill = await authorized_skill(id, user, 'write', db) + entry = await selected_history(skill, history_id, db) + result = await Skills.update_skill_by_id( + id, {**entry.snapshot, **form_data.model_dump(), '_restore': True}, db=db, user_id=user.id + ) + await publish_event(request, EVENTS.SKILL_UPDATED, actor=user, subject_id=id, data={'name': result.name}) + return result + + +class CloneSkillForm(BaseModel): + id: str + name: str + version_id: str | None = None + + +@router.post('/id/{id}/clone') +async def clone_skill( + id: str, + request: Request, + form_data: CloneSkillForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + skill = await authorized_skill(id, user, db=db) + snapshot = await get_skill_snapshot(skill, form_data.version_id, db) + data = { + **snapshot, + 'id': form_data.id, + 'name': form_data.name, + 'is_active': skill.is_active, + 'access_grants': [], + } + return await create_new_skill(request, SkillForm(**data), user, db) + + +async def read_import(files: list[UploadFile]): + packages, total = [], 0 + try: + if len(files) > 10000: + raise ValueError('Too many uploaded files') + folder_files = [] + for file in files: + data = await file.read(MAX_IMPORT_BYTES - total + 1) + total += len(data) + if total > MAX_IMPORT_BYTES: + raise ValueError('Import exceeds 200 MiB') + if '/' in (file.filename or ''): + from open_webui.utils.skill_files import encode_file, validate_path + + folder_files.append(encode_file(validate_path(file.filename), data)) + else: + packages.extend(parse_import(data, file.filename or '')) + if folder_files: + roots = sorted(f['path'][: -len('SKILL.md')] for f in folder_files if f['path'].endswith('/SKILL.md')) + roots = [root for root in roots if not any(root != parent and root.startswith(parent) for parent in roots)] + if not roots: + raise ValueError('Select a folder containing one or more non-nested skills') + if any(not any(f['path'].startswith(root) for root in roots) for f in folder_files): + raise ValueError('Files outside skill directories') + for root in roots: + package = { + 'files': [{**f, 'path': f['path'][len(root) :]} for f in folder_files if f['path'].startswith(root)] + } + packages.extend(parse_import(json.dumps(package).encode(), 'skill.json')) + if sum(len(file_bytes(f)) for p in packages for f in p['files']) > MAX_IMPORT_BYTES: + raise ValueError('Import exceeds 200 MiB decoded') + return packages + except (ValueError, KeyError, TypeError, UnicodeError, zipfile.BadZipFile) as error: + raise HTTPException(400, str(error)) + + +async def require_import(user, db): + if user.role != 'admin' and not await has_permission( + user.id, 'workspace.skills_import', await Config.get('user.permissions'), db=db + ): + raise HTTPException(403, 'Import permission required') + + +@router.post('/import/preview') +async def preview_skill_import( + files: list[UploadFile] = File(...), user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + await require_import(user, db) + packages = await read_import(files) + results = [] + for index, package in enumerate(packages): + existing = await Skills.get_skill_by_id(package['id'], db=db) + writable = False + if existing: + try: + await authorized_skill(existing.id, user, 'write', db) + writable = True + except HTTPException: + pass + results.append( + { + 'index': index, + **{k: v for k, v in package.items() if k != 'files'}, + 'files': file_summaries(package['files']), + 'id_taken': existing is not None, + 'name_taken': await Skills.get_skill_by_name(package['name'], db=db) is not None, + 'can_replace': writable, + 'expected_version_id': existing.version_id if writable else None, + } + ) + return results + + +@router.post('/import') +async def import_skills( + request: Request, + files: list[UploadFile] = File(...), + decisions: str = Form(...), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await require_import(user, db) + packages = await read_import(files) + try: + choices = json.loads(decisions) + if ( + not isinstance(choices, list) + or len(choices) != len(packages) + or any(not isinstance(choice, dict) for choice in choices) + ): + raise ValueError('One decision is required per skill') + except (ValueError, TypeError) as error: + raise HTTPException(400, str(error)) + results = [] + for package, choice in zip(packages, choices): + try: + action = choice.get('action', 'skip') + if action == 'skip': + results.append({'status': 'skipped', 'id': package['id']}) + continue + data = {**package, 'id': choice.get('id', package['id']), 'name': choice.get('name', package['name'])} + if action == 'replace': + await authorized_skill(data['id'], user, 'write', db) + if not choice.get('expected_version_id'): + raise HTTPException(400, 'Replacement requires expected_version_id') + result = await update_skill_by_id( + request, data['id'], SkillForm(**data, expected_version_id=choice['expected_version_id']), user, db + ) + elif action in ('create', 'copy'): + result = await create_new_skill(request, SkillForm(**data, access_grants=[]), user, db) + else: + raise HTTPException(400, 'Unknown import action') + results.append({'status': 'saved', 'id': result.id}) + except (HTTPException, ValueError) as error: + results.append( + { + 'status': 'error', + 'id': package['id'], + 'error': error.detail if isinstance(error, HTTPException) else str(error), + } + ) + return results diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 5943df6d39..df211dbec5 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -69,6 +69,7 @@ from open_webui.utils.chat_id import is_saved_chat_id from open_webui.utils.json_codec import JSONCodec from open_webui.utils.notifications import notify_target from open_webui.utils.sanitize import sanitize_code +from open_webui.utils.skill_files import SkillFile, SkillFileOperation log = logging.getLogger(__name__) @@ -3487,19 +3488,12 @@ async def view_skill( __metadata__: dict = None, __event_call__: callable = None, ) -> str: + """Load the current SKILL.md and its file manifest. Supporting file reads use this same snapshot. + + :param id: Skill ID from the available skills manifest. """ - Load the full instructions of a skill by its id from the available skills manifest. - Use this when you need detailed instructions for a skill listed in . - - :param id: The id of the skill to load (as shown in the manifest) - :return: The full skill instructions as markdown content - """ - if __request__ is None: - return JSONCodec.dumps({'error': 'Request context not available'}) - - if not __user__: - return JSONCodec.dumps({'error': 'User context not available'}) - + if __request__ is None or not __user__: + return JSONCodec.dumps({'error': 'Request and user context required'}) try: terminal_skill_prefix = 'terminal:' if isinstance(id, str) and id.startswith(terminal_skill_prefix): @@ -3513,42 +3507,199 @@ async def view_skill( return JSONCodec.dumps({'error': f"Skill '{id}' not found"}) return JSONCodec.dumps(skill, ensure_ascii=False) - from open_webui.models.access_grants import AccessGrants - from open_webui.models.skills import Skills - - user_id = __user__.get('id') - - # Direct DB lookup by id (case-insensitive since IDs are stored lowercase) - skill = await Skills.get_skill_by_id(id.lower()) - - if not skill or not skill.is_active: - return JSONCodec.dumps({'error': f"Skill '{id}' 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 await Groups.get_groups_by_member_id(user_id, include_inherited=True) - ] - if not await AccessGrants.has_access( - user_id=user_id, - resource_type='skill', - resource_id=skill.id, - permission='read', - user_group_ids=set(user_group_ids), - ): - return JSONCodec.dumps({'error': 'Access denied'}) + from types import SimpleNamespace + from open_webui.models.skills import get_skill_snapshot + from open_webui.routers.skills import authorized_skill + from open_webui.utils.skill_files import file_summaries + actor = SimpleNamespace(**__user__) + skill = await authorized_skill(id.lower(), actor) + if not skill.is_active: + await authorized_skill(skill.id, actor, 'write') + metadata = __metadata__ if __metadata__ is not None else {} + context = metadata.get('chat_context') or {} + metadata['chat_context'] = context + versions = context.setdefault('skill_versions', {}) + version_id = skill.version_id + snapshot = await get_skill_snapshot(skill, version_id) + versions[skill.id] = version_id return JSONCodec.dumps( { - 'name': skill.name, - 'content': skill.content, + 'id': skill.id, + 'version_id': version_id, + 'name': snapshot['name'], + 'content': snapshot['content'], + 'files': file_summaries(snapshot['data']['files']), }, ensure_ascii=False, ) - except Exception as e: - log.exception(f'view_skill error: {e}') - return JSONCodec.dumps({'error': str(e)}) + except Exception as error: + return JSONCodec.dumps({'error': getattr(error, 'detail', str(error))}) + + +async def read_skill_file( + id: str, + path: str, + offset: int = 0, + max_chars: int = 10000, + __request__: Request = None, + __user__: dict = None, + __metadata__: dict = None, +) -> str: + """Read one skill resource from the snapshot loaded by view_skill, or the current snapshot if none was loaded. + + :param id: Skill ID. + :param path: Relative path within the skill. + :param offset: Character offset for paging text. + :param max_chars: Maximum characters to return, up to 100000. + """ + try: + from types import SimpleNamespace + from urllib.parse import urlencode + from open_webui.models.skills import get_skill_snapshot + from open_webui.routers.skills import authorized_skill + from open_webui.utils.skill_files import file_summaries + + if not __user__ or __request__ is None: + raise ValueError('Request and user context required') + skill = await authorized_skill(id, SimpleNamespace(**__user__)) + if not skill.is_active: + await authorized_skill(id, SimpleNamespace(**__user__), 'write') + metadata = __metadata__ if __metadata__ is not None else {} + context = metadata.get('chat_context') or {} + metadata['chat_context'] = context + versions = context.setdefault('skill_versions', {}) + version_id = versions.get(skill.id) or skill.version_id + snapshot = await get_skill_snapshot(skill, version_id) + versions[skill.id] = version_id + file = next((f for f in snapshot['data']['files'] if f['path'] == path), None) + if file is None: + raise ValueError('File not found') + if file.get('encoding'): + return JSONCodec.dumps( + { + **file_summaries([file])[0], + 'version_id': version_id, + 'url': '/workspace/skills/edit?' + urlencode({'id': id, 'version_id': version_id, 'path': path}), + } + ) + offset, max_chars = max(0, offset), min(100000, max(1, max_chars)) + return JSONCodec.dumps( + { + 'path': path, + 'version_id': version_id, + 'content': file['content'][offset : offset + max_chars], + 'next_offset': offset + max_chars if offset + max_chars < len(file['content']) else None, + }, + ensure_ascii=False, + ) + except Exception as error: + return JSONCodec.dumps({'error': getattr(error, 'detail', str(error))}) + + +async def create_skill( + id: str, + name: str, + content: str, + files: list[SkillFile] = None, + commit_message: str = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """Create a private workspace skill with SKILL.md and optional UTF-8 supporting files. No terminal is needed. + + :param id: Unique lowercase skill slug. + :param name: Display name. + :param content: Complete SKILL.md text, including name and description frontmatter. + :param files: Optional supporting files, each with path and content. UTF-8 text only. + :param commit_message: Optional description of this save. + """ + try: + from types import SimpleNamespace + from open_webui.routers.skills import create_new_skill + from open_webui.models.skills import SkillForm + from open_webui.utils.skill_files import frontmatter + from open_webui.utils.access_control import has_permission + + if __request__ is None or not __user__: + raise ValueError('Request and user context required') + files = [f.model_dump(exclude_none=True) if isinstance(f, SkillFile) else f for f in files or []] + if any(f.get('encoding') or f.get('path') == 'SKILL.md' for f in files): + raise ValueError('Supporting files must be UTF-8 text; pass SKILL.md in content') + # Agent authoring requires the workspace editor permission, not just import access. + if __user__.get('role') != 'admin' and not await has_permission( + __user__['id'], 'workspace.skills', await Config.get('user.permissions') + ): + raise ValueError('Skill authoring permission required') + result = await create_new_skill( + __request__, + SkillForm( + id=id, + name=name, + content=content, + description=str(frontmatter(content).get('description', '')), + files=[{'path': 'SKILL.md', 'content': content}, *(files or [])], + commit_message=commit_message, + access_grants=[], + ), + SimpleNamespace(**__user__), + None, + ) + return JSONCodec.dumps( + {'id': result.id, 'version_id': result.version_id, 'url': '/workspace/skills/edit?id=' + result.id} + ) + except Exception as error: + return JSONCodec.dumps({'error': getattr(error, 'detail', str(error))}) + + +async def update_skill_files( + id: str, + operations: list[SkillFileOperation], + commit_message: str = None, + __request__: Request = None, + __user__: dict = None, + __metadata__: dict = None, +) -> str: + """Save file operations as a new skill version, preserving untouched files. If the skill changed since it was read, call view_skill again and reapply the edit. + + :param id: Skill to edit. Requires write access. + :param operations: File operations: {op: put, path, content}, {op: move, path, destination}, or {op: delete, path}. Use put with path SKILL.md to edit the instructions. Text writes only. + :param commit_message: Optional description of the change. + """ + try: + from types import SimpleNamespace + from open_webui.routers.skills import authorized_skill + from open_webui.models.skills import Skills + from open_webui.events import EVENTS, publish_event + + if __request__ is None or not __user__: + raise ValueError('Request and user context required') + user = SimpleNamespace(**__user__) + skill = await authorized_skill(id, user, 'write') + metadata = __metadata__ if __metadata__ is not None else {} + context = metadata.get('chat_context') or {} + metadata['chat_context'] = context + versions = context.setdefault('skill_versions', {}) + expected_version_id = versions.get(skill.id) or skill.version_id + operations = [ + op.model_dump(exclude_none=True) if isinstance(op, SkillFileOperation) else op for op in operations or [] + ] + if any(op.get('encoding') for op in operations): + raise ValueError('Agent file writes support UTF-8 text only') + result = await Skills.update_skill_by_id( + id, + { + 'expected_version_id': expected_version_id, + 'operations': operations or [], + 'commit_message': commit_message, + }, + user_id=user.id, + ) + versions[skill.id] = result.version_id + await publish_event(__request__, EVENTS.SKILL_UPDATED, actor=user, subject_id=id, data={'name': result.name}) + return JSONCodec.dumps({'id': result.id, 'version_id': result.version_id}) + except Exception as error: + return JSONCodec.dumps({'error': getattr(error, 'detail', str(error))}) # ============================================================================= diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index afdce7c9bd..2d69ca267a 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -134,7 +134,7 @@ from open_webui.utils.sanitize import sanitize_code from open_webui.utils.skills import ( apply_skills_create_prompt, extract_skill_ids_from_messages, - has_prior_real_chat_content, + has_prior_user_message, strip_skill_mentions, ) from open_webui.utils.task import ( @@ -2762,8 +2762,16 @@ async def process_chat_payload(request, form_data, user, metadata, model): files = form_data.pop('files', None) form_data.pop('folder_id', None) metadata['terminal_id'] = terminal_id - skill_authoring_allowed = bool(terminal_id) and has_prior_real_chat_content(form_data.get('messages', [])) - skill_create_denial_reason = 'empty_chat' if terminal_id else 'disabled' + can_author_skills = ( + use_builtin_tools + and (model.get('info', {}).get('meta', {}).get('builtinTools') or {}).get('skills', True) + and ( + user.role == 'admin' + or await has_permission(user.id, 'workspace.skills', await Config.get('user.permissions')) + ) + ) + skill_authoring_allowed = can_author_skills and has_prior_user_message(form_data.get('messages', [])) + skill_create_denial_reason = 'empty_chat' if can_author_skills else 'disabled' apply_skills_create_prompt( form_data.get('messages', []), allowed=skill_authoring_allowed, @@ -2776,6 +2784,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Mentioned skills get full content; selected/default skills can be loaded through view_skill. chat_context = metadata.get('chat_context') or {} + metadata['chat_context'] = chat_context + skill_versions = chat_context.setdefault('skill_versions', {}) mentioned_skill_ids = set(chat_context.get('mentioned_skill_ids') or []) | extract_skill_ids_from_messages( form_data.get('messages', []) ) @@ -2839,8 +2849,27 @@ async def process_chat_payload(request, form_data, user, metadata, model): skill_manifest = '' for skill in available_skills: if skill.id in mentioned_skill_ids or not use_builtin_tools or not model_builtin_tools.get('skills', True): + from open_webui.models.skills import get_skill_snapshot + + version_id = skill_versions.get(skill.id) or skill.version_id + try: + snapshot = await get_skill_snapshot(skill, version_id) + except HTTPException as error: + if error.status_code != 404: + raise + continue + skill_versions[skill.id] = version_id + root_content = snapshot['content'] + resources = '\n'.join(f['path'] for f in snapshot['data']['files'] if f['path'] != 'SKILL.md') + resource_hint = ( + f'\nRead supporting files with read_skill_file(id="{skill.id}", path=...).\n{resources}' + if use_builtin_tools and model_builtin_tools.get('skills', True) + else '\nSupporting files require a model with skill tools enabled.' + if resources + else '' + ) form_data['messages'] = add_or_update_system_message( - f'\n{skill.content}\n', + f'\n{root_content}{resource_hint}\n', form_data['messages'], append=True, ) diff --git a/backend/open_webui/utils/skill_files.py b/backend/open_webui/utils/skill_files.py new file mode 100644 index 0000000000..9622d7ee61 --- /dev/null +++ b/backend/open_webui/utils/skill_files.py @@ -0,0 +1,216 @@ +"""Portable skill snapshots. Paths are virtual; packages are never extracted to disk.""" + +import base64 +import io +import json +import re +import stat +import zipfile +from pathlib import PurePosixPath +from typing import Literal + +import yaml +from pydantic import BaseModel, ConfigDict + +MAX_FILE_BYTES = 10 * 1024 * 1024 +MAX_SKILL_BYTES = 50 * 1024 * 1024 +MAX_IMPORT_BYTES = 200 * 1024 * 1024 +MAX_FILES = 1000 + + +class SkillFile(BaseModel): + model_config = ConfigDict(extra='forbid') + path: str + content: str + encoding: Literal['base64'] | None = None + + +class SkillFileOperation(BaseModel): + op: Literal['put', 'move', 'delete'] + path: str + content: str | None = None + encoding: Literal['base64'] | None = None + destination: str | None = None + + +def validate_path(path: str) -> str: + if not path or path.startswith('/') or '\\' in path or '\x00' in path or ':' in path: + raise ValueError('File paths must be relative POSIX paths') + if any(part in ('', '.', '..') for part in path.split('/')): + raise ValueError('Invalid file path') + return path + + +def file_bytes(file: dict) -> bytes: + if file.get('encoding') == 'base64': + return base64.b64decode(file['content'], validate=True) + return file['content'].encode('utf-8') + + +def encode_file(path: str, data: bytes) -> dict: + try: + content = data.decode('utf-8') + if '\x00' in content: + raise UnicodeError() + return {'path': path, 'content': content} + except UnicodeError: + return {'path': path, 'content': base64.b64encode(data).decode('ascii'), 'encoding': 'base64'} + + +def validate_files(files: list[dict], previous: list[dict] | None = None) -> list[dict]: + result, paths, total = [], set(), 0 + previous = {f['path']: f for f in (previous or [])} + if len(files) > max(MAX_FILES, len(previous)): + raise ValueError(f'A skill may contain at most {MAX_FILES} files') + for item in files: + file = SkillFile.model_validate(item).model_dump(exclude_none=True) + path = validate_path(file['path']) + if path in paths: + raise ValueError(f'Duplicate file: {path}') + paths.add(path) + size = len(file_bytes(file)) + old_size = len(file_bytes(previous[path])) if path in previous else 0 + if size > max(MAX_FILE_BYTES, old_size): + raise ValueError(f'File exceeds 10 MiB: {path}') + total += size + result.append(file) + if total > max(MAX_SKILL_BYTES, sum(len(file_bytes(f)) for f in previous.values())): + raise ValueError('Skill exceeds 50 MiB') + for path in paths: + if any(str(parent) in paths for parent in PurePosixPath(path).parents if str(parent) != '.'): + raise ValueError(f'A file is also used as a directory: {path}') + root = next((f for f in result if f['path'] == 'SKILL.md'), None) + if root is None or root.get('encoding'): + raise ValueError('A skill requires a UTF-8 SKILL.md at its root') + return sorted(result, key=lambda f: f['path']) + + +def apply_operations(files: list[dict], operations: list[dict]) -> list[dict]: + result = {f['path']: dict(f) for f in files} + for raw in operations: + operation = SkillFileOperation.model_validate(raw) + path = validate_path(operation.path) + if operation.op == 'put': + if operation.content is None: + raise ValueError('put requires content') + result[path] = {'path': path, 'content': operation.content} + if operation.encoding: + result[path]['encoding'] = operation.encoding + else: + if path == 'SKILL.md': + raise ValueError('SKILL.md cannot be moved or deleted') + matched = [p for p in result if p == path or p.startswith(path + '/')] + if not matched: + raise ValueError(f'File or directory not found: {path}') + if operation.op == 'move': + destination = validate_path(operation.destination or '') + if destination.startswith(path + '/'): + raise ValueError('Cannot move a directory into itself') + moved = {destination + p[len(path) :]: result[p] for p in matched} + if any(p in result and p not in matched for p in moved): + raise ValueError('Move would overwrite an existing file') + for p in matched: + del result[p] + result.update({p: {**f, 'path': p} for p, f in moved.items()}) + else: + for p in matched: + del result[p] + return validate_files(list(result.values()), files) + + +def frontmatter(content: str) -> dict: + match = re.match(r'\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)', content, re.DOTALL) + if not match: + return {} + try: + value = yaml.safe_load(match.group(1)) + return value if isinstance(value, dict) else {} + except yaml.YAMLError: + return {} + + +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]: + if len(data) > MAX_IMPORT_BYTES: + raise ValueError('Import exceeds 200 MiB') + if filename.lower().endswith('.json'): + value = json.loads(data) + packages = value if isinstance(value, list) else [value] + elif filename.lower().endswith('.zip'): + entries = {} + total = 0 + with zipfile.ZipFile(io.BytesIO(data)) as archive: + for info in archive.infolist(): + path = validate_path(info.filename.rstrip('/')) + mode = info.external_attr >> 16 + if stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR): + raise ValueError('Archives cannot contain links or special files') + if info.is_dir(): + continue + if path in entries: + raise ValueError(f'Duplicate archive path: {path}') + 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) + roots = ( + [''] if 'SKILL.md' in entries else sorted(p[: -len('SKILL.md')] for p in entries if p.endswith('/SKILL.md')) + ) + 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): + 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)]} + for root in roots + ] + elif filename.lower().endswith('.md'): + packages = [{'files': [encode_file('SKILL.md', data)]}] + else: + raise ValueError('Use JSON, ZIP, or Markdown') + total = 0 + result = [] + for package in packages: + if not isinstance(package, dict): + raise ValueError('Each imported skill must be an object') + files = validate_files( + package['files'] if 'files' in package else [{'path': 'SKILL.md', 'content': package.get('content', '')}] + ) + total += sum(len(file_bytes(f)) for f in files) + if total > MAX_IMPORT_BYTES: + raise ValueError('Import exceeds 200 MiB decoded') + fm = frontmatter(next(f['content'] for f in files if f['path'] == 'SKILL.md')) + name = package.get('name') or fm.get('name') or 'Imported skill' + result.append( + { + 'id': package.get('id') + or re.sub(r'[^a-z0-9_-]+', '-', str(name).lower()).strip('-') + or 'imported-skill', + 'name': str(name), + 'description': package.get('description', fm.get('description', '')), + 'meta': package.get('meta') or {}, + 'is_active': package.get('is_active', True), + 'files': files, + } + ) + return result + + +def zip_export(packages: list[dict]) -> bytes: + output, roots = io.BytesIO(), set() + with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as archive: + for package in packages: + root_file = next(f for f in package['files'] if f['path'] == 'SKILL.md') + name = frontmatter(root_file['content']).get('name') + root = name if isinstance(name, str) and re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', name) else package['id'] + validate_path(root) + if root in roots: + raise ValueError('Selected skills have duplicate export directory names') + roots.add(root) + for file in package['files']: + archive.writestr(root + '/' + validate_path(file['path']), file_bytes(file)) + return output.getvalue() diff --git a/backend/open_webui/utils/skills.py b/backend/open_webui/utils/skills.py index 1cbe439dc1..1e7414705d 100644 --- a/backend/open_webui/utils/skills.py +++ b/backend/open_webui/utils/skills.py @@ -39,11 +39,10 @@ Follow the Open WebUI skill-authoring standards: Frontmatter: - name: lowercase-hyphenated, <=64 chars, no spaces. -- description: one sentence, <=60 characters, ends with a period. State the +- description: a clear description, <=1024 characters, ends with a period. State the capability, not the implementation. Do not repeat the skill name. Avoid marketing words like powerful, comprehensive, seamless, advanced, or robust. Count the characters before saving. -- version: 0.1.0. - platforms: declare [macos], [linux], or [windows] only when the skill uses OS-bound primitives. Omit it for portable skills. @@ -87,7 +86,7 @@ def _build_skill_create_prompt(user_request: str) -> str: ) return ( '[/skills:create] The user wants you to create a reusable Open WebUI skill ' - 'for the selected Open Terminal and save it.\n\n' + 'in the workspace and save it with create_skill. No terminal is required.\n\n' f'THE REQUEST:\n{req}\n\n' 'The request is open-ended and may mix SOURCES to gather (directories, ' 'file paths, URLs, what we just did, pasted notes) and REQUIREMENTS that ' @@ -104,12 +103,9 @@ def _build_skill_create_prompt(user_request: str) -> str: '2. Apply every requirement, focus, and constraint in the request to what ' 'the skill covers and emphasizes.\n' '3. Author one SKILL.md using the standards below.\n' - '4. Save with the selected terminal tools under the terminal root cwd: ' - '/.agents/skills//SKILL.md. Do not save ' - 'under a browsed subfolder or transient shell pwd. If the terminal root ' - 'is unavailable, use `pwd` as the fallback. If the skill needs ' - 'supporting files, add them under scripts/, references/, templates/, ' - 'or assets/.\n\n' + '4. Save using create_skill with the complete SKILL.md content and any supporting ' + 'UTF-8 files. Use update_skill_files with file operations to revise an existing ' + 'writable skill. These workspace files do not execute scripts.\n\n' f'{OPEN_WEBUI_SKILL_AUTHORING_STANDARDS}\n\n' 'When done, tell the user the skill name, location, and one-line summary.' ) @@ -120,7 +116,7 @@ def _message_has_real_content(message: dict) -> bool: return bool(text or message.get('tool_calls') or message.get('output')) -def has_prior_real_chat_content(messages: list[dict]) -> bool: +def has_prior_user_message(messages: list[dict]) -> bool: last_user_idx = next( (idx for idx in range(len(messages) - 1, -1, -1) if messages[idx].get('role') == 'user'), len(messages), @@ -133,7 +129,7 @@ def has_prior_real_chat_content(messages: list[dict]) -> bool: def _build_skill_create_gate_prompt(reason: str = 'empty_chat') -> str: if reason == 'disabled': return ( - '[/skills:create] Skill creation requires a selected Open Terminal. ' + '[/skills:create] Skill creation requires workspace skill permission and a model with skill tools enabled. ' 'Explain this briefly and do not try to create or update a skill.' ) return ( diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index c38e7ea1cd..e3622d09ef 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -99,6 +99,9 @@ from open_webui.tools.builtin import ( view_knowledge_file, view_note, view_skill, + read_skill_file, + create_skill, + update_skill_files, write_note, ) from open_webui.utils.access_control import has_access, has_connection_access, has_permission @@ -800,8 +803,12 @@ async def get_builtin_tools( ) # Skills tools - view_skill allows model to load full skill instructions on demand - if is_builtin_tool_enabled('skills') and extra_params.get('__skill_ids__'): - builtin_functions.append(view_skill) + if is_builtin_tool_enabled('skills'): + builtin_functions.extend([view_skill, read_skill_file, update_skill_files]) + if user.get('role') == 'admin' or await has_permission( + user.get('id', ''), 'workspace.skills', await Config.get('user.permissions') + ): + builtin_functions.append(create_skill) # Task management - break down complex work into trackable steps # Task state is stored on the chats row; local/channel IDs do not have one. diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts index aba372a1f2..c71e46f73d 100644 --- a/src/lib/apis/skills/index.ts +++ b/src/lib/apis/skills/index.ts @@ -329,3 +329,105 @@ export const deleteSkillById = async (token: string, id: string) => { return res; }; + +export type SkillFile = { path: string; content: string; encoding?: 'base64' }; +export type SkillFileSummary = { path: string; size: number; encoding?: 'base64' | null }; +export type SkillFileOperation = { + op: 'put' | 'move' | 'delete'; + path: string; + content?: string; + encoding?: 'base64'; + destination?: string; +}; + +export const skillRequest = async (token: string, path: string, options: RequestInit = {}) => { + const response = await fetch(`${WEBUI_API_BASE_URL}/skills${path}`, { + ...options, + headers: { + authorization: `Bearer ${token}`, + ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...options.headers + } + }); + if (!response.ok) { + const body = await response.json(); + throw body.detail ?? body; + } + return response; +}; +export const getSkillFiles = async (token: string, id: string, versionId: string) => + ( + await skillRequest(token, `/id/${id}/files?${new URLSearchParams({ version_id: versionId })}`) + ).json(); +export const getSkillFile = async (token: string, id: string, versionId: string, path: string) => + ( + await skillRequest( + token, + `/id/${id}/files/content?${new URLSearchParams({ version_id: versionId, path })}` + ) + ).blob(); +export const getSkillHistory = async (token: string, id: string, page = 1) => + (await skillRequest(token, `/id/${id}/history?page=${page}`)).json(); +export const getSkillVersion = async (token: string, id: string, versionId: string) => + (await skillRequest(token, `/id/${id}/history/${versionId}`)).json(); +export const restoreSkillVersion = async ( + token: string, + id: string, + versionId: string, + expectedVersionId: string +) => + ( + await skillRequest(token, `/id/${id}/history/${versionId}/restore`, { + method: 'POST', + body: JSON.stringify({ expected_version_id: expectedVersionId }) + }) + ).json(); +export const cloneSkill = async ( + token: string, + id: string, + name: string, + newId: string, + versionId?: string +) => + ( + await skillRequest(token, `/id/${id}/clone`, { + method: 'POST', + body: JSON.stringify({ id: newId, name, version_id: versionId }) + }) + ).json(); +export const exportSkillBundle = async ( + token: string, + format: 'json' | 'zip', + ids: string[] = [], + versionId?: string +) => { + const query = new URLSearchParams({ format }); + ids.forEach((id) => query.append('ids', id)); + if (versionId) query.set('version_id', versionId); + return (await skillRequest(token, `/export?${query}`)).blob(); +}; +export const importSkillBundles = async (token: string, files: File[], decisions?: object[]) => { + const body = new FormData(); + files.forEach((file) => body.append('files', file, file.webkitRelativePath || file.name)); + if (decisions) body.append('decisions', JSON.stringify(decisions)); + return ( + await skillRequest(token, decisions ? '/import' : '/import/preview', { method: 'POST', body }) + ).json(); +}; +export const skillError = (error: unknown): string => + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : JSON.stringify(error); + +export const downloadSkillBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +}; diff --git a/src/lib/components/chat/FileNav/FileCodeEditor.svelte b/src/lib/components/chat/FileNav/FileCodeEditor.svelte index e27b580655..e94c4aec0c 100644 --- a/src/lib/components/chat/FileNav/FileCodeEditor.svelte +++ b/src/lib/components/chat/FileNav/FileCodeEditor.svelte @@ -10,6 +10,7 @@ import { onMount, onDestroy } from 'svelte'; export let value = ''; + export let readOnly = false; export let filePath: string | null = null; export let onSave: ((content: string) => Promise) | null = null; export let searchTarget: { @@ -23,6 +24,7 @@ let editor: EditorView | null = null; let editorTheme = new Compartment(); let editorLanguage = new Compartment(); + let editorReadOnly = new Compartment(); let internalValue = ''; let lastSearchTargetRequestId = 0; @@ -69,6 +71,14 @@ } }; + $: if (editor) + editor.dispatch({ + effects: editorReadOnly.reconfigure([ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly) + ]) + }); + // React to external value changes (e.g. switching files) $: if (editor && value !== internalValue) { internalValue = value; @@ -97,7 +107,7 @@ { key: 'Mod-s', run: () => { - if (onSave) { + if (onSave && !readOnly) { onSave(editor?.state.doc.toString() ?? ''); } return true; @@ -113,6 +123,7 @@ }), editorTheme.of(isDark ? oneDark : []), editorLanguage.of([]), + editorReadOnly.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]), EditorView.theme({ '&': { fontSize: '0.75rem', height: '100%' }, '.cm-content': { diff --git a/src/lib/components/chat/FileNav/FileEntryRow.svelte b/src/lib/components/chat/FileNav/FileEntryRow.svelte index d1f7f8c6e6..3d1349b339 100644 --- a/src/lib/components/chat/FileNav/FileEntryRow.svelte +++ b/src/lib/components/chat/FileNav/FileEntryRow.svelte @@ -9,6 +9,8 @@ import FileTypeIcon from './FileTypeIcon.svelte'; import Icon from './Icon.svelte'; + export let dragType = 'application/x-terminal-file-move'; + export let variant: 'terminal' | 'workspace' = 'terminal'; const i18n: any = getContext('i18n'); export let entry: FileEntry; @@ -29,6 +31,7 @@ // ── Selection ───────────────────────────────────────────────────────── export let selected: boolean = false; + export let active = false; export let selectionMode: boolean = false; export let selectedPaths: Set = new Set(); export let onSelect: ( @@ -48,6 +51,10 @@ $: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`; $: writable = entry.writable !== false; $: canMutate = parentWritable && writable; + $: menuItemClass = + variant === 'workspace' + ? 'select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-xs hover:text-gray-900 dark:hover:text-gray-100' + : 'select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition'; $: rowIndent = `${8 + depth * 16}px`; const formatRelativeTime = (epoch: number): string => { @@ -76,6 +83,7 @@ let renameInput: HTMLInputElement; const startRename = async () => { + if (!canMutate) return; renameValue = entry.name; renaming = true; await tick(); @@ -161,8 +169,10 @@
  • { if (entry.type !== 'directory') return; if (!writable) return; - if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return; + if (!e.dataTransfer?.types.includes(dragType)) return; e.preventDefault(); e.stopPropagation(); dragOverFolder = true; @@ -190,7 +200,7 @@ on:drop={async (e) => { if (entry.type !== 'directory') return; if (!writable) return; - const raw = e.dataTransfer?.getData('application/x-terminal-file-move'); + const raw = e.dataTransfer?.getData(dragType); if (!raw) return; e.preventDefault(); e.stopPropagation(); @@ -207,7 +217,9 @@ } catch {} }} > - {#if entry.type === 'directory'} + {#if variant === 'workspace'} + + {:else if entry.type === 'directory'} - + {#if variant !== 'workspace' || entry.type !== 'directory'} + + {/if} {#if entry.type === 'directory'} - {#if !selectedFile} - -
    -
    - - - - -
    - {#if edit} -
    - {id} -
    - {:else} - - - - {/if} - - - - -
    -
    - + +
    +
    {#if !disabled} @@ -190,30 +319,264 @@
    -
    - {#if disabled} -
    -
    {content}
    -
    - {:else} - - {/if} + disabled={readOnly} + /> + + +
    + + + + + {#if edit} +
    + {id} +
    + {:else} + + + + {/if} +
    - {#if !disabled} -
    + {#if historical} +
    + {$i18n.t('Read Only')} + + {#if !disabled}{/if} +
    + {/if} + {#if historyDiff} +
    + + + {#each Object.entries(historyDiff.metadata) as [key, values]}

    + {key}: {JSON.stringify(values)} +

    {/each} + {#each historyDiff.files as file}{/each} +
    {fileDiff}
    +
    + {/if} + {#if conflict}{/if} +
    + {#if ready} + {#snippet version()} + {#if edit} +
    + { + if (open) { + historyPage = 1; + history = []; + loadHistory(); + } + }} + > + +
    + + + {#if historyLoading || historyError || history.some((entry) => entry.id !== currentVersionId)} +
    + {/if} + {#if historyLoading} +
    + {$i18n.t('Loading...')} +
    + {:else if historyError} + + {:else} + {#each history.filter((entry) => entry.id !== currentVersionId) as entry} + + {:else} + {#if historyPage > 1} +
    + {$i18n.t('No more versions')} +
    + {/if} + {/each} + {/if} + {#if historyPage > 1 || history.length === 20} +
    +
    + + +
    + {/if} +
    +
    +
    +
    + {/if} + {/snippet} + {#snippet exports()} + + + {/snippet} +
    {/if} +
    + + {#if !readOnly} +
    + + {/if} + {selected} + {#if /\.md$/i.test(selected) && !loading}{/if} + +
    + {#if loadError} + {:else if loading}

    {$i18n.t('Loading...')}

    + {:else if objectUrl} + {#if /\.(png|jpe?g|gif|webp|avif)$/i.test(selected)}{selected} + {:else if /\.pdf$/i.test(selected)} + {:else}

    Binary file. Download to open.

    {/if} + {:else if preview} + +
    { + const link = (event.target as Element).closest('a[data-skill-path]') as HTMLAnchorElement; + if (link) { + event.preventDefault(); + const path = link.dataset.skillPath!; + if (files.some((f) => f.path === path)) open(path); + else toast.error('File not found'); + } + }} + > + {@html previewHtml} +
    + {:else}
    + { + flush(); + await onSave(); + }} + /> +
    {/if} +
    +{/snippet} + +{#snippet createInput()} + {#if creating && !readOnly} +
    + + { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + submitCreate(); + } + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + cancelCreate(); + } + }} + on:blur={submitCreate} + /> +
    + {/if} +{/snippet} + +{#snippet fileList()} +
    +
    +
    {@render version()}
    + {#if !readOnly || canExport} + + +
    + + {#if !readOnly} + + + + + {/if} + {#if canExport} + {#if !readOnly}
    {/if} + {@render exports()} + {/if} +
    +
    +
    + {/if} +
    + {#if $mobile && !showFileDrawer && loadError} + + {/if} + {#if selectedPaths.size && !readOnly}{/if} + {#if !currentPath}{@render createInput()}{/if} +
      + {#each rows as { entry, path, depth } (path)} + 0} + parentWritable={!readOnly && path !== 'SKILL.md'} + {selectedPaths} + onOpen={(entry) => (entry.type === 'directory' ? toggleFolder(path) : open(path))} + onSelect={(_, __, path) => { + path = path.replace(/\/$/, ''); + selectedPaths.has(path) ? selectedPaths.delete(path) : selectedPaths.add(path); + selectedPaths = new Set(selectedPaths); + }} + onToggleExpand={toggleFolder} + onRename={(path, name) => rename(path, path.slice(0, path.lastIndexOf('/') + 1) + name)} + onDelete={(path) => + ask('Delete this file or folder?', false, () => remove(path.replace(/\/$/, '')))} + onMove={(paths, destination) => + paths.forEach((path) => { + path = path.replace(/\/$/, ''); + rename(path, destination + (path.split('/').pop() || 'file')); + })} + onDownload={download} + /> + {#if creating && !readOnly && entry.type === 'directory' && currentPath === path + '/'} +
    • {@render createInput()}
    • + {/if} + {/each} +
    + {#if !rows.length}

    + Empty folders are saved when they contain files. +

    {/if} +
    +{/snippet} diff --git a/src/lib/components/workspace/Skills/SkillImport.svelte b/src/lib/components/workspace/Skills/SkillImport.svelte new file mode 100644 index 0000000000..fa58379861 --- /dev/null +++ b/src/lib/components/workspace/Skills/SkillImport.svelte @@ -0,0 +1,122 @@ + + + +
    +
    +

    Import skills

    + +
    +

    + New skills and copies are private. Replace saves a new version and preserves existing sharing. +

    +
    + {#each items as item, index} +
    +
    + + +
    + + {#if item.id_taken || item.name_taken}

    + ID or name already exists. Choose a unique ID and name for a copy. +

    {/if} +
    + {item.files.length} files{#each item.files as file}
    + {file.path} ({file.size} bytes) +
    {/each} +
    + {#if results[index]}

    + {results[index].status}{results[index].error + ? `: ${skillError(results[index].error)}` + : ''} +

    {/if} +
    + {/each} +
    + +
    +
    diff --git a/src/lib/components/workspace/Skills/SkillMenu.svelte b/src/lib/components/workspace/Skills/SkillMenu.svelte index 5e5e8fd61b..04c6995c29 100644 --- a/src/lib/components/workspace/Skills/SkillMenu.svelte +++ b/src/lib/components/workspace/Skills/SkillMenu.svelte @@ -79,13 +79,20 @@ + {/if}
    diff --git a/src/routes/(app)/workspace/skills/create/+page.svelte b/src/routes/(app)/workspace/skills/create/+page.svelte index 3985cea955..e61c291ac4 100644 --- a/src/routes/(app)/workspace/skills/create/+page.svelte +++ b/src/routes/(app)/workspace/skills/create/+page.svelte @@ -4,12 +4,14 @@ import { skills } from '$lib/stores'; import { onMount, getContext } from 'svelte'; - const i18n = getContext('i18n'); + const i18n = getContext('i18n'); import { createNewSkill, getSkills } from '$lib/apis/skills'; import SkillEditor from '$lib/components/workspace/Skills/SkillEditor.svelte'; let skill: { + meta?: any; + files?: any[]; name: string; id: string; description: string; @@ -20,16 +22,13 @@ let clone = false; - const onSubmit = async (_skill) => { - const res = await createNewSkill(localStorage.token, _skill).catch((error) => { - toast.error(`${error}`); - return null; - }); + const onSubmit = async (_skill: any) => { + const res = await createNewSkill(localStorage.token, _skill); if (res) { toast.success($i18n.t('Skill created successfully')); await skills.set(await getSkills(localStorage.token)); - await goto('/workspace/skills'); + return res; } }; @@ -44,6 +43,7 @@ id: _skill.id || '', description: _skill.description || '', content: _skill.content || '', + files: _skill.files, meta: _skill.meta ?? {}, is_active: _skill.is_active ?? true, access_grants: _skill.access_grants !== undefined ? _skill.access_grants : [] diff --git a/src/routes/(app)/workspace/skills/edit/+page.svelte b/src/routes/(app)/workspace/skills/edit/+page.svelte index 47a4ceb709..a4ce361cd8 100644 --- a/src/routes/(app)/workspace/skills/edit/+page.svelte +++ b/src/routes/(app)/workspace/skills/edit/+page.svelte @@ -4,39 +4,27 @@ import { skills } from '$lib/stores'; import { onMount, getContext } from 'svelte'; - const i18n = getContext('i18n'); + const i18n = getContext('i18n'); import { getSkillById, getSkills, updateSkillById } from '$lib/apis/skills'; import { page } from '$app/stores'; import SkillEditor from '$lib/components/workspace/Skills/SkillEditor.svelte'; - let skill = null; + let skill: any = null; let disabled = false; $: skillId = $page.url.searchParams.get('id'); - const onSubmit = async (_skill) => { - const updatedSkill = await updateSkillById(localStorage.token, skillId, _skill).catch( - (error) => { - toast.error(`${error}`); - return null; - } - ); + const onSubmit = async (_skill: any) => { + const updatedSkill = await updateSkillById(localStorage.token, skillId!, _skill); if (updatedSkill) { toast.success($i18n.t('Skill updated successfully')); await skills.set(await getSkills(localStorage.token)); - skill = { - id: updatedSkill.id, - name: updatedSkill.name, - description: updatedSkill.description, - content: updatedSkill.content, - meta: updatedSkill.meta ?? {}, - is_active: updatedSkill.is_active, - access_grants: updatedSkill?.access_grants === undefined ? [] : updatedSkill?.access_grants - }; + skill = updatedSkill; } + return updatedSkill; }; onMount(async () => { @@ -47,16 +35,8 @@ }); if (_skill) { - disabled = !_skill.write_access ?? true; - skill = { - id: _skill.id, - name: _skill.name, - description: _skill.description, - content: _skill.content, - meta: _skill.meta ?? {}, - is_active: _skill.is_active, - access_grants: _skill?.access_grants === undefined ? [] : _skill?.access_grants - }; + disabled = !_skill.write_access; + skill = _skill; } else { goto('/workspace/skills'); }