This commit is contained in:
Timothy Jaeryang Baek 2026-10-09 16:31:34 +04:00
parent 550311b218
commit 24ee1cb168
8 changed files with 215 additions and 139 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void> = 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}
/>
<ConfirmDialog
bind:show={showDeleteVersion}
title={$i18n.t('Delete Version')}
message={$i18n.t(
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
) +
(deleteVersionId === selectedVersionId && dirty
? ' ' + $i18n.t('Unsaved changes to this version will be discarded.')
: '')}
confirmLabel={$i18n.t('Delete')}
onConfirm={() => {
if (deleteVersionId) return handleDeleteHistory(deleteVersionId);
}}
/>
<div
class="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden {modal
? 'px-5 pt-3 pb-1'
@ -520,6 +535,13 @@
{entry}
selected={selectedVersionId === entry.id}
onSelect={() => chooseVersion(entry)}
onDelete={disabled
? undefined
: () => {
deleteVersionId = entry.id;
showHistory = false;
showDeleteVersion = true;
}}
/>
{/each}
{#if historyLoading}<div
@ -552,20 +574,6 @@
className="size-3.5"
/>{/if}</button
>
{#if edit && !disabled && selectedVersionId && !editingHistory}
<PromptHistoryMenu
isProduction={!historical}
onDelete={() => handleDeleteHistory(selectedVersionId)}
onClose={() => {}}
>
<button
type="button"
aria-label={$i18n.t('More Options')}
class="shrink-0 p-0.5 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
><EllipsisHorizontal className="size-3.5" /></button
>
</PromptHistoryMenu>
{/if}
</div>
<textarea
bind:this={contentInput}

View file

@ -1,77 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
const i18n = getContext('i18n');
export let isProduction = false;
export let onDelete: Function;
export let onClose: Function;
let show = false;
let showDeleteConfirmDialog = false;
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
title={$i18n.t('Delete Version')}
message={$i18n.t(
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
)}
confirmLabel={$i18n.t('Delete')}
onConfirm={() => {
onDelete();
}}
/>
<Dropdown
bind:show
onOpenChange={(state) => {
if (state === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot>
<button
class="p-1 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label={$i18n.t('More Options')}
>
<EllipsisHorizontal className="size-5" />
</button>
</slot>
</Tooltip>
<div slot="content">
<DropdownMenu className="min-w-[10.625rem]">
{#if isProduction}
<Tooltip content={$i18n.t('Cannot delete the production version')} placement="top">
<div
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] opacity-40 cursor-not-allowed"
>
<GarbageBin className="size-3.5" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</div>
</Tooltip>
{:else}
<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={() => {
show = false;
showDeleteConfirmDialog = true;
}}
>
<GarbageBin className="size-3.5" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
{/if}
</DropdownMenu>
</div>
</Dropdown>

View file

@ -25,8 +25,9 @@
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import {
getSkillHistory,
deleteSkillHistoryVersion,
getSkillVersion,
restoreSkillVersion,
setProductionSkillVersion,
getSkillById,
createNewSkill,
exportSkillBundle,
@ -67,6 +68,8 @@
let historyDiff: any = null;
let comparing = false;
let discard = false;
let showDeleteVersion = false;
let deleteVersionId: string | null = null;
let discardAction = () => {};
$: historical = edit && versionId !== currentVersionId;
$: readOnly = disabled || historical;
@ -105,6 +108,21 @@
showHistory = false;
if (selected !== versionId) confirmDiscard(() => selectVersion(selected));
};
const deleteVersion = async () => {
if (disabled || loading || !deleteVersionId || deleteVersionId === currentVersionId) return;
loading = true;
try {
await deleteSkillHistoryVersion(localStorage.token, id, deleteVersionId);
if (versionId === deleteVersionId) await reload();
historyPage = 1;
await loadHistory();
toast.success($i18n.t('Version deleted'));
} catch (error) {
toast.error(skillError(error));
} finally {
loading = false;
}
};
const compareToCurrent = async () => {
if (comparing || historyDiff) return;
const fromId = versionId!;
@ -149,23 +167,27 @@
toast.error(skillError(error));
}
};
const restore = async () => {
const setProductionVersion = async () => {
if (disabled || !historical || loading) return;
loading = true;
try {
const restored = await restoreSkillVersion(
const updated = await setProductionSkillVersion(
localStorage.token,
id,
versionId!,
currentVersionId!
);
currentVersionId = restored.version_id;
versionId = restored.version_id;
currentVersionId = updated.version_id;
versionId = updated.version_id;
historyDiff = null;
remember();
await loadHistory();
toast.success($i18n.t('Saved'));
toast.success($i18n.t('Production version updated'));
} catch (error) {
if ((error as any)?.code === 'version_conflict') conflict = true;
toast.error(skillError(error));
} finally {
loading = false;
}
};
const exportVersion = async (format: 'json' | 'zip') => {
@ -276,6 +298,16 @@
}}
/>
<ConfirmDialog
bind:show={showDeleteVersion}
title={$i18n.t('Delete Version')}
message={$i18n.t(
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
)}
confirmLabel={$i18n.t('Delete')}
onConfirm={deleteVersion}
/>
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessGrants
@ -475,6 +507,13 @@
{entry}
selected={entry.id === versionId}
onSelect={() => chooseVersion(entry.id)}
onDelete={disabled
? undefined
: () => {
deleteVersionId = entry.id;
showHistory = false;
showDeleteVersion = true;
}}
/>
{:else}
{#if historyPage > 1}
@ -542,7 +581,7 @@
{#if !disabled}<button
type="button"
class="flex h-7 items-center rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={restore}>{$i18n.t('Restore as new version')}</button
on:click={setProductionVersion}>{$i18n.t('Set as Production')}</button
>{/if}
</div>
</div>

View file

@ -4,6 +4,11 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import Check from '$lib/components/icons/Check.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
let showActions = false;
export let entry: {
id: string;
@ -15,6 +20,7 @@
export let status = '';
export let selected = false;
export let onSelect: () => void;
export let onDelete: (() => void) | undefined = undefined;
const i18n = getContext<any>('i18n');
$: authorId = entry?.user?.id || entry?.user_id;
$: message = entry?.commit_message || entry?.id.slice(0, 7) || status;
@ -24,26 +30,51 @@
$: date = entry?.created_at ? new Date(entry.created_at * 1000) : null;
</script>
<button
type="button"
role="menuitemradio"
aria-label={status || message}
aria-checked={selected}
class="text-left"
title={entry
? `${entry.id}${date ? ` · ${date.toLocaleString($i18n.language)}` : ''}`
: undefined}
on:click={onSelect}
<div
class="group flex h-[1.6875rem] w-full items-center rounded-xl hover:bg-gray-50/60 dark:hover:bg-gray-800/60"
>
<Tooltip as="span" className="flex shrink-0" content={author} allowHTML={false}>
<ProfileImage
src={authorId ? `/api/v1/users/${authorId}/profile/image` : '/user.png'}
className="size-4 shrink-0 rounded-full!"
/>
</Tooltip>
<Tooltip as="span" className="block min-w-0 flex-1" content={message} allowHTML={false}>
<span class="block truncate text-xs">{message}</span>
</Tooltip>
{#if status && entry}<span class="shrink-0 text-[0.625rem] text-gray-400">{status}</span>{/if}
{#if selected}<Check className="size-3.5 shrink-0 text-blue-500" />{/if}
</button>
<button
type="button"
role="menuitemradio"
aria-label={status || message}
aria-checked={selected}
class="flex h-full min-w-0 flex-1 items-center gap-2 rounded-xl px-2 text-left"
title={entry
? `${entry.id}${date ? ` · ${date.toLocaleString($i18n.language)}` : ''}`
: undefined}
on:click={onSelect}
>
<Tooltip as="span" className="flex shrink-0" content={author} allowHTML={false}>
<ProfileImage
src={authorId ? `/api/v1/users/${authorId}/profile/image` : '/user.png'}
className="size-4 shrink-0 rounded-full!"
/>
</Tooltip>
<Tooltip as="span" className="block min-w-0 flex-1" content={message} allowHTML={false}>
<span class="block truncate text-xs">{message}</span>
</Tooltip>
{#if status && entry}<span class="shrink-0 text-[0.625rem] text-gray-400">{status}</span>{/if}
{#if selected}<Check className="size-3.5 shrink-0 text-blue-500" />{/if}
</button>
{#if onDelete}
<Dropdown bind:show={showActions} align="end">
<button
type="button"
aria-label={$i18n.t('More Options')}
class="mr-0.5 flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
><EllipsisHorizontal className="size-3.5" /></button
>
<div slot="content">
<DropdownMenu className="min-w-32">
<button
type="button"
on:click={() => {
showActions = false;
onDelete?.();
}}><GarbageBin className="size-3.5" /><span>{$i18n.t('Delete')}</span></button
>
</DropdownMenu>
</div>
</Dropdown>
{/if}
</div>