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
a8388243d6
commit
e6476928ee
23 changed files with 2158 additions and 428 deletions
|
|
@ -0,0 +1,64 @@
|
|||
"""Add Tool and Function history without executing or rewriting plugin source."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'f8c0e5b134cd'
|
||||
down_revision = 'e7b9d4a023bc'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
for kind in ('tool', 'function'):
|
||||
op.add_column(kind, sa.Column('version_id', sa.Text(), nullable=True))
|
||||
history = op.create_table(
|
||||
f'{kind}_history',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column(f'{kind}_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(f'ix_{kind}_history_{kind}_id', f'{kind}_history', [f'{kind}_id'])
|
||||
table = sa.Table(kind, sa.MetaData(), autoload_with=connection)
|
||||
for row in connection.execute(sa.select(table)).mappings():
|
||||
meta = row['meta'] or {}
|
||||
meta = json.loads(meta) if isinstance(meta, str) else dict(meta)
|
||||
for key in (
|
||||
('manifest', 'has_user_valves', 'toggle') if kind == 'function' else ('manifest', 'has_user_valves')
|
||||
):
|
||||
meta.pop(key, None)
|
||||
meta.setdefault('description', None)
|
||||
if not meta.get('i18n'):
|
||||
meta.pop('i18n', None)
|
||||
snapshot = {'name': row['name'], 'content': row['content'] or '', 'meta': meta}
|
||||
version_id = str(uuid.uuid4())
|
||||
connection.execute(
|
||||
history.insert().values(
|
||||
**{
|
||||
'id': version_id,
|
||||
f'{kind}_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(table.update().where(table.c.id == row['id']).values(version_id=version_id))
|
||||
|
||||
|
||||
def downgrade():
|
||||
for kind in ('function', 'tool'):
|
||||
op.drop_index(f'ix_{kind}_history_{kind}_id', table_name=f'{kind}_history')
|
||||
op.drop_table(f'{kind}_history')
|
||||
with op.batch_alter_table(kind) as batch:
|
||||
batch.drop_column('version_id')
|
||||
162
backend/open_webui/models/function_history.py
Normal file
162
backend/open_webui/models/function_history.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Immutable snapshots of function configuration; the live row remains Production."""
|
||||
|
||||
import difflib
|
||||
import time
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
|
||||
from fastapi import HTTPException
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Column, Text, select, update
|
||||
|
||||
|
||||
class FunctionHistory(Base):
|
||||
__tablename__ = 'function_history'
|
||||
id = Column(Text, primary_key=True)
|
||||
function_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 FunctionHistoryResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: str
|
||||
function_id: str
|
||||
parent_id: str | None = None
|
||||
user_id: str
|
||||
commit_message: str | None = None
|
||||
created_at: int
|
||||
user: dict | None = None
|
||||
|
||||
|
||||
class FunctionHistoryModel(FunctionHistoryResponse):
|
||||
snapshot: dict
|
||||
|
||||
|
||||
class FunctionHistoryTable:
|
||||
async def delete_history_entry(self, function_id, history_id, db=None):
|
||||
from open_webui.models.functions import Function
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
# Serialize with production switches on both SQLite and PostgreSQL.
|
||||
await session.execute(
|
||||
update(Function).where(Function.id == function_id).values(version_id=Function.version_id)
|
||||
)
|
||||
model = await session.get(Function, function_id, populate_existing=True)
|
||||
if not model:
|
||||
return False
|
||||
if model.version_id == history_id:
|
||||
raise HTTPException(400, 'Cannot delete the current version')
|
||||
entry = (
|
||||
await session.execute(select(FunctionHistory).filter_by(id=history_id, function_id=function_id))
|
||||
).scalar_one_or_none()
|
||||
if not entry:
|
||||
return False
|
||||
await session.execute(
|
||||
update(FunctionHistory)
|
||||
.where(FunctionHistory.function_id == function_id, FunctionHistory.parent_id == history_id)
|
||||
.values(parent_id=entry.parent_id)
|
||||
)
|
||||
await session.delete(entry)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
def new_entry(self, function_id, snapshot, user_id, parent_id=None, commit_message=None):
|
||||
return FunctionHistory(
|
||||
id=str(uuid.uuid4()),
|
||||
function_id=function_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, function_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(FunctionHistory).filter_by(function_id=function_id, id=history_id))
|
||||
).scalar_one_or_none()
|
||||
if not entry:
|
||||
return None
|
||||
result = FunctionHistoryModel.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_function_id(self, function_id, page=1, db=None):
|
||||
from open_webui.models.users import User
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
columns = [getattr(FunctionHistory, key) for key in FunctionHistoryResponse.model_fields if key != 'user']
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(*columns, User.name.label('author_name'))
|
||||
.outerjoin(User, User.id == FunctionHistory.user_id)
|
||||
.where(FunctionHistory.function_id == function_id)
|
||||
.order_by(FunctionHistory.created_at.desc(), FunctionHistory.id.desc())
|
||||
.offset((max(1, page) - 1) * 20)
|
||||
.limit(20)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
FunctionHistoryResponse(
|
||||
**{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
|
||||
]
|
||||
|
||||
|
||||
FunctionHistories = FunctionHistoryTable()
|
||||
|
||||
|
||||
def function_snapshot(resource):
|
||||
data = (
|
||||
resource if isinstance(resource, dict) else {key: getattr(resource, key) for key in ('name', 'content', 'meta')}
|
||||
)
|
||||
meta = data.get('meta') or {}
|
||||
meta = meta.model_dump() if isinstance(meta, BaseModel) else deepcopy(meta)
|
||||
meta.setdefault('description', None)
|
||||
if not meta.get('i18n'):
|
||||
meta.pop('i18n', None)
|
||||
for key in ('manifest', 'has_user_valves', 'toggle'):
|
||||
meta.pop(key, None)
|
||||
return {'name': data.get('name'), 'content': data.get('content') or '', 'meta': meta}
|
||||
|
||||
|
||||
def function_diff(before, after):
|
||||
left, right = before.snapshot, after.snapshot
|
||||
metadata = {
|
||||
key: {'before': left.get(key), 'after': right.get(key)}
|
||||
for key in ('name', 'meta')
|
||||
if left.get(key) != right.get(key)
|
||||
}
|
||||
old, new = left.get('content') or '', right.get('content') or ''
|
||||
# splitlines handles a missing final newline and CRLF without breaking the renderer.
|
||||
patch = '\n'.join(
|
||||
difflib.unified_diff(
|
||||
old.splitlines(), new.splitlines(), fromfile='selected.py', tofile='production.py', lineterm=''
|
||||
)
|
||||
)
|
||||
return {
|
||||
'from_id': before.id,
|
||||
'to_id': after.id,
|
||||
'metadata': metadata,
|
||||
'content_diff': patch,
|
||||
'line_endings_only': old != new and not patch,
|
||||
}
|
||||
|
|
@ -6,9 +6,11 @@ import logging
|
|||
import time
|
||||
|
||||
# local imports
|
||||
from fastapi import HTTPException
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.function_history import FunctionHistories, FunctionHistory, function_snapshot
|
||||
from open_webui.models.users import User, UserResponse, Users, UserSettings
|
||||
from open_webui.utils.valves import decrypt_valves, encrypt_valves
|
||||
from open_webui.utils.valves import decrypt_valves, encrypt_valves, validate_valves
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -20,6 +22,7 @@ class Function(Base): # database table mapping
|
|||
__tablename__ = 'function'
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
version_id = Column(Text, nullable=True)
|
||||
user_id = Column(String, index=True) # creator user id
|
||||
name = Column(Text, nullable=False) # function identifier
|
||||
type = Column(Text, nullable=False) # function type (pipe, filter, etc.)
|
||||
|
|
@ -41,6 +44,7 @@ class FunctionMeta(BaseModel):
|
|||
|
||||
|
||||
class FunctionModel(BaseModel):
|
||||
version_id: str | None = None
|
||||
id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
|
|
@ -57,6 +61,7 @@ class FunctionModel(BaseModel):
|
|||
|
||||
# --- form / schema definitions ---
|
||||
class FunctionWithValvesModel(BaseModel):
|
||||
version_id: str | None = None
|
||||
id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
|
|
@ -78,6 +83,7 @@ class FunctionWithValvesModel(BaseModel):
|
|||
|
||||
|
||||
class FunctionResponse(BaseModel):
|
||||
version_id: str | None = None
|
||||
id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
type: str
|
||||
|
|
@ -96,6 +102,7 @@ class FunctionUserResponse(FunctionResponse):
|
|||
|
||||
|
||||
class FunctionForm(BaseModel):
|
||||
commit_message: str | None = None
|
||||
id: str
|
||||
name: str
|
||||
content: str
|
||||
|
|
@ -107,79 +114,117 @@ class FunctionValves(BaseModel):
|
|||
|
||||
|
||||
class FunctionsTable:
|
||||
async def insert_new_function(
|
||||
async def _lock_function(self, session, id):
|
||||
# UPDATE also serializes writers on SQLite, where SELECT FOR UPDATE does not.
|
||||
await session.execute(update(Function).where(Function.id == id).values(version_id=Function.version_id))
|
||||
return await session.get(Function, id, populate_existing=True)
|
||||
|
||||
async def _write_function(
|
||||
self,
|
||||
user_id: str,
|
||||
type: str,
|
||||
form_data: FunctionForm,
|
||||
db: AsyncSession | None = None,
|
||||
) -> FunctionModel | None:
|
||||
function = FunctionModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
'user_id': user_id,
|
||||
'type': type,
|
||||
'updated_at': int(time.time()),
|
||||
'created_at': int(time.time()),
|
||||
}
|
||||
)
|
||||
session,
|
||||
resource,
|
||||
updated,
|
||||
user_id=None,
|
||||
version_id=None,
|
||||
module=None,
|
||||
merge_meta=False,
|
||||
):
|
||||
updated = dict(updated)
|
||||
message = updated.pop('commit_message', None)
|
||||
updated.pop('version_id', None) # Imported pointers never belong to this resource.
|
||||
before = function_snapshot(resource)
|
||||
if version_id:
|
||||
entry = (
|
||||
await session.execute(select(FunctionHistory).filter_by(id=version_id, function_id=resource.id))
|
||||
).scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(404, 'Version not found')
|
||||
# The prepared candidate must be the exact selected saved configuration.
|
||||
if function_snapshot(updated) != function_snapshot(entry.snapshot):
|
||||
raise HTTPException(400, 'Version configuration does not match the saved snapshot')
|
||||
if module is not None:
|
||||
validate_valves(module, updated.get('valves', resource.valves))
|
||||
if merge_meta:
|
||||
updated['meta'] = {**(resource.meta or {}), **updated.get('meta', {})}
|
||||
for key, value in updated.items():
|
||||
setattr(resource, key, value)
|
||||
after = function_snapshot(resource)
|
||||
if version_id:
|
||||
resource.version_id = version_id
|
||||
elif after != before or not resource.version_id:
|
||||
entry = FunctionHistories.new_entry(
|
||||
resource.id, after, user_id or resource.user_id or '', resource.version_id, message
|
||||
)
|
||||
session.add(entry)
|
||||
resource.version_id = entry.id
|
||||
resource.updated_at = int(time.time())
|
||||
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = Function(**function.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
if result:
|
||||
return FunctionModel.model_validate(result)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f'Error creating a new function: {e}')
|
||||
return None
|
||||
async def insert_new_function(self, user_id, type, form_data, db=None, module=None):
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
function = Function(
|
||||
**form_data.model_dump(exclude={'commit_message'}),
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
is_active=False,
|
||||
is_global=False,
|
||||
updated_at=int(time.time()),
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
session.add(function)
|
||||
await self._write_function(
|
||||
session,
|
||||
function,
|
||||
{'commit_message': form_data.commit_message},
|
||||
user_id,
|
||||
module=module,
|
||||
)
|
||||
await session.flush()
|
||||
result = FunctionModel.model_validate(function)
|
||||
await session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def sync_functions(
|
||||
self,
|
||||
user_id: str,
|
||||
functions: list[FunctionWithValvesModel],
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[FunctionWithValvesModel]:
|
||||
# Synchronize functions by updating existing ones, inserting new ones,
|
||||
# and removing those that are no longer present.
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Get existing functions
|
||||
result = await db.execute(select(Function))
|
||||
existing_functions = result.scalars().all()
|
||||
existing_ids = {func.id for func in existing_functions}
|
||||
|
||||
# Prepare a set of new function IDs
|
||||
new_function_ids = {func.id for func in functions}
|
||||
|
||||
# Update or insert functions
|
||||
async def sync_functions(self, user_id, functions, db=None, modules=None):
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
# Lock all existing rows in a stable order before applying the batch.
|
||||
ids = (await session.execute(select(Function.id).order_by(Function.id))).scalars().all()
|
||||
existing = {id: await self._lock_function(session, id) for id in ids}
|
||||
incoming = {func.id for func in functions}
|
||||
for func in functions:
|
||||
func_data = func.model_dump()
|
||||
func_data['valves'] = encrypt_valves(func_data['valves']) if func_data.get('valves') else None
|
||||
func_data['user_id'] = user_id
|
||||
func_data['updated_at'] = int(time.time())
|
||||
|
||||
if func.id in existing_ids:
|
||||
await db.execute(update(Function).filter_by(id=func.id).values(**func_data))
|
||||
else:
|
||||
new_func = Function(**func_data)
|
||||
db.add(new_func)
|
||||
|
||||
# Remove functions that are no longer present
|
||||
for func in existing_functions:
|
||||
if func.id not in new_function_ids:
|
||||
await db.delete(func)
|
||||
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(select(Function))
|
||||
return [FunctionModel.model_validate(func) for func in result.scalars().all()]
|
||||
except Exception as e:
|
||||
log.exception(f'Error syncing functions for user {user_id}: {e}')
|
||||
return []
|
||||
data = func.model_dump(exclude={'version_id'})
|
||||
data['valves'] = encrypt_valves(data.get('valves'))
|
||||
data['user_id'] = user_id
|
||||
resource = existing.get(func.id)
|
||||
if resource is None:
|
||||
resource = Function(**data)
|
||||
session.add(resource)
|
||||
await self._write_function(
|
||||
session,
|
||||
resource,
|
||||
data,
|
||||
user_id,
|
||||
module=(modules or {}).get(func.id),
|
||||
)
|
||||
for id in set(existing) - incoming:
|
||||
await session.execute(delete(FunctionHistory).filter_by(function_id=id))
|
||||
await session.delete(existing[id])
|
||||
await session.flush()
|
||||
rows = (await session.execute(select(Function))).scalars().all()
|
||||
result = [
|
||||
FunctionWithValvesModel.model_validate(
|
||||
{**FunctionModel.model_validate(row).model_dump(), 'valves': decrypt_valves(row.valves)}
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
await session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def get_function_by_id(self, id: str, db: AsyncSession | None = None) -> FunctionModel | None:
|
||||
try:
|
||||
|
|
@ -329,27 +374,8 @@ class FunctionsTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
async def update_function_metadata_by_id(
|
||||
self, id: str, metadata: dict, db: AsyncSession | None = None
|
||||
) -> FunctionModel | None:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
function = await db.get(Function, id)
|
||||
|
||||
if function:
|
||||
if function.meta:
|
||||
function.meta = {**function.meta, **metadata}
|
||||
else:
|
||||
function.meta = metadata
|
||||
|
||||
function.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
return FunctionModel.model_validate(function)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f'Error updating function metadata by id {id}: {e}')
|
||||
return None
|
||||
async def update_function_metadata_by_id(self, id, metadata, db=None, user_id=None):
|
||||
return await self.update_function_by_id(id, {'meta': metadata}, db=db, user_id=user_id, merge_meta=True)
|
||||
|
||||
async def get_user_valves_by_id_and_user_id(
|
||||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
|
|
@ -396,23 +422,29 @@ class FunctionsTable:
|
|||
return None
|
||||
|
||||
async def update_function_by_id(
|
||||
self, id: str, updated: dict, db: AsyncSession | None = None
|
||||
) -> FunctionModel | None:
|
||||
async with get_async_db_context(db) as db:
|
||||
self, id, updated, db=None, user_id=None, version_id=None, module=None, merge_meta=False
|
||||
):
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
await db.execute(
|
||||
update(Function)
|
||||
.filter_by(id=id)
|
||||
.values(
|
||||
**updated,
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
function = await self._lock_function(session, id)
|
||||
if not function:
|
||||
raise ValueError('Function not found')
|
||||
await self._write_function(
|
||||
session,
|
||||
function,
|
||||
updated,
|
||||
user_id,
|
||||
version_id,
|
||||
module,
|
||||
merge_meta,
|
||||
)
|
||||
await db.commit()
|
||||
function = await db.get(Function, id)
|
||||
return FunctionModel.model_validate(function) if function else None
|
||||
await session.flush()
|
||||
result = FunctionModel.model_validate(function)
|
||||
await session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
return None
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def deactivate_all_functions(self, db: AsyncSession | None = None) -> bool | None:
|
||||
async with get_async_db_context(db) as db:
|
||||
|
|
@ -431,6 +463,8 @@ class FunctionsTable:
|
|||
async def delete_function_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
await self._lock_function(db, id)
|
||||
await db.execute(delete(FunctionHistory).filter_by(function_id=id))
|
||||
await db.execute(delete(Function).filter_by(id=id))
|
||||
await db.commit()
|
||||
|
||||
|
|
|
|||
160
backend/open_webui/models/tool_history.py
Normal file
160
backend/open_webui/models/tool_history.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Immutable snapshots of tool configuration; the live row remains Production."""
|
||||
|
||||
import difflib
|
||||
import time
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
|
||||
from fastapi import HTTPException
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Column, Text, select, update
|
||||
|
||||
|
||||
class ToolHistory(Base):
|
||||
__tablename__ = 'tool_history'
|
||||
id = Column(Text, primary_key=True)
|
||||
tool_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 ToolHistoryResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: str
|
||||
tool_id: str
|
||||
parent_id: str | None = None
|
||||
user_id: str
|
||||
commit_message: str | None = None
|
||||
created_at: int
|
||||
user: dict | None = None
|
||||
|
||||
|
||||
class ToolHistoryModel(ToolHistoryResponse):
|
||||
snapshot: dict
|
||||
|
||||
|
||||
class ToolHistoryTable:
|
||||
async def delete_history_entry(self, tool_id, history_id, db=None):
|
||||
from open_webui.models.tools import Tool
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
# Serialize with production switches on both SQLite and PostgreSQL.
|
||||
await session.execute(update(Tool).where(Tool.id == tool_id).values(version_id=Tool.version_id))
|
||||
model = await session.get(Tool, tool_id, populate_existing=True)
|
||||
if not model:
|
||||
return False
|
||||
if model.version_id == history_id:
|
||||
raise HTTPException(400, 'Cannot delete the current version')
|
||||
entry = (
|
||||
await session.execute(select(ToolHistory).filter_by(id=history_id, tool_id=tool_id))
|
||||
).scalar_one_or_none()
|
||||
if not entry:
|
||||
return False
|
||||
await session.execute(
|
||||
update(ToolHistory)
|
||||
.where(ToolHistory.tool_id == tool_id, ToolHistory.parent_id == history_id)
|
||||
.values(parent_id=entry.parent_id)
|
||||
)
|
||||
await session.delete(entry)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
def new_entry(self, tool_id, snapshot, user_id, parent_id=None, commit_message=None):
|
||||
return ToolHistory(
|
||||
id=str(uuid.uuid4()),
|
||||
tool_id=tool_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, tool_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(ToolHistory).filter_by(tool_id=tool_id, id=history_id))
|
||||
).scalar_one_or_none()
|
||||
if not entry:
|
||||
return None
|
||||
result = ToolHistoryModel.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_tool_id(self, tool_id, page=1, db=None):
|
||||
from open_webui.models.users import User
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
columns = [getattr(ToolHistory, key) for key in ToolHistoryResponse.model_fields if key != 'user']
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(*columns, User.name.label('author_name'))
|
||||
.outerjoin(User, User.id == ToolHistory.user_id)
|
||||
.where(ToolHistory.tool_id == tool_id)
|
||||
.order_by(ToolHistory.created_at.desc(), ToolHistory.id.desc())
|
||||
.offset((max(1, page) - 1) * 20)
|
||||
.limit(20)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
ToolHistoryResponse(
|
||||
**{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
|
||||
]
|
||||
|
||||
|
||||
ToolHistories = ToolHistoryTable()
|
||||
|
||||
|
||||
def tool_snapshot(resource):
|
||||
data = (
|
||||
resource if isinstance(resource, dict) else {key: getattr(resource, key) for key in ('name', 'content', 'meta')}
|
||||
)
|
||||
meta = data.get('meta') or {}
|
||||
meta = meta.model_dump() if isinstance(meta, BaseModel) else deepcopy(meta)
|
||||
meta.setdefault('description', None)
|
||||
if not meta.get('i18n'):
|
||||
meta.pop('i18n', None)
|
||||
for key in ('manifest', 'has_user_valves'):
|
||||
meta.pop(key, None)
|
||||
return {'name': data.get('name'), 'content': data.get('content') or '', 'meta': meta}
|
||||
|
||||
|
||||
def tool_diff(before, after):
|
||||
left, right = before.snapshot, after.snapshot
|
||||
metadata = {
|
||||
key: {'before': left.get(key), 'after': right.get(key)}
|
||||
for key in ('name', 'meta')
|
||||
if left.get(key) != right.get(key)
|
||||
}
|
||||
old, new = left.get('content') or '', right.get('content') or ''
|
||||
# splitlines handles a missing final newline and CRLF without breaking the renderer.
|
||||
patch = '\n'.join(
|
||||
difflib.unified_diff(
|
||||
old.splitlines(), new.splitlines(), fromfile='selected.py', tofile='production.py', lineterm=''
|
||||
)
|
||||
)
|
||||
return {
|
||||
'from_id': before.id,
|
||||
'to_id': after.id,
|
||||
'metadata': metadata,
|
||||
'content_diff': patch,
|
||||
'line_endings_only': old != new and not patch,
|
||||
}
|
||||
|
|
@ -7,10 +7,12 @@ import time
|
|||
|
||||
# local imports
|
||||
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.tool_history import ToolHistories, ToolHistory, tool_snapshot
|
||||
from open_webui.models.users import UserResponse, Users
|
||||
from open_webui.utils.valves import decrypt_valves, encrypt_valves
|
||||
from open_webui.utils.valves import decrypt_valves, encrypt_valves, validate_valves
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Column, String, Text, delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -22,6 +24,7 @@ class Tool(Base): # database table definition
|
|||
__tablename__ = 'tool'
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
version_id = Column(Text, nullable=True)
|
||||
user_id = Column(String, index=True) # owner user id
|
||||
name = Column(Text) # human-readable label
|
||||
content = Column(Text) # Python source code
|
||||
|
|
@ -34,6 +37,7 @@ class Tool(Base): # database table definition
|
|||
|
||||
|
||||
class ToolMeta(BaseModel):
|
||||
model_config = ConfigDict(extra='allow')
|
||||
i18n: dict[str, dict[str, str]] | None = None
|
||||
description: str | None = None
|
||||
manifest: dict | None = {}
|
||||
|
|
@ -41,6 +45,7 @@ class ToolMeta(BaseModel):
|
|||
|
||||
|
||||
class ToolModel(BaseModel):
|
||||
version_id: str | None = None
|
||||
id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
|
|
@ -66,6 +71,7 @@ class ToolUserModel(ToolModel):
|
|||
|
||||
|
||||
class ToolResponse(BaseModel):
|
||||
version_id: str | None = None
|
||||
id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
|
|
@ -86,6 +92,7 @@ class ToolAccessResponse(ToolUserResponse):
|
|||
|
||||
|
||||
class ToolForm(BaseModel):
|
||||
commit_message: str | None = None
|
||||
id: str
|
||||
name: str
|
||||
content: str
|
||||
|
|
@ -98,6 +105,48 @@ class ToolValves(BaseModel):
|
|||
|
||||
|
||||
class ToolsTable:
|
||||
async def _lock_tool(self, session, id):
|
||||
# UPDATE also serializes writers on SQLite, where SELECT FOR UPDATE does not.
|
||||
await session.execute(update(Tool).where(Tool.id == id).values(version_id=Tool.version_id))
|
||||
return await session.get(Tool, id, populate_existing=True)
|
||||
|
||||
async def _write_tool(
|
||||
self,
|
||||
session,
|
||||
resource,
|
||||
updated,
|
||||
user_id=None,
|
||||
version_id=None,
|
||||
module=None,
|
||||
):
|
||||
updated = dict(updated)
|
||||
message = updated.pop('commit_message', None)
|
||||
updated.pop('version_id', None) # Imported pointers never belong to this resource.
|
||||
before = tool_snapshot(resource)
|
||||
if version_id:
|
||||
entry = (
|
||||
await session.execute(select(ToolHistory).filter_by(id=version_id, tool_id=resource.id))
|
||||
).scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(404, 'Version not found')
|
||||
# The prepared candidate must be the exact selected saved configuration.
|
||||
if tool_snapshot(updated) != tool_snapshot(entry.snapshot):
|
||||
raise HTTPException(400, 'Version configuration does not match the saved snapshot')
|
||||
if module is not None:
|
||||
validate_valves(module, updated.get('valves', resource.valves))
|
||||
for key, value in updated.items():
|
||||
setattr(resource, key, value)
|
||||
after = tool_snapshot(resource)
|
||||
if version_id:
|
||||
resource.version_id = version_id
|
||||
elif after != before or not resource.version_id:
|
||||
entry = ToolHistories.new_entry(
|
||||
resource.id, after, user_id or resource.user_id or '', resource.version_id, message
|
||||
)
|
||||
session.add(entry)
|
||||
resource.version_id = entry.id
|
||||
resource.updated_at = int(time.time())
|
||||
|
||||
async def _get_access_grants(self, tool_id: str, db: AsyncSession | None = None) -> list[AccessGrantModel]:
|
||||
return await AccessGrants.get_grants_by_resource('tool', tool_id, db=db)
|
||||
|
||||
|
|
@ -113,34 +162,29 @@ class ToolsTable:
|
|||
)
|
||||
return tool_model
|
||||
|
||||
async def insert_new_tool(
|
||||
self,
|
||||
user_id: str,
|
||||
form_data: ToolForm,
|
||||
specs: list[dict],
|
||||
db: AsyncSession | None = None,
|
||||
) -> ToolModel | None:
|
||||
async with get_async_db_context(db) as db:
|
||||
async def insert_new_tool(self, user_id, form_data, specs, db=None, module=None):
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
result = Tool(
|
||||
**{
|
||||
**form_data.model_dump(exclude={'access_grants'}),
|
||||
'specs': specs,
|
||||
'user_id': user_id,
|
||||
'updated_at': int(time.time()),
|
||||
'created_at': int(time.time()),
|
||||
}
|
||||
data = form_data.model_dump(exclude={'access_grants', 'commit_message'})
|
||||
tool = Tool(
|
||||
**data, specs=specs, 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('tool', result.id, form_data.access_grants, db=db)
|
||||
if result:
|
||||
return await self._to_tool_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f'Error creating a new tool: {e}')
|
||||
return None # creation failed
|
||||
session.add(tool)
|
||||
await self._write_tool(
|
||||
session,
|
||||
tool,
|
||||
{'commit_message': form_data.commit_message},
|
||||
user_id,
|
||||
module=module,
|
||||
)
|
||||
await session.flush()
|
||||
grants = await AccessGrants.replace_access_grants(session, 'tool', tool.id, form_data.access_grants)
|
||||
result = await self._to_tool_model(tool, access_grants=grants)
|
||||
await session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def get_tool_by_id(
|
||||
self,
|
||||
|
|
@ -182,7 +226,16 @@ class ToolsTable:
|
|||
# Skip Tool.content (plugin source, potentially large) via a
|
||||
# column select; Row attributes satisfy from_attributes.
|
||||
stmt = (
|
||||
select(Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at)
|
||||
select(
|
||||
Tool.id,
|
||||
Tool.version_id,
|
||||
Tool.user_id,
|
||||
Tool.name,
|
||||
Tool.specs,
|
||||
Tool.meta,
|
||||
Tool.updated_at,
|
||||
Tool.created_at,
|
||||
)
|
||||
if defer_content
|
||||
else select(Tool)
|
||||
).order_by(Tool.updated_at.desc())
|
||||
|
|
@ -311,31 +364,48 @@ class ToolsTable:
|
|||
log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}')
|
||||
return None
|
||||
|
||||
async def update_tool_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> ToolModel | None:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
access_grants = updated.pop('access_grants', None)
|
||||
await db.execute(update(Tool).filter_by(id=id).values(**updated, updated_at=int(time.time())))
|
||||
await db.commit()
|
||||
if access_grants is not None:
|
||||
await AccessGrants.set_access_grants('tool', id, access_grants, db=db)
|
||||
|
||||
# populate_existing: the Core update above bypasses any identity-map copy
|
||||
tool = await db.get(Tool, id, populate_existing=True)
|
||||
return await self._to_tool_model(tool, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def delete_tool_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('tool', id, db=db)
|
||||
await db.execute(delete(Tool).filter_by(id=id))
|
||||
await db.commit()
|
||||
async def update_tool_by_id(
|
||||
self, id, updated, db=None, user_id=None, version_id=None, module=None, allow_code_changes=True
|
||||
):
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
tool = await self._lock_tool(session, id)
|
||||
if not tool:
|
||||
raise ValueError('Tool not found')
|
||||
if not allow_code_changes and updated.get('content', tool.content) != tool.content:
|
||||
raise HTTPException(401, 'You do not have permission to change executable Tool code')
|
||||
updated = dict(updated)
|
||||
grants = updated.pop('access_grants', None)
|
||||
await self._write_tool(session, tool, updated, user_id, version_id, module)
|
||||
if grants is not None:
|
||||
await AccessGrants.replace_access_grants(session, 'tool', id, grants)
|
||||
await session.flush()
|
||||
grants = (
|
||||
(await session.execute(select(AccessGrant).filter_by(resource_type='tool', resource_id=id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
result = await self._to_tool_model(
|
||||
tool, access_grants=[AccessGrantModel.model_validate(g) for g in grants]
|
||||
)
|
||||
await session.commit()
|
||||
return result
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def delete_tool_by_id(self, id, db=None):
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
await self._lock_tool(session, id)
|
||||
await session.execute(delete(AccessGrant).filter_by(resource_type='tool', resource_id=id))
|
||||
await session.execute(delete(ToolHistory).filter_by(tool_id=id))
|
||||
await session.execute(delete(Tool).filter_by(id=id))
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
Tools = ToolsTable() # singleton tool registry
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES
|
|||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_FUNCTIONS
|
||||
from open_webui.events import EVENTS, build_event, dispatch_event_functions, publish_event, schedule_webhook_dispatch
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.function_history import FunctionHistories, function_diff
|
||||
from open_webui.models.functions import (
|
||||
FunctionForm,
|
||||
FunctionModel,
|
||||
|
|
@ -29,6 +30,7 @@ from open_webui.utils.plugin import (
|
|||
load_function_module_by_id,
|
||||
replace_imports,
|
||||
resolve_valves_schema_options,
|
||||
set_function_module_in_cache,
|
||||
)
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -168,22 +170,27 @@ async def sync_functions(
|
|||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
modules = {}
|
||||
source_modules = {}
|
||||
previous_ids = {entry.id for entry in await Functions.get_functions(db=db)}
|
||||
for function in form_data.functions:
|
||||
function.content = replace_imports(function.content)
|
||||
function_module, function_type, frontmatter = await load_function_module_by_id(
|
||||
function.id,
|
||||
content=function.content,
|
||||
module, function.type, frontmatter, source_module = await load_function_module_by_id(
|
||||
function.id, content=function.content
|
||||
)
|
||||
|
||||
if hasattr(function_module, 'Valves') and function.valves:
|
||||
Valves = function_module.Valves
|
||||
try:
|
||||
Valves(**{k: v for k, v in function.valves.items() if v is not None})
|
||||
except Exception as e:
|
||||
log.exception(f'Error validating valves for function {function.id}: {e}')
|
||||
raise e
|
||||
|
||||
return await Functions.sync_functions(user.id, form_data.functions, db=db)
|
||||
function.meta.manifest = frontmatter
|
||||
function.meta.toggle = function.type == 'filter' and bool(getattr(module, 'toggle', False))
|
||||
modules[function.id] = module
|
||||
source_modules[function.id] = source_module
|
||||
result = await Functions.sync_functions(user.id, form_data.functions, db=db, modules=modules)
|
||||
for function in result:
|
||||
set_function_module_in_cache(
|
||||
request, function.id, function.content, modules[function.id], source_modules[function.id]
|
||||
)
|
||||
for id in previous_ids - {entry.id for entry in result}:
|
||||
get_functions_cache(request).pop(id, None)
|
||||
get_function_contents_cache(request).pop(id, None)
|
||||
return result
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to load a function: {e}')
|
||||
raise HTTPException(
|
||||
|
|
@ -216,24 +223,22 @@ async def create_new_function(
|
|||
if function is None:
|
||||
try:
|
||||
form_data.content = replace_imports(form_data.content)
|
||||
function_module, function_type, frontmatter = await load_function_module_by_id(
|
||||
function_module, function_type, frontmatter, source_module = await load_function_module_by_id(
|
||||
form_data.id,
|
||||
content=form_data.content,
|
||||
)
|
||||
form_data.meta.manifest = frontmatter
|
||||
form_data.meta.toggle = function_type == 'filter' and bool(getattr(function_module, 'toggle', False))
|
||||
|
||||
FUNCTIONS = get_functions_cache(request)
|
||||
FUNCTIONS[form_data.id] = function_module
|
||||
|
||||
function = await Functions.insert_new_function(user.id, function_type, form_data, db=db)
|
||||
function = await Functions.insert_new_function(
|
||||
user.id, function_type, form_data, db=db, module=function_module
|
||||
)
|
||||
|
||||
function_cache_dir = CACHE_DIR / 'functions' / form_data.id
|
||||
function_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if function_type == 'filter' and getattr(function_module, 'toggle', None):
|
||||
await Functions.update_function_metadata_by_id(form_data.id, {'toggle': True}, db=db)
|
||||
|
||||
if function:
|
||||
set_function_module_in_cache(request, function.id, function.content, function_module, source_module)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.FUNCTION_CREATED,
|
||||
|
|
@ -384,23 +389,28 @@ async def update_function_by_id(
|
|||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
return await _update_function(request, id, form_data, user, db)
|
||||
|
||||
|
||||
async def _update_function(request, id, form_data, user, db, version_id=None):
|
||||
try:
|
||||
form_data.content = replace_imports(form_data.content)
|
||||
function_module, function_type, frontmatter = await load_function_module_by_id(id, content=form_data.content)
|
||||
if version_id is None:
|
||||
form_data.content = replace_imports(form_data.content)
|
||||
function_module, function_type, frontmatter, source_module = await load_function_module_by_id(
|
||||
id, content=form_data.content
|
||||
)
|
||||
form_data.meta.manifest = frontmatter
|
||||
|
||||
FUNCTIONS = get_functions_cache(request)
|
||||
FUNCTIONS[id] = function_module
|
||||
form_data.meta.toggle = function_type == 'filter' and bool(getattr(function_module, 'toggle', False))
|
||||
|
||||
updated = {**form_data.model_dump(exclude={'id'}), 'type': function_type}
|
||||
log.debug(updated)
|
||||
|
||||
function = await Functions.update_function_by_id(id, updated, db=db)
|
||||
|
||||
if function_type == 'filter' and getattr(function_module, 'toggle', None):
|
||||
await Functions.update_function_metadata_by_id(id, {'toggle': True}, db=db)
|
||||
function = await Functions.update_function_by_id(
|
||||
id, updated, db=db, user_id=user.id, version_id=version_id, module=function_module
|
||||
)
|
||||
|
||||
if function:
|
||||
set_function_module_in_cache(request, function.id, function.content, function_module, source_module)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.FUNCTION_UPDATED,
|
||||
|
|
@ -420,7 +430,7 @@ async def update_function_by_id(
|
|||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e, 'Error updating function'),
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -666,3 +676,74 @@ async def update_function_user_valves_by_id(
|
|||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
async def require_function_history_access(id, user, db):
|
||||
resource = await Functions.get_function_by_id(id, db=db)
|
||||
if not resource:
|
||||
raise HTTPException(404, 'Not found')
|
||||
return resource
|
||||
|
||||
|
||||
async def require_function_history_entry(id, history_id, db):
|
||||
entry = await FunctionHistories.get_history_by_id(id, history_id, db=db)
|
||||
if not entry:
|
||||
raise HTTPException(404, 'Version not found')
|
||||
return entry
|
||||
|
||||
|
||||
@router.get('/id/{id}/history')
|
||||
async def get_function_history(
|
||||
id: str, page: int = 1, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await require_function_history_access(id, user, db)
|
||||
return await FunctionHistories.get_history_by_function_id(id, page, db=db)
|
||||
|
||||
|
||||
@router.get('/id/{id}/history/diff')
|
||||
async def get_function_history_diff(
|
||||
id: str, from_id: str, to_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await require_function_history_access(id, user, db)
|
||||
before = await require_function_history_entry(id, from_id, db)
|
||||
after = await require_function_history_entry(id, to_id, db)
|
||||
return function_diff(before, after)
|
||||
|
||||
|
||||
@router.get('/id/{id}/history/{history_id}')
|
||||
async def get_function_history_entry(
|
||||
id: str, history_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await require_function_history_access(id, user, db)
|
||||
return await require_function_history_entry(id, history_id, db)
|
||||
|
||||
|
||||
@router.delete('/id/{id}/history/{history_id}')
|
||||
async def delete_function_history_entry(
|
||||
id: str, history_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await require_function_history_access(id, user, db)
|
||||
if not await FunctionHistories.delete_history_entry(id, history_id, db=db):
|
||||
raise HTTPException(404, 'Version not found')
|
||||
return True
|
||||
|
||||
|
||||
class FunctionVersionForm(BaseModel):
|
||||
version_id: str
|
||||
|
||||
|
||||
@router.post('/id/{id}/update/version', response_model=FunctionModel)
|
||||
async def set_function_production(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: FunctionVersionForm,
|
||||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await require_function_history_access(id, user, db)
|
||||
entry = await require_function_history_entry(id, form_data.version_id, db)
|
||||
try:
|
||||
saved = FunctionForm(id=id, **entry.snapshot)
|
||||
except ValueError as error:
|
||||
raise HTTPException(400, str(error)) from error
|
||||
return await _update_function(request, id, saved, user, db, version_id=entry.id)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from open_webui.models.access_grants import AccessGrants
|
|||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.tool_history import ToolHistories, tool_diff
|
||||
from open_webui.models.tools import (
|
||||
ToolAccessResponse,
|
||||
ToolForm,
|
||||
|
|
@ -44,6 +45,7 @@ from open_webui.utils.plugin import (
|
|||
load_tool_module_by_id,
|
||||
replace_imports,
|
||||
resolve_valves_schema_options,
|
||||
set_tool_module_in_cache,
|
||||
)
|
||||
from open_webui.utils.tools import connect_mcp_server, get_tool_servers
|
||||
from open_webui.utils.tools import get_tool_specs as get_local_tool_specs
|
||||
|
|
@ -422,20 +424,20 @@ async def create_new_tools(
|
|||
)
|
||||
|
||||
form_data.content = replace_imports(form_data.content)
|
||||
tool_module, frontmatter = await load_tool_module_by_id(form_data.id, content=form_data.content)
|
||||
tool_module, frontmatter, source_module = await load_tool_module_by_id(
|
||||
form_data.id, content=form_data.content
|
||||
)
|
||||
form_data.meta.manifest = frontmatter
|
||||
form_data.meta.has_user_valves = hasattr(tool_module, 'UserValves')
|
||||
|
||||
TOOLS = get_tools_cache(request)
|
||||
TOOLS[form_data.id] = tool_module
|
||||
|
||||
specs = get_local_tool_specs(TOOLS[form_data.id])
|
||||
tools = await Tools.insert_new_tool(user.id, form_data, specs, db=db)
|
||||
specs = get_local_tool_specs(tool_module)
|
||||
tools = await Tools.insert_new_tool(user.id, form_data, specs, db=db, module=tool_module)
|
||||
|
||||
tool_cache_dir = CACHE_DIR / 'tools' / form_data.id
|
||||
tool_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if tools:
|
||||
set_tool_module_in_cache(request, tools.id, tools.content, tool_module, source_module)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.TOOL_CREATED,
|
||||
|
|
@ -526,6 +528,10 @@ async def update_tools_by_id(
|
|||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
return await _update_tool(request, id, form_data, user, db)
|
||||
|
||||
|
||||
async def _update_tool(request, id, form_data, user, db, version_id=None):
|
||||
"""Update an existing tool's source code and metadata."""
|
||||
tools = await Tools.get_tool_by_id(id, db=db)
|
||||
if not tools:
|
||||
|
|
@ -551,45 +557,49 @@ async def update_tools_by_id(
|
|||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Content edits trigger exec on load — gate them behind workspace.tools (matches /create).
|
||||
if form_data.content != tools.content:
|
||||
if user.role != 'admin' and not (
|
||||
await has_permission(user.id, 'workspace.tools', await Config.get('user.permissions'), db=db)
|
||||
or await has_permission(user.id, 'workspace.tools_import', await Config.get('user.permissions'), db=db)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
# Check again under the row lock when committing, in case Production changes meanwhile.
|
||||
allow_code_changes = await can_change_tool_code(user, db)
|
||||
if form_data.content != tools.content and not allow_code_changes:
|
||||
raise HTTPException(401, ERROR_MESSAGES.UNAUTHORIZED)
|
||||
|
||||
try:
|
||||
form_data.content = replace_imports(form_data.content)
|
||||
tool_module, frontmatter = await load_tool_module_by_id(id, content=form_data.content)
|
||||
if version_id is None:
|
||||
form_data.content = replace_imports(form_data.content)
|
||||
tool_module, frontmatter, source_module = await load_tool_module_by_id(id, content=form_data.content)
|
||||
form_data.meta.manifest = frontmatter
|
||||
form_data.meta.has_user_valves = hasattr(tool_module, 'UserValves')
|
||||
|
||||
TOOLS = get_tools_cache(request)
|
||||
TOOLS[id] = tool_module
|
||||
specs = get_local_tool_specs(tool_module)
|
||||
|
||||
specs = get_local_tool_specs(TOOLS[id])
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_tools',
|
||||
)
|
||||
if version_id is None:
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_tools',
|
||||
)
|
||||
|
||||
updated = {
|
||||
**form_data.model_dump(exclude={'id'}),
|
||||
'specs': specs,
|
||||
}
|
||||
|
||||
log.debug(updated)
|
||||
tools = await Tools.update_tool_by_id(id, updated, db=db)
|
||||
if version_id is not None:
|
||||
updated.pop('access_grants', None)
|
||||
|
||||
tools = await Tools.update_tool_by_id(
|
||||
id,
|
||||
updated,
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
version_id=version_id,
|
||||
module=tool_module,
|
||||
allow_code_changes=allow_code_changes,
|
||||
)
|
||||
|
||||
if tools:
|
||||
set_tool_module_in_cache(request, tools.id, tools.content, tool_module, source_module)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.TOOL_UPDATED,
|
||||
|
|
@ -609,7 +619,7 @@ async def update_tools_by_id(
|
|||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e, 'Error updating tool'),
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1022,3 +1032,89 @@ async def update_tools_user_valves_by_id(
|
|||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
async def can_change_tool_code(user, db):
|
||||
return user.role == 'admin' or (
|
||||
await has_permission(user.id, 'workspace.tools', await Config.get('user.permissions'), db=db)
|
||||
or await has_permission(user.id, 'workspace.tools_import', await Config.get('user.permissions'), db=db)
|
||||
)
|
||||
|
||||
|
||||
async def require_tool_history_access(id, user, db):
|
||||
resource = await Tools.get_tool_by_id(id, db=db)
|
||||
if not resource:
|
||||
raise HTTPException(404, 'Not found')
|
||||
if not (
|
||||
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or resource.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id, resource_type='tool', resource_id=id, permission='write', db=db
|
||||
)
|
||||
):
|
||||
raise HTTPException(401, ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
return resource
|
||||
|
||||
|
||||
async def require_tool_history_entry(id, history_id, db):
|
||||
entry = await ToolHistories.get_history_by_id(id, history_id, db=db)
|
||||
if not entry:
|
||||
raise HTTPException(404, 'Version not found')
|
||||
return entry
|
||||
|
||||
|
||||
@router.get('/id/{id}/history')
|
||||
async def get_tool_history(
|
||||
id: str, page: int = 1, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await require_tool_history_access(id, user, db)
|
||||
return await ToolHistories.get_history_by_tool_id(id, page, db=db)
|
||||
|
||||
|
||||
@router.get('/id/{id}/history/diff')
|
||||
async def get_tool_history_diff(
|
||||
id: str, from_id: str, to_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await require_tool_history_access(id, user, db)
|
||||
before = await require_tool_history_entry(id, from_id, db)
|
||||
after = await require_tool_history_entry(id, to_id, db)
|
||||
return tool_diff(before, after)
|
||||
|
||||
|
||||
@router.get('/id/{id}/history/{history_id}')
|
||||
async def get_tool_history_entry(
|
||||
id: str, history_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await require_tool_history_access(id, user, db)
|
||||
return await require_tool_history_entry(id, history_id, db)
|
||||
|
||||
|
||||
@router.delete('/id/{id}/history/{history_id}')
|
||||
async def delete_tool_history_entry(
|
||||
id: str, history_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await require_tool_history_access(id, user, db)
|
||||
if not await ToolHistories.delete_history_entry(id, history_id, db=db):
|
||||
raise HTTPException(404, 'Version not found')
|
||||
return True
|
||||
|
||||
|
||||
class ToolVersionForm(BaseModel):
|
||||
version_id: str
|
||||
|
||||
|
||||
@router.post('/id/{id}/update/version', response_model=ToolModel)
|
||||
async def set_tool_production(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ToolVersionForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await require_tool_history_access(id, user, db)
|
||||
entry = await require_tool_history_entry(id, form_data.version_id, db)
|
||||
try:
|
||||
saved = ToolForm(id=id, **entry.snapshot)
|
||||
except ValueError as error:
|
||||
raise HTTPException(400, str(error)) from error
|
||||
return await _update_tool(request, id, saved, user, db, version_id=entry.id)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import subprocess
|
|||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import uuid
|
||||
from importlib import util
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -205,6 +206,7 @@ def replace_imports(content):
|
|||
# May the intent of the one who wrote it survive every
|
||||
# import and transformation, as a deed survives the generations.
|
||||
async def load_tool_module_by_id(tool_id, content=None):
|
||||
"""Prepare a Tool instance, frontmatter, and Python module without changing the live cache."""
|
||||
if not ENABLE_TOOLS:
|
||||
raise RuntimeError('Tools are disabled by ENABLE_PLUGINS or ENABLE_TOOLS')
|
||||
|
||||
|
|
@ -215,9 +217,6 @@ async def load_tool_module_by_id(tool_id, content=None):
|
|||
raise Exception(f'Toolkit not found: {tool_id}')
|
||||
|
||||
content = tool.content
|
||||
|
||||
content = replace_imports(content)
|
||||
await Tools.update_tool_by_id(tool_id, {'content': content})
|
||||
else:
|
||||
frontmatter = extract_frontmatter(content)
|
||||
# Install required packages found within the frontmatter.
|
||||
|
|
@ -225,7 +224,8 @@ async def load_tool_module_by_id(tool_id, content=None):
|
|||
# offload to a thread so it doesn't block the event loop.
|
||||
await asyncio.to_thread(install_frontmatter_requirements, frontmatter.get('requirements', ''))
|
||||
|
||||
module_name = f'tool_{tool_id}'
|
||||
content = replace_imports(content)
|
||||
module_name = f'tool_{tool_id}_{uuid.uuid4().hex}'
|
||||
module = types.ModuleType(module_name)
|
||||
sys.modules[module_name] = module
|
||||
|
||||
|
|
@ -246,18 +246,19 @@ async def load_tool_module_by_id(tool_id, content=None):
|
|||
|
||||
# Create and return the object if the class 'Tools' is found in the module
|
||||
if hasattr(module, 'Tools'):
|
||||
return module.Tools(), frontmatter
|
||||
return module.Tools(), frontmatter, module
|
||||
else:
|
||||
raise Exception('No Tools class found in the module')
|
||||
except Exception as e:
|
||||
log.error(f'Error loading module: {tool_id}: {e}')
|
||||
del sys.modules[module_name] # Clean up
|
||||
raise e
|
||||
finally:
|
||||
os.unlink(temp_file.name)
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
|
||||
async def load_function_module_by_id(function_id: str, content: str | None = None):
|
||||
"""Prepare a Function instance, type, frontmatter, and Python module without publishing it."""
|
||||
if not ENABLE_FUNCTIONS:
|
||||
raise RuntimeError('Functions are disabled by ENABLE_PLUGINS or ENABLE_FUNCTIONS')
|
||||
|
||||
|
|
@ -267,15 +268,13 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non
|
|||
if not function:
|
||||
raise Exception(f'Function not found: {function_id}')
|
||||
content = function.content
|
||||
|
||||
content = replace_imports(content)
|
||||
await Functions.update_function_by_id(function_id, {'content': content})
|
||||
else:
|
||||
frontmatter = extract_frontmatter(content)
|
||||
# `pip install` via subprocess can block for a long time; offload it.
|
||||
await asyncio.to_thread(install_frontmatter_requirements, frontmatter.get('requirements', ''))
|
||||
|
||||
module_name = f'function_{function_id}'
|
||||
content = replace_imports(content)
|
||||
module_name = f'function_{function_id}_{uuid.uuid4().hex}'
|
||||
module = types.ModuleType(module_name)
|
||||
sys.modules[module_name] = module
|
||||
|
||||
|
|
@ -296,24 +295,21 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non
|
|||
|
||||
# Create appropriate object based on available class type in the module
|
||||
if hasattr(module, 'Pipe'):
|
||||
return module.Pipe(), 'pipe', frontmatter
|
||||
return module.Pipe(), 'pipe', frontmatter, module
|
||||
elif hasattr(module, 'Filter'):
|
||||
return module.Filter(), 'filter', frontmatter
|
||||
return module.Filter(), 'filter', frontmatter, module
|
||||
elif hasattr(module, 'Action'):
|
||||
return module.Action(), 'action', frontmatter
|
||||
return module.Action(), 'action', frontmatter, module
|
||||
elif hasattr(module, 'Event'):
|
||||
return module.Event(), 'event', frontmatter
|
||||
return module.Event(), 'event', frontmatter, module
|
||||
else:
|
||||
raise Exception('No Function class found in the module')
|
||||
except Exception as e:
|
||||
log.error(f'Error loading module: {function_id}: {e}')
|
||||
# Cleanup by removing the module in case of error
|
||||
del sys.modules[module_name]
|
||||
|
||||
await Functions.update_function_by_id(function_id, {'is_active': False})
|
||||
raise e
|
||||
finally:
|
||||
os.unlink(temp_file.name)
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
|
||||
def _state_cache(request, name: str) -> dict:
|
||||
|
|
@ -338,6 +334,26 @@ def get_function_contents_cache(request) -> dict:
|
|||
return _state_cache(request, 'FUNCTION_CONTENTS')
|
||||
|
||||
|
||||
def set_tool_module_in_cache(request, tool_id, content, tool_module, source_module):
|
||||
previous = sys.modules.get(f'tool_{tool_id}')
|
||||
if previous is not None:
|
||||
sys.modules.pop(previous.__name__, None)
|
||||
sys.modules[source_module.__name__] = source_module
|
||||
sys.modules[f'tool_{tool_id}'] = source_module
|
||||
get_tools_cache(request)[tool_id] = tool_module
|
||||
get_tool_contents_cache(request)[tool_id] = content
|
||||
|
||||
|
||||
def set_function_module_in_cache(request, function_id, content, function_module, source_module):
|
||||
previous = sys.modules.get(f'function_{function_id}')
|
||||
if previous is not None:
|
||||
sys.modules.pop(previous.__name__, None)
|
||||
sys.modules[source_module.__name__] = source_module
|
||||
sys.modules[f'function_{function_id}'] = source_module
|
||||
get_functions_cache(request)[function_id] = function_module
|
||||
get_function_contents_cache(request)[function_id] = content
|
||||
|
||||
|
||||
async def get_tool_module_from_cache(request, tool_id, load_from_db=True):
|
||||
if not ENABLE_TOOLS:
|
||||
raise RuntimeError('Tools are disabled by ENABLE_PLUGINS or ENABLE_TOOLS')
|
||||
|
|
@ -353,25 +369,16 @@ async def get_tool_module_from_cache(request, tool_id, load_from_db=True):
|
|||
raise Exception(f'Tool not found: {tool_id}')
|
||||
content = tool.content
|
||||
|
||||
new_content = replace_imports(content)
|
||||
if new_content != content:
|
||||
content = new_content
|
||||
# Update the tool content in the database
|
||||
await Tools.update_tool_by_id(tool_id, {'content': content})
|
||||
|
||||
if tool_id in tool_contents_cache and tool_id in tools_cache:
|
||||
if tool_contents_cache[tool_id] == content:
|
||||
return tools_cache[tool_id], None
|
||||
|
||||
tool_module, frontmatter = await load_tool_module_by_id(tool_id, content)
|
||||
else:
|
||||
if tool_id in tools_cache:
|
||||
return tools_cache[tool_id], None
|
||||
|
||||
tool_module, frontmatter = await load_tool_module_by_id(tool_id)
|
||||
|
||||
tools_cache[tool_id] = tool_module
|
||||
tool_contents_cache[tool_id] = content
|
||||
tool_module, frontmatter, source_module = await load_tool_module_by_id(tool_id, content)
|
||||
set_tool_module_in_cache(request, tool_id, content, tool_module, source_module)
|
||||
|
||||
return tool_module, frontmatter
|
||||
|
||||
|
|
@ -397,17 +404,10 @@ async def get_function_module_from_cache(
|
|||
raise Exception(f'Function not found: {function_id}')
|
||||
content = function.content
|
||||
|
||||
new_content = replace_imports(content)
|
||||
if new_content != content:
|
||||
content = new_content
|
||||
# Update the function content in the database
|
||||
await Functions.update_function_by_id(function_id, {'content': content})
|
||||
|
||||
if function_id in function_contents_cache and function_id in functions_cache:
|
||||
if function_contents_cache[function_id] == content:
|
||||
return functions_cache[function_id], None, None
|
||||
|
||||
function_module, function_type, frontmatter = await load_function_module_by_id(function_id, content)
|
||||
else:
|
||||
# Load from cache (e.g. "stream" hook)
|
||||
# This is useful for performance reasons
|
||||
|
|
@ -415,10 +415,15 @@ async def get_function_module_from_cache(
|
|||
if function_id in functions_cache:
|
||||
return functions_cache[function_id], None, None
|
||||
|
||||
function_module, function_type, frontmatter = await load_function_module_by_id(function_id)
|
||||
try:
|
||||
function_module, function_type, frontmatter, source_module = await load_function_module_by_id(
|
||||
function_id, content
|
||||
)
|
||||
except Exception:
|
||||
await Functions.update_function_by_id(function_id, {'is_active': False})
|
||||
raise
|
||||
|
||||
functions_cache[function_id] = function_module
|
||||
function_contents_cache[function_id] = content
|
||||
set_function_module_in_cache(request, function_id, content, function_module, source_module)
|
||||
|
||||
return function_module, function_type, frontmatter
|
||||
|
||||
|
|
|
|||
|
|
@ -116,7 +116,12 @@ from open_webui.utils.json_codec import JSONCodec
|
|||
from open_webui.utils.mcp.client import MCPClient, OAuthTokenAuth
|
||||
from open_webui.utils.misc import is_string_allowed
|
||||
from open_webui.utils.oauth import get_system_oauth_token
|
||||
from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id
|
||||
from open_webui.utils.plugin import (
|
||||
get_tool_contents_cache,
|
||||
get_tools_cache,
|
||||
load_tool_module_by_id,
|
||||
set_tool_module_in_cache,
|
||||
)
|
||||
from open_webui.utils.terminals import (
|
||||
TERMINAL_CONTEXT_HEADER,
|
||||
get_terminal_server_url,
|
||||
|
|
@ -375,9 +380,8 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
|
|||
tool_contents_cache = get_tool_contents_cache(request)
|
||||
module = tools_cache.get(tool_id)
|
||||
if module is None or tool_contents_cache.get(tool_id) != tool.content:
|
||||
module, _ = await load_tool_module_by_id(tool_id, content=tool.content)
|
||||
tools_cache[tool_id] = module
|
||||
tool_contents_cache[tool_id] = tool.content
|
||||
module, _, source_module = await load_tool_module_by_id(tool_id, content=tool.content)
|
||||
set_tool_module_in_cache(request, tool_id, tool.content, module, source_module)
|
||||
|
||||
__user__ = {
|
||||
**extra_params['__user__'],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import logging
|
|||
from functools import lru_cache
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from open_webui.env import ENABLE_VALVE_ENCRYPTION, WEBUI_SECRET_KEY
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
|
|
@ -39,3 +41,20 @@ def decrypt_valves(valves) -> dict:
|
|||
return {}
|
||||
|
||||
return decrypted if isinstance(decrypted, dict) else {}
|
||||
|
||||
|
||||
def validate_valves(module, valves):
|
||||
if hasattr(module, 'Valves'):
|
||||
values = decrypt_valves(valves) or {}
|
||||
try:
|
||||
# Validate without replacing the stored values (including unused keys/secrets).
|
||||
module.Valves(**{key: value for key, value in values.items() if value is not None})
|
||||
except Exception as error:
|
||||
detail = (
|
||||
'; '.join(
|
||||
f'{".".join(map(str, item["loc"]))}: {item["msg"]}' for item in error.errors(include_input=False)
|
||||
)
|
||||
if isinstance(error, ValidationError)
|
||||
else str(error)
|
||||
)
|
||||
raise HTTPException(400, f'Current Valves are incompatible with this code: {detail}') from error
|
||||
|
|
|
|||
|
|
@ -518,3 +518,101 @@ export const updateUserValvesById = async (token: string, id: string, valves: ob
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
export type FunctionHistoryEntry = {
|
||||
id: string;
|
||||
function_id: string;
|
||||
parent_id: string | null;
|
||||
user_id: string;
|
||||
commit_message: string | null;
|
||||
created_at: number;
|
||||
user?: { name: string } | null;
|
||||
};
|
||||
|
||||
export type FunctionSnapshot = {
|
||||
name: string;
|
||||
content: string;
|
||||
meta: Record<string, any>;
|
||||
};
|
||||
|
||||
export const getFunctionHistory = async (
|
||||
token: string,
|
||||
id: string,
|
||||
page = 1
|
||||
): Promise<FunctionHistoryEntry[]> => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/functions/id/${encodeURIComponent(id)}/history?page=${page}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw await response.json();
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getFunctionHistoryEntry = async (
|
||||
token: string,
|
||||
id: string,
|
||||
versionId: string
|
||||
): Promise<FunctionHistoryEntry & { snapshot: FunctionSnapshot }> => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/functions/id/${encodeURIComponent(id)}/history/${encodeURIComponent(versionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw await response.json();
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getFunctionHistoryDiff = async (
|
||||
token: string,
|
||||
id: string,
|
||||
fromId: string,
|
||||
toId: string
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/functions/id/${encodeURIComponent(id)}/history/diff?${new URLSearchParams({ from_id: fromId, to_id: toId })}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw await response.json();
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteFunctionHistoryVersion = async (
|
||||
token: string,
|
||||
id: string,
|
||||
versionId: string
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/functions/id/${encodeURIComponent(id)}/history/${encodeURIComponent(versionId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw await response.json();
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const setProductionFunctionVersion = async (
|
||||
token: string,
|
||||
id: string,
|
||||
versionId: string
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/functions/id/${encodeURIComponent(id)}/update/version`,
|
||||
{
|
||||
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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -496,3 +496,93 @@ export const updateUserValvesById = async (token: string, id: string, valves: ob
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
export type ToolHistoryEntry = {
|
||||
id: string;
|
||||
tool_id: string;
|
||||
parent_id: string | null;
|
||||
user_id: string;
|
||||
commit_message: string | null;
|
||||
created_at: number;
|
||||
user?: { name: string } | null;
|
||||
};
|
||||
|
||||
export type ToolSnapshot = {
|
||||
name: string;
|
||||
content: string;
|
||||
meta: Record<string, any>;
|
||||
};
|
||||
|
||||
export const getToolHistory = async (
|
||||
token: string,
|
||||
id: string,
|
||||
page = 1
|
||||
): Promise<ToolHistoryEntry[]> => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/tools/id/${encodeURIComponent(id)}/history?page=${page}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw await response.json();
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getToolHistoryEntry = async (
|
||||
token: string,
|
||||
id: string,
|
||||
versionId: string
|
||||
): Promise<ToolHistoryEntry & { snapshot: ToolSnapshot }> => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/tools/id/${encodeURIComponent(id)}/history/${encodeURIComponent(versionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw await response.json();
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getToolHistoryDiff = async (
|
||||
token: string,
|
||||
id: string,
|
||||
fromId: string,
|
||||
toId: string
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/tools/id/${encodeURIComponent(id)}/history/diff?${new URLSearchParams({ from_id: fromId, to_id: toId })}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw await response.json();
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteToolHistoryVersion = async (token: string, id: string, versionId: string) => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/tools/id/${encodeURIComponent(id)}/history/${encodeURIComponent(versionId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw await response.json();
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const setProductionToolVersion = async (token: string, id: string, versionId: string) => {
|
||||
const response = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/tools/id/${encodeURIComponent(id)}/update/version`,
|
||||
{
|
||||
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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { goto } from '$app/navigation';
|
||||
import VersionDiff from '$lib/components/workspace/common/VersionDiff.svelte';
|
||||
import FunctionHistory from './FunctionHistory.svelte';
|
||||
import { getFunctionHistoryDiff } from '$lib/apis/functions';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const i18n = getContext<any>('i18n');
|
||||
|
||||
import { extractFrontmatter, formatSkillName, nameToId } from '$lib/utils';
|
||||
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
|
||||
|
|
@ -15,49 +19,106 @@
|
|||
import PluginTranslations from '$lib/components/workspace/common/PluginTranslations.svelte';
|
||||
import { pruneEmptyLocaleEntries } from '$lib/utils/localizedContent';
|
||||
let locale = '';
|
||||
$: warning = [
|
||||
$i18n.t('Warning:'),
|
||||
$i18n.t('Functions can execute arbitrary code.'),
|
||||
$i18n.t('Only install functions from sources you trust.')
|
||||
].join(' ');
|
||||
|
||||
let formElement = null;
|
||||
let formElement: HTMLFormElement | null = null;
|
||||
let loading = false;
|
||||
let showConfirm = false;
|
||||
|
||||
export let onSave = /** @param {any} _value */ async (_value) => false;
|
||||
export let onSave: (value: any) => Promise<any> = async (_value) => false;
|
||||
|
||||
export let edit = false;
|
||||
export let clone = false;
|
||||
|
||||
export let id = '';
|
||||
export let version_id: string | null = null;
|
||||
export let onProduction: (resource: any) => Promise<void> = async (_resource) => {};
|
||||
let selected: any = null;
|
||||
let selecting = false;
|
||||
let promoting = false;
|
||||
let historyControl: { requestPromotion: () => void };
|
||||
let commitMessage = '';
|
||||
let comparison: any = null;
|
||||
let comparing = false;
|
||||
let comparisonRequest = 0;
|
||||
let comparisonSelection: string | null | undefined = null;
|
||||
$: if (selected?.id !== comparisonSelection) {
|
||||
comparisonSelection = selected?.id;
|
||||
comparison = null;
|
||||
comparing = false;
|
||||
comparisonRequest++;
|
||||
}
|
||||
const compareToCurrent = async () => {
|
||||
if (!selected || !version_id || comparing) return;
|
||||
const request = ++comparisonRequest;
|
||||
comparing = true;
|
||||
try {
|
||||
const result = await getFunctionHistoryDiff(localStorage.token, id, selected.id, version_id);
|
||||
if (request === comparisonRequest) comparison = result;
|
||||
} catch (error: any) {
|
||||
if (request === comparisonRequest) toast.error(error?.detail || String(error));
|
||||
} finally {
|
||||
if (request === comparisonRequest) comparing = false;
|
||||
}
|
||||
};
|
||||
const applyProduction = async (resource: any) => {
|
||||
name = resource.name;
|
||||
meta = structuredClone(resource.meta);
|
||||
content = resource.content;
|
||||
_content = content;
|
||||
version_id = resource.version_id;
|
||||
commitMessage = '';
|
||||
comparison = null;
|
||||
savedDraft = draftSnapshot(id, name, meta, content);
|
||||
};
|
||||
const productionHandler = async (resource: any) => {
|
||||
await applyProduction(resource);
|
||||
try {
|
||||
await onProduction(resource);
|
||||
} catch (error: any) {
|
||||
toast.error(String(error));
|
||||
}
|
||||
};
|
||||
export let name = '';
|
||||
/** @type {{description: string, i18n?: Record<string, Record<string, string>>, manifest?: {translations?: Record<string, Record<string, string>>}}} */
|
||||
export let meta = {
|
||||
export let meta: {
|
||||
description: string;
|
||||
i18n?: Record<string, Record<string, string>>;
|
||||
[key: string]: any;
|
||||
} = {
|
||||
description: ''
|
||||
};
|
||||
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) =>
|
||||
const draftSnapshot = (id: string, name: string, meta: Record<string, any>, content: string) =>
|
||||
JSON.stringify({
|
||||
id,
|
||||
name,
|
||||
meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) },
|
||||
meta: {
|
||||
...meta,
|
||||
manifest: undefined,
|
||||
has_user_valves: undefined,
|
||||
toggle: undefined,
|
||||
i18n: pruneEmptyLocaleEntries(meta.i18n)
|
||||
},
|
||||
content
|
||||
});
|
||||
let savedDraft = draftSnapshot(id, name, meta, content);
|
||||
$: draft = draftSnapshot(id, name, meta, _content);
|
||||
$: changed = draft !== savedDraft;
|
||||
|
||||
$: if (content) {
|
||||
updateContent();
|
||||
}
|
||||
|
||||
const updateContent = () => {
|
||||
_content = content;
|
||||
};
|
||||
$: _content = content;
|
||||
|
||||
$: if (name && !edit && !clone) {
|
||||
id = nameToId(name);
|
||||
}
|
||||
|
||||
let codeEditor;
|
||||
let codeEditor: CodeEditor;
|
||||
let starterType = 'filter';
|
||||
const filterBoilerplate = `"""
|
||||
title: Example Filter
|
||||
|
|
@ -170,7 +231,7 @@ class Event:
|
|||
let boilerplate = filterBoilerplate;
|
||||
|
||||
/** @param {'filter' | 'event'} type */
|
||||
const setStarterType = (type) => {
|
||||
const setStarterType = (type: 'filter' | 'event') => {
|
||||
starterType = type;
|
||||
boilerplate = type === 'event' ? eventBoilerplate : filterBoilerplate;
|
||||
content = boilerplate;
|
||||
|
|
@ -178,7 +239,7 @@ class Event:
|
|||
};
|
||||
|
||||
/** @param {string} value */
|
||||
const selectStarterType = (value) => {
|
||||
const selectStarterType = (value: string) => {
|
||||
setStarterType(value === 'event' ? 'event' : 'filter');
|
||||
};
|
||||
|
||||
|
|
@ -340,26 +401,35 @@ class Pipe:
|
|||
if (!name.trim() || !meta.description?.trim()) {
|
||||
locale = '';
|
||||
toast.error($i18n.t('Name and description are required'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const submittedDraft = draftSnapshot(id, name, meta, content);
|
||||
const saved = await onSave({
|
||||
commit_message: commitMessage || null,
|
||||
id,
|
||||
name,
|
||||
meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) },
|
||||
content
|
||||
});
|
||||
if (saved) savedDraft = submittedDraft;
|
||||
if (saved) {
|
||||
if (saved.version_id && saved.content !== undefined) await applyProduction(saved);
|
||||
else {
|
||||
savedDraft = submittedDraft;
|
||||
commitMessage = '';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
if (loading || (edit && !changed)) return;
|
||||
if (selected || selecting || promoting || loading || (edit && !changed)) return;
|
||||
if (codeEditor) {
|
||||
loading = true;
|
||||
content = _content;
|
||||
await tick();
|
||||
|
||||
|
|
@ -383,6 +453,7 @@ class Pipe:
|
|||
bind:this={formElement}
|
||||
class="flex h-full min-h-0 min-w-0 flex-col"
|
||||
on:submit|preventDefault={() => {
|
||||
if (selected) return;
|
||||
if (edit) {
|
||||
submitHandler();
|
||||
} else {
|
||||
|
|
@ -391,20 +462,36 @@ class Pipe:
|
|||
}}
|
||||
>
|
||||
<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 min-w-0 items-center gap-3">
|
||||
<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>
|
||||
{#if edit && version_id}
|
||||
<FunctionHistory
|
||||
bind:this={historyControl}
|
||||
resource={{ id, version_id }}
|
||||
bind:selected
|
||||
bind:selecting
|
||||
bind:promoting
|
||||
dirty={changed || !!commitMessage}
|
||||
disabled={loading}
|
||||
onProduction={productionHandler}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 pr-0.5">
|
||||
<LanguageModeSelect
|
||||
bind:value={locale}
|
||||
translatedLocales={Object.keys(pruneEmptyLocaleEntries(meta.i18n))}
|
||||
translatedLocales={Object.keys(
|
||||
pruneEmptyLocaleEntries(selected ? selected.snapshot.meta.i18n : meta.i18n)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -412,13 +499,25 @@ class Pipe:
|
|||
<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">
|
||||
<Tooltip content={$i18n.t('e.g. My Filter')} placement="top-start">
|
||||
<LocalizedField
|
||||
placeholder={$i18n.t('Function Name')}
|
||||
bind:value={name}
|
||||
bind:translations={meta.i18n}
|
||||
{locale}
|
||||
required
|
||||
/>
|
||||
{#if selected}
|
||||
<LocalizedField
|
||||
disabled
|
||||
showControls={false}
|
||||
placeholder={$i18n.t('Function Name')}
|
||||
value={selected.snapshot.name}
|
||||
translations={selected.snapshot.meta.i18n}
|
||||
{locale}
|
||||
required
|
||||
/>
|
||||
{:else}
|
||||
<LocalizedField
|
||||
placeholder={$i18n.t('Function Name')}
|
||||
bind:value={name}
|
||||
bind:translations={meta.i18n}
|
||||
{locale}
|
||||
required
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip>
|
||||
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
|
||||
|
|
@ -449,14 +548,27 @@ class Pipe:
|
|||
content={$i18n.t('e.g. A filter to remove profanity from text')}
|
||||
placement="top-start"
|
||||
>
|
||||
<LocalizedField
|
||||
placeholder={$i18n.t('Function Description')}
|
||||
bind:value={meta.description}
|
||||
bind:translations={meta.i18n}
|
||||
{locale}
|
||||
field="description"
|
||||
required
|
||||
/>
|
||||
{#if selected}
|
||||
<LocalizedField
|
||||
disabled
|
||||
showControls={false}
|
||||
placeholder={$i18n.t('Function Description')}
|
||||
value={selected.snapshot.meta.description}
|
||||
translations={selected.snapshot.meta.i18n}
|
||||
{locale}
|
||||
field="description"
|
||||
required
|
||||
/>
|
||||
{:else}
|
||||
<LocalizedField
|
||||
placeholder={$i18n.t('Function Description')}
|
||||
bind:value={meta.description}
|
||||
bind:translations={meta.i18n}
|
||||
{locale}
|
||||
field="description"
|
||||
required
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -477,25 +589,71 @@ class Pipe:
|
|||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-hidden rounded-lg flex flex-col">
|
||||
{#if locale}
|
||||
<PluginTranslations
|
||||
id={edit ? id : ''}
|
||||
kind="function"
|
||||
{locale}
|
||||
bind:translations={meta.i18n}
|
||||
{#if locale && !comparison}
|
||||
{#key selected?.id}
|
||||
{#if selected}
|
||||
<PluginTranslations
|
||||
{id}
|
||||
kind="function"
|
||||
{locale}
|
||||
readOnly
|
||||
translations={selected.snapshot.meta.i18n}
|
||||
/>
|
||||
{:else}
|
||||
<PluginTranslations
|
||||
id={edit ? id : ''}
|
||||
kind="function"
|
||||
{locale}
|
||||
bind:translations={meta.i18n}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#if comparison}
|
||||
<VersionDiff
|
||||
currentLabel={$i18n.t('Production')}
|
||||
showFileHeaders={false}
|
||||
diff={{
|
||||
fromId: comparison.from_id,
|
||||
toId: comparison.to_id,
|
||||
metadata: comparison.metadata,
|
||||
files:
|
||||
comparison.content_diff || comparison.line_endings_only
|
||||
? [{ path: `${id}.py`, status: 'modified', binary: false }]
|
||||
: []
|
||||
}}
|
||||
loadFileDiff={async () => ({
|
||||
diff: comparison.content_diff,
|
||||
line_endings_only: comparison.line_endings_only
|
||||
})}
|
||||
onClose={() => {
|
||||
comparison = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class={locale ? 'hidden' : 'h-full'}>
|
||||
{#if selected && !locale && !comparison}
|
||||
<CodeEditor
|
||||
value={selected.snapshot.content}
|
||||
lang="python"
|
||||
readOnly
|
||||
className="text-[0.6875rem]"
|
||||
/>
|
||||
{/if}
|
||||
<div class={locale || comparison || selected ? 'hidden' : 'h-full min-h-0'}>
|
||||
<CodeEditor
|
||||
bind:this={codeEditor}
|
||||
value={content}
|
||||
readOnly={!!selected || selecting || promoting}
|
||||
lang="python"
|
||||
{boilerplate}
|
||||
className="text-[0.6875rem]"
|
||||
onChange={(e) => {
|
||||
onChange={(e: string) => {
|
||||
if (selected) return;
|
||||
content = e;
|
||||
_content = e;
|
||||
if (!edit) {
|
||||
const fm = extractFrontmatter(e);
|
||||
const fm = extractFrontmatter(e) as Record<string, string>;
|
||||
if (fm.title && !name) {
|
||||
name = formatSkillName(fm.title);
|
||||
id = nameToId(fm.title);
|
||||
|
|
@ -516,24 +674,55 @@ class Pipe:
|
|||
|
||||
<div class="shrink-0 py-2 text-xs text-gray-500">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<span class="font-normal dark:text-gray-200">{$i18n.t('Warning:')}</span>
|
||||
{$i18n.t('Functions can execute arbitrary code.')}
|
||||
<span class="font-normal dark:text-gray-400">
|
||||
{$i18n.t('Only install functions from sources you trust.')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 || (edit && !changed)}
|
||||
<Tooltip
|
||||
className="min-w-0 flex-1"
|
||||
content={warning}
|
||||
allowHTML={false}
|
||||
placement="top-start"
|
||||
>
|
||||
{$i18n.t(edit ? 'Save' : 'Save & Create')}
|
||||
{#if loading}
|
||||
<Spinner className="size-3" />
|
||||
<div class="truncate">{warning}</div>
|
||||
</Tooltip>
|
||||
|
||||
<div class="ml-auto flex min-w-0 max-w-full shrink-0 items-center gap-3">
|
||||
{#if selected}
|
||||
{#if !comparison}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
disabled={comparing || promoting || selecting}
|
||||
on:click={compareToCurrent}
|
||||
>
|
||||
{$i18n.t('Compare to current')}{#if comparing}<Spinner className="size-3" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900"
|
||||
disabled={promoting || selecting}
|
||||
on:click={() => historyControl?.requestPromotion()}
|
||||
>
|
||||
{$i18n.t('Set as Production')}{#if promoting}<Spinner className="size-3" />{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<input
|
||||
class="w-32 min-w-0 flex-1 border-0 bg-transparent text-xs outline-hidden placeholder:text-gray-400"
|
||||
aria-label={$i18n.t('Describe this change')}
|
||||
placeholder={$i18n.t('Describe this change')}
|
||||
bind:value={commitMessage}
|
||||
disabled={loading}
|
||||
/>
|
||||
<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 || selecting || promoting || (edit && !changed)}
|
||||
>
|
||||
{edit ? $i18n.t('Save') : $i18n.t('Save & Create')}
|
||||
{#if loading}
|
||||
<Spinner className="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
211
src/lib/components/admin/Functions/FunctionHistory.svelte
Normal file
211
src/lib/components/admin/Functions/FunctionHistory.svelte
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<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 '$lib/components/workspace/common/VersionMenuItem.svelte';
|
||||
import {
|
||||
getFunctionHistory,
|
||||
deleteFunctionHistoryVersion,
|
||||
getFunctionHistoryEntry,
|
||||
setProductionFunctionVersion,
|
||||
type FunctionHistoryEntry,
|
||||
type FunctionSnapshot
|
||||
} from '$lib/apis/functions';
|
||||
|
||||
export let resource: any;
|
||||
export let dirty = false;
|
||||
export let disabled = false;
|
||||
export let onProduction: (resource: any) => Promise<void>;
|
||||
const i18n = getContext<any>('i18n');
|
||||
let show = false;
|
||||
let page = 1;
|
||||
let history: FunctionHistoryEntry[] = [];
|
||||
let production: FunctionHistoryEntry | null = null;
|
||||
export let selected: (FunctionHistoryEntry & { snapshot: FunctionSnapshot }) | null = null;
|
||||
let loading = false;
|
||||
export let selecting = false;
|
||||
export let promoting = false;
|
||||
let error = '';
|
||||
let confirmPromotion = false;
|
||||
let showDeleteVersion = false;
|
||||
let deleteVersionId = '';
|
||||
let deleting = false;
|
||||
let selection = 0;
|
||||
const message = (error: any) =>
|
||||
typeof error?.detail === 'string'
|
||||
? error.detail
|
||||
: error?.message || $i18n.t('Failed to load function version');
|
||||
|
||||
async function loadHistory() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
history = await getFunctionHistory(localStorage.token, resource.id, page);
|
||||
production =
|
||||
history.find((entry) => entry.id === resource.version_id) ||
|
||||
(await getFunctionHistoryEntry(localStorage.token, resource.id, resource.version_id));
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(id: string) {
|
||||
show = false;
|
||||
const request = ++selection;
|
||||
if (id === resource.version_id) {
|
||||
selected = null;
|
||||
selecting = false;
|
||||
return;
|
||||
}
|
||||
selecting = true;
|
||||
try {
|
||||
const entry = await getFunctionHistoryEntry(localStorage.token, resource.id, id);
|
||||
if (request === selection) {
|
||||
selected = entry;
|
||||
}
|
||||
} catch (e) {
|
||||
if (request === selection) toast.error(message(e));
|
||||
} finally {
|
||||
if (request === selection) selecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVersion() {
|
||||
if (deleting || promoting || !deleteVersionId || deleteVersionId === resource.version_id)
|
||||
return;
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteFunctionHistoryVersion(localStorage.token, resource.id, deleteVersionId);
|
||||
// Invalidate an in-flight preview response for the deleted version.
|
||||
selection++;
|
||||
selecting = false;
|
||||
if (selected?.id === deleteVersionId) selected = null;
|
||||
page = 1;
|
||||
await loadHistory();
|
||||
toast.success($i18n.t('Version deleted'));
|
||||
} catch (e) {
|
||||
toast.error(message(e));
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function requestPromotion() {
|
||||
if (dirty) confirmPromotion = true;
|
||||
else promote();
|
||||
}
|
||||
|
||||
async function promote() {
|
||||
if (!selected || promoting || deleting) return;
|
||||
promoting = true;
|
||||
try {
|
||||
const result = await setProductionFunctionVersion(
|
||||
localStorage.token,
|
||||
resource.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}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteVersion}
|
||||
title={$i18n.t('Delete Version')}
|
||||
message={$i18n.t(
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
|
||||
)}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
onConfirm={deleteVersion}
|
||||
/>
|
||||
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Dropdown bind:show align="start">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Select version')}
|
||||
class="flex min-w-0 items-center gap-1.5 text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
disabled={disabled || promoting || deleting}
|
||||
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(resource.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 !== resource.version_id)}<hr
|
||||
class="my-1 border-gray-100 dark:border-gray-850"
|
||||
/>{/if}
|
||||
{#each history.filter((entry) => entry.id !== resource.version_id) as entry (entry.id)}
|
||||
<VersionMenuItem
|
||||
{entry}
|
||||
selected={entry.id === selected?.id}
|
||||
onSelect={() => selectVersion(entry.id)}
|
||||
onDelete={() => {
|
||||
deleteVersionId = entry.id;
|
||||
show = false;
|
||||
showDeleteVersion = true;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#if page > 1 || history.length === 20}
|
||||
<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>
|
||||
|
|
@ -27,13 +27,14 @@
|
|||
export let boilerplate = '';
|
||||
export let value = '';
|
||||
export let className = 'text-sm';
|
||||
export let readOnly = false;
|
||||
|
||||
export let onSave = () => {};
|
||||
export let onChange = () => {};
|
||||
export let onChange: (value: string) => void = () => {};
|
||||
|
||||
let _value = '';
|
||||
|
||||
$: if (value) {
|
||||
$: if (value !== undefined) {
|
||||
updateValue();
|
||||
}
|
||||
|
||||
|
|
@ -89,9 +90,20 @@
|
|||
let editorTheme = new Compartment();
|
||||
let editorLanguage = new Compartment();
|
||||
const editorPlaceholder = new Compartment();
|
||||
const editorReadOnly = new Compartment();
|
||||
$: if (codeEditor) {
|
||||
codeEditor.dispatch({
|
||||
effects: editorPlaceholder.reconfigure(placeholder($i18n.t('Enter your code here...')))
|
||||
effects: editorReadOnly.reconfigure([
|
||||
EditorState.readOnly.of(readOnly),
|
||||
EditorView.editable.of(!readOnly)
|
||||
])
|
||||
});
|
||||
}
|
||||
$: if (codeEditor) {
|
||||
codeEditor.dispatch({
|
||||
effects: editorPlaceholder.reconfigure(
|
||||
readOnly ? [] : placeholder($i18n.t('Enter your code here...'))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +197,7 @@ print("${endTag}")
|
|||
};
|
||||
|
||||
export const formatPythonCodeHandler = async () => {
|
||||
if (readOnly) return false;
|
||||
if (codeEditor) {
|
||||
const res = await (
|
||||
$user?.role === 'admin'
|
||||
|
|
@ -194,14 +207,14 @@ print("${endTag}")
|
|||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
if (res && res.code) {
|
||||
if (!readOnly && res && typeof res.code === 'string') {
|
||||
const formattedCode = res.code;
|
||||
codeEditor.dispatch({
|
||||
changes: [{ from: 0, to: codeEditor.state.doc.length, insert: formattedCode }]
|
||||
});
|
||||
|
||||
_value = formattedCode;
|
||||
onChange(_value);
|
||||
if (!readOnly) onChange(_value);
|
||||
await tick();
|
||||
|
||||
toast.success($i18n.t('Code formatted successfully'));
|
||||
|
|
@ -214,13 +227,15 @@ print("${endTag}")
|
|||
|
||||
let extensions = [
|
||||
basicSetup,
|
||||
EditorView.contentAttributes.of({ tabindex: '0' }),
|
||||
keymap.of([{ key: 'Tab', run: acceptCompletion }, indentWithTab]),
|
||||
indentUnit.of(' '),
|
||||
editorPlaceholder.of([]),
|
||||
editorReadOnly.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]),
|
||||
EditorView.updateListener.of((e) => {
|
||||
if (e.docChanged) {
|
||||
_value = e.state.doc.toString();
|
||||
onChange(_value);
|
||||
if (!readOnly) onChange(_value);
|
||||
}
|
||||
}),
|
||||
editorTheme.of([]),
|
||||
|
|
@ -241,7 +256,7 @@ print("${endTag}")
|
|||
};
|
||||
|
||||
onMount(() => {
|
||||
if (value === '') {
|
||||
if (value === '' && !readOnly) {
|
||||
value = boilerplate;
|
||||
}
|
||||
|
||||
|
|
@ -293,14 +308,15 @@ print("${endTag}")
|
|||
});
|
||||
|
||||
const keydownHandler = async (e) => {
|
||||
if (!codeEditor?.hasFocus) return;
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
|
||||
onSave();
|
||||
if (!readOnly) onSave();
|
||||
}
|
||||
|
||||
// Format code when Ctrl + Shift + F is pressed
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'f') {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault();
|
||||
await formatPythonCodeHandler();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
<script lang="ts">
|
||||
// Keep keys in nested template expressions visible to i18next-parser.
|
||||
// $i18n.t("Translation");
|
||||
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { validateDictionary } from '$lib/utils/translationDictionary';
|
||||
|
|
@ -13,9 +16,11 @@
|
|||
export let filename = 'translations.json';
|
||||
export let onChange = (_value: Record<string, string>) => {};
|
||||
export let allowNewKeys = false;
|
||||
export let readOnly = false;
|
||||
let adding = false;
|
||||
let newKey = '';
|
||||
const addKey = () => {
|
||||
if (readOnly) return;
|
||||
if (newKey.trim()) {
|
||||
update(newKey.trim(), value?.[newKey.trim()] ?? '');
|
||||
query = newKey.trim();
|
||||
|
|
@ -52,16 +57,19 @@
|
|||
$: pages = Math.max(1, Math.ceil(keys.length / 50));
|
||||
$: if (page >= pages) page = pages - 1;
|
||||
const update = (key: string, text: string) => {
|
||||
if (readOnly) return;
|
||||
value = { ...value, [key]: text };
|
||||
onChange(value);
|
||||
};
|
||||
const reset = (key: string) => {
|
||||
if (readOnly) return;
|
||||
const next = { ...value };
|
||||
delete next[key];
|
||||
value = next;
|
||||
onChange(value);
|
||||
};
|
||||
const importFile = async (event: Event) => {
|
||||
if (readOnly) return;
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
try {
|
||||
if (input.files?.[0]) {
|
||||
|
|
@ -88,9 +96,11 @@
|
|||
hidden
|
||||
on:change={importFile}
|
||||
/>
|
||||
<button type="button" on:click={() => fileInput.click()}>{$i18n.t('Import')}</button>
|
||||
<button type="button" disabled={readOnly} on:click={() => fileInput.click()}
|
||||
>{$i18n.t('Import')}</button
|
||||
>
|
||||
<button type="button" on:click={exportFile}>{$i18n.t('Export')}</button>
|
||||
{#if allowNewKeys}<Tooltip content={$i18n.t('Add translation')}
|
||||
{#if allowNewKeys && !readOnly}<Tooltip content={$i18n.t('Add translation')}
|
||||
><button
|
||||
class="flex size-6 items-center justify-center"
|
||||
type="button"
|
||||
|
|
@ -99,7 +109,7 @@
|
|||
></Tooltip
|
||||
>{/if}
|
||||
</div>
|
||||
{#if adding}<div class="flex items-center gap-2 text-xs">
|
||||
{#if adding && !readOnly}<div class="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
class="min-w-0 flex-1 rounded-lg border border-gray-200 bg-transparent px-2 py-1.5 dark:border-gray-800"
|
||||
aria-label={$i18n.t('Translation key')}
|
||||
|
|
@ -147,6 +157,7 @@
|
|||
</div>
|
||||
<textarea
|
||||
rows="2"
|
||||
readonly={readOnly}
|
||||
class="w-full min-w-0 resize-y rounded-md border border-gray-100 bg-transparent px-2 py-1 text-xs outline-hidden focus:border-gray-400 dark:border-gray-800"
|
||||
aria-label={key}
|
||||
placeholder={source[key] || key}
|
||||
|
|
@ -158,7 +169,7 @@
|
|||
type="button"
|
||||
class="flex size-6 items-center justify-center disabled:opacity-30"
|
||||
aria-label={`${$i18n.t('Use default')}: ${key}`}
|
||||
disabled={!Object.hasOwn(value ?? {}, key)}
|
||||
disabled={readOnly || !Object.hasOwn(value ?? {}, key)}
|
||||
on:click={() => reset(key)}><ArrowUturnLeft className="size-3.5" /></button
|
||||
></Tooltip
|
||||
>
|
||||
|
|
|
|||
207
src/lib/components/workspace/Tools/ToolHistory.svelte
Normal file
207
src/lib/components/workspace/Tools/ToolHistory.svelte
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<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 '$lib/components/workspace/common/VersionMenuItem.svelte';
|
||||
import {
|
||||
getToolHistory,
|
||||
deleteToolHistoryVersion,
|
||||
getToolHistoryEntry,
|
||||
setProductionToolVersion,
|
||||
type ToolHistoryEntry,
|
||||
type ToolSnapshot
|
||||
} from '$lib/apis/tools';
|
||||
|
||||
export let resource: any;
|
||||
export let dirty = false;
|
||||
export let disabled = false;
|
||||
export let onProduction: (resource: any) => Promise<void>;
|
||||
const i18n = getContext<any>('i18n');
|
||||
let show = false;
|
||||
let page = 1;
|
||||
let history: ToolHistoryEntry[] = [];
|
||||
let production: ToolHistoryEntry | null = null;
|
||||
export let selected: (ToolHistoryEntry & { snapshot: ToolSnapshot }) | null = null;
|
||||
let loading = false;
|
||||
export let selecting = false;
|
||||
export let promoting = false;
|
||||
let error = '';
|
||||
let confirmPromotion = false;
|
||||
let showDeleteVersion = false;
|
||||
let deleteVersionId = '';
|
||||
let deleting = false;
|
||||
let selection = 0;
|
||||
const message = (error: any) =>
|
||||
typeof error?.detail === 'string'
|
||||
? error.detail
|
||||
: error?.message || $i18n.t('Failed to load tool version');
|
||||
|
||||
async function loadHistory() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
history = await getToolHistory(localStorage.token, resource.id, page);
|
||||
production =
|
||||
history.find((entry) => entry.id === resource.version_id) ||
|
||||
(await getToolHistoryEntry(localStorage.token, resource.id, resource.version_id));
|
||||
} catch (e) {
|
||||
error = message(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(id: string) {
|
||||
show = false;
|
||||
const request = ++selection;
|
||||
if (id === resource.version_id) {
|
||||
selected = null;
|
||||
selecting = false;
|
||||
return;
|
||||
}
|
||||
selecting = true;
|
||||
try {
|
||||
const entry = await getToolHistoryEntry(localStorage.token, resource.id, id);
|
||||
if (request === selection) {
|
||||
selected = entry;
|
||||
}
|
||||
} catch (e) {
|
||||
if (request === selection) toast.error(message(e));
|
||||
} finally {
|
||||
if (request === selection) selecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVersion() {
|
||||
if (deleting || promoting || !deleteVersionId || deleteVersionId === resource.version_id)
|
||||
return;
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteToolHistoryVersion(localStorage.token, resource.id, deleteVersionId);
|
||||
// Invalidate an in-flight preview response for the deleted version.
|
||||
selection++;
|
||||
selecting = false;
|
||||
if (selected?.id === deleteVersionId) selected = null;
|
||||
page = 1;
|
||||
await loadHistory();
|
||||
toast.success($i18n.t('Version deleted'));
|
||||
} catch (e) {
|
||||
toast.error(message(e));
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function requestPromotion() {
|
||||
if (dirty) confirmPromotion = true;
|
||||
else promote();
|
||||
}
|
||||
|
||||
async function promote() {
|
||||
if (!selected || promoting || deleting) return;
|
||||
promoting = true;
|
||||
try {
|
||||
const result = await setProductionToolVersion(localStorage.token, resource.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}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteVersion}
|
||||
title={$i18n.t('Delete Version')}
|
||||
message={$i18n.t(
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
|
||||
)}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
onConfirm={deleteVersion}
|
||||
/>
|
||||
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Dropdown bind:show align="start">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Select version')}
|
||||
class="flex min-w-0 items-center gap-1.5 text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
disabled={disabled || promoting || deleting}
|
||||
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(resource.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 !== resource.version_id)}<hr
|
||||
class="my-1 border-gray-100 dark:border-gray-850"
|
||||
/>{/if}
|
||||
{#each history.filter((entry) => entry.id !== resource.version_id) as entry (entry.id)}
|
||||
<VersionMenuItem
|
||||
{entry}
|
||||
selected={entry.id === selected?.id}
|
||||
onSelect={() => selectVersion(entry.id)}
|
||||
onDelete={() => {
|
||||
deleteVersionId = entry.id;
|
||||
show = false;
|
||||
showDeleteVersion = true;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#if page > 1 || history.length === 20}
|
||||
<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>
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const i18n = getContext<any>('i18n');
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import VersionDiff from '$lib/components/workspace/common/VersionDiff.svelte';
|
||||
import ToolHistory from './ToolHistory.svelte';
|
||||
import { getToolHistoryDiff } from '$lib/apis/tools';
|
||||
import { user } from '$lib/stores';
|
||||
import { updateToolAccessGrants } from '$lib/apis/tools';
|
||||
|
||||
|
|
@ -21,8 +24,13 @@
|
|||
import PluginTranslations from '$lib/components/workspace/common/PluginTranslations.svelte';
|
||||
import { pruneEmptyLocaleEntries } from '$lib/utils/localizedContent';
|
||||
let locale = '';
|
||||
$: warning = [
|
||||
$i18n.t('Warning:'),
|
||||
$i18n.t('Tools can execute arbitrary code.'),
|
||||
$i18n.t('Only install tools from sources you trust.')
|
||||
].join(' ');
|
||||
|
||||
let formElement = null;
|
||||
let formElement: HTMLFormElement | null = null;
|
||||
let loading = false;
|
||||
|
||||
let showConfirm = false;
|
||||
|
|
@ -31,43 +39,96 @@
|
|||
export let edit = false;
|
||||
export let clone = false;
|
||||
|
||||
export let onSave = /** @param {any} _value */ async (_value) => false;
|
||||
export let onSave: (value: any) => Promise<any> = async (_value) => false;
|
||||
|
||||
export let id = '';
|
||||
export let version_id: string | null = null;
|
||||
export let onProduction: (resource: any) => Promise<void> = async (_resource) => {};
|
||||
let selected: any = null;
|
||||
let selecting = false;
|
||||
let promoting = false;
|
||||
let historyControl: { requestPromotion: () => void };
|
||||
let commitMessage = '';
|
||||
let comparison: any = null;
|
||||
let comparing = false;
|
||||
let comparisonRequest = 0;
|
||||
let comparisonSelection: string | null | undefined = null;
|
||||
$: if (selected?.id !== comparisonSelection) {
|
||||
comparisonSelection = selected?.id;
|
||||
comparison = null;
|
||||
comparing = false;
|
||||
comparisonRequest++;
|
||||
}
|
||||
const compareToCurrent = async () => {
|
||||
if (!selected || !version_id || comparing) return;
|
||||
const request = ++comparisonRequest;
|
||||
comparing = true;
|
||||
try {
|
||||
const result = await getToolHistoryDiff(localStorage.token, id, selected.id, version_id);
|
||||
if (request === comparisonRequest) comparison = result;
|
||||
} catch (error: any) {
|
||||
if (request === comparisonRequest) toast.error(error?.detail || String(error));
|
||||
} finally {
|
||||
if (request === comparisonRequest) comparing = false;
|
||||
}
|
||||
};
|
||||
const applyProduction = async (resource: any) => {
|
||||
name = resource.name;
|
||||
meta = structuredClone(resource.meta);
|
||||
content = resource.content;
|
||||
_content = content;
|
||||
version_id = resource.version_id;
|
||||
commitMessage = '';
|
||||
comparison = null;
|
||||
savedDraft = draftSnapshot(id, name, meta, content);
|
||||
accessGrants = resource.access_grants ?? accessGrants;
|
||||
};
|
||||
const productionHandler = async (resource: any) => {
|
||||
await applyProduction(resource);
|
||||
try {
|
||||
await onProduction(resource);
|
||||
} catch (error: any) {
|
||||
toast.error(String(error));
|
||||
}
|
||||
};
|
||||
export let name = '';
|
||||
/** @type {{description: string, i18n?: Record<string, Record<string, string>>, manifest?: {translations?: Record<string, Record<string, string>>}}} */
|
||||
export let meta = {
|
||||
export let meta: {
|
||||
description: string;
|
||||
i18n?: Record<string, Record<string, string>>;
|
||||
[key: string]: any;
|
||||
} = {
|
||||
description: ''
|
||||
};
|
||||
export let content = '';
|
||||
export let accessGrants = [];
|
||||
export let accessGrants: any[] = [];
|
||||
|
||||
let _content = '';
|
||||
/** @param {string} id @param {string} name @param {Record<string, any>} meta @param {string} content */
|
||||
const draftSnapshot = (id, name, meta, content) =>
|
||||
const draftSnapshot = (id: string, name: string, meta: Record<string, any>, content: string) =>
|
||||
JSON.stringify({
|
||||
id,
|
||||
name,
|
||||
meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) },
|
||||
meta: {
|
||||
...meta,
|
||||
manifest: undefined,
|
||||
has_user_valves: undefined,
|
||||
toggle: undefined,
|
||||
i18n: pruneEmptyLocaleEntries(meta.i18n)
|
||||
},
|
||||
content
|
||||
});
|
||||
let savedDraft = draftSnapshot(id, name, meta, content);
|
||||
$: draft = draftSnapshot(id, name, meta, _content);
|
||||
$: changed = draft !== savedDraft;
|
||||
|
||||
$: if (content) {
|
||||
updateContent();
|
||||
}
|
||||
|
||||
const updateContent = () => {
|
||||
_content = content;
|
||||
};
|
||||
$: _content = content;
|
||||
|
||||
$: if (name && !edit && !clone) {
|
||||
id = nameToId(name);
|
||||
}
|
||||
|
||||
let codeEditor;
|
||||
let codeEditor: CodeEditor;
|
||||
let boilerplate = `import os
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
|
@ -179,27 +240,36 @@ class Tools:
|
|||
if (!name.trim() || !meta.description?.trim()) {
|
||||
locale = '';
|
||||
toast.error($i18n.t('Name and description are required'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const submittedDraft = draftSnapshot(id, name, meta, content);
|
||||
const saved = await onSave({
|
||||
commit_message: commitMessage || null,
|
||||
id,
|
||||
name,
|
||||
meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) },
|
||||
content,
|
||||
access_grants: accessGrants
|
||||
});
|
||||
if (saved) savedDraft = submittedDraft;
|
||||
if (saved) {
|
||||
if (saved.version_id && saved.content !== undefined) await applyProduction(saved);
|
||||
else {
|
||||
savedDraft = submittedDraft;
|
||||
commitMessage = '';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
if (loading || (edit && !changed)) return;
|
||||
if (selected || selecting || promoting || loading || (edit && !changed)) return;
|
||||
if (codeEditor) {
|
||||
loading = true;
|
||||
content = _content;
|
||||
await tick();
|
||||
|
||||
|
|
@ -231,7 +301,7 @@ class Tools:
|
|||
try {
|
||||
await updateToolAccessGrants(localStorage.token, id, accessGrants);
|
||||
toast.success($i18n.t('Saved'));
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -243,6 +313,7 @@ class Tools:
|
|||
bind:this={formElement}
|
||||
class="flex h-full min-h-0 min-w-0 flex-col"
|
||||
on:submit|preventDefault={() => {
|
||||
if (selected) return;
|
||||
if (edit) {
|
||||
submitHandler();
|
||||
} else {
|
||||
|
|
@ -251,22 +322,39 @@ class Tools:
|
|||
}}
|
||||
>
|
||||
<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 min-w-0 items-center gap-3">
|
||||
<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>
|
||||
{#if edit && version_id}
|
||||
<ToolHistory
|
||||
bind:this={historyControl}
|
||||
resource={{ id, version_id }}
|
||||
bind:selected
|
||||
bind:selecting
|
||||
bind:promoting
|
||||
dirty={changed || !!commitMessage}
|
||||
disabled={loading}
|
||||
onProduction={productionHandler}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 pr-0.5">
|
||||
<LanguageModeSelect
|
||||
bind:value={locale}
|
||||
translatedLocales={Object.keys(pruneEmptyLocaleEntries(meta.i18n))}
|
||||
translatedLocales={Object.keys(
|
||||
pruneEmptyLocaleEntries(selected ? selected.snapshot.meta.i18n : meta.i18n)
|
||||
)}
|
||||
/>
|
||||
<AccessButton
|
||||
disabled={!!selected || loading || selecting || promoting}
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
|
|
@ -277,14 +365,26 @@ class Tools:
|
|||
<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">
|
||||
<Tooltip content={$i18n.t('e.g. My Tools')} placement="top-start">
|
||||
<LocalizedField
|
||||
placeholder={$i18n.t('Tool Name')}
|
||||
showControls={false}
|
||||
bind:value={name}
|
||||
bind:translations={meta.i18n}
|
||||
{locale}
|
||||
required
|
||||
/>
|
||||
{#if selected}
|
||||
<LocalizedField
|
||||
disabled
|
||||
placeholder={$i18n.t('Tool Name')}
|
||||
showControls={false}
|
||||
value={selected.snapshot.name}
|
||||
translations={selected.snapshot.meta.i18n}
|
||||
{locale}
|
||||
required
|
||||
/>
|
||||
{:else}
|
||||
<LocalizedField
|
||||
placeholder={$i18n.t('Tool Name')}
|
||||
showControls={false}
|
||||
bind:value={name}
|
||||
bind:translations={meta.i18n}
|
||||
{locale}
|
||||
required
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip>
|
||||
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
|
||||
|
|
@ -315,40 +415,99 @@ class Tools:
|
|||
content={$i18n.t('e.g. Tools for performing various operations')}
|
||||
placement="top-start"
|
||||
>
|
||||
<LocalizedField
|
||||
placeholder={$i18n.t('Tool Description')}
|
||||
showControls={false}
|
||||
bind:value={meta.description}
|
||||
bind:translations={meta.i18n}
|
||||
{locale}
|
||||
field="description"
|
||||
required
|
||||
/>
|
||||
{#if selected}
|
||||
<LocalizedField
|
||||
disabled
|
||||
placeholder={$i18n.t('Tool Description')}
|
||||
showControls={false}
|
||||
value={selected.snapshot.meta.description}
|
||||
translations={selected.snapshot.meta.i18n}
|
||||
{locale}
|
||||
field="description"
|
||||
required
|
||||
/>
|
||||
{:else}
|
||||
<LocalizedField
|
||||
placeholder={$i18n.t('Tool Description')}
|
||||
showControls={false}
|
||||
bind:value={meta.description}
|
||||
bind:translations={meta.i18n}
|
||||
{locale}
|
||||
field="description"
|
||||
required
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-hidden rounded-lg flex flex-col">
|
||||
{#if locale}
|
||||
<PluginTranslations
|
||||
id={edit ? id : ''}
|
||||
kind="tool"
|
||||
{locale}
|
||||
bind:translations={meta.i18n}
|
||||
{#if locale && !comparison}
|
||||
{#key selected?.id}
|
||||
{#if selected}
|
||||
<PluginTranslations
|
||||
{id}
|
||||
kind="tool"
|
||||
{locale}
|
||||
readOnly
|
||||
translations={selected.snapshot.meta.i18n}
|
||||
/>
|
||||
{:else}
|
||||
<PluginTranslations
|
||||
id={edit ? id : ''}
|
||||
kind="tool"
|
||||
{locale}
|
||||
bind:translations={meta.i18n}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#if comparison}
|
||||
<VersionDiff
|
||||
currentLabel={$i18n.t('Production')}
|
||||
showFileHeaders={false}
|
||||
diff={{
|
||||
fromId: comparison.from_id,
|
||||
toId: comparison.to_id,
|
||||
metadata: comparison.metadata,
|
||||
files:
|
||||
comparison.content_diff || comparison.line_endings_only
|
||||
? [{ path: `${id}.py`, status: 'modified', binary: false }]
|
||||
: []
|
||||
}}
|
||||
loadFileDiff={async () => ({
|
||||
diff: comparison.content_diff,
|
||||
line_endings_only: comparison.line_endings_only
|
||||
})}
|
||||
onClose={() => {
|
||||
comparison = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class={locale ? 'hidden' : 'h-full'}>
|
||||
{#if selected && !locale && !comparison}
|
||||
<CodeEditor
|
||||
value={selected.snapshot.content}
|
||||
lang="python"
|
||||
readOnly
|
||||
className="text-[0.6875rem]"
|
||||
/>
|
||||
{/if}
|
||||
<div class={locale || comparison || selected ? 'hidden' : 'h-full min-h-0'}>
|
||||
<CodeEditor
|
||||
bind:this={codeEditor}
|
||||
value={content}
|
||||
readOnly={!!selected || selecting || promoting}
|
||||
lang="python"
|
||||
{boilerplate}
|
||||
className="text-[0.6875rem]"
|
||||
onChange={(e) => {
|
||||
onChange={(e: string) => {
|
||||
if (selected) return;
|
||||
content = e;
|
||||
_content = e;
|
||||
if (!edit) {
|
||||
const fm = extractFrontmatter(e);
|
||||
const fm = extractFrontmatter(e) as Record<string, string>;
|
||||
if (fm.title && !name) {
|
||||
name = formatSkillName(fm.title);
|
||||
id = nameToId(fm.title);
|
||||
|
|
@ -369,24 +528,55 @@ class Tools:
|
|||
|
||||
<div class="shrink-0 py-2 text-xs text-gray-500">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<span class="font-normal dark:text-gray-200">{$i18n.t('Warning:')}</span>
|
||||
{$i18n.t('Tools can execute arbitrary code.')}
|
||||
<span class="font-normal dark:text-gray-400">
|
||||
{$i18n.t('Only install tools from sources you trust.')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 || (edit && !changed)}
|
||||
<Tooltip
|
||||
className="min-w-0 flex-1"
|
||||
content={warning}
|
||||
allowHTML={false}
|
||||
placement="top-start"
|
||||
>
|
||||
{$i18n.t(edit ? 'Save' : 'Save & Create')}
|
||||
{#if loading}
|
||||
<Spinner className="size-3" />
|
||||
<div class="truncate">{warning}</div>
|
||||
</Tooltip>
|
||||
|
||||
<div class="ml-auto flex min-w-0 max-w-full shrink-0 items-center gap-3">
|
||||
{#if selected}
|
||||
{#if !comparison}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
disabled={comparing || promoting || selecting}
|
||||
on:click={compareToCurrent}
|
||||
>
|
||||
{$i18n.t('Compare to current')}{#if comparing}<Spinner className="size-3" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900"
|
||||
disabled={promoting || selecting}
|
||||
on:click={() => historyControl?.requestPromotion()}
|
||||
>
|
||||
{$i18n.t('Set as Production')}{#if promoting}<Spinner className="size-3" />{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<input
|
||||
class="w-32 min-w-0 flex-1 border-0 bg-transparent text-xs outline-hidden placeholder:text-gray-400"
|
||||
aria-label={$i18n.t('Describe this change')}
|
||||
placeholder={$i18n.t('Describe this change')}
|
||||
bind:value={commitMessage}
|
||||
disabled={loading}
|
||||
/>
|
||||
<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 || selecting || promoting || (edit && !changed)}
|
||||
>
|
||||
{edit ? $i18n.t('Save') : $i18n.t('Save & Create')}
|
||||
{#if loading}
|
||||
<Spinner className="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import TranslationTable from '$lib/components/common/TranslationTable.svelte';
|
||||
const i18n = getContext<any>('i18n');
|
||||
export let id = '';
|
||||
export let readOnly = false;
|
||||
export let kind: 'tool' | 'function';
|
||||
export let locale: string;
|
||||
export let translations: Record<string, Record<string, string>> = {};
|
||||
|
|
@ -23,7 +24,7 @@
|
|||
Object.entries(translations?.[locale] ?? {}).filter(([key]) => !headerFields.includes(key))
|
||||
);
|
||||
const load = async () => {
|
||||
if (!id) return;
|
||||
if (!id || readOnly) return;
|
||||
loadError = false;
|
||||
const loaders =
|
||||
kind === 'tool'
|
||||
|
|
@ -44,6 +45,7 @@
|
|||
);
|
||||
};
|
||||
const changed = (next: Record<string, string>) => {
|
||||
if (readOnly) return;
|
||||
translations = {
|
||||
...translations,
|
||||
[locale]: {
|
||||
|
|
@ -63,6 +65,7 @@
|
|||
>
|
||||
</div>{/if}
|
||||
<TranslationTable
|
||||
{readOnly}
|
||||
value={values}
|
||||
{source}
|
||||
filename={`${kind}-${id}-${locale}.json`}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
id: data.id,
|
||||
name: data.name,
|
||||
meta: data.meta,
|
||||
content: data.content
|
||||
content: data.content,
|
||||
commit_message: data.commit_message
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
|
|
@ -101,9 +102,7 @@
|
|||
meta={func?.meta ?? { description: '' }}
|
||||
content={func?.content ?? ''}
|
||||
{clone}
|
||||
onSave={(value) => {
|
||||
saveHandler(value);
|
||||
}}
|
||||
onSave={saveHandler}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@
|
|||
id: data.id,
|
||||
name: data.name,
|
||||
meta: data.meta,
|
||||
content: data.content
|
||||
content: data.content,
|
||||
commit_message: data.commit_message
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
|
|
@ -54,7 +55,9 @@
|
|||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
|
||||
$config?.features?.enable_direct_connections
|
||||
? ($settings?.directConnections ?? null)
|
||||
: null,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
|
@ -92,6 +95,20 @@
|
|||
name={func.name}
|
||||
meta={func.meta}
|
||||
content={func.content}
|
||||
version_id={func.version_id}
|
||||
onProduction={async () => {
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections
|
||||
? ($settings?.directConnections ?? null)
|
||||
: null,
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
}}
|
||||
onSave={saveHandler}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
name: data.name,
|
||||
meta: data.meta,
|
||||
content: data.content,
|
||||
commit_message: data.commit_message,
|
||||
access_grants: data.access_grants
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
|
|
@ -88,9 +89,7 @@
|
|||
content={tool?.content ?? ''}
|
||||
accessGrants={tool?.access_grants !== undefined ? tool.access_grants : []}
|
||||
{clone}
|
||||
onSave={(value) => {
|
||||
saveHandler(value);
|
||||
}}
|
||||
onSave={saveHandler}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
name: data.name,
|
||||
meta: data.meta,
|
||||
content: data.content,
|
||||
commit_message: data.commit_message,
|
||||
access_grants: data.access_grants
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
|
|
@ -89,6 +90,10 @@
|
|||
meta={tool.meta}
|
||||
content={tool.content}
|
||||
accessGrants={tool.access_grants ?? []}
|
||||
version_id={tool.version_id}
|
||||
onProduction={async () => {
|
||||
tools.set(await getTools(localStorage.token));
|
||||
}}
|
||||
onSave={saveHandler}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue