This commit is contained in:
Timothy Jaeryang Baek 2026-10-09 17:05:53 +04:00
parent 16849284ff
commit 27b48b70df
8 changed files with 104 additions and 7 deletions

View file

@ -4,8 +4,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
def model_snapshot(model) -> dict:
@ -51,6 +52,35 @@ class ModelHistoryModel(ModelHistoryResponse):
class ModelHistoryTable:
async def delete_history_entry(self, model_id, history_id, db=None):
from open_webui.models.models import Model
async with get_async_db_context(db) as session:
try:
# Serialize with production switches on both SQLite and PostgreSQL.
await session.execute(update(Model).where(Model.id == model_id).values(version_id=Model.version_id))
model = await session.get(Model, model_id, populate_existing=True)
if not model:
return False
if model.version_id == history_id:
raise HTTPException(400, 'Cannot delete the current version')
entry = (
await session.execute(select(ModelHistory).filter_by(id=history_id, model_id=model_id))
).scalar_one_or_none()
if not entry:
return False
await session.execute(
update(ModelHistory)
.where(ModelHistory.model_id == model_id, ModelHistory.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, model_id, snapshot, user_id, parent_id=None, commit_message=None):
return ModelHistory(
id=str(uuid.uuid4()),

View file

@ -281,6 +281,10 @@ class ModelsTable:
)
session.add_all([current, entry])
else:
if production_version_id is not None:
# Serialize with history deletion before reading the selected snapshot.
await session.execute(update(Model).where(Model.id == current.id).values(version_id=Model.version_id))
await session.refresh(current)
values = {key: value for key, value in data.items() if key != 'id'}
# Omitted operational state must not reset a disabled model.
if 'is_active' not in form.model_fields_set:

View file

@ -901,6 +901,19 @@ async def get_model_history_entry(
return entry
@router.delete('/model/history/{history_id}', response_model=bool)
async def delete_model_history_entry(
id: str,
history_id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await authorized_model_history(id, user, db)
if not await ModelHistories.delete_history_entry(id, history_id, db):
raise HTTPException(404, 'Model version not found')
return True
async def _verify_version_dependencies(request, form, user, db):
from open_webui.models.functions import Functions
from open_webui.models.knowledge import Knowledges

View file

@ -515,6 +515,18 @@ export const getModelHistoryEntry = async (
return response.json();
};
export const deleteModelHistoryVersion = async (token: string, id: string, versionId: string) => {
const response = await fetch(
`${WEBUI_API_BASE_URL}/models/model/history/${encodeURIComponent(versionId)}?${new URLSearchParams({ id })}`,
{
method: 'DELETE',
headers: { authorization: `Bearer ${token}` }
}
);
if (!response.ok) throw await response.json();
return response.json();
};
export const setProductionModelVersion = async (token: string, id: string, versionId: string) => {
const response = await fetch(
`${WEBUI_API_BASE_URL}/models/model/update/version?${new URLSearchParams({ id })}`,

View file

@ -9,6 +9,7 @@
import VersionMenuItem from '../common/VersionMenuItem.svelte';
import {
getModelHistory,
deleteModelHistoryVersion,
getModelHistoryEntry,
setProductionModelVersion,
type ModelHistoryEntry,
@ -29,6 +30,9 @@
export let promoting = false;
let error = '';
let confirmPromotion = false;
let showDeleteVersion = false;
let deleteVersionId = '';
let deleting = false;
let selection = 0;
const message = (error: any) =>
typeof error?.detail === 'string'
@ -71,13 +75,32 @@
}
}
async function deleteVersion() {
if (deleting || promoting || !deleteVersionId || deleteVersionId === model.version_id) return;
deleting = true;
try {
await deleteModelHistoryVersion(localStorage.token, model.id, deleteVersionId);
// Invalidate an in-flight preview response for the deleted version.
selection++;
selecting = false;
if (selected?.id === deleteVersionId) selected = null;
page = 1;
await loadHistory();
toast.success($i18n.t('Version deleted'));
} catch (e) {
toast.error(message(e));
} finally {
deleting = false;
}
}
export function requestPromotion() {
if (dirty) confirmPromotion = true;
else promote();
}
async function promote() {
if (!selected || promoting) return;
if (!selected || promoting || deleting) return;
promoting = true;
try {
const result = await setProductionModelVersion(localStorage.token, model.id, selected.id);
@ -102,13 +125,23 @@
on:confirm={promote}
/>
<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}
/>
<div class="flex shrink-0 items-center">
<Dropdown bind:show align="start">
<button
type="button"
aria-label={$i18n.t('Select version')}
class="flex min-w-0 items-center gap-1.5 text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
disabled={promoting}
disabled={promoting || deleting}
on:click={() => {
page = 1;
loadHistory();
@ -138,6 +171,11 @@
{entry}
selected={entry.id === selected?.id}
onSelect={() => selectVersion(entry.id)}
onDelete={() => {
deleteVersionId = entry.id;
show = false;
showDeleteVersion = true;
}}
/>
{/each}
{#if page > 1 || history.length === 20}

View file

@ -99,7 +99,7 @@
bind:this={inputElement}
bind:value
id={`${id}-input`}
class="{className} text-sm bg-transparent outline-hidden"
class="{className} block h-5 py-0 text-xs leading-5 bg-transparent outline-hidden"
type="text"
{placeholder}
role="combobox"

View file

@ -19,7 +19,7 @@
</div>
<select
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
class="block h-5 w-full py-0 text-xs leading-5 bg-transparent outline-hidden cursor-pointer"
bind:value={terminalId}
>
<option value="">{$i18n.t('None')}</option>

View file

@ -33,8 +33,8 @@
<div class="my-3 flex items-center justify-between gap-3">
<div>
<div class="text-xs text-gray-500">{$i18n.t('Voice avatar')}</div>
<div class="text-sm mt-0.5">{$i18n.t(value ? 'Custom avatar' : 'Default orb')}</div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Voice avatar')}</div>
<div class="text-xs leading-5">{$i18n.t(value ? 'Custom avatar' : 'Default orb')}</div>
</div>
<button
type="button"