mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-10 03:27:57 +00:00
refac
This commit is contained in:
parent
fc84c2dfce
commit
9bbb95048b
23 changed files with 2783 additions and 417 deletions
|
|
@ -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')
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
88
backend/open_webui/models/skill_history.py
Normal file
88
backend/open_webui/models/skill_history.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
216
backend/open_webui/utils/skill_files.py
Normal file
216
backend/open_webui/utils/skill_files.py
Normal 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()
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' } }]
|
||||
: [])
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
754
src/lib/components/workspace/Skills/SkillFiles.svelte
Normal file
754
src/lib/components/workspace/Skills/SkillFiles.svelte
Normal 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}
|
||||
122
src/lib/components/workspace/Skills/SkillImport.svelte
Normal file
122
src/lib/components/workspace/Skills/SkillImport.svelte
Normal 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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 : []
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue