This commit is contained in:
Timothy Jaeryang Baek 2026-10-09 15:07:43 +04:00
parent fc84c2dfce
commit 9bbb95048b
23 changed files with 2783 additions and 417 deletions

View file

@ -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')

View file

@ -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,

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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 <available_skills>.
: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))})
# =============================================================================

View file

@ -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'<skill name="{skill.name}">\n{skill.content}\n</skill>',
f'<skill id="{skill.id}" version_id="{version_id}" name="{skill.name}">\n{root_content}{resource_hint}\n</skill>',
form_data['messages'],
append=True,
)

View file

@ -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()

View file

@ -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: '
'<terminal-root-cwd>/.agents/skills/<skill-name>/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 (

View file

@ -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.

View file

@ -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);
};

View file

@ -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<void>) | 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': {

View file

@ -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<string> = 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 @@
<li class="group" data-file-row>
<div
class="w-full flex items-center transition-colors duration-75
{selected ? 'bg-gray-100 dark:bg-white/8' : 'hover:bg-gray-50/40 dark:hover:bg-white/4'}
class="w-full flex items-center transition-colors duration-75 {variant === 'workspace'
? 'rounded-xl my-0.5 py-0.5'
: ''}
{selected || active ? 'bg-gray-100 dark:bg-white/8' : 'hover:bg-gray-50/40 dark:hover:bg-white/4'}
{dragOverFolder
? 'bg-gray-100 dark:bg-white/8 ring-1 ring-black/15 dark:ring-white/15 ring-inset'
: ''}"
@ -170,7 +180,7 @@
on:dragover={(e) => {
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'}
<span class="shrink-0" style:width={rowIndent}></span>
{:else if entry.type === 'directory'}
<button
type="button"
class="mr-1.5 flex w-5 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400"
@ -253,10 +265,7 @@
const filePath = entryPath.replace(/\/$/, '');
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ paths: [...selectedPaths] })
);
e.dataTransfer?.setData(dragType, JSON.stringify({ paths: [...selectedPaths] }));
// Custom drag ghost showing count
const ghost = document.createElement('div');
ghost.style.cssText =
@ -267,14 +276,14 @@
requestAnimationFrame(() => ghost.remove());
} else {
e.dataTransfer?.setData(
'application/x-terminal-file-move',
dragType,
JSON.stringify({
path: entry.type === 'directory' ? directoryPath : filePath,
name: entry.name
})
);
}
if (entry.type === 'file') {
if (entry.type === 'file' && terminalUrl) {
e.dataTransfer?.setData(
'application/x-terminal-file',
JSON.stringify({
@ -289,18 +298,27 @@
on:pointerdown={onPointerDown}
on:pointerup={onPointerUp}
on:pointercancel={onPointerCancel}
aria-expanded={variant === 'workspace' && entry.type === 'directory' ? expanded : undefined}
on:click={handleClick}
on:dblclick|preventDefault|stopPropagation={() => {
startRename();
}}
>
<FileTypeIcon name={entry.name} type={entry.type} size={12} />
{#if variant === 'workspace'}
{#if entry.type === 'directory'}
<Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={12} strokeWidth={1.5} />
{:else}<span class="w-3 shrink-0"></span>{/if}
{/if}
<FileTypeIcon name={entry.name} type={entry.type} size={variant === 'workspace' ? 14 : 12} />
{#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<input
bind:this={renameInput}
bind:value={renameValue}
class="flex-1 text-xs bg-transparent border border-gray-100 dark:border-white/[0.06] rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500 text-gray-800 dark:text-gray-200 min-w-0"
class="flex-1 text-xs bg-transparent outline-none text-gray-800 dark:text-gray-200 min-w-0 {variant ===
'workspace'
? 'h-4 p-0 leading-4'
: 'border border-gray-100 dark:border-white/[0.06] rounded px-1.5 py-0.5 focus:border-blue-400 dark:focus:border-blue-500'}"
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@ -338,6 +356,7 @@
<Dropdown bind:show={menuOpen} align="end" sideOffset={4}>
<button
type="button"
class="shrink-0 flex h-5 w-5 items-center justify-center mr-1 rounded transition
text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400
hover:bg-gray-50/40 dark:hover:bg-white/4"
@ -348,29 +367,30 @@
<div slot="content">
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
<button
type="button"
class="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"
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
onOpen(entry);
}}
>
<Icon
name={entry.type === 'directory' ? 'folder' : 'eye'}
size={12}
strokeWidth={1.4}
/>
<div class="flex items-center">
{entry.type === 'directory' ? $i18n.t('Open Folder') : $i18n.t('Open')}
</div>
</button>
{#if variant !== 'workspace' || entry.type !== 'directory'}
<button
type="button"
class={menuItemClass}
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
onOpen(entry);
}}
>
<Icon
name={entry.type === 'directory' ? 'folder' : 'eye'}
size={12}
strokeWidth={1.4}
/>
<div class="flex items-center">
{entry.type === 'directory' ? $i18n.t('Open Folder') : $i18n.t('Open')}
</div>
</button>
{/if}
{#if entry.type === 'directory'}
<button
type="button"
class="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"
class={menuItemClass}
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
@ -389,7 +409,7 @@
{:else}
<button
type="button"
class="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"
class={menuItemClass}
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
@ -403,7 +423,7 @@
<button
type="button"
class="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"
class={menuItemClass}
on:click={async (e) => {
e.stopPropagation();
menuOpen = false;
@ -418,7 +438,7 @@
<button
type="button"
class="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 disabled:opacity-40 disabled:hover:bg-transparent"
class="{menuItemClass} disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
@ -433,7 +453,7 @@
<button
type="button"
class="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 disabled:opacity-40 disabled:hover:bg-transparent"
class="{menuItemClass} disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();

View file

@ -6,6 +6,7 @@
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Icon from './Icon.svelte';
export let dragType = 'application/x-terminal-file-move';
const i18n: any = getContext('i18n');
export let breadcrumbs: { label: string; path: string }[] = [];
@ -58,6 +59,7 @@
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
type="button"
class="shrink-0 flex h-5 min-w-6 items-center justify-center rounded px-1.5 transition-colors duration-100 {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
@ -72,6 +74,7 @@
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
type="button"
class="shrink-0 flex h-5 min-w-6 items-center justify-center rounded px-1.5 transition-colors duration-100 {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
@ -93,15 +96,16 @@
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none">/</span>
{/if}
<button
type="button"
class="text-xs shrink-0 p-0 transition
{!selectedFile && i === breadcrumbs.length - 1
{!selectedFile && i === breadcrumbs.length - 1
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'}
{dragOverCrumb === i ? 'bg-gray-100 dark:bg-white/8 ring-1 ring-black/15 dark:ring-white/15' : ''}"
{dragOverCrumb === i ? 'bg-gray-100 dark:bg-white/8 ring-1 ring-black/15 dark:ring-white/15' : ''}"
on:click={() => onNavigate(crumb.path)}
on:dragover={(e) => {
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();
dragOverCrumb = i;
@ -111,7 +115,7 @@
}}
on:drop={async (e) => {
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();
@ -141,6 +145,7 @@
<Tooltip content={$i18n.t('Refresh')}>
<button
type="button"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onRefresh}
aria-label={$i18n.t('Refresh')}
@ -148,11 +153,11 @@
<Icon name="refresh" size={11} strokeWidth={1.4} class={loading ? 'animate-spin' : ''} />
</button>
</Tooltip>
{#if !selectedFile}
<Dropdown bind:show={sortMenuOpen} align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Sort')}>
<button
type="button"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={$i18n.t('Sort')}
>
@ -228,6 +233,7 @@
<Dropdown bind:show={actionsMenuOpen} align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Actions')}>
<button
type="button"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={$i18n.t('Actions')}
>

View file

@ -372,6 +372,7 @@
{#if searchTarget}
<div class="absolute inset-0">
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
@ -414,6 +415,7 @@
{:else if isHtml && showRaw}
<div class="absolute inset-0">
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
@ -427,6 +429,7 @@
{:else if isMarkdown && showRaw}
<div class="absolute inset-0">
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
@ -483,6 +486,7 @@
{:else if isCode && !showRaw}
<div class="absolute inset-0">
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}

View file

@ -9,6 +9,7 @@
type TerminalSkill
} from '$lib/apis/terminal';
import {
user,
chatId,
selectedTerminalId,
settings,
@ -69,7 +70,9 @@
...('settings'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'settings' } }]
: []),
...(hasChatContent && $selectedTerminalId && 'skills:create'.startsWith(query.toLowerCase())
...(hasChatContent &&
($user?.role === 'admin' || $user?.permissions?.workspace?.skills) &&
'skills:create'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'skills:create' } }]
: [])
];

View file

@ -21,14 +21,12 @@
import { goto } from '$app/navigation';
import {
getSkills,
getSkillById,
getSkillItems,
exportSkills,
createNewSkill,
deleteSkillById,
toggleSkillById
} from '$lib/apis/skills';
import { capitalizeFirstLetter, parseFrontmatter, formatSkillName, slugify } from '$lib/utils';
import { capitalizeFirstLetter } from '$lib/utils';
import TagInput from '$lib/components/common/Tags/TagInput.svelte';
import Tooltip from '../common/Tooltip.svelte';
@ -43,6 +41,11 @@
import Badge from '$lib/components/common/Badge.svelte';
import Switch from '../common/Switch.svelte';
import SkillMenu from './Skills/SkillMenu.svelte';
import SkillImport from './Skills/SkillImport.svelte';
import { cloneSkill, exportSkillBundle, skillError } from '$lib/apis/skills';
let showImport = false;
let bundleFiles: File[] = [];
let folderImportInput: HTMLInputElement;
import Pagination from '../common/Pagination.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
@ -50,7 +53,6 @@
let shiftKey = false;
let loaded = false;
let importFiles;
let importInputElement: HTMLInputElement;
let query = '';
@ -81,10 +83,28 @@
},
{
id: 'skills-import',
label: $i18n.t('Import JSON'),
label: $i18n.t('Import'),
onClick: () => importInputElement?.click(),
visible: $user?.role === 'admin' || $user?.permissions?.workspace?.skills_import
},
{
id: 'skills-import-folder',
label: 'Import folder',
onClick: () => folderImportInput?.click(),
visible: $user?.role === 'admin' || $user?.permissions?.workspace?.skills_import
},
{
id: 'skills-export-zip',
label: 'Export ZIP',
onClick: async () => {
try {
saveAs(await exportSkillBundle(localStorage.token, 'zip'), 'skills.zip');
} catch (error) {
toast.error(skillError(error));
}
},
visible: $user?.role === 'admin' || $user?.permissions?.workspace?.skills_export
},
{
id: 'skills-export',
label: $i18n.t('Export JSON'),
@ -184,32 +204,28 @@
};
const cloneHandler = async (skill) => {
const _skill = await getSkillById(localStorage.token, skill.id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_skill) {
sessionStorage.skill = JSON.stringify({
..._skill,
id: `${_skill.id}_clone`,
name: `${_skill.name} (Clone)`
});
goto('/workspace/skills/create');
try {
const suffix = crypto.randomUUID().slice(0, 7);
const result = await cloneSkill(
localStorage.token,
skill.id,
`${skill.name} (${suffix})`,
`${skill.id}-${suffix}`
);
await goto(`/workspace/skills/edit?id=${result.id}`);
} catch (error) {
toast.error(skillError(error));
}
};
const exportHandler = async (skill) => {
const _skill = await getSkillById(localStorage.token, skill.id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_skill) {
let blob = new Blob([JSON.stringify([_skill])], {
type: 'application/json'
});
saveAs(blob, `skill-${_skill.id}-export-${Date.now()}.json`);
const exportHandler = async (skill, format: 'json' | 'zip' = 'json') => {
try {
saveAs(
await exportSkillBundle(localStorage.token, format, [skill.id]),
`${skill.id}.${format}`
);
} catch (error) {
toast.error(skillError(error));
}
};
@ -276,69 +292,37 @@
</svelte:head>
{#if loaded}
<SkillImport
bind:show={showImport}
files={bundleFiles}
onImported={async () => {
page = 1;
await loadSkillItems();
_skills.set(await getSkills(localStorage.token));
}}
/>
<input
bind:this={importInputElement}
bind:files={importFiles}
type="file"
accept=".md,.json"
accept=".md,.json,.zip"
multiple
hidden
on:change={() => {
if (importFiles && importFiles.length > 0) {
const file = importFiles[0];
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext === 'json') {
// JSON import: create skills via API
const reader = new FileReader();
reader.onload = async (event) => {
try {
const content = event.target?.result;
if (typeof content !== 'string') return;
const parsedSkills = JSON.parse(content);
const items = Array.isArray(parsedSkills) ? parsedSkills : [parsedSkills];
for (const skill of items) {
await createNewSkill(localStorage.token, skill).catch((error) => {
toast.error(`${error}`);
});
}
toast.success($i18n.t('Skill imported successfully'));
page = 1;
loadSkillItems();
_skills.set(await getSkills(localStorage.token));
} catch (e) {
toast.error($i18n.t('Invalid JSON file'));
}
};
reader.readAsText(file);
} else {
// Markdown import: parse frontmatter and open in editor
const reader = new FileReader();
reader.onload = (event) => {
const mdContent = event.target?.result;
if (typeof mdContent === 'string') {
const fm = parseFrontmatter(mdContent);
const fileName = file.name.replace(/\.md$/, '');
const rawName = fm.name || fileName;
const displayName = formatSkillName(rawName);
sessionStorage.skill = JSON.stringify({
name: displayName,
id: slugify(rawName),
description: fm.description || '',
content: mdContent,
is_active: true,
access_grants: []
});
goto('/workspace/skills/create');
}
};
reader.readAsText(file);
}
importInputElement.value = '';
}
bundleFiles = Array.from(importInputElement.files || []);
showImport = true;
importInputElement.value = '';
}}
/>
<input
bind:this={folderImportInput}
type="file"
webkitdirectory
multiple
hidden
on:change={() => {
bundleFiles = Array.from(folderImportInput.files || []);
showImport = true;
folderImportInput.value = '';
}}
/>
@ -558,8 +542,8 @@
cloneHandler={() => {
cloneHandler(skill);
}}
exportHandler={() => {
exportHandler(skill);
exportHandler={(format) => {
exportHandler(skill, format);
}}
deleteHandler={async () => {
selectedSkill = skill;

View file

@ -1,21 +1,38 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import { toast } from 'svelte-sonner';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import AccessButton from '$lib/components/common/AccessButton.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
import Download from '$lib/components/icons/Download.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedField from '$lib/components/common/LocalizedField.svelte';
let locale = '';
let meta: { i18n?: Record<string, Record<string, string>>; [key: string]: any } = {};
import { user } from '$lib/stores';
import { slugify, parseFrontmatter, formatSkillName } from '$lib/utils';
import { slugify } from '$lib/utils';
import Spinner from '$lib/components/common/Spinner.svelte';
import { updateSkillAccessGrants } from '$lib/apis/skills';
import { goto } from '$app/navigation';
import { goto, beforeNavigate } from '$app/navigation';
import SkillFiles from './SkillFiles.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import {
getSkillHistory,
getSkillVersion,
restoreSkillVersion,
getSkillById,
createNewSkill,
exportSkillBundle,
skillRequest,
skillError
} from '$lib/apis/skills';
import { downloadSkillBlob as saveAs } from '$lib/apis/skills';
export let onSubmit: Function;
export let edit = false;
@ -26,31 +43,161 @@
const i18n = getContext<any>('i18n');
let loading = false;
let ready = false;
let name = '';
let id = '';
let description = '';
let content = '';
let fileEditor: SkillFiles;
let fileDirty = false;
let reloadKey = 0;
let versionId: string | null = null;
let currentVersionId: string | null = null;
let history: any[] = [];
let historyPage = 1;
let showHistory = false;
let historyLoading = false;
let historyError = false;
let currentVersionName = '';
let commitMessage = '';
let conflict = false;
let baseline = '';
let historyDiff: any = null;
let fileDiff = '';
let discard = false;
let discardAction = () => {};
$: historical = edit && versionId !== currentVersionId;
$: readOnly = disabled || historical;
$: dirty = fileDirty || (!!baseline && baseline !== JSON.stringify({ name, description, meta }));
const remember = () => {
baseline = JSON.stringify({ name, description, meta });
fileDirty = false;
};
const confirmDiscard = (action: () => void) => {
if (dirty) {
discardAction = action;
discard = true;
} else action();
};
beforeNavigate(({ cancel }) => {
if (dirty && !window.confirm('Discard unsaved skill changes?')) cancel();
});
const loadHistory = async () => {
historyLoading = true;
historyError = false;
try {
history = await getSkillHistory(localStorage.token, id, historyPage);
} catch (error) {
historyError = true;
toast.error(skillError(error));
} finally {
historyLoading = false;
}
};
const chooseVersion = (selected: string) => {
showHistory = false;
if (selected !== versionId) confirmDiscard(() => selectVersion(selected));
};
const compareToCurrent = async () => {
try {
historyDiff = await (
await skillRequest(
localStorage.token,
`/id/${id}/history/diff?${new URLSearchParams({ from_id: versionId!, to_id: currentVersionId! })}`
)
).json();
historyDiff.fromId = versionId;
fileDiff = '';
} catch (error) {
toast.error(skillError(error));
}
};
const selectVersion = async (selected: string) => {
try {
const entry = await getSkillVersion(localStorage.token, id, selected);
name = entry.snapshot.name;
description = entry.snapshot.description || '';
meta = entry.snapshot.meta || {};
versionId = selected;
reloadKey++;
remember();
historyDiff = null;
fileDiff = '';
} catch (error) {
toast.error(skillError(error));
}
};
const reload = async () => {
try {
const latest = await getSkillById(localStorage.token, id);
currentVersionId = latest.version_id;
currentVersionName = latest.name;
await selectVersion(latest.version_id);
conflict = false;
} catch (error) {
toast.error(skillError(error));
}
};
const restore = async () => {
try {
const restored = await restoreSkillVersion(
localStorage.token,
id,
versionId!,
currentVersionId!
);
currentVersionId = restored.version_id;
currentVersionName = restored.name;
versionId = restored.version_id;
historyDiff = null;
fileDiff = '';
remember();
await loadHistory();
toast.success($i18n.t('Saved'));
} catch (error) {
if ((error as any)?.code === 'version_conflict') conflict = true;
toast.error(skillError(error));
}
};
const exportVersion = async (format: 'json' | 'zip') => {
try {
saveAs(
await exportSkillBundle(localStorage.token, format, [id], versionId!),
`${id}-${versionId?.slice(0, 7)}.${format}`
);
} catch (error) {
toast.error(skillError(error));
}
};
const saveCopy = async () => {
try {
const suffix = crypto.randomUUID().slice(0, 7);
const result = await createNewSkill(localStorage.token, {
id: `${id}-${suffix}`,
name: `${name} (${suffix})`,
description,
meta,
files: await fileEditor.getFiles(),
access_grants: []
});
remember();
await goto(`/workspace/skills/edit?id=${result.id}`);
window.location.reload();
} catch (error) {
toast.error(skillError(error));
}
};
let accessGrants = [];
let accessGrants: any[] = [];
let showAccessControlModal = false;
$: if (!edit && !clone && name) {
id = slugify(name);
}
const handleContentInput = () => {
if (edit) return;
const fm = parseFrontmatter(content);
if (fm.name && !name) {
name = formatSkillName(fm.name);
}
if (fm.description && !description) {
description = fm.description;
}
};
const submitHandler = async () => {
if (disabled) {
if (loading) return;
if (readOnly) {
toast.error($i18n.t('You do not have permission to edit this skill.'));
return;
}
@ -63,15 +210,31 @@
if (!edit) id = slugify(id);
try {
await onSubmit({
const result = await onSubmit({
id,
name,
description,
content,
...(edit
? { operations: await fileEditor.getOperations(), expected_version_id: currentVersionId }
: { files: await fileEditor.getFiles() }),
commit_message: commitMessage,
is_active: skill?.is_active ?? true,
meta,
access_grants: accessGrants
...(!edit ? { access_grants: accessGrants } : {})
});
if (result) {
currentVersionId = result.version_id;
currentVersionName = result.name;
versionId = result.version_id;
commitMessage = '';
conflict = false;
remember();
if (showHistory) await loadHistory();
if (!edit) await goto(`/workspace/skills/edit?id=${result.id}`);
}
} catch (error) {
if ((error as any)?.code === 'version_conflict') conflict = true;
toast.error(skillError(error));
} finally {
loading = false;
}
@ -85,11 +248,28 @@
id = skill.id || '';
description = skill.description || '';
content = skill.content || '';
currentVersionId = skill.version_id || null;
currentVersionName = skill.name;
versionId = currentVersionId;
accessGrants = skill?.access_grants === undefined ? [] : skill?.access_grants;
}
remember();
ready = true;
const requestedVersion = new URLSearchParams(location.search).get('version_id');
if (edit && requestedVersion && requestedVersion !== versionId)
await selectVersion(requestedVersion);
});
</script>
<ConfirmDialog
bind:show={discard}
title="Discard unsaved changes?"
on:confirm={() => {
remember();
discardAction();
}}
/>
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessGrants
@ -111,73 +291,22 @@
/>
<div class="flex h-full w-full min-w-0 flex-col overflow-hidden">
<form class="flex h-full min-h-0 min-w-0 flex-col" on:submit|preventDefault={submitHandler}>
<button
class="mb-1 flex h-6 w-fit items-center gap-1 rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/workspace/skills');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 flex-col gap-2 pb-2 px-1 sm:flex-row sm:items-start">
<div class="min-w-0 w-full flex-1">
<Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start">
<LocalizedField
placeholder={$i18n.t('Skill Name')}
showControls={false}
bind:value={name}
bind:translations={meta.i18n}
{locale}
required
{disabled}
/>
</Tooltip>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
{#if edit}
<div class="shrink-0 truncate font-mono" title={id}>
{id}
</div>
{:else}
<Tooltip
className="min-w-[8rem] flex-1"
content={$i18n.t('e.g. code-review-guidelines')}
placement="top-start"
>
<input
class="w-full bg-transparent font-mono outline-hidden disabled:text-gray-500"
type="text"
placeholder={$i18n.t('Skill ID')}
aria-label={$i18n.t('Skill ID')}
bind:value={id}
required
disabled={edit}
/>
</Tooltip>
{/if}
<Tooltip
className="flex min-w-0 flex-1 items-center"
content={$i18n.t('e.g. Step-by-step instructions for code reviews')}
placement="top-start"
>
<LocalizedField
placeholder={$i18n.t('Skill Description')}
showControls={false}
bind:value={description}
bind:translations={meta.i18n}
{locale}
field="description"
{disabled}
/>
</Tooltip>
</div>
</div>
<form
inert={loading}
class="flex h-full min-h-0 min-w-0 flex-col"
on:submit|preventDefault={submitHandler}
>
<div class="flex shrink-0 items-center justify-between gap-2">
<button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/workspace/skills');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
<LanguageModeSelect bind:value={locale} translatedLocales={Object.keys(meta.i18n ?? {})} />
{#if !disabled}
@ -190,30 +319,264 @@
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden rounded-lg bg-gray-50/60 dark:bg-white/[0.03]">
{#if disabled}
<div class="h-full overflow-y-auto px-3 py-2">
<pre
class="whitespace-pre-wrap font-mono text-[0.6875rem] leading-relaxed">{content}</pre>
</div>
{:else}
<textarea
class="h-full w-full resize-none bg-transparent px-3 py-2 font-mono text-[0.6875rem] leading-relaxed outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-600"
bind:value={content}
on:input={handleContentInput}
placeholder={$i18n.t('Enter skill instructions in markdown...')}
aria-label={$i18n.t('Skill Instructions')}
<div class="shrink-0 pb-2 px-1">
<Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start">
<LocalizedField
placeholder={$i18n.t('Skill Name')}
showControls={false}
bind:value={name}
bind:translations={meta.i18n}
{locale}
required
></textarea>
{/if}
disabled={readOnly}
/>
</Tooltip>
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
<Tooltip
className="flex min-w-0 flex-1 items-center"
content={$i18n.t('e.g. Step-by-step instructions for code reviews')}
placement="top-start"
>
<LocalizedField
placeholder={$i18n.t('Skill Description')}
showControls={false}
bind:value={description}
bind:translations={meta.i18n}
{locale}
field="description"
disabled={readOnly}
/>
</Tooltip>
{#if edit}
<div class="shrink-0 truncate font-mono" title={id}>
{id}
</div>
{:else}
<Tooltip
className="min-w-[8rem] flex-1"
content={$i18n.t('e.g. code-review-guidelines')}
placement="top-start"
>
<input
class="w-full bg-transparent font-mono outline-hidden disabled:text-gray-500"
type="text"
placeholder={$i18n.t('Skill ID')}
aria-label={$i18n.t('Skill ID')}
bind:value={id}
required
disabled={edit}
/>
</Tooltip>
{/if}
</div>
</div>
{#if !disabled}
<div class="flex shrink-0 justify-end py-2">
{#if historical}
<div class="flex w-full flex-wrap items-center gap-3 px-1 py-2 text-xs text-gray-500">
<span>{$i18n.t('Read Only')}</span>
<button
type="button"
class="hover:text-gray-900 dark:hover:text-gray-100"
on:click={compareToCurrent}>{$i18n.t('Compare to current')}</button
>
{#if !disabled}<button
type="button"
class="hover:text-gray-900 dark:hover:text-gray-100"
on:click={restore}>{$i18n.t('Restore as new version')}</button
>{/if}
</div>
{/if}
{#if historyDiff}
<div
class="max-h-56 shrink-0 overflow-auto rounded-lg border border-gray-100 p-2 text-xs dark:border-gray-800"
>
<button
type="button"
class="mb-2 text-gray-500"
on:click={() => {
historyDiff = null;
fileDiff = '';
}}>{$i18n.t('Close')}</button
>
{#each Object.entries(historyDiff.metadata) as [key, values]}<p>
{key}: {JSON.stringify(values)}
</p>{/each}
{#each historyDiff.files as file}<button
type="button"
class="block py-1"
on:click={async () => {
try {
const result = await (
await skillRequest(
localStorage.token,
`/id/${id}/history/diff/file?${new URLSearchParams({ from_id: historyDiff.fromId, to_id: currentVersionId!, path: file.path })}`
)
).json();
fileDiff = result.binary ? 'Binary file changed' : result.diff;
} catch (error) {
toast.error(skillError(error));
}
}}>{file.status}: {file.path}</button
>{/each}
<pre class="whitespace-pre-wrap">{fileDiff}</pre>
</div>
{/if}
{#if conflict}<div
role="alert"
class="flex flex-wrap gap-3 bg-amber-50 p-3 text-xs text-amber-900 dark:bg-amber-950 dark:text-amber-100"
>
<span>This skill has a newer version. Your draft is preserved.</span><button
type="button"
on:click={() => confirmDiscard(reload)}>Reload latest</button
><button type="button" on:click={saveCopy}>Save draft as copy</button><button
type="button"
on:click={() => (conflict = false)}>Keep editing</button
>
</div>{/if}
<div
class="min-h-0 flex-1 overflow-hidden rounded-2xl border border-gray-100 bg-gray-50/60 dark:border-white/5 dark:bg-white/[0.03]"
>
{#if ready}<SkillFiles
bind:this={fileEditor}
skillId={id}
{versionId}
{reloadKey}
initialContent={content}
initialFiles={skill?.files || null}
{readOnly}
bind:dirty={fileDirty}
initialPath={new URLSearchParams(location.search).get('path') || 'SKILL.md'}
onSave={submitHandler}
canExport={edit &&
($user?.role === 'admin' || $user?.permissions?.workspace?.skills_export)}
>
{#snippet version()}
{#if edit}
<div class="min-w-0">
<Dropdown
bind:show={showHistory}
onOpenChange={(open) => {
if (open) {
historyPage = 1;
history = [];
loadHistory();
}
}}
>
<button
type="button"
aria-label="Select version"
class="flex h-7 max-w-full items-center gap-2 rounded-lg bg-transparent px-2 text-xs text-gray-500 transition hover:text-gray-900 dark:hover:text-gray-100"
>
<span class="truncate"
>{name} · {historical ? versionId?.slice(0, 7) : $i18n.t('Current')}</span
>
<ChevronDown className="size-3 shrink-0" />
</button>
<div slot="content">
<DropdownMenu className="w-56 max-w-[calc(100vw-2rem)]">
<button
type="button"
role="menuitemradio"
aria-checked={versionId === currentVersionId}
on:click={() => chooseVersion(currentVersionId!)}
title={currentVersionId || ''}
>
<span class="min-w-0 flex-1 truncate text-left"
>{currentVersionName} · {$i18n.t('Current')}</span
>
{#if versionId === currentVersionId}<Check
className="size-3.5 text-blue-500"
/>{/if}
</button>
{#if historyLoading || historyError || history.some((entry) => entry.id !== currentVersionId)}
<hr class="border-gray-100 dark:border-gray-800" />
{/if}
{#if historyLoading}
<div class="flex items-center gap-2 px-2 py-2 text-xs text-gray-500">
<Spinner className="size-3" />{$i18n.t('Loading...')}
</div>
{:else if historyError}
<button type="button" on:click={loadHistory}>{$i18n.t('Retry')}</button>
{:else}
{#each history.filter((entry) => entry.id !== currentVersionId) as entry}
<button
type="button"
role="menuitemradio"
aria-checked={entry.id === versionId}
title={`${entry.id} · ${new Date(entry.created_at * 1000).toLocaleString()} · ${entry.user?.name || entry.user_id}`}
on:click={() => chooseVersion(entry.id)}
>
<span class="min-w-0 flex-1 truncate text-left"
>{entry.commit_message || entry.id.slice(0, 7)}</span
>
{#if entry.id === versionId}<Check
className="size-3.5 text-blue-500"
/>{/if}
</button>
{:else}
{#if historyPage > 1}
<div class="px-2 py-2 text-xs text-gray-500">
{$i18n.t('No more versions')}
</div>
{/if}
{/each}
{/if}
{#if historyPage > 1 || history.length === 20}
<hr class="border-gray-100 dark:border-gray-800" />
<div class="flex justify-between px-2 py-1 text-xs">
<button
type="button"
disabled={historyLoading || historyPage === 1}
class="disabled:opacity-40"
on:click={() => {
historyPage--;
loadHistory();
}}>{$i18n.t('Previous')}</button
>
<button
type="button"
disabled={historyLoading || history.length < 20}
class="disabled:opacity-40"
on:click={() => {
historyPage++;
loadHistory();
}}>{$i18n.t('Next')}</button
>
</div>
{/if}
</DropdownMenu>
</div>
</Dropdown>
</div>
{/if}
{/snippet}
{#snippet exports()}
<button type="button" on:click={() => exportVersion('json')}
><Download className="size-3.5" />{$i18n.t('Export JSON')}</button
>
<button type="button" on:click={() => exportVersion('zip')}
><Download className="size-3.5" />{$i18n.t('Export ZIP')}</button
>
{/snippet}
</SkillFiles>{/if}
</div>
{#if !readOnly}
<div class="flex shrink-0 justify-end gap-3 py-2">
<input
class="min-w-0 flex-1 bg-transparent px-2 text-xs outline-hidden"
placeholder="Describe this change"
aria-label="Commit message"
bind:value={commitMessage}
/>
<button
class="flex h-7 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading}
disabled={loading || !fileEditor}
>
{$i18n.t(edit ? 'Save' : 'Save & Create')}
{#if loading}

View file

@ -0,0 +1,754 @@
<script lang="ts">
import { onDestroy, getContext, tick, type Snippet } from 'svelte';
import { toast } from 'svelte-sonner';
import { createFocusTrap } from 'focus-trap';
import { mobile } from '$lib/stores';
import Drawer from '$lib/components/common/Drawer.svelte';
import ResizableSidePanel from '$lib/components/common/ResizableSidePanel.svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { downloadSkillBlob as saveAs } from '$lib/apis/skills';
import FileTypeIcon from '$lib/components/chat/FileNav/FileTypeIcon.svelte';
import FileEntryRow from '$lib/components/chat/FileNav/FileEntryRow.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
import ArrowUpTray from '$lib/components/icons/ArrowUpTray.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import FileCodeEditor from '$lib/components/chat/FileNav/FileCodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import {
getSkillFiles,
getSkillFile,
skillError,
type SkillFile,
type SkillFileSummary,
type SkillFileOperation
} from '$lib/apis/skills';
import type { FileEntry } from '$lib/apis/terminal';
export let version: Snippet;
export let exports: Snippet;
export let skillId = '';
export let versionId: string | null = null;
export let initialContent = '';
export let initialFiles: SkillFile[] | null = null;
export let readOnly = false;
export let canExport = false;
export let dirty = false;
export let reloadKey = 0;
export let initialPath = 'SKILL.md';
export let onSave: () => Promise<void> = async () => {};
const i18n = getContext<any>('i18n');
type Draft = SkillFileSummary & {
sourcePath?: string;
content?: string;
upload?: File;
changed?: boolean;
};
let files: Draft[] = [];
let emptyFolders: string[] = [];
let currentPath = '';
let expandedFolders = new Set<string>();
let selected = 'SKILL.md';
let showFileDrawer = false;
let sidebarWidth = 256;
let codeEditor: FileCodeEditor | undefined;
let value = '';
let loadedValue = '';
let loadedPath = '';
let objectUrl = '';
let previewHtml = '';
let preview = false;
let resourceUrls: string[] = [];
let loading = false;
let loadError = '';
let loadKey = '';
let requestId = 0;
let folderInput: HTMLInputElement;
let uploadInput: HTMLInputElement;
let selectedPaths = new Set<string>();
let creating: 'file' | 'directory' | null = null;
let newName = '';
let newNameInput: HTMLInputElement;
let dialog = { show: false, title: '', input: false, value: '', action: (_: string) => {} };
const dragType = `application/x-skill-file-${crypto.randomUUID()}`;
const ask = (title: string, input: boolean, action: (value: string) => void, value = '') => {
dialog = { show: true, title, input, value, action };
};
const validPath = (path: string) =>
!!path && !/[\\\x00:]/.test(path) && path.split('/').every((p) => p && p !== '.' && p !== '..');
const validatePaths = (paths: string[]) => {
if (
new Set(paths).size !== paths.length ||
paths.some(
(p) => !validPath(p) || paths.some((other) => other !== p && p.startsWith(other + '/'))
)
)
throw new Error('Invalid or conflicting file path');
};
const cancelCreate = () => {
creating = null;
newName = '';
};
const startCreate = async (type: 'file' | 'directory') => {
if (readOnly) return;
creating = type;
newName = '';
await tick();
newNameInput?.focus();
};
const submitCreate = () => {
if (!creating) return;
if (readOnly || !newName.trim()) {
cancelCreate();
return;
}
const path = currentPath + newName.trim();
try {
validatePaths([...files.map((file) => file.path), path]);
if (emptyFolders.some((folder) => folder === path || folder.startsWith(path + '/')))
throw new Error('A file or folder already exists at this path');
const type = creating;
cancelCreate();
expandParents(path);
if (type === 'directory') emptyFolders = [...emptyFolders, path];
else {
files = [...files, { path, content: '', size: 0, changed: true }];
dirty = true;
open(path);
}
} catch (error) {
toast.error(skillError(error));
}
};
const clearUrls = () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
resourceUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrl = '';
resourceUrls = [];
previewHtml = '';
};
const flush = () => {
if (loadedPath && codeEditor) value = codeEditor.getValue();
const file = files.find((f) => f.path === loadedPath);
if (file && !readOnly && value !== loadedValue) {
file.content = value;
file.changed = true;
file.size = new TextEncoder().encode(value).length;
files = [...files];
loadedValue = value;
dirty = true;
}
};
const fileDrawer = (node: HTMLElement) => {
const trap = createFocusTrap(node, { escapeDeactivates: false });
trap.activate();
return {
destroy: () => {
flush();
trap.deactivate();
}
};
};
$: if (!$mobile) showFileDrawer = false;
$: if (loadedPath && value !== loadedValue && !readOnly) dirty = true;
const blobFor = async (file: Draft) => {
if (file.content !== undefined)
return new Blob([
file.encoding === 'base64'
? Uint8Array.from(atob(file.content), (c) => c.charCodeAt(0))
: file.content
]);
if (file.upload) return file.upload;
return getSkillFile(localStorage.token, skillId, versionId!, file.sourcePath || file.path);
};
const resolveRelative = (href: string, base: string) => {
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('/') || href.startsWith('#'))
return null;
const parts = base.split('/').slice(0, -1);
let decoded: string;
try {
decoded = decodeURIComponent(href.split(/[?#]/)[0]);
} catch {
return null;
}
for (const part of decoded.split('/')) {
if (part === '..') {
if (!parts.length) return null;
parts.pop();
} else if (part && part !== '.') parts.push(part);
}
return parts.join('/');
};
const renderPreview = async () => {
flush();
const request = requestId;
const document = new DOMParser().parseFromString(
DOMPurify.sanitize(await marked.parse(value)),
'text/html'
);
for (const image of Array.from(document.querySelectorAll('img'))) {
const path = resolveRelative(image.getAttribute('src') || '', selected);
image.removeAttribute('src');
const file = files.find((f) => f.path === path);
if (file && /\.(png|jpe?g|gif|webp|avif)$/i.test(file.path)) {
const url = URL.createObjectURL(await blobFor(file));
if (request !== requestId) {
URL.revokeObjectURL(url);
return;
}
resourceUrls.push(url);
image.src = url;
}
}
for (const link of Array.from(document.querySelectorAll('a'))) {
const href = link.getAttribute('href') || '';
const path = resolveRelative(href, selected);
if (path) {
link.dataset.skillPath = path;
link.href = '#';
} else if (/^https?:\/\//i.test(href)) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
} else link.removeAttribute('href');
}
if (request === requestId) previewHtml = document.body.innerHTML;
};
const expandParents = (path: string) => {
const parts = path.split('/');
for (let index = 1; index < parts.length; index++)
expandedFolders.add(parts.slice(0, index).join('/'));
expandedFolders = new Set(expandedFolders);
};
const open = async (path: string, reveal = true) => {
flush();
clearUrls();
preview = false;
selected = path;
loadedPath = '';
value = '';
loadedValue = '';
loadError = '';
const file = files.find((f) => f.path === path);
if (!file) return;
expandParents(path);
currentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : '';
if (reveal && $mobile) showFileDrawer = true;
const request = ++requestId;
loading = true;
try {
const blob = await blobFor(file);
if (request !== requestId) return;
if (file.encoding === 'base64') objectUrl = URL.createObjectURL(blob);
else {
const text = await blob.text();
if (request !== requestId) return;
value = text;
loadedValue = text;
loadedPath = path;
}
} catch (error) {
loadError = skillError(error);
} finally {
if (request === requestId) loading = false;
}
};
const load = async () => {
showFileDrawer = false;
cancelCreate();
++requestId;
clearUrls();
loadedPath = '';
value = '';
loadedValue = '';
files = [];
emptyFolders = [];
dirty = false;
loadError = '';
loading = true;
const key = loadKey;
try {
if (versionId) {
const result = await getSkillFiles(localStorage.token, skillId, versionId);
if (key !== loadKey) return;
files = result.files.map((file: SkillFileSummary) => ({ ...file, sourcePath: file.path }));
} else
files = (initialFiles || [{ path: 'SKILL.md', content: initialContent }]).map((file) => ({
...file,
size: 0,
changed: true
}));
currentPath = '';
expandedFolders = new Set();
selectedPaths = new Set();
await open(files.some((f) => f.path === initialPath) ? initialPath : 'SKILL.md', false);
} catch (error) {
loadError = skillError(error);
loading = false;
}
};
$: if (`${versionId ? skillId : 'new'}:${versionId}:${reloadKey}` !== loadKey) {
loadKey = `${versionId ? skillId : 'new'}:${versionId}:${reloadKey}`;
load();
}
const toggleFolder = (path: string) => {
cancelCreate();
path = path.replace(/\/$/, '');
if (expandedFolders.has(path)) {
expandedFolders.delete(path);
currentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : '';
} else {
expandedFolders.add(path);
currentPath = path + '/';
}
expandedFolders = new Set(expandedFolders);
};
$: rows = (() => {
const directories = new Map<string, Map<string, FileEntry>>();
for (const file of [...files, ...emptyFolders.map((path) => ({ path: path + '/', size: 0 }))]) {
const parts = file.path.split('/');
parts.forEach((name, index) => {
if (!name) return;
const parent = parts.slice(0, index).join('/');
if (!directories.has(parent)) directories.set(parent, new Map());
directories.get(parent)!.set(name, {
name,
type: index < parts.length - 1 ? 'directory' : 'file',
size: file.size,
modified: 0,
writable: !readOnly
});
});
}
const result: { entry: FileEntry; path: string; depth: number }[] = [];
const append = (parent: string, depth: number) => {
const entries = [...(directories.get(parent)?.values() || [])].sort((a, b) =>
a.type !== b.type ? (a.type === 'directory' ? -1 : 1) : a.name.localeCompare(b.name)
);
for (const entry of entries) {
const path = parent ? `${parent}/${entry.name}` : entry.name;
result.push({ entry, path, depth });
if (entry.type === 'directory' && expandedFolders.has(path)) append(path, depth + 1);
}
};
append('', 0);
return result;
})();
const rename = (path: string, destination: string) => {
if (readOnly || path === 'SKILL.md') return;
flush();
try {
if (!validPath(destination) || destination.startsWith(path + '/'))
throw new Error('Invalid destination');
const moved = files.map((file) =>
file.path === path || file.path.startsWith(path + '/')
? { ...file, path: destination + file.path.slice(path.length) }
: file
);
validatePaths(moved.map((f) => f.path));
files = moved;
expandedFolders = new Set(
[...expandedFolders].map((folder) =>
folder === path || folder.startsWith(path + '/')
? destination + folder.slice(path.length)
: folder
)
);
if (currentPath.startsWith(path + '/'))
currentPath = destination + currentPath.slice(path.length);
emptyFolders = emptyFolders.map((p) =>
p === path || p.startsWith(path + '/') ? destination + p.slice(path.length) : p
);
if (selected === path || selected.startsWith(path + '/')) {
selected = destination + selected.slice(path.length);
loadedPath = selected;
}
dirty = true;
} catch (error) {
toast.error(skillError(error));
}
};
const remove = (path: string) => {
if (readOnly || path === 'SKILL.md') return;
flush();
files = files.filter((f) => f.path !== path && !f.path.startsWith(path + '/'));
emptyFolders = emptyFolders.filter((p) => p !== path && !p.startsWith(path + '/'));
expandedFolders = new Set(
[...expandedFolders].filter((p) => p !== path && !p.startsWith(path + '/'))
);
if (currentPath.startsWith(path + '/')) currentPath = '';
dirty = true;
if (!files.some((f) => f.path === selected)) open('SKILL.md');
};
const upload = async (uploads: File[]) => {
if (readOnly) return;
flush();
try {
const drafts: Draft[] = [];
for (const file of uploads) {
if (file.size > 10 * 1024 * 1024) throw new Error('Files must be at most 10 MiB');
const path = currentPath + (file.webkitRelativePath || file.name);
const bytes = new Uint8Array(await file.arrayBuffer());
let text: string | undefined;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
if (text.includes('\0')) text = undefined;
} catch {}
if (path === 'SKILL.md' && text === undefined) throw new Error('SKILL.md must be UTF-8');
drafts.push({
path,
size: file.size,
upload: file,
content: text,
encoding: text === undefined ? 'base64' : undefined,
changed: true
});
}
validatePaths([...files.map((f) => f.path), ...drafts.map((f) => f.path)]);
files = [...files, ...drafts];
dirty = true;
} catch (error) {
toast.error(skillError(error));
}
};
const download = async (path: string) => {
flush();
const file = files.find((f) => f.path === path);
if (file)
try {
saveAs(await blobFor(file), path.split('/').pop() || 'file');
} catch (error) {
toast.error(skillError(error));
}
};
const encoded = async (file: Draft): Promise<SkillFile> => {
if (file.encoding !== 'base64')
return { path: file.path, content: file.content ?? (await (await blobFor(file)).text()) };
const blob = await blobFor(file);
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result).split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
return { path: file.path, content, encoding: 'base64' };
};
export const getFiles = async () => {
flush();
return Promise.all(files.map(encoded));
};
export const getOperations = async (): Promise<SkillFileOperation[]> => {
flush();
// Reconcile structural edits against original paths. New draft files never reach the server until save.
const original = new Set(files.map((f) => f.sourcePath).filter(Boolean));
const baseline = versionId
? (await getSkillFiles(localStorage.token, skillId, versionId)).files
: [];
const changes: SkillFileOperation[] = baseline
.filter((f: SkillFileSummary) => !original.has(f.path) && f.path !== 'SKILL.md')
.map((f: SkillFileSummary) => ({ op: 'delete', path: f.path }));
// Put moved files from their immutable source, then remove old paths. This also supports swaps.
for (const file of files)
if (file.changed || file.sourcePath !== file.path)
changes.push({ op: 'put', ...(await encoded(file)) });
for (const file of files)
if (
file.sourcePath &&
file.sourcePath !== file.path &&
!files.some((f) => f.path === file.sourcePath)
)
changes.push({ op: 'delete', path: file.sourcePath });
return changes;
};
onDestroy(() => {
++requestId;
clearUrls();
});
</script>
<ConfirmDialog
bind:show={dialog.show}
title={dialog.title}
input={dialog.input}
inputValue={dialog.value}
on:confirm={(event) => dialog.action(event.detail)}
/>
<input
type="file"
multiple
webkitdirectory
bind:this={folderInput}
hidden
on:change={() => {
upload(Array.from(folderInput.files || []));
folderInput.value = '';
}}
/>
<input
type="file"
multiple
hidden
bind:this={uploadInput}
on:change={() => {
if (!readOnly) upload(Array.from(uploadInput.files || []));
uploadInput.value = '';
}}
/>
<div class="flex h-full min-h-0 flex-col">
<div class="flex min-h-0 flex-1 flex-col md:flex-row">
{#if $mobile}
{@render fileList()}
{:else}
<ResizableSidePanel
open
side="left"
bind:width={sidebarWidth}
minWidth={180}
maxWidth={480}
minSiblingWidth={320}
className="min-h-0"
resizerId="skill-files-resizer"
>
{@render fileList()}
</ResizableSidePanel>
{@render fileContent()}
{/if}
</div>
</div>
{#if $mobile}
<Drawer bind:show={showFileDrawer} className="h-full" onClose={flush}>
<div
class="h-full"
role="dialog"
aria-modal="true"
aria-label={selected}
tabindex="-1"
use:fileDrawer
>
{@render fileContent()}
</div>
</Drawer>
{/if}
{#snippet fileContent()}
<div
class="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-white dark:bg-gray-900"
>
<div class="flex shrink-0 items-center gap-1 px-3 py-1.5 text-xs dark:bg-black">
{#if $mobile}
<button
type="button"
aria-label={$i18n.t('Back to files')}
class="flex shrink-0 items-center gap-1 whitespace-nowrap py-1.5 pr-2 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
flush();
showFileDrawer = false;
}}
>
<ChevronLeft className="size-3.5" />{$i18n.t('Back')}
</button>
{/if}
<span class="min-w-0 flex-1 truncate text-gray-500" title={selected}>{selected}</span>
{#if /\.md$/i.test(selected) && !loading}<button
type="button"
class="rounded-xl px-2 py-1.5 text-gray-500 transition hover:text-gray-900 dark:hover:text-gray-100"
on:click={async () => {
preview = !preview;
if (preview) await renderPreview();
}}>{preview ? 'Edit' : 'Preview'}</button
>{/if}
<button
type="button"
class="rounded-xl px-2 py-1.5 text-gray-500 transition hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => download(selected)}>Download</button
>
</div>
{#if loadError}<p role="alert" class="p-3 text-red-500">{loadError}</p>
{:else if loading}<p class="p-3 text-xs">{$i18n.t('Loading...')}</p>
{:else if objectUrl}
{#if /\.(png|jpe?g|gif|webp|avif)$/i.test(selected)}<img
src={objectUrl}
alt={selected}
class="min-h-0 object-contain p-3"
/>
{:else if /\.pdf$/i.test(selected)}<iframe
title={selected}
src={objectUrl}
sandbox=""
class="min-h-0 flex-1"
></iframe>
{:else}<p class="p-3 text-xs text-gray-500">Binary file. Download to open.</p>{/if}
{:else if preview}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div
class="prose dark:prose-invert min-h-0 max-w-none flex-1 overflow-auto p-4"
on:click={(event) => {
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}
</div>
{:else}<div class="min-h-0 flex-1">
<FileCodeEditor
bind:this={codeEditor}
bind:value
filePath={selected}
{readOnly}
onSave={async () => {
flush();
await onSave();
}}
/>
</div>{/if}
</div>
{/snippet}
{#snippet createInput()}
{#if creating && !readOnly}
<div
class="flex h-8 items-center gap-2 pr-2"
style:padding-left={`${24 + currentPath.split('/').filter(Boolean).length * 16}px`}
>
<FileTypeIcon name={newName} type={creating} size={14} />
<input
bind:this={newNameInput}
bind:value={newName}
class="min-w-0 flex-1 bg-transparent py-0.5 text-xs outline-hidden"
placeholder={$i18n.t(creating === 'file' ? 'File name' : 'Folder name')}
aria-label={$i18n.t(creating === 'file' ? 'File name' : 'Folder name')}
on:keydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
submitCreate();
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
cancelCreate();
}
}}
on:blur={submitCreate}
/>
</div>
{/if}
{/snippet}
{#snippet fileList()}
<div class="h-full min-h-0 w-full overflow-auto p-1.5">
<div class="mb-1 flex w-full items-center gap-1">
<div class="min-w-0 flex-1">{@render version()}</div>
{#if !readOnly || canExport}
<Dropdown closeOnSelect align="end">
<button
type="button"
aria-label={$i18n.t('Actions')}
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl px-2 text-gray-500 transition hover:text-gray-900 dark:hover:text-gray-100"
>
<EllipsisHorizontal className="size-4" />
</button>
<div slot="content">
<DropdownMenu className="min-w-[10.625rem]">
{#if !readOnly}
<button
type="button"
class="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"
on:click={() => startCreate('file')}
><Plus className="size-3.5" />{$i18n.t('New File')}</button
>
<button
type="button"
class="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"
on:click={() => startCreate('directory')}
><Folder className="size-3.5" />{$i18n.t('New Folder')}</button
>
<button
type="button"
class="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"
on:click={() => uploadInput.click()}
><ArrowUpTray className="size-3.5" />{$i18n.t('Upload')}</button
>
<button
type="button"
class="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"
on:click={() => folderInput.click()}
><Folder className="size-3.5" />{$i18n.t('Upload Folder')}</button
>
{/if}
{#if canExport}
{#if !readOnly}<hr class="border-gray-100 dark:border-gray-800" />{/if}
{@render exports()}
{/if}
</DropdownMenu>
</div>
</Dropdown>
{/if}
</div>
{#if $mobile && !showFileDrawer && loadError}
<p role="alert" class="p-3 text-xs text-red-500">{loadError}</p>
{/if}
{#if selectedPaths.size && !readOnly}<button
type="button"
class="px-2 text-xs"
on:click={() =>
ask('Delete selected files?', false, () => {
selectedPaths.forEach(remove);
selectedPaths = new Set();
})}>Delete selected</button
>{/if}
{#if !currentPath}{@render createInput()}{/if}
<ul>
{#each rows as { entry, path, depth } (path)}
<FileEntryRow
variant="workspace"
{entry}
currentPath=""
fullPath={path}
{depth}
expanded={expandedFolders.has(path)}
{dragType}
active={entry.type === 'file' && selected === path}
selected={selectedPaths.has(path)}
selectionMode={selectedPaths.size > 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 + '/'}
<li>{@render createInput()}</li>
{/if}
{/each}
</ul>
{#if !rows.length}<p class="p-3 text-xs text-gray-500">
Empty folders are saved when they contain files.
</p>{/if}
</div>
{/snippet}

View file

@ -0,0 +1,122 @@
<script lang="ts">
import Modal from '$lib/components/common/Modal.svelte';
import { toast } from 'svelte-sonner';
import { importSkillBundles, skillError } from '$lib/apis/skills';
export let show = false;
export let files: File[] = [];
export let onImported: () => Promise<void> = async () => {};
let loading = false;
let items: any[] = [];
let results: any[] = [];
let loadedFiles: File[] | null = null;
$: if (show && files !== loadedFiles) {
loadedFiles = files;
preview();
}
const preview = async () => {
loading = true;
results = [];
items = [];
try {
items = (await importSkillBundles(localStorage.token, files)).map((item: any) => ({
...item,
action: item.id_taken || item.name_taken ? 'skip' : 'create'
}));
} catch (error) {
toast.error(skillError(error));
} finally {
loading = false;
}
};
const save = async () => {
loading = true;
try {
results = await importSkillBundles(
localStorage.token,
files,
items.map(({ action, id, name, expected_version_id }) => ({
action,
id,
name,
expected_version_id
}))
);
results.forEach((result, index) => {
if (result.status === 'saved') items[index].action = 'skip';
});
items = [...items];
if (results.some((r) => r.status === 'saved')) await onImported();
} catch (error) {
toast.error(skillError(error));
} finally {
loading = false;
}
};
</script>
<Modal bind:show size="lg">
<div class="p-5">
<div class="mb-3 flex justify-between">
<h2 class="font-medium">Import skills</h2>
<button type="button" on:click={() => (show = false)}>Close</button>
</div>
<p class="mb-3 text-xs text-gray-500">
New skills and copies are private. Replace saves a new version and preserves existing sharing.
</p>
<div class="max-h-[60vh] space-y-3 overflow-auto">
{#each items as item, index}
<div class="rounded-lg border p-3 dark:border-gray-800">
<div class="flex flex-wrap gap-2">
<input
aria-label="Skill name"
class="min-w-0 flex-1 bg-transparent"
bind:value={item.name}
disabled={loading}
/>
<select
aria-label="Import action"
class="rounded bg-transparent text-xs"
bind:value={item.action}
disabled={loading}
>
<option value="create">Create</option><option value="copy">Create copy</option
>{#if item.can_replace}<option value="replace">Replace</option>{/if}<option
value="skip">Skip</option
>
</select>
</div>
<input
aria-label="Skill ID"
class="mt-2 w-full bg-transparent font-mono text-xs"
bind:value={item.id}
disabled={loading || item.action === 'replace'}
/>
{#if item.id_taken || item.name_taken}<p class="mt-1 text-xs text-amber-600">
ID or name already exists. Choose a unique ID and name for a copy.
</p>{/if}
<details class="mt-2 text-xs">
<summary>{item.files.length} files</summary>{#each item.files as file}<div
class="font-mono"
>
{file.path} ({file.size} bytes)
</div>{/each}
</details>
{#if results[index]}<p
class="mt-2 text-xs"
class:text-red-500={results[index].status === 'error'}
>
{results[index].status}{results[index].error
? `: ${skillError(results[index].error)}`
: ''}
</p>{/if}
</div>
{/each}
</div>
<button
type="button"
class="mt-4 rounded-lg bg-black px-3 py-2 text-sm text-white disabled:opacity-50 dark:bg-white dark:text-black"
disabled={loading || !items.length || items.every((i) => i.action === 'skip')}
on:click={save}>{loading ? 'Loading…' : 'Import selected'}</button
>
</div>
</Modal>

View file

@ -79,13 +79,20 @@
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
exportHandler();
exportHandler('json');
closeMenu();
}}
>
<Download className="size-3.5" />
<div class="flex items-center">{$i18n.t('Export')}</div>
<div class="flex items-center">{$i18n.t('Export JSON')}</div>
</button>
<button
class="flex h-7 w-full items-center gap-2 px-2 text-[0.8125rem]"
on:click={() => {
exportHandler('zip');
closeMenu();
}}><Download className="size-3.5" />Export ZIP</button
>
{/if}
<hr class="border-gray-50 dark:border-gray-850/30 mx-1 my-0.5" />

View file

@ -4,12 +4,14 @@
import { skills } from '$lib/stores';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
const i18n = getContext<any>('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 : []

View file

@ -4,39 +4,27 @@
import { skills } from '$lib/stores';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
const i18n = getContext<any>('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');
}