This commit is contained in:
Timothy Jaeryang Baek 2026-10-09 17:00:44 +04:00
parent 4266802993
commit 16849284ff
14 changed files with 1196 additions and 482 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, any>;
meta: Record<string, any>;
};
export const getModelHistory = async (
token: string,
id: string,
page = 1
): Promise<ModelHistoryEntry[]> => {
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<ModelHistoryEntry & { snapshot: ModelSnapshot }> => {
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();
};

View file

@ -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<string, any>} 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:
}
}}
>
<button
class="mb-1 flex h-6 w-fit items-center gap-1 rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/admin/functions');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-center justify-between gap-2">
<button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/admin/functions');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(pruneEmptyLocaleEntries(meta.i18n))}
/>
</div>
</div>
<div class="flex shrink-0 flex-col gap-2 pb-2 px-1 sm:flex-row sm:items-start">
<div class="min-w-0 w-full flex-1">
@ -440,10 +462,6 @@ class Pipe:
</div>
<div class="flex shrink-0 items-center gap-1">
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(pruneEmptyLocaleEntries(meta.i18n))}
/>
{#if !edit}
<select
class="h-7 rounded-lg border border-gray-100 bg-transparent px-2 text-xs outline-hidden dark:border-gray-800"
@ -509,7 +527,7 @@ class Pipe:
<button
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading}
disabled={loading || (edit && !changed)}
>
{$i18n.t(edit ? 'Save' : 'Save & Create')}
{#if loading}

View file

@ -1,16 +1,25 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { beforeNavigate, goto } from '$app/navigation';
import { getModels } from '$lib/apis';
import ModelHistory from './ModelHistory.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { onMount, onDestroy, getContext, tick } from 'svelte';
import { config, models, tools, functions, user } from '$lib/stores';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL, DEFAULT_CAPABILITIES } from '$lib/constants';
import { config, models, tools, functions, user, settings } from '$lib/stores';
import { WEBUI_BASE_URL, DEFAULT_CAPABILITIES } from '$lib/constants';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { getFunctions } from '$lib/apis/functions';
import { getModelsDefaults } from '$lib/apis/configs';
import { getLanguages } from '$lib/i18n';
import { getBaseModelTags, getModelTags } from '$lib/apis/models';
import {
getBaseModelTags,
getModelTags,
type ModelHistoryEntry,
type ModelSnapshot
} from '$lib/apis/models';
import { getVoices } from '$lib/apis/audio';
import { uploadFile, deleteFileById } from '$lib/apis/files';
@ -49,7 +58,8 @@
const i18n: any = getContext('i18n');
export let onSubmit: Function;
export let onSubmit: Function = () => {};
export let readOnly = false;
export let onBack: null | Function = null;
export let model: any = null;
@ -59,6 +69,39 @@
export let preset = true;
let loading = false;
let historyMenu: ModelHistory;
let selectedVersion: (ModelHistoryEntry & { snapshot: ModelSnapshot }) | null = null;
let selectingVersion = false;
let promotingVersion = false;
$: historical = !!selectedVersion || selectingVersion;
let commitMessage = '';
let savedDraft = '';
let defaultMeta: Record<string, any> = {};
let allowNavigation = false;
let showDiscard = false;
let afterDiscard: (() => void) | null = null;
$: configurationChanged =
!!savedDraft &&
(JSON.stringify(modelInfo) !== savedDraft ||
!!backgroundFile ||
!!avatarFile ||
Object.keys(animationFiles).length > 0);
$: dirty = configurationChanged || (!!savedDraft && !!commitMessage);
const discardThen = (action: () => void) => {
if (!dirty) return action();
afterDiscard = action;
showDiscard = true;
};
beforeNavigate(({ cancel, to, willUnload }) => {
if (readOnly || !dirty || allowNavigation) return;
cancel();
if (!willUnload && to)
discardThen(() => {
allowNavigation = true;
goto(to.url.href);
});
});
let voiceAvatar: VoiceAvatarConfig | null = null;
let avatarFile: File | null = null;
let animationFiles: AnimationFiles = {};
@ -420,8 +463,16 @@
return modelInfo;
})();
const preventReadOnlyEdit = (event: Event) => {
if (readOnly && event.target instanceof Element && event.target.closest('fieldset:disabled')) {
if (event instanceof KeyboardEvent && event.key === 'Tab') return;
event.preventDefault();
event.stopPropagation();
}
};
const submitHandler = async () => {
if (loading) return;
if (readOnly || loading || historical || (edit && !configurationChanged)) return;
loading = true;
if (id === '') {
@ -461,6 +512,7 @@
info = structuredClone(modelInfo);
let saveAttempted = false;
let uploadedId: string | null = null;
const previousBackground = info.meta.background_image_url;
const previousAvatar = structuredClone(info.meta.voice_avatar);
@ -499,52 +551,31 @@
asset.file_id = replacements.get(asset.file_id) ?? asset.file_id;
}
}
const saved = await onSubmit(info);
if (saved === false) throw new Error($i18n.t('Failed to save model'));
saveAttempted = true;
allowNavigation = true;
const saved = await onSubmit({ ...info, commit_message: commitMessage || undefined });
if (!saved) throw new Error($i18n.t('Failed to save model'));
if (typeof saved === 'object') await loadModel(saved);
commitMessage = '';
backgroundFile = null;
avatarFile = null;
animationFiles = {};
voiceAvatar = info.meta.voice_avatar;
clearBackgroundPreview();
await tick();
savedDraft = JSON.stringify(modelInfo);
allowNavigation = false;
} catch (error: any) {
info.meta.background_image_url = previousBackground;
info.meta.voice_avatar = previousAvatar;
if (uploadedAvatarIds.length) {
try {
const response = await fetch(
`${WEBUI_API_BASE_URL}/models/model?${new URLSearchParams({ id: info.id })}`,
{ headers: { authorization: `Bearer ${localStorage.token}` } }
);
if (response.status === 404 || response.ok) {
const referenced = response.ok
? avatarAssetIds((await response.json())?.meta?.voice_avatar)
: [];
for (const id of uploadedAvatarIds)
if (!referenced.includes(id)) await deleteFileById(localStorage.token, id);
}
} catch {
/* Keep uploads when the save result is uncertain. */
}
}
if (uploadedId) {
// A failed response can follow a committed save; only delete an unused upload.
try {
const response = await fetch(
`${WEBUI_API_BASE_URL}/models/model?${new URLSearchParams({ id: info.id })}`,
{ headers: { authorization: `Bearer ${localStorage.token}` } }
);
if (
response.status === 404 ||
(response.ok &&
(await response.json())?.meta?.background_image_url !==
`/api/v1/files/${uploadedId}/content`)
) {
await deleteFileById(localStorage.token, uploadedId);
}
} catch {
/* Leave uncertain uploads for file management. */
allowNavigation = false;
// A lost response can follow a committed version. Keep its uploads until the outcome is known.
if (!saveAttempted) {
for (const fileId of [...uploadedAvatarIds, ...(uploadedId ? [uploadedId] : [])]) {
await deleteFileById(localStorage.token, fileId).catch(() => {});
}
}
toast.error(`${error?.detail ?? error?.message ?? error}`);
} finally {
loading = false;
@ -552,36 +583,14 @@
}
};
onMount(async () => {
languages = await getLanguages();
await tools.set((await getTools(localStorage.token).catch(() => null)) ?? []);
skillsList = (await getSkills(localStorage.token).catch(() => null)) ?? [];
if (!$functions) {
await functions.set(await getFunctions(localStorage.token));
}
if (suggestionTags.length === 0) {
await loadSuggestionTags();
}
if (voices.length === 0) {
await loadVoices();
}
// Fetch admin-configured default model metadata so the editor
// reflects the actual defaults rather than hardcoded values
const modelsConfig = await getModelsDefaults(localStorage.token).catch(() => null);
const defaultMeta = modelsConfig?.DEFAULT_MODEL_METADATA ?? {};
// Use admin defaults as base, falling back to hardcoded defaults
capabilities = { ...DEFAULT_CAPABILITIES, ...(defaultMeta.capabilities ?? {}) };
defaultFeatureIds = defaultMeta.defaultFeatureIds ?? [];
builtinTools = defaultMeta.builtinTools ?? {};
// Scroll to top 'workspace-container' element
const workspaceContainer = document.getElementById('workspace-container');
if (workspaceContainer) {
workspaceContainer.scrollTop = 0;
}
const loadModel = async (value: any) => {
model = value ? structuredClone(value) : null;
backgroundFile = null;
avatarFile = null;
animationFiles = {};
commitMessage = '';
editingLocale = '';
clearBackgroundPreview();
if (model) {
name = model.name;
voiceAvatar = model.meta?.voice_avatar ? structuredClone(model.meta.voice_avatar) : null;
@ -609,7 +618,7 @@
system = model?.params?.system ?? '';
params = { ...params, ...model?.params };
params = { system: '', ...model?.params };
params.stop = params?.stop
? (typeof params.stop === 'string' ? params.stop.split(',') : (params?.stop ?? [])).join(
','
@ -642,38 +651,87 @@
actionIds = model?.meta?.actionIds ?? [];
// Per-model overrides take precedence over admin defaults
capabilities = { ...capabilities, ...(model?.meta?.capabilities ?? {}) };
defaultFeatureIds = model?.meta?.defaultFeatureIds ?? defaultFeatureIds;
builtinTools = model?.meta?.builtinTools ?? builtinTools;
capabilities = {
...DEFAULT_CAPABILITIES,
...(defaultMeta.capabilities ?? {}),
...(model?.meta?.capabilities ?? {})
};
defaultFeatureIds = model?.meta?.defaultFeatureIds ?? defaultMeta.defaultFeatureIds ?? [];
builtinTools = model?.meta?.builtinTools ?? defaultMeta.builtinTools ?? {};
terminalId = model?.meta?.terminalId ?? '';
tts = { voice: model?.meta?.tts?.voice ?? '' };
voice = { voice: model?.meta?.voice?.voice ?? '' };
accessGrants = model?.access_grants ?? [];
info = {
...info,
...JSON.parse(
JSON.stringify(
model
? model
: {
id: model.id,
name: model.name
}
)
)
};
info = structuredClone(model);
info.meta.i18n = info.meta.i18n ?? {};
console.log(model);
}
await tick();
savedDraft = JSON.stringify(modelInfo);
};
const productionHandler = async (value: any) => {
await loadModel(value);
try {
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections
? ($settings?.directConnections ?? null)
: null
)
);
} catch (error) {
toast.error(`${error}`);
}
};
onMount(async () => {
languages = await getLanguages();
await tools.set((await getTools(localStorage.token).catch(() => null)) ?? []);
skillsList = (await getSkills(localStorage.token).catch(() => null)) ?? [];
if (!$functions) {
await functions.set(await getFunctions(localStorage.token));
}
if (suggestionTags.length === 0) {
await loadSuggestionTags();
}
if (voices.length === 0) {
await loadVoices();
}
// Fetch admin-configured default model metadata so the editor
// reflects the actual defaults rather than hardcoded values
const modelsConfig = await getModelsDefaults(localStorage.token).catch(() => null);
defaultMeta = modelsConfig?.DEFAULT_MODEL_METADATA ?? {};
// Use admin defaults as base, falling back to hardcoded defaults
capabilities = { ...DEFAULT_CAPABILITIES, ...(defaultMeta.capabilities ?? {}) };
defaultFeatureIds = defaultMeta.defaultFeatureIds ?? [];
builtinTools = defaultMeta.builtinTools ?? {};
// Scroll to top 'workspace-container' element
const workspaceContainer = document.getElementById('workspace-container');
if (workspaceContainer) {
workspaceContainer.scrollTop = 0;
}
await loadModel(model);
loaded = true;
});
</script>
{#if loaded}
<ConfirmDialog
bind:show={showDiscard}
title={$i18n.t('Discard unsaved changes?')}
message={$i18n.t('Your unsaved changes will be lost.')}
on:confirm={() => {
allowNavigation = true;
afterDiscard?.();
}}
/>
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessGrants
@ -686,20 +744,75 @@
/>
<div class="flex h-full min-h-0 w-full flex-col">
{#if onBack}
<button
class="mb-1 flex h-6 w-fit items-center gap-1 rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
onBack();
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
{/if}
<div class="flex shrink-0 items-center gap-3">
{#if onBack}
<button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
discardThen(() => {
allowNavigation = true;
onBack?.();
});
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
{/if}
{#if edit && model?.version_id && !readOnly}
<ModelHistory
bind:this={historyMenu}
{model}
{dirty}
bind:selected={selectedVersion}
bind:selecting={selectingVersion}
bind:promoting={promotingVersion}
onProduction={productionHandler}
/>
{/if}
{#if !historical && !readOnly}
<div class="ms-auto flex shrink-0 items-center gap-1 pr-0.5">
<LanguageModeSelect
bind:value={editingLocale}
{languages}
{translatedLocales}
className="w-fit"
/>
<AccessButton on:click={() => (showAccessControlModal = true)} />
</div>
{/if}
</div>
<div class="min-h-0 w-full flex-1 overflow-y-auto pr-1 scrollbar-hover">
{#if selectingVersion}
<div class="flex flex-1 justify-center py-8"><Spinner className="size-5" /></div>
{:else if selectedVersion}
<section aria-label={$i18n.t('Model version preview')} class="min-h-0 flex-1">
{#key selectedVersion.id}
<svelte:self
model={{ id: model.id, ...selectedVersion.snapshot }}
edit
readOnly
{admin}
preset={!!selectedVersion.snapshot.base_model_id}
/>
{/key}
</section>
<div class="flex shrink-0 justify-end px-1 py-2">
<button
type="button"
class="flex h-7 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
disabled={promotingVersion}
on:click={() => historyMenu.requestPromotion()}
>
{$i18n.t('Set as Production')}
</button>
</div>
{/if}
<div
class:hidden={historical}
class="min-h-0 w-full flex-1 overflow-y-auto pr-1 scrollbar-hover"
>
<input
bind:this={filesInputElement}
bind:files={inputFiles}
@ -778,14 +891,18 @@
/>
{#if !edit || (edit && model)}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions (Capture listeners block changes from custom controls in read-only fieldsets.) -->
<form
class="flex w-full flex-col gap-2.5 md:flex-row"
on:click|capture={preventReadOnlyEdit}
on:keydown|capture={preventReadOnlyEdit}
on:pointerdown|capture={preventReadOnlyEdit}
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="w-full px-1">
<div class="flex w-full flex-col gap-3">
<fieldset disabled={readOnly} class="flex min-w-0 w-full flex-col gap-3">
<div class="flex w-full min-w-0 items-center gap-3 py-0.5">
<div class="flex min-w-0 flex-1 items-center gap-3">
<!-- LICENSE covers this Open WebUI fallback logo.
@ -876,19 +993,6 @@
required
/>
{/if}
<LanguageModeSelect
bind:value={editingLocale}
{languages}
{translatedLocales}
className="hidden w-fit sm:inline-flex"
/>
<AccessButton
on:click={() => {
showAccessControlModal = true;
}}
/>
</div>
{#if editingLocale}
@ -915,14 +1019,6 @@
disabled={edit}
required
/>
<div class="mt-1 sm:hidden">
<LanguageModeSelect
bind:value={editingLocale}
{languages}
{translatedLocales}
/>
</div>
</div>
</div>
</div>
@ -1119,7 +1215,7 @@
}}
/>
</div>
</div>
</fieldset>
<section class="mt-2.5">
<div class="mb-2 text-xs text-gray-400 dark:text-gray-600">
@ -1127,7 +1223,7 @@
</div>
<div class="space-y-2.5">
<div>
<fieldset disabled={readOnly} class="min-w-0">
<div class="mb-1 text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('System Prompt')}
</div>
@ -1197,7 +1293,7 @@
{/if}
</div>
{/if}
</div>
</fieldset>
<div class="flex h-7 w-full justify-between">
<div class="self-center text-xs text-gray-600 dark:text-gray-400">
@ -1220,214 +1316,229 @@
</div>
{#if showAdvanced}
<div class="my-2">
<fieldset disabled={readOnly} class="min-w-0 my-2">
<AdvancedParams admin={true} custom={true} layout="grid" bind:params />
</div>
</fieldset>
{/if}
{#if admin}
<ModelControls bind:controls={params.model_controls} />
<fieldset disabled={readOnly} class="min-w-0">
<ModelControls bind:controls={params.model_controls} />
</fieldset>
{/if}
</div>
</section>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<fieldset disabled={readOnly} class="min-w-0">
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<section class="my-2.5">
<div class="flex w-full items-center justify-between">
<div class="self-center text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Prompts')}
<section class="my-2.5">
<div class="flex w-full items-center justify-between">
<div class="self-center text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Prompts')}
</div>
{#if !editingLocale}
<button
class="text-xs text-gray-500 transition hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
if ((info?.meta?.suggestion_prompts ?? null) === null) {
info.meta.suggestion_prompts = [{ content: '', title: ['', ''] }];
} else {
info.meta.suggestion_prompts = null;
}
}}
>
{#if (info?.meta?.suggestion_prompts ?? null) === null}
<span>{$i18n.t('Default')}</span>
{:else}
<span>{$i18n.t('Custom')}</span>
{/if}
</button>
{/if}
</div>
{#if !editingLocale}
<button
class="text-xs text-gray-500 transition hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
if ((info?.meta?.suggestion_prompts ?? null) === null) {
info.meta.suggestion_prompts = [{ content: '', title: ['', ''] }];
} else {
info.meta.suggestion_prompts = null;
}
}}
>
{#if (info?.meta?.suggestion_prompts ?? null) === null}
<span>{$i18n.t('Default')}</span>
{:else}
<span>{$i18n.t('Custom')}</span>
{/if}
</button>
{/if}
</div>
{#if editingLocale}
<LocalizedPromptSuggestions
promptSuggestions={info.meta.suggestion_prompts ?? []}
bind:localizedPromptSuggestions={info.meta.i18n}
locale={editingLocale}
localeLabel={editingLocaleLabel}
/>
{:else if info?.meta?.suggestion_prompts}
<PromptSuggestions bind:promptSuggestions={info.meta.suggestion_prompts} />
{/if}
</section>
<div class="my-3">
<Knowledge bind:selectedItems={knowledge} />
</div>
<div class="my-3">
<ToolsSelector bind:selectedToolIds={toolIds} tools={$tools ?? []} />
</div>
<div class="my-3">
<SkillsSelector bind:selectedSkillIds={skillIds} skills={skillsList} />
</div>
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0 || ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<hr class="my-3 border-gray-100/30 dark:border-gray-850/30" />
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0}
<div class="my-3">
<FiltersSelector
bind:selectedFilterIds={filterIds}
filters={($functions ?? []).filter((func) => func.type === 'filter')}
{#if editingLocale}
<LocalizedPromptSuggestions
promptSuggestions={info.meta.suggestion_prompts ?? []}
bind:localizedPromptSuggestions={info.meta.i18n}
locale={editingLocale}
localeLabel={editingLocaleLabel}
/>
</div>
{:else if info?.meta?.suggestion_prompts}
<PromptSuggestions bind:promptSuggestions={info.meta.suggestion_prompts} />
{/if}
</section>
{@const toggleableFilters = $functions.filter(
(func) =>
func.type === 'filter' &&
(filterIds.includes(func.id) || func?.is_global) &&
func?.meta?.toggle
)}
<div class="my-3">
<Knowledge bind:selectedItems={knowledge} />
</div>
{#if toggleableFilters.length > 0}
<div class="my-3">
<ToolsSelector bind:selectedToolIds={toolIds} tools={$tools ?? []} />
</div>
<div class="my-3">
<SkillsSelector bind:selectedSkillIds={skillIds} skills={skillsList} />
</div>
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0 || ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<hr class="my-3 border-gray-100/30 dark:border-gray-850/30" />
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0}
<div class="my-3">
<DefaultFiltersSelector
bind:selectedFilterIds={defaultFilterIds}
filters={toggleableFilters}
<FiltersSelector
bind:selectedFilterIds={filterIds}
filters={($functions ?? []).filter((func) => func.type === 'filter')}
/>
</div>
{@const toggleableFilters = $functions.filter(
(func) =>
func.type === 'filter' &&
(filterIds.includes(func.id) || func?.is_global) &&
func?.meta?.toggle
)}
{#if toggleableFilters.length > 0}
<div class="my-3">
<DefaultFiltersSelector
bind:selectedFilterIds={defaultFilterIds}
filters={toggleableFilters}
/>
</div>
{/if}
{/if}
{#if ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<div class="my-3">
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={($functions ?? []).filter((func) => func.type === 'action')}
/>
</div>
{/if}
{/if}
{#if ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<hr class="my-3 border-gray-100/30 dark:border-gray-850/30" />
<div class="my-3">
<Capabilities bind:capabilities />
</div>
{#if Object.keys(capabilities).filter((key) => capabilities[key]).length > 0}
{@const availableFeatures = Object.entries(capabilities)
.filter(
([key, value]) =>
value && ['web_search', 'code_interpreter', 'image_generation'].includes(key)
)
.map(([key, value]) => key)}
{#if availableFeatures.length > 0}
<div class="my-3">
<DefaultFeatures {availableFeatures} bind:featureIds={defaultFeatureIds} />
</div>
{/if}
{/if}
{#if capabilities.builtin_tools}
<div class="my-3">
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={($functions ?? []).filter((func) => func.type === 'action')}
<BuiltinTools bind:builtinTools />
</div>
{/if}
{#if capabilities.terminal}
<div class="my-3">
<TerminalSelector bind:terminalId />
</div>
{/if}
{#if $config?.audio?.realtime?.enabled}
<div class="my-3">
<div class="flex w-full justify-between mb-1">
<label
for="realtime-voice-input"
class="self-center text-xs font-normal text-gray-500"
>
{$i18n.t('Realtime Voice')}
</label>
</div>
<TTSVoiceInput
id="realtime-voice"
bind:value={voice.voice}
placeholder={$i18n.t('Admin default')}
/>
</div>
{/if}
{/if}
<hr class="my-3 border-gray-100/30 dark:border-gray-850/30" />
<div class="my-3">
<Capabilities bind:capabilities />
</div>
{#if Object.keys(capabilities).filter((key) => capabilities[key]).length > 0}
{@const availableFeatures = Object.entries(capabilities)
.filter(
([key, value]) =>
value && ['web_search', 'code_interpreter', 'image_generation'].includes(key)
)
.map(([key, value]) => key)}
{#if availableFeatures.length > 0}
<div class="my-3">
<DefaultFeatures {availableFeatures} bind:featureIds={defaultFeatureIds} />
</div>
{#if $config?.audio?.realtime?.enabled || voiceAvatar}
<VoiceAvatarSettings
bind:value={voiceAvatar}
bind:file={avatarFile}
bind:animationFiles
disabled={loading}
/>
{/if}
{/if}
{#if capabilities.builtin_tools}
<div class="my-3">
<BuiltinTools bind:builtinTools />
</div>
{/if}
{#if capabilities.terminal}
<div class="my-3">
<TerminalSelector bind:terminalId />
</div>
{/if}
{#if $config?.audio?.realtime?.enabled}
<div class="my-3">
<div class="flex w-full justify-between mb-1">
<label
for="realtime-voice-input"
class="self-center text-xs font-normal text-gray-500"
>
{$i18n.t('Realtime Voice')}
</label>
<div class="self-center text-xs font-normal text-gray-500">
{$i18n.t('TTS Voice')}
</div>
</div>
<TTSVoiceInput
id="realtime-voice"
bind:value={voice.voice}
placeholder={$i18n.t('Admin default')}
bind:value={tts.voice}
{voices}
placeholder={$i18n.t('e.g. alloy, echo, shimmer')}
/>
</div>
{/if}
{#if $config?.audio?.realtime?.enabled || voiceAvatar}
<VoiceAvatarSettings
bind:value={voiceAvatar}
bind:file={avatarFile}
bind:animationFiles
disabled={loading}
/>
{/if}
<div class="my-3">
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-normal text-gray-500">
{$i18n.t('TTS Voice')}
</div>
</div>
<TTSVoiceInput
bind:value={tts.voice}
{voices}
placeholder={$i18n.t('e.g. alloy, echo, shimmer')}
/>
</div>
</fieldset>
<hr class="my-3 border-gray-100/30 dark:border-gray-850/30" />
<div class="my-2 flex justify-end">
<button
class=" text-sm px-3 py-2 transition rounded-lg {loading
? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading}
>
<div class=" self-center font-normal">
{#if edit}
{$i18n.t('Save & Update')}
{:else}
{$i18n.t('Save & Create')}
{/if}
</div>
{#if loading}
<div class="ml-1.5 self-center">
<Spinner />
</div>
{#if !readOnly}
<div class="my-2 flex items-center justify-end gap-2">
{#if edit}
<input
type="text"
aria-label={$i18n.t('Commit message')}
placeholder={$i18n.t('Describe this change')}
class="min-w-0 flex-1 border-0 bg-transparent px-1 text-xs outline-hidden focus:ring-0"
bind:value={commitMessage}
/>
{/if}
</button>
</div>
<button
class="flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading || (edit && !configurationChanged)}
>
<div class=" self-center font-normal">
{#if edit}
{$i18n.t('Save & Update')}
{:else}
{$i18n.t('Save & Create')}
{/if}
</div>
<div class="my-2 text-gray-300 dark:text-gray-700 pb-20">
{#if loading}
<div class="ml-1.5 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
{/if}
<div class="my-2 pb-2 text-xs text-gray-400 dark:text-gray-500">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-normal">{$i18n.t('JSON Preview')}</div>
<div class="self-center text-xs font-normal">{$i18n.t('JSON Preview')}</div>
<div class="flex items-center">
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
class="px-1.5 py-0.5 text-xs flex rounded-sm transition"
type="button"
on:click={async () => {
const copied = await copyToClipboard(JSON.stringify(modelInfo, null, 2));
const copied = await copyToClipboard(
JSON.stringify(readOnly ? model : modelInfo, null, 2)
);
if (copied) {
toast.success($i18n.t('Copied to clipboard'));
}
@ -1436,16 +1547,16 @@
{$i18n.t('Copy')}
</button>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
class="px-1.5 py-0.5 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
showPreview = !showPreview;
}}
>
{#if showPreview}
<span class="ml-2 self-center">{$i18n.t('Hide')}</span>
<span class="self-center">{$i18n.t('Hide')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Show')}</span>
<span class="self-center">{$i18n.t('Show')}</span>
{/if}
</button>
</div>
@ -1454,9 +1565,9 @@
{#if showPreview}
<div>
<textarea
class="text-sm w-full bg-transparent outline-hidden resize-none"
rows="10"
value={JSON.stringify(modelInfo, null, 2)}
class="w-full bg-transparent text-xs leading-5 outline-hidden resize-none"
rows="8"
value={JSON.stringify(readOnly ? model : modelInfo, null, 2)}
disabled
readonly
/>

View file

@ -0,0 +1,167 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
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 Spinner from '$lib/components/common/Spinner.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import VersionMenuItem from '../common/VersionMenuItem.svelte';
import {
getModelHistory,
getModelHistoryEntry,
setProductionModelVersion,
type ModelHistoryEntry,
type ModelSnapshot
} from '$lib/apis/models';
export let model: any;
export let dirty = false;
export let onProduction: (model: any) => Promise<void>;
const i18n = getContext<any>('i18n');
let show = false;
let page = 1;
let history: ModelHistoryEntry[] = [];
let production: ModelHistoryEntry | null = null;
export let selected: (ModelHistoryEntry & { snapshot: ModelSnapshot }) | null = null;
let loading = false;
export let selecting = false;
export let promoting = false;
let error = '';
let confirmPromotion = false;
let selection = 0;
const message = (error: any) =>
typeof error?.detail === 'string'
? error.detail
: error?.message || $i18n.t('Failed to load model version');
async function loadHistory() {
loading = true;
error = '';
try {
history = await getModelHistory(localStorage.token, model.id, page);
production =
history.find((entry) => entry.id === model.version_id) ||
(await getModelHistoryEntry(localStorage.token, model.id, model.version_id));
} catch (e) {
error = message(e);
} finally {
loading = false;
}
}
async function selectVersion(id: string) {
show = false;
const request = ++selection;
if (id === model.version_id) {
selected = null;
selecting = false;
return;
}
selecting = true;
try {
const entry = await getModelHistoryEntry(localStorage.token, model.id, id);
if (request === selection) {
selected = entry;
}
} catch (e) {
if (request === selection) toast.error(message(e));
} finally {
if (request === selection) selecting = false;
}
}
export function requestPromotion() {
if (dirty) confirmPromotion = true;
else promote();
}
async function promote() {
if (!selected || promoting) return;
promoting = true;
try {
const result = await setProductionModelVersion(localStorage.token, model.id, selected.id);
await onProduction(result);
selected = null;
production = null;
history = [];
toast.success($i18n.t('Production version updated'));
} catch (e) {
toast.error(message(e));
} finally {
promoting = false;
}
}
</script>
<ConfirmDialog
bind:show={confirmPromotion}
title={$i18n.t('Discard unsaved changes?')}
message={$i18n.t('Setting this version as Production will discard your unsaved changes.')}
confirmLabel={$i18n.t('Set as Production')}
on:confirm={promote}
/>
<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}
on:click={() => {
page = 1;
loadHistory();
}}
>
<span>{selected ? selected.id.slice(0, 7) : $i18n.t('Production')}</span>
<ChevronDown className="size-3" />
</button>
<div slot="content">
<DropdownMenu className="w-56">
<VersionMenuItem
entry={production}
status={$i18n.t('Production')}
selected={!selected}
onSelect={() => selectVersion(model.version_id)}
/>
{#if loading}
<div class="flex justify-center py-2"><Spinner className="size-4" /></div>
{:else if error}
<button type="button" on:click={loadHistory}>{$i18n.t('Retry')}</button>
{:else}
{#if history.some((entry) => entry.id !== model.version_id)}<hr
class="my-1 border-gray-100 dark:border-gray-850"
/>{/if}
{#each history.filter((entry) => entry.id !== model.version_id) as entry (entry.id)}
<VersionMenuItem
{entry}
selected={entry.id === selected?.id}
onSelect={() => selectVersion(entry.id)}
/>
{/each}
{#if page > 1 || history.length === 20}
<div class="flex justify-between gap-2 pt-1">
<button
type="button"
disabled={page === 1}
on:click={() => {
page--;
loadHistory();
}}>{$i18n.t('Previous')}</button
>
<button
type="button"
disabled={history.length < 20}
on:click={() => {
page++;
loadHistory();
}}>{$i18n.t('Next')}</button
>
</div>
{/if}
{/if}
</DropdownMenu>
</div>
</Dropdown>
</div>

View file

@ -31,7 +31,7 @@
export let edit = false;
export let clone = false;
export let onSave = /** @param {any} _value */ async (_value) => {};
export let onSave = /** @param {any} _value */ async (_value) => false;
export let id = '';
export let name = '';
@ -43,6 +43,17 @@
export let accessGrants = [];
let _content = '';
/** @param {string} id @param {string} name @param {Record<string, any>} 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();
@ -172,19 +183,22 @@ class Tools:
}
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,
access_grants: accessGrants
});
if (saved) savedDraft = submittedDraft;
} finally {
loading = false;
}
};
const submitHandler = async () => {
if (loading || (edit && !changed)) return;
if (codeEditor) {
content = _content;
await tick();
@ -236,16 +250,29 @@ class Tools:
}
}}
>
<button
class="mb-1 flex h-6 w-fit items-center gap-1 rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/workspace/tools');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-center justify-between gap-2">
<button
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
goto('/workspace/tools');
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(pruneEmptyLocaleEntries(meta.i18n))}
/>
<AccessButton
on:click={() => {
showAccessControlModal = true;
}}
/>
</div>
</div>
<div class="flex shrink-0 flex-col gap-2 pb-2 px-1 sm:flex-row sm:items-start">
<div class="min-w-0 w-full flex-1">
@ -300,18 +327,6 @@ class Tools:
</Tooltip>
</div>
</div>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(pruneEmptyLocaleEntries(meta.i18n))}
/>
<AccessButton
on:click={() => {
showAccessControlModal = true;
}}
/>
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden rounded-lg flex flex-col">
@ -365,7 +380,7 @@ class Tools:
<button
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
type="submit"
disabled={loading}
disabled={loading || (edit && !changed)}
>
{$i18n.t(edit ? 'Save' : 'Save & Create')}
{#if loading}

View file

@ -60,6 +60,7 @@
)
);
}
return res;
};
onMount(async () => {
@ -91,9 +92,7 @@
name={func.name}
meta={func.meta}
content={func.content}
onSave={(value) => {
saveHandler(value);
}}
onSave={saveHandler}
/>
</div>
{:else}

View file

@ -63,7 +63,7 @@
} catch (error: any) {
toast.error(`${error?.message ?? error}`);
}
return true;
return res;
}
}
return false;

View file

@ -54,7 +54,7 @@
} catch (error) {
toast.error(`${error}`);
}
return true;
return res;
}
return false;
};

View file

@ -52,6 +52,7 @@
// await goto('/workspace/tools');
}
return res;
};
onMount(async () => {
@ -88,9 +89,7 @@
meta={tool.meta}
content={tool.content}
accessGrants={tool.access_grants ?? []}
onSave={(value) => {
saveHandler(value);
}}
onSave={saveHandler}
/>
</div>
{:else}