This commit is contained in:
Timothy Jaeryang Baek 2026-10-09 18:37:55 +04:00
parent a8388243d6
commit e6476928ee
23 changed files with 2158 additions and 428 deletions

View file

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

View 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,
}

View file

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

View 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,
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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__'],

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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