mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-10 03:27:57 +00:00
refac
This commit is contained in:
parent
4266802993
commit
16849284ff
14 changed files with 1196 additions and 482 deletions
|
|
@ -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')
|
||||
107
backend/open_webui/models/model_history.py
Normal file
107
backend/open_webui/models/model_history.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
167
src/lib/components/workspace/Models/ModelHistory.svelte
Normal file
167
src/lib/components/workspace/Models/ModelHistory.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
} catch (error: any) {
|
||||
toast.error(`${error?.message ?? error}`);
|
||||
}
|
||||
return true;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
return true;
|
||||
return res;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue