open-webui/backend/open_webui/models/skills.py
Classic298 2dff26b1b9
fix: skill versions saved within the same second are listed in random order (#32167)
When a skill was saved several times within one second, its version history in the skill editor listed those versions in a shuffled order. This happens when a model edits a skill several times in one reply, or when an import replaces a skill right after it was created. Each new version now gets a save time at least one second later than the one before it, so the history keeps the order the versions were saved in. During such a burst, the shown save time runs ahead by about one second per extra save.

Fixes #32134
2026-10-10 19:49:21 +04:00

514 lines
21 KiB
Python

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 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.misc import json_text_variants
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, cast, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
####################
# Skills DB Schema
####################
class Skill(Base):
__tablename__ = 'skill'
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
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)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
class SkillMeta(BaseModel):
i18n: dict[str, dict[str, str]] | None = None
tags: Optional[list[str]] = []
class SkillModel(BaseModel):
id: str
user_id: str
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)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class SkillUserModel(SkillModel):
user: Optional[UserResponse] = None
class SkillResponse(BaseModel):
version_id: str | None = None
id: str
user_id: str
name: str
description: Optional[str] = None
meta: SkillMeta
is_active: bool = True
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
class SkillUserResponse(SkillResponse):
user: Optional[UserResponse] = None
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 | 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
class SkillListResponse(BaseModel):
items: list[SkillUserResponse] = []
total: int = 0
class SkillAccessListResponse(BaseModel):
items: list[SkillAccessResponse] = []
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)
async def _to_skill_model(
self,
skill: Skill,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> SkillModel:
skill_model = SkillModel.model_validate(skill)
skill_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(skill_model.id, db=db)
)
return skill_model
async def insert_new_skill(
self,
user_id: str,
form_data: SkillForm,
db: Optional[AsyncSession] = None,
) -> Optional[SkillModel]:
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(
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()),
)
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:
async with get_async_db_context(db) as db:
skill = await db.get(Skill, id)
return await self._to_skill_model(skill, db=db) if skill else None
except Exception:
return None
async def get_skill_by_name(self, name: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Skill).filter_by(name=name))
skill = result.scalars().first()
return await self._to_skill_model(skill, db=db) if skill else None
except Exception:
return None
async def get_skills(
self,
user_id: str | None = None,
ids: list[str] | None = None,
db: AsyncSession | None = None,
) -> list[SkillUserModel]:
async with get_async_db_context(db) as db:
stmt = select(Skill).order_by(Skill.updated_at.desc())
if ids is not None:
stmt = stmt.filter(Skill.id.in_(ids))
if user_id is not None:
user_group_ids = {
group.id for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
}
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,
DocumentModel=Skill,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='skill',
permission='read',
)
result = await db.execute(stmt)
all_skills = result.scalars().all()
user_ids = list(set(skill.user_id for skill in all_skills))
skill_ids = [skill.id for skill in all_skills]
users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db)
skills = []
for skill in all_skills:
user = users_dict.get(skill.user_id)
skills.append(
SkillUserModel.model_validate(
{
**(
await self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
)
).model_dump(),
'user': user.model_dump() if user else None,
}
)
)
return skills
async def search_skills(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[AsyncSession] = None,
) -> SkillListResponse:
try:
async with get_async_db_context(db) as db:
# Join with User table for user filtering
stmt = select(Skill, User).outerjoin(User, User.id == Skill.user_id)
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(
or_(
Skill.name.ilike(f'%{query_key}%'),
Skill.description.ilike(f'%{query_key}%'),
Skill.id.ilike(f'%{query_key}%'),
User.name.ilike(f'%{query_key}%'),
User.email.ilike(f'%{query_key}%'),
*(
cast(Skill.meta, String).icontains(variant, autoescape=True)
for variant in json_text_variants(query_key)
),
)
)
view_option = filter.get('view_option')
if view_option == 'created':
stmt = stmt.filter(Skill.user_id == user_id)
elif view_option == 'shared':
stmt = stmt.filter(Skill.user_id != user_id)
# Apply access grant filtering
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,
DocumentModel=Skill,
filter=filter,
resource_type='skill',
permission='read',
)
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(Skill.name.asc())
else:
stmt = stmt.order_by(Skill.name.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(Skill.created_at.asc())
else:
stmt = stmt.order_by(Skill.created_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Skill.updated_at.asc())
else:
stmt = stmt.order_by(Skill.updated_at.desc())
else:
stmt = stmt.order_by(Skill.updated_at.desc())
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
items = result.all()
skill_ids = [skill.id for skill, _ in items]
grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db)
skills = []
for skill, user in items:
skills.append(
SkillUserResponse(
**(
await self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
)
).model_dump(),
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
return SkillListResponse(items=skills, total=total)
except Exception as e:
log.exception(f'Error searching skills: {e}')
return SkillListResponse(items=[], total=0)
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:
entry = SkillHistories.new_entry(
id, snapshot, user_id or skill.user_id, skill.version_id, updated.get('commit_message')
)
# History is listed by whole-second save time, so keep same-second saves in order.
latest_created_at = (
await session.execute(select(func.max(SkillHistory.created_at)).filter_by(skill_id=id))
).scalar()
entry.created_at = max(entry.created_at, latest_created_at + 1)
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 update_skill_version(
self, id: str, version_id: str, expected_version_id: str, db=None
) -> Optional[SkillModel]:
async with get_async_db_context(db) as session:
try:
# Lock before reading the target revision so deletion cannot race promotion.
await session.execute(update(Skill).where(Skill.id == id).values(version_id=Skill.version_id))
skill = await session.get(Skill, id, populate_existing=True)
if not skill:
return None
if expected_version_id != skill.version_id:
raise HTTPException(409, {'code': 'version_conflict', 'current_version_id': skill.version_id})
entry = await SkillHistories.get_history_by_id(id, version_id, db=session)
if not entry:
raise HTTPException(404, 'Skill version not found')
snapshot = entry.snapshot
result = await session.execute(
update(Skill)
.where(Skill.id == id, Skill.version_id == expected_version_id)
.values(
**{key: snapshot[key] for key in ('name', 'description', 'content', 'data', 'meta')},
version_id=version_id,
updated_at=int(time.time()),
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
raise HTTPException(409, {'code': 'version_conflict'})
await session.commit()
await session.refresh(skill)
return await self._to_skill_model(skill, db=session)
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:
try:
result = await db.execute(select(Skill).filter_by(id=id))
skill = result.scalars().first()
if not skill:
return None
skill.is_active = not skill.is_active
skill.updated_at = int(time.time())
await db.commit()
return await self._to_skill_model(skill, db=db)
except Exception:
return None
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 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()
return True
except Exception:
return False
Skills = SkillsTable()