From 16849284ffbf7a7cd5deac179799ce4d5716848b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 9 Oct 2026 17:00:44 +0400 Subject: [PATCH] refac --- .../e7b9d4a023bc_add_model_history.py | 57 ++ backend/open_webui/models/model_history.py | 107 +++ backend/open_webui/models/models.py | 257 ++++--- backend/open_webui/routers/knowledge.py | 2 +- backend/open_webui/routers/models.py | 156 +++- src/lib/apis/models/index.ts | 88 ++- .../admin/Functions/FunctionEditor.svelte | 52 +- .../workspace/Models/ModelEditor.svelte | 713 ++++++++++-------- .../workspace/Models/ModelHistory.svelte | 167 ++++ .../workspace/Tools/ToolkitEditor.svelte | 65 +- .../(app)/admin/functions/edit/+page.svelte | 5 +- .../workspace/models/create/+page.svelte | 2 +- .../(app)/workspace/models/edit/+page.svelte | 2 +- .../(app)/workspace/tools/edit/+page.svelte | 5 +- 14 files changed, 1196 insertions(+), 482 deletions(-) create mode 100644 backend/open_webui/migrations/versions/e7b9d4a023bc_add_model_history.py create mode 100644 backend/open_webui/models/model_history.py create mode 100644 src/lib/components/workspace/Models/ModelHistory.svelte diff --git a/backend/open_webui/migrations/versions/e7b9d4a023bc_add_model_history.py b/backend/open_webui/migrations/versions/e7b9d4a023bc_add_model_history.py new file mode 100644 index 0000000000..32947fe6f7 --- /dev/null +++ b/backend/open_webui/migrations/versions/e7b9d4a023bc_add_model_history.py @@ -0,0 +1,57 @@ +"""Add model configuration history and initialize Production versions.""" + +import json +import uuid + +import sqlalchemy as sa +from alembic import op + +revision = 'e7b9d4a023bc' +down_revision = 'd6a8c3f912ab' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('model', sa.Column('version_id', sa.Text(), nullable=True)) + history = op.create_table( + 'model_history', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('model_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_model_history_model_id', 'model_history', ['model_id']) + connection = op.get_bind() + model = sa.Table('model', sa.MetaData(), autoload_with=connection) + for row in connection.execute(sa.select(model)).mappings(): + snapshot = {key: row[key] for key in ('name', 'base_model_id', 'params', 'meta')} + for key in ('params', 'meta'): + value = snapshot[key] + snapshot[key] = json.loads(value) if isinstance(value, str) else dict(value or {}) + meta = snapshot['meta'] + meta.pop('hidden', None) + meta.pop('chat_variables_schema', None) + version_id = str(uuid.uuid4()) + connection.execute( + history.insert().values( + id=version_id, + model_id=row['id'], + parent_id=None, + snapshot=snapshot, + user_id=row['user_id'] or '', + commit_message=None, + created_at=row['updated_at'] or row['created_at'] or 0, + ) + ) + connection.execute(model.update().where(model.c.id == row['id']).values(version_id=version_id)) + + +def downgrade(): + op.drop_index('ix_model_history_model_id', table_name='model_history') + op.drop_table('model_history') + with op.batch_alter_table('model') as batch: + batch.drop_column('version_id') diff --git a/backend/open_webui/models/model_history.py b/backend/open_webui/models/model_history.py new file mode 100644 index 0000000000..f3c764dec9 --- /dev/null +++ b/backend/open_webui/models/model_history.py @@ -0,0 +1,107 @@ +"""Immutable snapshots of model configuration; the model row remains Production.""" + +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 + + +def model_snapshot(model) -> dict: + data = ( + model + if isinstance(model, dict) + else {key: getattr(model, key) for key in ('name', 'base_model_id', 'params', 'meta')} + ) + snapshot = {key: data.get(key) for key in ('name', 'base_model_id', 'params', 'meta')} + for key in ('params', 'meta'): + value = snapshot[key] + snapshot[key] = value.model_dump() if isinstance(value, BaseModel) else dict(value or {}) + meta = snapshot['meta'] + meta.pop('hidden', None) + meta.pop('chat_variables_schema', None) + return snapshot + + +class ModelHistory(Base): + __tablename__ = 'model_history' + id = Column(Text, primary_key=True) + model_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 ModelHistoryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + model_id: str + parent_id: str | None = None + user_id: str + commit_message: str | None = None + created_at: int + user: dict | None = None + + +class ModelHistoryModel(ModelHistoryResponse): + snapshot: dict + + +class ModelHistoryTable: + def new_entry(self, model_id, snapshot, user_id, parent_id=None, commit_message=None): + return ModelHistory( + id=str(uuid.uuid4()), + model_id=model_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, model_id, history_id, db=None): + from open_webui.models.users import User + + async with get_async_db_context(db) as session: + entry = ( + await session.execute(select(ModelHistory).filter_by(model_id=model_id, id=history_id)) + ).scalar_one_or_none() + if not entry: + return None + result = ModelHistoryModel.model_validate(entry) + author = (await session.execute(select(User.name).where(User.id == entry.user_id))).scalar_one_or_none() + result.user = {'name': author} if author else None + return result + + async def get_history_by_model_id(self, model_id, page=1, db=None): + from open_webui.models.users import User + + async with get_async_db_context(db) as session: + columns = [getattr(ModelHistory, key) for key in ModelHistoryResponse.model_fields if key != 'user'] + rows = ( + ( + await session.execute( + select(*columns, User.name.label('author_name')) + .outerjoin(User, User.id == ModelHistory.user_id) + .where(ModelHistory.model_id == model_id) + .order_by(ModelHistory.created_at.desc(), ModelHistory.id.desc()) + .offset((max(1, page) - 1) * 20) + .limit(20) + ) + ) + .mappings() + .all() + ) + return [ + ModelHistoryResponse( + **{key: value for key, value in row.items() if key != 'author_name'}, + user={'name': row['author_name']} if row['author_name'] else None, + ) + for row in rows + ] + + +ModelHistories = ModelHistoryTable() diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 3c91f053d4..b8909f908c 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -6,8 +6,10 @@ import time from copy import deepcopy from typing import Annotated, Any, Literal +from fastapi import HTTPException +from open_webui.models.model_history import ModelHistory, ModelHistories, model_snapshot from open_webui.internal.db import Base, JSONField, 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.users import User, UserModel, UserResponse, Users from open_webui.utils.misc import json_text_variants @@ -188,12 +190,14 @@ class Model(Base): name = Column(Text) # human-readable display name params = Column(JSONField) # see ModelParams meta = Column(JSONField) # see ModelMeta + version_id = Column(Text, nullable=True) is_active = Column(Boolean, default=True) # soft-disable toggle updated_at = Column(BigInteger) # epoch seconds created_at = Column(BigInteger) # epoch seconds class ModelModel(BaseModel): + version_id: str | None = None id: str user_id: str base_model_id: str | None = None @@ -236,6 +240,8 @@ class ModelAccessListResponse(BaseModel): class ModelForm(BaseModel): + commit_message: str | None = None + model_config = ConfigDict(extra='ignore') id: str = Field(pattern=r'^\S+$') @@ -257,44 +263,85 @@ class ModelsTable: access_grants: list[AccessGrantModel] | None = None, db: AsyncSession | None = None, ) -> ModelModel: - if isinstance(model.meta, dict): - knowledge = model.meta.get('knowledge') - stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge) - if stripped_knowledge != knowledge: - model.meta = {**model.meta, 'knowledge': stripped_knowledge} - if db is not None: - await db.commit() - model_model = ModelModel.model_validate(model) model_model.access_grants = ( access_grants if access_grants is not None else await self._get_access_grants(model_model.id, db=db) ) return model_model + async def _write_model(self, session, form, user_id, current=None, production_version_id=None): + """Write configuration, history, and grants in the caller's transaction.""" + data = form.model_dump(exclude={'access_grants', 'commit_message'}) + data['meta'].pop('chat_variables_schema', None) + snapshot = model_snapshot(data) + if current is None: + entry = ModelHistories.new_entry(form.id, snapshot, user_id, commit_message=form.commit_message) + current = Model( + **data, user_id=user_id, version_id=entry.id, created_at=int(time.time()), updated_at=int(time.time()) + ) + session.add_all([current, entry]) + else: + 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: + values.pop('is_active', None) + previous = model_snapshot( + { + 'name': current.name, + 'base_model_id': current.base_model_id, + 'params': ModelParams.model_validate(current.params or {}), + 'meta': ModelMeta.model_validate(deepcopy(current.meta or {})), + } + ) + if production_version_id is not None: + entry = ( + await session.execute(select(ModelHistory).filter_by(id=production_version_id, model_id=current.id)) + ).scalar_one_or_none() + if entry is None: + raise HTTPException(404, 'Model version not found') + values['version_id'] = entry.id + values.pop('is_active', None) + # Visibility belongs to the live model, not the historical snapshot. + values['meta'].pop('hidden', None) + if 'hidden' in (current.meta or {}): + values['meta']['hidden'] = current.meta['hidden'] + elif snapshot != previous: + entry = ModelHistories.new_entry(current.id, snapshot, user_id, current.version_id, form.commit_message) + session.add(entry) + values['version_id'] = entry.id + values['updated_at'] = int(time.time()) + result = await session.execute( + update(Model) + .where(Model.id == current.id, Model.version_id == current.version_id) + .values(**values) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + raise HTTPException(409, {'code': 'version_conflict'}) + if form.access_grants is not None or current in session.new: + await AccessGrants.replace_access_grants(session, 'model', form.id, form.access_grants) + return current + + async def _written_model(self, session, model): + await session.refresh(model) + grants = ( + (await session.execute(select(AccessGrant).filter_by(resource_type='model', resource_id=model.id))) + .scalars() + .all() + ) + return await self._to_model_model(model, [AccessGrantModel.model_validate(g) for g in grants]) + async def insert_new_model( self, form_data: ModelForm, user_id: str, db: AsyncSession | None = None ) -> ModelModel | None: - try: - async with get_async_db_context(db) as db: - result = Model( - **{ - **form_data.model_dump(exclude={'access_grants'}), - 'user_id': user_id, - 'created_at': int(time.time()), - 'updated_at': int(time.time()), - } - ) - db.add(result) - await db.commit() - await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db) - - if result: - return await self._to_model_model(result, db=db) - else: - return None - except Exception as e: - log.exception(f'Failed to insert a new model: {e}') - return None + async with get_async_db_context(db) as session: + try: + model = await self._write_model(session, form_data, user_id) + await session.commit() + return await self._written_model(session, model) + except Exception: + await session.rollback() + raise async def get_all_models(self, db: AsyncSession | None = None) -> list[ModelModel]: async with get_async_db_context(db) as db: @@ -383,10 +430,13 @@ class ModelsTable: and ( meta.get('background_image_url') == f'/api/v1/files/{file_id}/content' or (meta.get('voice_avatar') or {}).get('file_id') == file_id - or any(asset.get('file_id') == file_id for asset in ( - list((meta.get('voice_avatar') or {}).get('states', {}).values()) - + (meta.get('voice_avatar') or {}).get('gestures', []) - )) + or any( + asset.get('file_id') == file_id + for asset in ( + list((meta.get('voice_avatar') or {}).get('states', {}).values()) + + (meta.get('voice_avatar') or {}).get('gestures', []) + ) + ) ) ) } @@ -626,22 +676,25 @@ class ModelsTable: except Exception: return None - async def update_model_by_id(self, id: str, model: ModelForm, db: AsyncSession | None = None) -> ModelModel | None: - try: - async with get_async_db_context(db) as db: - # update only the fields that are present in the model - data = model.model_dump(exclude={'id', 'access_grants'}) - data['updated_at'] = int(time.time()) - await db.execute(update(Model).filter_by(id=id).values(**data)) - - await db.commit() - if model.access_grants is not None: - await AccessGrants.set_access_grants('model', id, model.access_grants, db=db) - - return await self.get_model_by_id(id, db=db) - except Exception as e: - log.exception(f'Failed to update the model by id {id}: {e}') - return None + async def update_model_by_id( + self, + id: str, + model: ModelForm, + db: AsyncSession | None = None, + user_id: str | None = None, + production_version_id: str | None = None, + ) -> ModelModel | None: + async with get_async_db_context(db) as session: + try: + current = await session.get(Model, id, populate_existing=True) + if current is None: + return None + await self._write_model(session, model, user_id or current.user_id, current, production_version_id) + await session.commit() + return await self._written_model(session, current) + except Exception: + await session.rollback() + raise async def update_model_updated_at_by_id(self, id: str, db: AsyncSession | None = None) -> ModelModel | None: try: @@ -657,85 +710,51 @@ class ModelsTable: log.exception(f'Failed to update the model updated_at by id {id}: {e}') return None - async def delete_model_by_id(self, id: str, db: AsyncSession | None = None) -> bool: - try: - async with get_async_db_context(db) as db: - await AccessGrants.revoke_all_access('model', id, db=db) - await db.execute(delete(Model).filter_by(id=id)) - await db.commit() + async def _delete_models(self, session, ids): + await session.execute( + delete(AccessGrant).where(AccessGrant.resource_type == 'model', AccessGrant.resource_id.in_(ids)) + ) + await session.execute(delete(ModelHistory).where(ModelHistory.model_id.in_(ids))) + await session.execute(delete(Model).where(Model.id.in_(ids))) + async def delete_model_by_id(self, id: str, db: AsyncSession | None = None) -> bool: + async with get_async_db_context(db) as session: + try: + await self._delete_models(session, [id]) + await session.commit() return True - except Exception: - return False + except Exception: + await session.rollback() + raise async def delete_all_models(self, db: AsyncSession | None = None) -> bool: - try: - async with get_async_db_context(db) as db: - result = await db.execute(select(Model.id)) - model_ids = [row[0] for row in result.all()] - for model_id in model_ids: - await AccessGrants.revoke_all_access('model', model_id, db=db) - await db.execute(delete(Model)) - await db.commit() - + async with get_async_db_context(db) as session: + try: + ids = (await session.execute(select(Model.id))).scalars().all() + await self._delete_models(session, ids) + await session.commit() return True - except Exception: - return False + except Exception: + await session.rollback() + raise async def sync_models( self, user_id: str, models: list[ModelModel], db: AsyncSession | None = None ) -> list[ModelModel]: - try: - async with get_async_db_context(db) as db: - # Get existing models - result = await db.execute(select(Model)) - existing_models = result.scalars().all() - existing_ids = {model.id for model in existing_models} - - # Prepare a set of new model IDs - new_model_ids = {model.id for model in models} - - # Update or insert models + async with get_async_db_context(db) as session: + try: + existing = {model.id: model for model in (await session.execute(select(Model))).scalars()} + written = [] for model in models: - model_data = { - **model.model_dump(exclude={'access_grants'}), - 'user_id': user_id, - 'updated_at': int(time.time()), - } - - if model.id in existing_ids: - await db.execute(update(Model).filter_by(id=model.id).values(**model_data)) - else: - db.add(Model(**model_data)) - - # Remove models that are no longer present - for model in existing_models: - if model.id not in new_model_ids: - await db.delete(model) - - await db.commit() - - # Grants after the commit to avoid SQLite write-lock contention when session sharing is off - for model in models: - await AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db) - for model_id in existing_ids - new_model_ids: - await AccessGrants.revoke_all_access('model', model_id, db=db) - - result = await db.execute(select(Model)) - all_models = result.scalars().all() - model_ids = [model.id for model in all_models] - grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db) - return [ - await self._to_model_model( - model, - access_grants=grants_map.get(model.id, []), - db=db, - ) - for model in all_models - ] - except Exception as e: - log.exception(f'Error syncing models for user {user_id}: {e}') - return [] + # Imported version IDs are never local history identities. + form = ModelForm(**model.model_dump()) + written.append(await self._write_model(session, form, user_id, existing.get(model.id))) + await self._delete_models(session, existing.keys() - {model.id for model in models}) + await session.commit() + return [await self._written_model(session, model) for model in written] + except Exception: + await session.rollback() + raise Models = ModelsTable() # singleton model registry diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 9041f99bff..f6300e7d80 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -1747,7 +1747,7 @@ async def delete_knowledge_by_id( log.info('Updating model %s to remove knowledge base %s', model.id, id) model.meta.knowledge = updated_knowledge model_form = ModelForm(**model.model_dump()) - await Models.update_model_by_id(model.id, model_form, db=db) + await Models.update_model_by_id(model.id, model_form, db=db, user_id=user.id) # Clean up vector DB if is_external_knowledge(knowledge): diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 48403e9c56..94f42eded0 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -5,6 +5,7 @@ import base64 import io import logging import posixpath +from copy import deepcopy from typing import Optional from urllib.parse import unquote @@ -31,6 +32,7 @@ from open_webui.models.access_grants import AccessGrants, normalize_access_grant from open_webui.models.config import Config from open_webui.models.files import Files from open_webui.models.groups import Groups +from open_webui.models.model_history import ModelHistories, ModelHistoryModel, ModelHistoryResponse from open_webui.models.models import ( ModelAccessListResponse, ModelAccessResponse, @@ -50,7 +52,12 @@ from open_webui.utils.chat_variables import get_chat_variables_schema from open_webui.utils.json_codec import JSONCodec from open_webui.utils.models import get_all_models from open_webui.utils.validate import BACKGROUND_IMAGE_MAX_BYTES, validate_background_image -from open_webui.utils.voice_avatar import AVATAR_MAX_BYTES, ANIMATION_MAX_BYTES, validate_voice_avatar, validate_voice_animation +from open_webui.utils.voice_avatar import ( + AVATAR_MAX_BYTES, + ANIMATION_MAX_BYTES, + validate_voice_avatar, + validate_voice_animation, +) from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -159,9 +166,14 @@ async def _verify_voice_avatar(avatar, user, db, previous=None) -> None: if asset.file_id == avatar.file_id: raise HTTPException(status_code=400, detail='An animation must be a VRMA file, not the avatar.') assets[asset.file_id] = (ANIMATION_MAX_BYTES, validate_voice_animation) - previous_assets = ({previous.file_id: validate_voice_avatar} | { - asset.file_id: validate_voice_animation for asset in [*previous.states.values(), *previous.gestures] - }) if previous else {} + previous_assets = ( + ( + {previous.file_id: validate_voice_avatar} + | {asset.file_id: validate_voice_animation for asset in [*previous.states.values(), *previous.gestures]} + ) + if previous + else {} + ) for file_id, (limit, validate) in assets.items(): if previous_assets.get(file_id) is validate: continue @@ -662,6 +674,7 @@ async def import_models( await _check_model_controls(imported_model, existing_model, user, request) uploaded = None + save_attempted = False try: encoded = model_data.pop('background_image_data', None) if encoded is not None: @@ -704,15 +717,17 @@ async def import_models( db, existing_model.meta.voice_avatar if existing_model else None, ) + save_attempted = True saved = ( - await Models.update_model_by_id(model_id, imported_model, db=db) + await Models.update_model_by_id(model_id, imported_model, db=db, user_id=user.id) if existing_model else await Models.insert_new_model(user_id=user.id, form_data=imported_model, db=db) ) if not saved: raise HTTPException(status_code=500, detail=f'Could not import model {model_id}.') except Exception: - if uploaded: + # A failed response can follow a commit; history may retain this upload. + if uploaded and not save_attempted: try: await Files.delete_file_by_id(uploaded.id, db=db) await asyncio.to_thread(Storage.delete_file, uploaded.path) @@ -843,6 +858,133 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes ########################### +async def authorized_model_history(id, user, db): + model = await Models.get_model_by_id(id, db=db) + if not model: + raise HTTPException(404, ERROR_MESSAGES.NOT_FOUND) + if not ( + user.id == model.user_id + or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) + or await AccessGrants.has_access( + user_id=user.id, resource_type='model', resource_id=id, permission='write', db=db + ) + ): + raise HTTPException(403, ERROR_MESSAGES.ACCESS_PROHIBITED) + return model + + +@router.get('/model/history', response_model=list[ModelHistoryResponse]) +async def get_model_history( + id: str, + page: int = 1, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await authorized_model_history(id, user, db) + return await ModelHistories.get_history_by_model_id(id, page, db=db) + + +@router.get('/model/history/{history_id}', response_model=ModelHistoryModel) +async def get_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) + entry = await ModelHistories.get_history_by_id(id, history_id, db=db) + if not entry: + raise HTTPException(404, 'Model version not found') + if user.role != 'admin': + entry.snapshot = deepcopy(entry.snapshot) + entry.snapshot.get('params', {}).pop('model_controls', None) + return entry + + +async def _verify_version_dependencies(request, form, user, db): + from open_webui.models.functions import Functions + from open_webui.models.knowledge import Knowledges + from open_webui.models.skills import Skills + from open_webui.routers.terminals import list_terminal_servers + from open_webui.routers.tools import get_tools + + async def require_resource(resource_type, resource_id, resource): + if not resource or getattr(resource, 'is_active', True) is False: + raise HTTPException(400, f'Referenced {resource_type} is unavailable: {resource_id}') + if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) and resource.user_id != user.id: + if not await AccessGrants.has_access( + user_id=user.id, resource_type=resource_type, resource_id=resource_id, permission='read', db=db + ): + raise HTTPException(403, f'Referenced {resource_type} is not accessible: {resource_id}') + + if form.base_model_id: + available = await get_all_models(request, user=user) + if not any(model['id'] == form.base_model_id for model in available): + raise HTTPException(400, 'The base model is unavailable.') + for item in form.meta.knowledge or []: + if not isinstance(item, dict) or not item.get('id') or item.get('legacy'): + continue + if item.get('type') == 'file': + file = await Files.get_file_by_id(item['id'], db=db) + if not file or not (user.role == 'admin' or await has_access_to_file(file.id, 'read', user, db=db)): + raise HTTPException(400, 'A referenced knowledge file is missing or inaccessible.') + else: + await require_resource('knowledge', item['id'], await Knowledges.get_knowledge_by_id(item['id'], db=db)) + for skill_id in getattr(form.meta, 'skillIds', None) or []: + await require_resource('skill', skill_id, await Skills.get_skill_by_id(skill_id, db=db)) + tool_ids = set(getattr(form.meta, 'toolIds', None) or []) + if tool_ids: + available_tools = {tool.id for tool in await get_tools(request, user=user, db=db)} + if not tool_ids.issubset(available_tools): + raise HTTPException(400, 'A referenced tool is missing or inaccessible.') + for key in ('filterIds', 'defaultFilterIds', 'actionIds'): + for function_id in getattr(form.meta, key, None) or []: + function = await Functions.get_function_by_id(function_id, db=db) + if not function or not function.is_active: + raise HTTPException(400, f'A referenced function is unavailable: {function_id}') + terminal_id = getattr(form.meta, 'terminalId', None) + if terminal_id and terminal_id not in {t['id'] for t in await list_terminal_servers(request, user=user)}: + raise HTTPException(400, 'The referenced terminal is missing or inaccessible.') + + +class ModelVersionForm(BaseModel): + version_id: str + + +@router.post('/model/update/version', response_model=ModelModel) +async def set_model_version( + request: Request, + id: str, + form_data: ModelVersionForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + model = await authorized_model_history(id, user, db) + entry = await ModelHistories.get_history_by_id(id, form_data.version_id, db=db) + if not entry: + raise HTTPException(404, 'Model version not found') + form = ModelForm(id=id, **deepcopy(entry.snapshot)) + # A missing historical controls key means an empty configuration, not "preserve current". + form.params.model_fields_set.add('model_controls') + if user.role != 'admin' and model.base_model_id and not form.base_model_id: + raise HTTPException(403, ERROR_MESSAGES.ACCESS_PROHIBITED) + await _check_model_controls(form, model, user, request) + await _verify_version_dependencies(request, form, user, db) + await _verify_background_image(form.meta.background_image_url, user, db) + await _verify_voice_avatar(form.meta.voice_avatar, user, db) + result = await Models.update_model_by_id(id, form, db=db, user_id=user.id, production_version_id=entry.id) + if result is None: + raise HTTPException(404, ERROR_MESSAGES.NOT_FOUND) + await publish_event( + request, + EVENTS.MODEL_UPDATED, + actor=user, + subject_id=id, + data={'name': result.name, 'version_id': result.version_id}, + ) + return model_response(result, user) + + @router.get('/model/profile/image') async def get_model_profile_image( request: Request, @@ -1087,7 +1229,7 @@ async def update_model_by_id( ) await _check_model_controls(form_data, model, user, request) - model = await Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db) + model = await Models.update_model_by_id(form_data.id, form_data, db=db, user_id=user.id) if model: await publish_event( request, diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index 5fca52095f..ce00ae08c2 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -214,8 +214,18 @@ export const getBaseModels = async (token: string = '', tag: string = '') => { export const createNewModel = async (token: string, model: object) => { let error = null; - const { id, base_model_id, name, meta, params, access_grants, is_active } = model as any; - const payload = { id, base_model_id, name, meta, params, access_grants, is_active }; + const { id, base_model_id, name, meta, params, access_grants, is_active, commit_message } = + model as any; + const payload = { + id, + base_model_id, + name, + meta, + params, + access_grants, + is_active, + commit_message + }; const res = await fetch(`${WEBUI_API_BASE_URL}/models/create`, { method: 'POST', @@ -316,8 +326,18 @@ export const toggleModelById = async (token: string, id: string) => { export const updateModelById = async (token: string, id: string, model: object) => { let error = null; - const { base_model_id, name, meta, params, access_grants, is_active } = model as any; - const payload = { id, base_model_id, name, meta, params, access_grants, is_active }; + const { base_model_id, name, meta, params, access_grants, is_active, commit_message } = + model as any; + const payload = { + id, + base_model_id, + name, + meta, + params, + access_grants, + is_active, + commit_message + }; const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/update`, { method: 'POST', @@ -447,3 +467,63 @@ export const deleteAllModels = async (token: string) => { return res; }; + +export type ModelHistoryEntry = { + id: string; + model_id: string; + parent_id: string | null; + user_id: string; + commit_message: string | null; + created_at: number; + user?: { name: string } | null; +}; + +export type ModelSnapshot = { + name: string; + base_model_id: string | null; + params: Record; + meta: Record; +}; + +export const getModelHistory = async ( + token: string, + id: string, + page = 1 +): Promise => { + const response = await fetch( + `${WEBUI_API_BASE_URL}/models/model/history?${new URLSearchParams({ id, page: String(page) })}`, + { + headers: { authorization: `Bearer ${token}` } + } + ); + if (!response.ok) throw await response.json(); + return response.json(); +}; + +export const getModelHistoryEntry = async ( + token: string, + id: string, + versionId: string +): Promise => { + const response = await fetch( + `${WEBUI_API_BASE_URL}/models/model/history/${encodeURIComponent(versionId)}?${new URLSearchParams({ id })}`, + { + 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 })}`, + { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ version_id: versionId }) + } + ); + if (!response.ok) throw await response.json(); + return response.json(); +}; diff --git a/src/lib/components/admin/Functions/FunctionEditor.svelte b/src/lib/components/admin/Functions/FunctionEditor.svelte index 41f0c0fc05..e891da510c 100644 --- a/src/lib/components/admin/Functions/FunctionEditor.svelte +++ b/src/lib/components/admin/Functions/FunctionEditor.svelte @@ -20,7 +20,7 @@ let loading = false; let showConfirm = false; - export let onSave = /** @param {any} _value */ async (_value) => {}; + export let onSave = /** @param {any} _value */ async (_value) => false; export let edit = false; export let clone = false; @@ -33,6 +33,17 @@ }; export let content = ''; let _content = ''; + /** @param {string} id @param {string} name @param {Record} meta @param {string} content */ + const draftSnapshot = (id, name, meta, content) => + JSON.stringify({ + id, + name, + meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) }, + content + }); + let savedDraft = draftSnapshot(id, name, meta, content); + $: draft = draftSnapshot(id, name, meta, _content); + $: changed = draft !== savedDraft; $: if (content) { updateContent(); @@ -333,18 +344,21 @@ class Pipe: } loading = true; try { - await onSave({ + const submittedDraft = draftSnapshot(id, name, meta, content); + const saved = await onSave({ id, name, meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) }, content }); + if (saved) savedDraft = submittedDraft; } finally { loading = false; } }; const submitHandler = async () => { + if (loading || (edit && !changed)) return; if (codeEditor) { content = _content; await tick(); @@ -376,16 +390,24 @@ class Pipe: } }} > - +
+ +
+ +
+
@@ -440,10 +462,6 @@ class Pipe:
- {#if !edit} {#if !edit || (edit && model)} +
{ submitHandler(); }} >
-
+