From 24ee1cb168a1b2071195c0331aa186a1d62bc197 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 9 Oct 2026 16:31:34 +0400 Subject: [PATCH] refac --- backend/open_webui/models/skill_history.py | 32 +++++++- backend/open_webui/models/skills.py | 37 ++++++++- backend/open_webui/routers/skills.py | 30 +++++--- src/lib/apis/skills/index.ts | 8 +- .../workspace/Prompts/PromptEditor.svelte | 42 ++++++---- .../Prompts/PromptHistoryMenu.svelte | 77 ------------------- .../workspace/Skills/SkillEditor.svelte | 53 +++++++++++-- .../workspace/common/VersionMenuItem.svelte | 75 ++++++++++++------ 8 files changed, 215 insertions(+), 139 deletions(-) delete mode 100644 src/lib/components/workspace/Prompts/PromptHistoryMenu.svelte diff --git a/backend/open_webui/models/skill_history.py b/backend/open_webui/models/skill_history.py index 44a63b1f91..d3f637eeae 100644 --- a/backend/open_webui/models/skill_history.py +++ b/backend/open_webui/models/skill_history.py @@ -2,8 +2,9 @@ import time import uuid from open_webui.internal.db import Base, get_async_db_context +from fastapi import HTTPException from pydantic import BaseModel, ConfigDict -from sqlalchemy import JSON, BigInteger, Column, Text, select +from sqlalchemy import JSON, BigInteger, Column, Text, select, update class SkillHistory(Base): @@ -39,6 +40,35 @@ class SkillHistoryResponse(BaseModel): class SkillHistoryTable: + async def delete_history_entry(self, skill_id, history_id, db=None): + from open_webui.models.skills import Skill + + async with get_async_db_context(db) as session: + try: + # Serialize with production switches on both SQLite and PostgreSQL. + await session.execute(update(Skill).where(Skill.id == skill_id).values(version_id=Skill.version_id)) + skill = await session.get(Skill, skill_id, populate_existing=True) + if not skill: + return False + if skill.version_id == history_id: + raise HTTPException(400, 'Cannot delete the current version') + entry = ( + await session.execute(select(SkillHistory).filter_by(id=history_id, skill_id=skill_id)) + ).scalar_one_or_none() + if not entry: + return False + await session.execute( + update(SkillHistory) + .where(SkillHistory.skill_id == skill_id, SkillHistory.parent_id == history_id) + .values(parent_id=entry.parent_id) + ) + await session.delete(entry) + await session.commit() + return True + except Exception: + await session.rollback() + raise + def new_entry(self, skill_id, snapshot, user_id, parent_id=None, commit_message=None): return SkillHistory( id=str(uuid.uuid4()), diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 27542bed93..d101695c8d 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -408,7 +408,7 @@ class SkillsTable: 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'): + if snapshot != old: entry = SkillHistories.new_entry( id, snapshot, user_id or skill.user_id, skill.version_id, updated.get('commit_message') ) @@ -437,6 +437,41 @@ class SkillsTable: 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: diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 2f03dd6c5b..8e85b05dab 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -694,25 +694,33 @@ async def get_skill_history_entry( } -class RestoreSkillForm(BaseModel): +@router.delete('/id/{id}/history/{history_id}', response_model=bool) +async def delete_skill_history_entry( + id: str, history_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + await authorized_skill(id, user, 'write', db) + if not await SkillHistories.delete_history_entry(id, history_id, db): + raise HTTPException(404, 'Version not found') + return True + + +class SkillVersionUpdateForm(BaseModel): + version_id: str expected_version_id: str - commit_message: str | None = None -@router.post('/id/{id}/history/{history_id}/restore') -async def restore_skill( +@router.post('/id/{id}/update/version', response_model=SkillModel | None) +async def set_skill_version( id: str, - history_id: str, request: Request, - form_data: RestoreSkillForm, + form_data: SkillVersionUpdateForm, 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 authorized_skill(id, user, 'write', db) + result = await Skills.update_skill_version(id, form_data.version_id, form_data.expected_version_id, db=db) + if not result: + raise HTTPException(404, 'Skill not found') await publish_event(request, EVENTS.SKILL_UPDATED, actor=user, subject_id=id, data={'name': result.name}) return result diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts index c71e46f73d..a5bea20233 100644 --- a/src/lib/apis/skills/index.ts +++ b/src/lib/apis/skills/index.ts @@ -370,16 +370,18 @@ 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 ( +export const deleteSkillHistoryVersion = async (token: string, id: string, versionId: string) => + (await skillRequest(token, `/id/${id}/history/${versionId}`, { method: 'DELETE' })).json(); +export const setProductionSkillVersion = async ( token: string, id: string, versionId: string, expectedVersionId: string ) => ( - await skillRequest(token, `/id/${id}/history/${versionId}/restore`, { + await skillRequest(token, `/id/${id}/update/version`, { method: 'POST', - body: JSON.stringify({ expected_version_id: expectedVersionId }) + body: JSON.stringify({ version_id: versionId, expected_version_id: expectedVersionId }) }) ).json(); export const cloneSkill = async ( diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte index 47c866fd2f..2c7d4d44e4 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -12,7 +12,6 @@ import Clipboard from '$lib/components/icons/Clipboard.svelte'; import Check from '$lib/components/icons/Check.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; - import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; import AccessControlModal from '../common/AccessControlModal.svelte'; import { user } from '$lib/stores'; import { slugify, formatDate, copyToClipboard } from '$lib/utils'; @@ -31,7 +30,6 @@ } from '$lib/apis/prompts'; import dayjs from 'dayjs'; import localizedFormat from 'dayjs/plugin/localizedFormat'; - import PromptHistoryMenu from './PromptHistoryMenu.svelte'; import Tags from '$lib/components/common/Tags.svelte'; import VersionMenuItem from '../common/VersionMenuItem.svelte'; import VersionDiff from '../common/VersionDiff.svelte'; @@ -79,6 +77,8 @@ let metadataSave: Promise = Promise.resolve(); let suggestionTags: { name: string }[] = []; let showDiscard = false; + let showDeleteVersion = false; + let deleteVersionId: string | null = null; let discardAction: () => void = () => {}; $: selectedVersionId = selectedHistoryEntry?.id ?? prompt?.version_id; @@ -258,7 +258,7 @@ } }; const handleDeleteHistory = async (historyId: string) => { - if (disabled || loading) return; + if (disabled || loading || historyId === prompt.version_id) return; loading = true; try { await deletePromptHistoryVersion(localStorage.token, prompt.id, historyId); @@ -368,6 +368,21 @@ onConfirm={discardAction} /> + { + if (deleteVersionId) return handleDeleteHistory(deleteVersionId); + }} +/> +
{/if} - {#if edit && !disabled && selectedVersionId && !editingHistory} - handleDeleteHistory(selectedVersionId)} - onClose={() => {}} - > - - - {/if}