diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 0d792e78b2..43074299a1 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -687,7 +687,6 @@ def load_oauth_providers(): return client OAUTH_PROVIDERS['google'] = { - 'redirect_uri': GOOGLE_REDIRECT_URI.value, 'register': google_oauth_register, } @@ -708,7 +707,6 @@ def load_oauth_providers(): return client OAUTH_PROVIDERS['microsoft'] = { - 'redirect_uri': MICROSOFT_REDIRECT_URI.value, 'picture_url': MICROSOFT_CLIENT_PICTURE_URL.value, 'register': microsoft_oauth_register, } @@ -733,7 +731,6 @@ def load_oauth_providers(): return client OAUTH_PROVIDERS['github'] = { - 'redirect_uri': GITHUB_CLIENT_REDIRECT_URI.value, 'register': github_oauth_register, 'sub_claim': 'id', } @@ -775,7 +772,6 @@ def load_oauth_providers(): OAUTH_PROVIDERS['oidc'] = { 'name': OAUTH_PROVIDER_NAME.value, - 'redirect_uri': OPENID_REDIRECT_URI.value, 'register': oidc_oauth_register, } @@ -1226,10 +1222,16 @@ DEFAULT_MODEL_METADATA = PersistentConfig( {}, ) +try: + default_model_params = json.loads(os.environ.get('DEFAULT_MODEL_PARAMS', '{}')) +except Exception as e: + log.exception(f'Error loading DEFAULT_MODEL_PARAMS: {e}') + default_model_params = {} + DEFAULT_MODEL_PARAMS = PersistentConfig( 'DEFAULT_MODEL_PARAMS', 'models.default_params', - {}, + default_model_params, ) DEFAULT_USER_ROLE = PersistentConfig( @@ -1435,6 +1437,10 @@ USER_PERMISSIONS_FEATURES_API_KEYS = os.environ.get('USER_PERMISSIONS_FEATURES_A USER_PERMISSIONS_FEATURES_MEMORIES = os.environ.get('USER_PERMISSIONS_FEATURES_MEMORIES', 'True').lower() == 'true' +USER_PERMISSIONS_FEATURES_AUTOMATIONS = ( + os.environ.get('USER_PERMISSIONS_FEATURES_AUTOMATIONS', 'False').lower() == 'true' +) + USER_PERMISSIONS_SETTINGS_INTERFACE = os.environ.get('USER_PERMISSIONS_SETTINGS_INTERFACE', 'True').lower() == 'true' @@ -1504,6 +1510,7 @@ DEFAULT_USER_PERMISSIONS = { 'image_generation': USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, 'code_interpreter': USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, 'memories': USER_PERMISSIONS_FEATURES_MEMORIES, + 'automations': USER_PERMISSIONS_FEATURES_AUTOMATIONS, }, 'settings': { 'interface': USER_PERMISSIONS_SETTINGS_INTERFACE, @@ -3096,7 +3103,7 @@ WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig( WEB_FETCH_MAX_CONTENT_LENGTH = PersistentConfig( 'WEB_FETCH_MAX_CONTENT_LENGTH', - 'rag.web.search.fetch_url_max_content_length', + 'rag.web.fetch.max_content_length', (int(os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH')) if os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH') else None), ) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 2caa392563..1e1386e139 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -97,6 +97,7 @@ from open_webui.routers import ( utils, scim, terminals, + automations, ) from open_webui.routers.retrieval import ( @@ -650,6 +651,10 @@ async def lifespan(app: FastAPI): asyncio.create_task(periodic_usage_pool_cleanup()) asyncio.create_task(periodic_session_pool_cleanup()) + from open_webui.utils.automations import automation_worker_loop + + asyncio.create_task(automation_worker_loop(app)) + if app.state.config.ENABLE_BASE_MODELS_CACHE: try: await get_all_models( @@ -1526,6 +1531,7 @@ if ENABLE_ADMIN_ANALYTICS: app.include_router(analytics.router, prefix='/api/v1/analytics', tags=['analytics']) app.include_router(utils.router, prefix='/api/v1/utils', tags=['utils']) app.include_router(terminals.router, prefix='/api/v1/terminals', tags=['terminals']) +app.include_router(automations.router, prefix='/api/v1/automations', tags=['automations']) # SCIM 2.0 API for identity management if ENABLE_SCIM: @@ -1845,13 +1851,28 @@ async def chat_completion( except Exception: pass finally: + # Clean up MCP clients. Shield the entire block from + # CancelledError so disconnect() can finish even when the + # task is being stopped. Each client is isolated so one + # failure doesn't skip the rest. try: if mcp_clients := metadata.get('mcp_clients'): - for client in reversed(mcp_clients.values()): - await client.disconnect() + + async def _cleanup_mcp(): + for client in reversed(list(mcp_clients.values())): + try: + await client.disconnect() + except Exception as e: + log.debug(f'Error disconnecting MCP client: {e}') + + await asyncio.wait_for( + asyncio.shield(_cleanup_mcp()), + timeout=10.0, + ) + except asyncio.TimeoutError: + log.warning('MCP client cleanup timed out after 10 s') except Exception as e: - log.debug(f'Error cleaning up: {e}') - pass + log.debug(f'Error cleaning up MCP clients: {e}') # Emit chat:active=false when task completes try: if metadata.get('chat_id'): @@ -1881,6 +1902,10 @@ async def chat_completion( generate_chat_completions = chat_completion generate_chat_completion = chat_completion +# Expose as app.state so internal callers (e.g. automations) can +# use the full pipeline without importing from main.py (avoids circular deps). +app.state.CHAT_COMPLETION_HANDLER = chat_completion + ################################## # diff --git a/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py b/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py new file mode 100644 index 0000000000..20a3152cfe --- /dev/null +++ b/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py @@ -0,0 +1,28 @@ +"""Add tasks and summary columns to chat table + +Revision ID: a3dd5bedd151 +Revises: b2c3d4e5f6a7 +Create Date: 2026-03-29 22:15:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'a3dd5bedd151' +down_revision: Union[str, None] = 'b2c3d4e5f6a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True)) + op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('chat', 'summary') + op.drop_column('chat', 'tasks') diff --git a/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py b/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py new file mode 100644 index 0000000000..fb254432f6 --- /dev/null +++ b/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py @@ -0,0 +1,27 @@ +"""add last_read_at to chat + +Revision ID: b7c8d9e0f1a2 +Revises: d4e5f6a7b8c9 +Create Date: 2026-04-01 04:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b7c8d9e0f1a2' +down_revision = 'd4e5f6a7b8c9' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True)) + # Set existing chats to be marked as read + op.execute('UPDATE chat SET last_read_at = updated_at') + + +def downgrade(): + op.drop_column('chat', 'last_read_at') diff --git a/backend/open_webui/migrations/versions/d4e5f6a7b8c9_add_automation_tables.py b/backend/open_webui/migrations/versions/d4e5f6a7b8c9_add_automation_tables.py new file mode 100644 index 0000000000..fc90dc417f --- /dev/null +++ b/backend/open_webui/migrations/versions/d4e5f6a7b8c9_add_automation_tables.py @@ -0,0 +1,55 @@ +"""add automation tables + +Revision ID: d4e5f6a7b8c9 +Revises: f1e2d3c4b5a6 +Create Date: 2026-03-30 +""" + +from typing import Union + +from alembic import op +import sqlalchemy as sa + +revision: str = 'd4e5f6a7b8c9' +down_revision: Union[str, None] = 'a3dd5bedd151' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'automation', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, default=True), + sa.Column('last_run_at', sa.BigInteger(), nullable=True), + sa.Column('next_run_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + ) + op.create_index('ix_automation_next_run', 'automation', ['next_run_at']) + + op.create_table( + 'automation_run', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('automation_id', sa.Text(), nullable=False), + sa.Column('chat_id', sa.Text(), nullable=True), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + ) + op.create_index( + 'ix_automation_run_automation_id', + 'automation_run', + ['automation_id'], + ) + + +def downgrade(): + op.drop_index('ix_automation_run_automation_id') + op.drop_table('automation_run') + op.drop_index('ix_automation_next_run') + op.drop_table('automation') diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py new file mode 100644 index 0000000000..485f097d5f --- /dev/null +++ b/backend/open_webui/models/automations.py @@ -0,0 +1,337 @@ +import time +import logging +from typing import Optional +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String +from sqlalchemy.orm import Session + +from open_webui.internal.db import Base, get_db, get_db_context + +log = logging.getLogger(__name__) + + +#################### +# Automation DB Schema +#################### + + +class Automation(Base): + __tablename__ = 'automation' + + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + name = Column(Text, nullable=False) + data = Column(JSON, nullable=False) # {prompt, model_id, rrule} + meta = Column(JSON, nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + last_run_at = Column(BigInteger, nullable=True) + next_run_at = Column(BigInteger, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = (Index('ix_automation_next_run', 'next_run_at'),) + + +class AutomationRun(Base): + __tablename__ = 'automation_run' + + id = Column(Text, primary_key=True) + automation_id = Column(Text, nullable=False) + chat_id = Column(Text, nullable=True) + status = Column(Text, nullable=False) # success | error + error = Column(Text, nullable=True) + created_at = Column(BigInteger, nullable=False) + + __table_args__ = (Index('ix_automation_run_automation_id', 'automation_id'),) + + +#################### +# Pydantic Models +#################### + + +class AutomationTerminalConfig(BaseModel): + server_id: str + cwd: Optional[str] = None + + +class AutomationData(BaseModel): + prompt: str + model_id: str + rrule: str + terminal: Optional[AutomationTerminalConfig] = None + + +class AutomationModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + name: str + data: dict + meta: Optional[dict] = None + is_active: bool + last_run_at: Optional[int] = None + next_run_at: Optional[int] = None + + created_at: int + updated_at: int + + +class AutomationRunModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + automation_id: str + chat_id: Optional[str] = None + status: str + error: Optional[str] = None + created_at: int + + +class AutomationForm(BaseModel): + name: str + data: AutomationData + meta: Optional[dict] = None + is_active: Optional[bool] = True + + +class AutomationResponse(AutomationModel): + last_run: Optional[AutomationRunModel] = None + next_runs: Optional[list[int]] = None + + +class AutomationListResponse(BaseModel): + items: list[AutomationModel] + total: int + + +#################### +# AutomationTable +#################### + + +class AutomationTable: + def insert( + self, + user_id: str, + form: AutomationForm, + next_run_at: int, + db: Optional[Session] = None, + ) -> AutomationModel: + with get_db_context(db) as db: + now = int(time.time_ns()) + row = Automation( + id=str(uuid4()), + user_id=user_id, + name=form.name, + data=form.data.model_dump(), + meta=form.meta, + is_active=form.is_active, + next_run_at=next_run_at, + created_at=now, + updated_at=now, + ) + db.add(row) + db.commit() + db.refresh(row) + return AutomationModel.model_validate(row) + + def get_by_id(self, id: str, db: Optional[Session] = None) -> Optional[AutomationModel]: + with get_db_context(db) as db: + row = db.get(Automation, id) + return AutomationModel.model_validate(row) if row else None + + def search_automations( + self, + user_id: str, + query: Optional[str] = None, + status: Optional[str] = None, + skip: int = 0, + limit: int = 30, + db: Optional[Session] = None, + ) -> 'AutomationListResponse': + with get_db_context(db) as db: + q = db.query(Automation).filter_by(user_id=user_id) + + if query: + search = f'%{query}%' + # Search in name and prompt inside JSON data + q = q.filter( + or_( + Automation.name.ilike(search), + cast(Automation.data, String).ilike(search), + ) + ) + + if status == 'active': + q = q.filter(Automation.is_active == True) + elif status == 'paused': + q = q.filter(Automation.is_active == False) + + q = q.order_by(Automation.created_at.desc()) + + total = q.count() + + if skip: + q = q.offset(skip) + if limit: + q = q.limit(limit) + + rows = q.all() + return AutomationListResponse( + items=[AutomationModel.model_validate(r) for r in rows], + total=total, + ) + + def update_by_id( + self, + id: str, + form: AutomationForm, + next_run_at: int, + db: Optional[Session] = None, + ) -> Optional[AutomationModel]: + with get_db_context(db) as db: + row = db.get(Automation, id) + if not row: + return None + row.name = form.name + row.data = form.data.model_dump() + row.meta = form.meta + if form.is_active is not None: + row.is_active = form.is_active + row.next_run_at = next_run_at + row.updated_at = int(time.time_ns()) + db.commit() + db.refresh(row) + return AutomationModel.model_validate(row) + + def toggle( + self, + id: str, + next_run_at: Optional[int], + db: Optional[Session] = None, + ) -> Optional[AutomationModel]: + with get_db_context(db) as db: + row = db.get(Automation, id) + if not row: + return None + row.is_active = not row.is_active + row.next_run_at = next_run_at if row.is_active else None + row.updated_at = int(time.time_ns()) + db.commit() + db.refresh(row) + return AutomationModel.model_validate(row) + + def delete(self, id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: + row = db.get(Automation, id) + if not row: + return False + db.delete(row) + db.commit() + return True + + def claim_due(self, now_ns: int, limit: int = 10, db: Optional[Session] = None) -> list[AutomationModel]: + """ + Atomically claim due automations for execution. + + Advances next_run_at immediately so the row can never be + double-claimed. On PostgreSQL, uses FOR UPDATE SKIP LOCKED + for zero-contention distributed work claiming. + """ + with get_db_context(db) as db: + stmt = ( + select(Automation) + .where( + Automation.is_active == True, + Automation.next_run_at <= now_ns, + ) + .order_by(Automation.next_run_at) + .limit(limit) + ) + + if db.bind.dialect.name == 'postgresql': + stmt = stmt.with_for_update(skip_locked=True) + + rows = db.execute(stmt).scalars().all() + + from open_webui.utils.automations import next_run_ns + + for row in rows: + row.last_run_at = now_ns + row.next_run_at = next_run_ns(row.data.get('rrule', '')) + + db.commit() + + return [AutomationModel.model_validate(r) for r in rows] + + +#################### +# AutomationRunTable +#################### + + +class AutomationRunTable: + def insert( + self, + automation_id: str, + status: str, + chat_id: Optional[str] = None, + error: Optional[str] = None, + db: Optional[Session] = None, + ) -> AutomationRunModel: + with get_db_context(db) as db: + row = AutomationRun( + id=str(uuid4()), + automation_id=automation_id, + chat_id=chat_id, + status=status, + error=error, + created_at=int(time.time_ns()), + ) + db.add(row) + db.commit() + db.refresh(row) + return AutomationRunModel.model_validate(row) + + def get_latest(self, automation_id: str, db: Optional[Session] = None) -> Optional[AutomationRunModel]: + with get_db_context(db) as db: + row = ( + db.query(AutomationRun) + .filter_by(automation_id=automation_id) + .order_by(AutomationRun.created_at.desc()) + .first() + ) + return AutomationRunModel.model_validate(row) if row else None + + def get_by_automation( + self, + automation_id: str, + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, + ) -> list[AutomationRunModel]: + with get_db_context(db) as db: + rows = ( + db.query(AutomationRun) + .filter_by(automation_id=automation_id) + .order_by(AutomationRun.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [AutomationRunModel.model_validate(r) for r in rows] + + def delete_by_automation(self, automation_id: str, db: Optional[Session] = None) -> int: + with get_db_context(db) as db: + count = db.query(AutomationRun).filter_by(automation_id=automation_id).delete() + db.commit() + return count + + +Automations = AutomationTable() +AutomationRuns = AutomationRunTable() diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 97490c1602..ac75fcf973 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -169,7 +169,10 @@ class ChatMessageTable: info = data.get('info', {}) usage = info.get('usage') if info else None if usage: - existing.usage = usage + # Deep-merge: preserve existing keys not present in new data + # This prevents background tasks (follow-ups, title, tags) + # from accidentally clearing the primary response's token counts + existing.usage = {**(existing.usage or {}), **usage} existing.updated_at = now db.commit() db.refresh(existing) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index f19a5e7537..7183145c87 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -54,6 +54,11 @@ class Chat(Base): meta = Column(JSON, server_default='{}') folder_id = Column(Text, nullable=True) + tasks = Column(JSON, nullable=True) + summary = Column(Text, nullable=True) + + last_read_at = Column(BigInteger, nullable=True) + __table_args__ = ( # Performance indexes for common queries # WHERE folder_id = ... @@ -87,6 +92,11 @@ class ChatModel(BaseModel): meta: dict = {} folder_id: Optional[str] = None + tasks: Optional[list] = None + summary: Optional[str] = None + + last_read_at: Optional[int] = None + class ChatFile(Base): __tablename__ = 'chat_file' @@ -161,12 +171,16 @@ class ChatResponse(BaseModel): meta: dict = {} folder_id: Optional[str] = None + tasks: Optional[list] = None + summary: Optional[str] = None + class ChatTitleIdResponse(BaseModel): id: str title: str updated_at: int created_at: int + last_read_at: Optional[int] = None class SharedChatResponse(BaseModel): @@ -388,16 +402,34 @@ class ChatTable: except Exception: return None + def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: + try: + with get_db_context(db) as db: + chat = db.get(Chat, id) + if chat and chat.user_id == user_id: + chat.last_read_at = int(time.time()) + db.commit() + return True + return False + except Exception: + return False + def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]: - chat = self.get_chat_by_id(id) - if chat is None: + try: + with get_db_context() as db: + chat_item = db.get(Chat, id) + if chat_item is None: + return None + clean_title = self._clean_null_bytes(title) + chat_item.title = clean_title + chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title} + chat_item.updated_at = int(time.time()) + db.commit() + db.refresh(chat_item) + return ChatModel.model_validate(chat_item) + except Exception: return None - chat = chat.chat - chat['title'] = title - - return self.update_chat_by_id(id, chat) - def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> Optional[ChatModel]: with get_db_context() as db: chat = db.get(Chat, id) @@ -770,7 +802,7 @@ class ChatTable: skip: int = 0, limit: int = 50, db: Optional[Session] = None, - ) -> list[ChatModel]: + ) -> list[ChatTitleIdResponse]: with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id) if not include_archived: @@ -794,13 +826,28 @@ class ChatTable: else: query = query.order_by(Chat.updated_at.desc(), Chat.id) + query = query.with_entities( + Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at + ) + if skip: query = query.offset(skip) if limit: query = query.limit(limit) all_chats = query.all() - return [ChatModel.model_validate(chat) for chat in all_chats] + return [ + ChatTitleIdResponse.model_validate( + { + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], + 'last_read_at': chat[4], + } + ) + for chat in all_chats + ] def get_chat_title_id_list_by_user_id( self, @@ -825,7 +872,7 @@ class ChatTable: query = query.filter_by(archived=False) query = query.order_by(Chat.updated_at.desc(), Chat.id).with_entities( - Chat.id, Chat.title, Chat.updated_at, Chat.created_at + Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at ) if skip: @@ -843,6 +890,7 @@ class ChatTable: 'title': chat[1], 'updated_at': chat[2], 'created_at': chat[3], + 'last_read_at': chat[4], } ) for chat in all_chats @@ -986,7 +1034,7 @@ class ChatTable: db.query(Chat) .filter_by(user_id=user_id, pinned=True, archived=False) .order_by(Chat.updated_at.desc()) - .with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at) + .with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at) ) return [ ChatTitleIdResponse.model_validate( @@ -995,6 +1043,7 @@ class ChatTable: 'title': chat[1], 'updated_at': chat[2], 'created_at': chat[3], + 'last_read_at': chat[4], } ) for chat in all_chats @@ -1205,7 +1254,7 @@ class ChatTable: skip: int = 0, limit: int = 60, db: Optional[Session] = None, - ) -> list[ChatModel]: + ) -> list[ChatTitleIdResponse]: with get_db_context(db) as db: query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id) query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) @@ -1213,13 +1262,28 @@ class ChatTable: query = query.order_by(Chat.updated_at.desc(), Chat.id) + query = query.with_entities( + Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at + ) + if skip: query = query.offset(skip) if limit: query = query.limit(limit) all_chats = query.all() - return [ChatModel.model_validate(chat) for chat in all_chats] + return [ + ChatTitleIdResponse.model_validate( + { + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], + 'last_read_at': chat[4], + } + ) + for chat in all_chats + ] def get_chats_by_folder_ids_and_user_id( self, folder_ids: list[str], user_id: str, db: Optional[Session] = None @@ -1262,7 +1326,7 @@ class ChatTable: skip: int = 0, limit: int = 50, db: Optional[Session] = None, - ) -> list[ChatModel]: + ) -> list[ChatTitleIdResponse]: with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id) tag_id = tag_name.replace(' ', '_').lower() @@ -1281,9 +1345,30 @@ class ChatTable: else: raise NotImplementedError(f'Unsupported dialect: {db.bind.dialect.name}') + query = query.order_by(Chat.updated_at.desc(), Chat.id) + + query = query.with_entities( + Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at + ) + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + all_chats = query.all() - log.debug(f'all_chats: {all_chats}') - return [ChatModel.model_validate(chat) for chat in all_chats] + return [ + ChatTitleIdResponse.model_validate( + { + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], + 'last_read_at': chat[4], + } + ) + for chat in all_chats + ] def add_chat_tag_by_id_and_user_id_and_tag_name( self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None @@ -1461,8 +1546,8 @@ class ChatTable: def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - chats_by_user = db.query(Chat).filter_by(user_id=user_id).all() - shared_chat_ids = [f'shared-{chat.id}' for chat in chats_by_user] + id_rows = db.query(Chat.id).filter_by(user_id=user_id).all() + shared_chat_ids = [f'shared-{row[0]}' for row in id_rows] # Use subquery to delete chat_messages for shared chats shared_id_subq = db.query(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)).subquery() @@ -1552,5 +1637,27 @@ class ChatTable: return [ChatModel.model_validate(chat) for chat in all_chats] + def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]: + """Update the tasks list on a chat.""" + try: + with get_db_context() as db: + chat = db.get(Chat, id) + if chat is None: + return None + chat.tasks = tasks + db.commit() + db.refresh(chat) + return ChatModel.model_validate(chat) + except Exception: + return None + + def get_chat_tasks_by_id(self, id: str) -> list[dict]: + """Read the tasks list from a chat (lightweight column query).""" + with get_db_context() as db: + result = db.query(Chat.tasks).filter_by(id=id).first() + if result is None or result[0] is None: + return [] + return result[0] + Chats = ChatTable() diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index aa6c7bdcae..f930739f60 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -215,6 +215,13 @@ class FeedbackTable: query = db.query(Feedback, User).join(User, Feedback.user_id == User.id) if filter: + # Apply model_id filter (exact match) + model_id = filter.get('model_id') + if model_id: + query = query.filter( + Feedback.data['model_id'].as_string() == model_id + ) + order_by = filter.get('order_by') direction = filter.get('direction') @@ -288,6 +295,17 @@ class FeedbackTable: .all() ] + def get_distinct_model_ids(self, db: Optional[Session] = None) -> list[str]: + """Get distinct model_ids from feedback data for filter dropdowns.""" + with get_db_context(db) as db: + rows = ( + db.query(Feedback.data['model_id'].as_string()) + .filter(Feedback.data['model_id'].as_string().isnot(None)) + .distinct() + .all() + ) + return sorted([row[0] for row in rows if row[0]]) + def get_feedbacks_for_leaderboard(self, db: Optional[Session] = None) -> list[LeaderboardFeedbackData]: """Fetch only id and data for leaderboard computation (excludes snapshot/meta).""" with get_db_context(db) as db: diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 7007e529d5..ef90745efe 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -588,18 +588,13 @@ class UsersTable: return None @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) - def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> Optional[UserModel]: + def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> None: try: with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() - if not user: - return None - user.last_active_at = int(time.time()) + db.query(User).filter_by(id=id).update({'last_active_at': int(time.time())}) db.commit() - db.refresh(user) - return UserModel.model_validate(user) except Exception: - return None + pass def update_user_oauth_by_id( self, id: str, provider: str, sub: str, db: Optional[Session] = None diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py new file mode 100644 index 0000000000..803f59a6f2 --- /dev/null +++ b/backend/open_webui/routers/automations.py @@ -0,0 +1,272 @@ +import asyncio +import logging + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from open_webui.models.automations import ( + Automations, + AutomationRuns, + AutomationForm, + AutomationModel, + AutomationResponse, + AutomationRunModel, + AutomationListResponse, +) +from open_webui.utils.automations import ( + validate_rrule, + next_run_ns, + next_n_runs_ns, + execute_automation, +) +from open_webui.utils.auth import get_verified_user, get_admin_user +from open_webui.utils.access_control import has_permission +from open_webui.internal.db import get_session +from open_webui.constants import ERROR_MESSAGES + +log = logging.getLogger(__name__) + +router = APIRouter() + +PAGE_ITEM_COUNT = 30 + + +############################ +# Helpers +############################ + + +def check_automations_permission(request, user): + if user.role != 'admin' and not has_permission( + user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + +def check_automation_access(automation, user): + if not automation: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + if user.role != 'admin' and user.id != automation.user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + +def enrich_automation(automation: AutomationModel, db: Session, tz: str = None) -> AutomationResponse: + last_run = AutomationRuns.get_latest(automation.id, db=db) + return AutomationResponse( + **automation.model_dump(), + last_run=last_run, + next_runs=next_n_runs_ns(automation.data['rrule'], tz=tz), + ) + + +############################ +# GetAutomationItems (paginated) +############################ + + +@router.get('/list') +async def get_automation_items( + request: Request, + query: Optional[str] = None, + status: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_automations_permission(request, user) + limit = PAGE_ITEM_COUNT + page = max(1, page) + skip = (page - 1) * limit + + result = Automations.search_automations( + user_id=user.id, + query=query, + status=status, + skip=skip, + limit=limit, + db=db, + ) + + return { + 'items': [enrich_automation(item, db, tz=user.timezone) for item in result.items], + 'total': result.total, + } + + +############################ +# CreateNewAutomation +############################ + + +@router.post('/create', response_model=AutomationResponse) +async def create_new_automation( + request: Request, + form_data: AutomationForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_automations_permission(request, user) + try: + validate_rrule(form_data.data.rrule) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + # Validate terminal server exists if linked + if form_data.data.terminal and form_data.data.terminal.server_id: + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + if not any(c.get('id') == form_data.data.terminal.server_id for c in connections): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Terminal server not found', + ) + + tz = user.timezone + automation = Automations.insert(user.id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db) + return enrich_automation(automation, db, tz=tz) + + +############################ +# GetAutomationById +############################ + + +@router.get('/{id}', response_model=AutomationResponse) +async def get_automation_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_automations_permission(request, user) + automation = Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + return enrich_automation(automation, db, tz=user.timezone) + + +############################ +# UpdateAutomationById +############################ + + +@router.post('/{id}/update', response_model=AutomationResponse) +async def update_automation_by_id( + request: Request, + id: str, + form_data: AutomationForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_automations_permission(request, user) + automation = Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + + try: + validate_rrule(form_data.data.rrule) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + # Validate terminal server exists if linked + if form_data.data.terminal and form_data.data.terminal.server_id: + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + if not any(c.get('id') == form_data.data.terminal.server_id for c in connections): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Terminal server not found', + ) + + tz = user.timezone + updated = Automations.update_by_id(id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db) + return enrich_automation(updated, db, tz=tz) + + +############################ +# ToggleAutomationById +############################ + + +@router.post('/{id}/toggle', response_model=AutomationResponse) +async def toggle_automation_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_automations_permission(request, user) + automation = Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + toggled = Automations.toggle(id, next_run_ns(automation.data['rrule'], tz=user.timezone), db=db) + return enrich_automation(toggled, db, tz=user.timezone) + + +############################ +# RunAutomationById +############################ + + +@router.post('/{id}/run') +async def run_automation_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_automations_permission(request, user) + automation = Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + asyncio.create_task(execute_automation(request.app, automation)) + return enrich_automation(automation, db, tz=user.timezone) + + +############################ +# DeleteAutomationById +############################ + + +@router.delete('/{id}/delete') +async def delete_automation_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_automations_permission(request, user) + automation = Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + AutomationRuns.delete_by_automation(id, db=db) + return Automations.delete(id, db=db) + + +############################ +# GetAutomationRuns +############################ + + +@router.get('/{id}/runs', response_model=list[AutomationRunModel]) +async def get_automation_runs( + request: Request, + id: str, + skip: int = 0, + limit: int = 50, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_automations_permission(request, user) + automation = Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + return AutomationRuns.get_by_automation(id, skip=skip, limit=limit, db=db) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 68ea5ff7f8..aa1ea52662 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -218,7 +218,7 @@ async def get_all_channels( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) if user.role == 'admin': return Channels.get_channels(db=db) return Channels.get_channels_by_user_id(user.id, db=db) @@ -524,7 +524,7 @@ async def update_is_active_member_by_id_and_user_id( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -757,7 +757,7 @@ async def get_pinned_channel_messages( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1094,7 +1094,7 @@ async def post_new_message( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) try: message, channel = await new_message_handler(request, id, form_data, user, db) @@ -1143,7 +1143,7 @@ async def get_channel_message( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1183,7 +1183,7 @@ async def get_channel_message_data( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1223,7 +1223,7 @@ async def pin_channel_message( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1271,7 +1271,7 @@ async def get_channel_thread_messages( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1328,7 +1328,7 @@ async def update_message_by_id( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1395,7 +1395,7 @@ async def add_reaction_to_message( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1462,7 +1462,7 @@ async def remove_reaction_by_id_and_user_id_and_name( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1529,7 +1529,7 @@ async def delete_message_by_id( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1650,7 +1650,7 @@ async def get_channel_webhooks( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1670,7 +1670,7 @@ async def create_channel_webhook( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1695,7 +1695,7 @@ async def update_channel_webhook( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1723,7 +1723,7 @@ async def delete_channel_webhook( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - check_channels_access(request) + check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index eacc084b42..2d12e02523 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -643,9 +643,10 @@ async def get_chat_list_by_folder_id( limit = 10 skip = (page - 1) * limit + chats = Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db) return [ - {'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at} - for chat in Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db) + {'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at} + for chat in chats ] except Exception as e: diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index de97e172f3..a743f8f9f9 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -291,6 +291,11 @@ async def update_config( } +@router.get('/feedbacks/models', response_model=list[str]) +async def get_feedback_model_ids(user=Depends(get_admin_user), db: Session = Depends(get_session)): + return Feedbacks.get_distinct_model_ids(db=db) + + @router.get('/feedbacks/all', response_model=list[FeedbackResponse]) async def get_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)): feedbacks = Feedbacks.get_all_feedbacks(db=db) @@ -309,8 +314,17 @@ async def delete_all_feedbacks(user=Depends(get_admin_user), db: Session = Depen @router.get('/feedbacks/all/export', response_model=list[FeedbackModel]) -async def export_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)): +async def export_all_feedbacks( + model_id: Optional[str] = None, + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): feedbacks = Feedbacks.get_all_feedbacks(db=db) + if model_id: + feedbacks = [ + f for f in feedbacks + if f.data and f.data.get('model_id') == model_id + ] return feedbacks @@ -334,6 +348,7 @@ async def get_feedbacks( order_by: Optional[str] = None, direction: Optional[str] = None, page: Optional[int] = 1, + model_id: Optional[str] = None, user=Depends(get_admin_user), db: Session = Depends(get_session), ): @@ -347,6 +362,8 @@ async def get_feedbacks( filter['order_by'] = order_by if direction: filter['direction'] = direction + if model_id: + filter['model_id'] = model_id result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db) return result diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 5a56e11b68..76ed48d970 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -196,6 +196,14 @@ async def create_new_model( ) else: + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_models', + ) + model = Models.insert_new_model(form_data, user.id, db=db) if model: return model @@ -460,6 +468,7 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user), db: Sessi @router.post('/model/update', response_model=Optional[ModelModel]) async def update_model_by_id( + request: Request, form_data: ModelForm, user=Depends(get_verified_user), db: Session = Depends(get_session), @@ -487,6 +496,14 @@ async def update_model_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_models', + ) + model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db) return model diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 705d86e1c9..0eec88a251 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -175,6 +175,15 @@ async def create_new_note( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_notes', + db=db, + ) + try: note = Notes.insert_new_note(user.id, form_data, db=db) return note diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index e4af8bb513..3b579c2892 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -168,6 +168,14 @@ async def create_new_prompt( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_prompts', + ) + prompt = Prompts.get_prompt_by_command(form_data.command, db=db) if prompt is None: prompt = Prompts.insert_new_prompt(user.id, form_data, db=db) @@ -275,6 +283,7 @@ async def get_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), db: @router.post('/id/{prompt_id}/update', response_model=Optional[PromptModel]) async def update_prompt_by_id( + request: Request, prompt_id: str, form_data: PromptForm, user=Depends(get_verified_user), @@ -314,6 +323,14 @@ async def update_prompt_by_id( detail=f"Command '/{form_data.command}' is already in use by another prompt", ) + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_prompts', + ) + # Use the ID from the found prompt updated_prompt = Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db) if updated_prompt: diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index eac21f0420..3921039208 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -581,9 +581,9 @@ class WebConfig(BaseModel): WEB_SEARCH_TRUST_ENV: Optional[bool] = None WEB_SEARCH_RESULT_COUNT: Optional[int] = None WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None + WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] WEB_FETCH_MAX_CONTENT_LENGTH: Optional[int] = None WEB_LOADER_CONCURRENT_REQUESTS: Optional[int] = None - WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None @@ -1190,7 +1190,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'WEB_SEARCH_TRUST_ENV': request.app.state.config.WEB_SEARCH_TRUST_ENV, 'WEB_SEARCH_RESULT_COUNT': request.app.state.config.WEB_SEARCH_RESULT_COUNT, 'WEB_SEARCH_CONCURRENT_REQUESTS': request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, - 'FETCH_URL_MAX_CONTENT_LENGTH': request.app.state.config.FETCH_URL_MAX_CONTENT_LENGTH, + 'WEB_FETCH_MAX_CONTENT_LENGTH': request.app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH, 'WEB_LOADER_CONCURRENT_REQUESTS': request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, 'WEB_SEARCH_DOMAIN_FILTER_LIST': request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, @@ -2598,6 +2598,7 @@ async def process_files_batch( request: Request, form_data: BatchProcessFilesForm, user=Depends(get_verified_user), + db=None, ) -> BatchProcessFilesResponse: """ Process a batch of files and save them to the vector database. @@ -2620,7 +2621,7 @@ async def process_files_batch( for file in form_data.files: try: # Ownership check: verify the requesting user owns the file or is an admin - db_file = Files.get_file_by_id(file.id) + db_file = Files.get_file_by_id(file.id, db=db) if not db_file: file_errors.append( BatchProcessFilesResult( @@ -2682,7 +2683,7 @@ async def process_files_batch( # Update all files with collection name for file_update, file_result in zip(file_updates, file_results): - Files.update_file_by_id(id=file_result.file_id, form_data=file_update) + Files.update_file_by_id(id=file_result.file_id, form_data=file_update, db=db) file_result.status = 'completed' except Exception as e: diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 59f1f3ab48..34d5eb96d6 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -105,6 +105,10 @@ async def proxy_terminal( target_url += f'?{request.query_params}' headers = {'X-User-Id': user.id} + # Forward per-session cwd tracking header + session_id = request.headers.get('x-session-id') + if session_id: + headers['X-Session-Id'] = session_id cookies = {} auth_type = connection.get('auth_type', 'bearer') diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index a0b8bccd44..195a4eec3e 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -120,7 +120,7 @@ async def get_tools( auth_type = server.get('auth_type', 'none') session_token = None - if auth_type == 'oauth_2.1': + if auth_type in ('oauth_2.1', 'oauth_2.1_static'): splits = server_id.split(':') server_id = splits[-1] if len(splits) > 1 else server_id @@ -148,7 +148,7 @@ async def get_tools( { 'authenticated': session_token is not None, } - if auth_type == 'oauth_2.1' + if auth_type in ('oauth_2.1', 'oauth_2.1_static') else {} ), } @@ -354,6 +354,14 @@ async def create_new_tools( tools = Tools.get_tool_by_id(form_data.id, db=db) if tools is None: try: + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_tools', + ) + form_data.content = replace_imports(form_data.content) tool_module, frontmatter = load_tool_module_by_id(form_data.id, content=form_data.content) form_data.meta.manifest = frontmatter @@ -481,6 +489,14 @@ async def update_tools_by_id( specs = get_tool_specs(TOOLS[id]) + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_tools', + ) + updated = { **form_data.model_dump(exclude={'id'}), 'specs': specs, diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index b263140878..0ccc20185e 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -232,6 +232,7 @@ class FeaturesPermissions(BaseModel): image_generation: bool = True code_interpreter: bool = True memories: bool = True + automations: bool = False class SettingsPermissions(BaseModel): diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 33c9ffea05..80e8b5be1c 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -491,6 +491,19 @@ async def channel_events(sid, data): Channels.update_member_last_read_at(data['channel_id'], user['id']) +@sio.on('events:chat') +async def chat_events(sid, data): + user = SESSION_POOL.get(sid) + if not user: + return + + event_data = data.get('data', {}) + event_type = event_data.get('type') + + if event_type == 'last_read_at': + await asyncio.to_thread(Chats.update_chat_last_read_at_by_id, data['chat_id'], user['id']) + + def normalize_document_id(document_id: str) -> str: """Canonicalize document IDs to prevent auth bypass via prefix variants. @@ -601,10 +614,10 @@ async def document_save_handler(document_id, data, user): user_id=user.get('id'), resource_type='note', resource_id=note.id, - permission='read', + permission='write', ) ): - log.error(f'User {user.get("id")} does not have access to note {note_id}') + log.error(f'User {user.get("id")} does not have write access to note {note_id}') return Notes.update_note_by_id(note_id, NoteUpdateForm(data=data)) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index f02a082c42..3c63d318c3 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -393,7 +393,8 @@ async def execute_code( if CODE_INTERPRETER_BLOCKED_MODULES: import textwrap - blocking_code = textwrap.dedent(f""" + blocking_code = textwrap.dedent( + f""" import builtins BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES} @@ -409,7 +410,8 @@ async def execute_code( return _real_import(name, globals, locals, fromlist, level) builtins.__import__ = restricted_import - """) + """ + ) code = blocking_code + '\n' + code engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide') @@ -2272,15 +2274,15 @@ async def query_knowledge_bases( async def view_skill( - name: str, + id: str, __request__: Request = None, __user__: dict = None, ) -> str: """ - Load the full instructions of a skill by its name from the available skills manifest. + Load the full instructions of a skill by its id from the available skills manifest. Use this when you need detailed instructions for a skill listed in . - :param name: The name of the skill to load (as shown in the manifest) + :param id: The id of the skill to load (as shown in the manifest) :return: The full skill instructions as markdown content """ if __request__ is None: @@ -2295,11 +2297,11 @@ async def view_skill( user_id = __user__.get('id') - # Direct DB lookup by unique name - skill = Skills.get_skill_by_name(name) + # Direct DB lookup by id (case-insensitive since IDs are stored lowercase) + skill = Skills.get_skill_by_id(id.lower()) if not skill or not skill.is_active: - return json.dumps({'error': f"Skill '{name}' not found"}) + return json.dumps({'error': f"Skill '{id}' not found"}) # Check user access user_role = __user__.get('role', 'user') @@ -2324,3 +2326,174 @@ async def view_skill( except Exception as e: log.exception(f'view_skill error: {e}') return json.dumps({'error': str(e)}) + + +# ============================================================================= +# TASK MANAGEMENT TOOLS +# ============================================================================= + +from pydantic import BaseModel, Field +from typing import Literal + +VALID_TASK_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled'} + + +class TaskItem(BaseModel): + id: Optional[str] = Field(None, description='Unique identifier for the task. Auto-generated if omitted.') + content: Optional[str] = Field(None, description='Task description. Aliases: title, name, description.') + status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description='Task status.') + + +async def tasks( + tasks: Optional[list[TaskItem]] = None, + overwrite: bool = True, + __chat_id__: str = None, + __message_id__: str = None, + __event_emitter__: callable = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Track progress on multi-step work by maintaining a task checklist. + Use this whenever a request involves multiple steps or could take + significant effort. Call to set the full list, then call again + with overwrite=false after completing each task to mark it + completed. Do not leave tasks in_progress when the work is done. + Each task has an id, content, and status (pending, in_progress, + completed, cancelled). + + :param tasks: Optional list of task items. Each item: id (string), content (string, required for new tasks), status (pending|in_progress|completed|cancelled). Leave empty to fetch without modifying. + :param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones. + :return: JSON with the full task list and summary counts + """ + if __chat_id__ is None: + return json.dumps({'error': 'Chat context not available'}) + + try: + + def _to_dict(task) -> dict: + """Convert TaskItem or dict to plain dict.""" + if hasattr(task, 'model_dump'): + d = task.model_dump(exclude_none=True) + # Include any extra fields the model sent + if hasattr(task, 'model_extra') and task.model_extra: + d.update(task.model_extra) + return d + return dict(task) if not isinstance(task, dict) else task + + def _resolve_content(d: dict) -> str: + """Accept content, title, name, or description as the task text.""" + for key in ('content', 'title', 'name', 'description'): + val = str(d.get(key, '')).strip() + if val: + return val + return '' + + def _resolve_id(d: dict, idx: int) -> str: + """Use provided id, or auto-generate from index.""" + item_id = str(d.get('id', '') or '').strip() + return item_id if item_id else str(idx + 1) + + if tasks is None: + # Read-only - return current list + all_tasks = Chats.get_chat_tasks_by_id(__chat_id__) + elif overwrite: + # Full replacement - validate and write + all_tasks = [] + for idx, task in enumerate(tasks): + d = _to_dict(task) + item_id = _resolve_id(d, idx) + content = _resolve_content(d) + if not content: + continue + + status = str(d.get('status', 'pending')).strip().lower() + if status not in VALID_TASK_STATUSES: + status = 'pending' + + all_tasks.append( + { + 'id': item_id, + 'content': content, + 'status': status, + } + ) + else: + # Partial update - merge by id + existing_tasks = Chats.get_chat_tasks_by_id(__chat_id__) + existing_by_id = {t['id']: t for t in existing_tasks} + + seen_ids = set() + for idx, task in enumerate(tasks): + d = _to_dict(task) + item_id = _resolve_id(d, len(existing_tasks) + idx) + + seen_ids.add(item_id) + + if item_id in existing_by_id: + resolved = _resolve_content(d) + if resolved: + existing_by_id[item_id]['content'] = resolved + status = str(d.get('status', '')).strip().lower() + if status and status in VALID_TASK_STATUSES: + existing_by_id[item_id]['status'] = status + else: + content = _resolve_content(d) + if not content: + continue + + status = str(d.get('status', 'pending')).strip().lower() + if status not in VALID_TASK_STATUSES: + status = 'pending' + + existing_by_id[item_id] = { + 'id': item_id, + 'content': content, + 'status': status, + } + + # Preserve order of existing, append new + all_tasks = [] + for t in existing_tasks: + if t['id'] in existing_by_id: + all_tasks.append(existing_by_id[t['id']]) + for item_id in seen_ids: + if not any(t['id'] == item_id for t in existing_tasks): + all_tasks.append(existing_by_id[item_id]) + + # Persist to DB and emit (skip for read-only) + if tasks is not None: + Chats.update_chat_tasks_by_id(__chat_id__, all_tasks) + + if __event_emitter__: + await __event_emitter__( + { + 'type': 'chat:message:tasks', + 'data': { + 'tasks': all_tasks, + }, + } + ) + + # Build summary counts + pending = sum(1 for t in all_tasks if t['status'] == 'pending') + in_progress = sum(1 for t in all_tasks if t['status'] == 'in_progress') + completed = sum(1 for t in all_tasks if t['status'] == 'completed') + cancelled = sum(1 for t in all_tasks if t['status'] == 'cancelled') + + return json.dumps( + { + 'tasks': all_tasks, + 'summary': { + 'total': len(all_tasks), + 'pending': pending, + 'in_progress': in_progress, + 'completed': completed, + 'cancelled': cancelled, + }, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'tasks error: {e}') + return json.dumps({'error': str(e)}) diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py new file mode 100644 index 0000000000..4d9eb2fb6c --- /dev/null +++ b/backend/open_webui/utils/automations.py @@ -0,0 +1,408 @@ +""" +Automation utilities. + +RRULE helpers, worker loop, and execution logic. +Follows the utils/.py pattern (cf. utils/channels.py, utils/task.py). + +Environment: + AUTOMATION_POLL_INTERVAL – seconds between polls (default: 10) +""" + +import asyncio +import logging +import os +import random +import time +from datetime import datetime +from typing import Optional +from uuid import uuid4 +from zoneinfo import ZoneInfo + +from dateutil.rrule import rrulestr +from fastapi import Request +from starlette.datastructures import Headers + +from open_webui.models.automations import Automations, AutomationRuns, AutomationModel +from open_webui.models.chats import ChatForm, Chats +from open_webui.models.users import Users +from open_webui.utils.task import prompt_template +from open_webui.internal.db import get_db + +log = logging.getLogger(__name__) + +AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10')) + + +#################### +# RRULE Helpers +#################### + + +def _parse_rule(s: str): + """Parse RRULE with clock-aligned DTSTART for sub-daily frequencies. + + MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00) + so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10). + """ + raw = s.replace('RRULE:', '') + parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p) + freq = parts.get('FREQ', '') + + if freq in ('MINUTELY', 'HOURLY'): + epoch = datetime(2000, 1, 1, 0, 0, 0) + return rrulestr(s, dtstart=epoch, ignoretz=True) + return rrulestr(s, ignoretz=True) + + +def validate_rrule(s: str) -> None: + """Raise ValueError if the RRULE is malformed or exhausted.""" + try: + rule = _parse_rule(s) + except Exception as e: + raise ValueError(f'Invalid RRULE: {e}') + if rule.after(datetime.now()) is None: + raise ValueError('RRULE has no future occurrences') + + +def next_run_ns(s: str, tz: str = None) -> Optional[int]: + """Next occurrence as epoch nanoseconds, respecting user timezone.""" + now = datetime.now(ZoneInfo(tz)) if tz else datetime.now() + dt = _parse_rule(s).after(now.replace(tzinfo=None)) + if dt is None: + return None + if tz: + dt = dt.replace(tzinfo=ZoneInfo(tz)) + return int(dt.timestamp() * 1_000_000_000) + + +def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]: + """Compute next N occurrences for UI preview.""" + rule = _parse_rule(s) + result = [] + dt = datetime.now() + for _ in range(n): + dt = rule.after(dt) + if not dt: + break + if tz: + dt_tz = dt.replace(tzinfo=ZoneInfo(tz)) + result.append(int(dt_tz.timestamp() * 1_000_000_000)) + else: + result.append(int(dt.timestamp() * 1_000_000_000)) + return result + + +############################ +# Worker Loop +############################ + + +async def automation_worker_loop(app) -> None: + """Poll for due automations, claim, fire-and-forget execute. + + Runs on every instance. Poll interval is configurable via + AUTOMATION_POLL_INTERVAL env var (default: 10 seconds). + """ + log.info(f'Automation worker started (poll interval: {AUTOMATION_POLL_INTERVAL}s)') + while True: + try: + with get_db() as db: + batch = Automations.claim_due(int(time.time_ns()), limit=10, db=db) + if batch: + log.info(f'Claimed {len(batch)} due automation(s)') + for automation in batch: + asyncio.create_task(execute_automation(app, automation)) + except Exception: + log.exception('Automation worker error') + + # Jitter to spread load across instances + await asyncio.sleep(AUTOMATION_POLL_INTERVAL + random.uniform(0, 2)) + + +########################## +# Execute +#################### + + +def _build_request(app) -> Request: + """Build a minimal ASGI Request for chat_completion. + + Mirrors the mock-request pattern used in main.py lifespan + (model pre-fetch, tool server init) for consistency. + """ + scope = { + 'type': 'http', + 'asgi': {'version': '3.0', 'spec_version': '2.0'}, + 'method': 'POST', + 'path': '/api/v1/automations/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 0), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': app, + } + request = Request(scope) + # Ensure request.state is initialized with required attributes + request.state.token = None + request.state.enable_api_keys = False + return request + + +def _resolve_model_tool_ids(app, model_id: str) -> list[str]: + """Read model-attached tool_ids from model config. + + The frontend does this in Chat.svelte (model.info.meta.toolIds). + The backend never auto-resolves them, so we must do it explicitly. + """ + models = getattr(app.state, 'MODELS', {}) + model = models.get(model_id, {}) + tool_ids = model.get('info', {}).get('meta', {}).get('toolIds', []) + return list(tool_ids) if tool_ids else [] + + +def _resolve_model_features(app, model_id: str) -> dict: + """Read model default features from model config. + + The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds + + model.info.meta.capabilities). Enables features like web_search, + code_interpreter, image_generation when the model has them as defaults + AND the capability is enabled AND the admin has enabled the feature. + """ + models = getattr(app.state, 'MODELS', {}) + model = models.get(model_id, {}) + meta = model.get('info', {}).get('meta', {}) + + default_feature_ids = meta.get('defaultFeatureIds', []) + if not default_feature_ids: + return {} + + capabilities = meta.get('capabilities', {}) + config = app.state.config + features = {} + + # code_interpreter is excluded: it requires the frontend event emitter + # and does not work in headless backend execution. + feature_checks = { + 'web_search': getattr(config, 'ENABLE_WEB_SEARCH', False), + 'image_generation': getattr(config, 'ENABLE_IMAGE_GENERATION', False), + } + + for feature_id in default_feature_ids: + if feature_id in feature_checks: + # Feature must be: in defaultFeatureIds + capability enabled + admin enabled + if capabilities.get(feature_id) and feature_checks[feature_id]: + features[feature_id] = True + + return features + + +def _resolve_model_filter_ids(app, model_id: str) -> list[str]: + """Read model default filter_ids from model config.""" + models = getattr(app.state, 'MODELS', {}) + model = models.get(model_id, {}) + filter_ids = model.get('info', {}).get('meta', {}).get('defaultFilterIds', []) + return list(filter_ids) if filter_ids else [] + + +async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None: + """Set the working directory on a terminal server via the proxy. + + Routes through the open-webui terminal proxy endpoint so that + auth headers, orchestrator policy routing, and X-User-Id are + handled correctly — same path the frontend uses. + """ + import aiohttp + + connections = getattr(getattr(app, 'state', None), 'config', None) + if connections is None: + return + connections = getattr(connections, 'TERMINAL_SERVER_CONNECTIONS', None) or [] + connection = next((c for c in connections if c.get('id') == server_id), None) + if connection is None: + log.warning(f'Terminal server {server_id} not found for CWD set') + return + + base_url = (connection.get('url') or '').rstrip('/') + if not base_url: + return + + # Build target URL — route through orchestrator policy if configured + policy_id = connection.get('policy_id') + if connection.get('server_type') == 'orchestrator' and policy_id: + target_url = f'{base_url}/p/{policy_id}/files/cwd' + else: + target_url = f'{base_url}/files/cwd' + + headers = {'Content-Type': 'application/json', 'X-User-Id': user.id} + if chat_id: + headers['X-Session-Id'] = chat_id + + auth_type = connection.get('auth_type', 'bearer') + if auth_type == 'bearer': + headers['Authorization'] = f'Bearer {connection.get("key", "")}' + + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: + async with session.post( + target_url, + json={'path': cwd}, + headers=headers, + ) as resp: + if resp.status != 200: + body = await resp.text() + log.warning(f'Failed to set terminal CWD to {cwd}: HTTP {resp.status} — {body[:200]}') + except Exception as e: + log.warning(f'Failed to set terminal CWD: {e}') + + +async def execute_automation(app, automation: AutomationModel) -> None: + """Execute an automation through the full chat completion pipeline. + + Creates a real chat, then calls chat_completion exactly like the frontend: + session_id + chat_id + message_id → async task → pipeline handles everything + (filters, model params, knowledge/RAG, tools, DB saves, webhooks). + """ + try: + user = Users.get_user_by_id(automation.user_id) + if not user: + _record_run(automation.id, 'error', error='User not found') + return + + prompt = prompt_template(automation.data['prompt'], user) + model_id = automation.data['model_id'] + terminal_config = automation.data.get('terminal') + + # Generate proper UUIDs for messages (same as frontend) + user_msg_id = str(uuid4()) + assistant_msg_id = str(uuid4()) + + # Create the chat with user message (same structure as frontend) + chat = Chats.insert_new_chat( + automation.user_id, + ChatForm( + chat={ + 'title': automation.name, + 'models': [model_id], + 'history': { + 'currentId': assistant_msg_id, + 'messages': { + user_msg_id: { + 'id': user_msg_id, + 'parentId': None, + 'role': 'user', + 'content': prompt, + 'childrenIds': [assistant_msg_id], + 'timestamp': int(time.time()), + 'models': [model_id], + }, + assistant_msg_id: { + 'id': assistant_msg_id, + 'parentId': user_msg_id, + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': model_id, + 'childrenIds': [], + 'timestamp': int(time.time()), + }, + }, + }, + 'messages': [ + {'role': 'user', 'content': prompt}, + ], + 'meta': {'automation_id': automation.id}, + } + ), + ) + + if not chat: + _record_run(automation.id, 'error', error='Failed to create chat') + return + + # Notify frontend to refresh chat list + from open_webui.socket.main import sio + + await sio.emit( + 'events', + { + 'chat_id': chat.id, + 'message_id': user_msg_id, + 'data': {'type': 'chat:list'}, + }, + room=f'user:{automation.user_id}', + ) + + # Resolve model defaults (frontend does this, backend doesn't) + tool_ids = _resolve_model_tool_ids(app, model_id) + features = _resolve_model_features(app, model_id) + filter_ids = _resolve_model_filter_ids(app, model_id) + + # If a terminal is linked, set the CWD before building the payload + terminal_id = None + if terminal_config and terminal_config.get('server_id'): + terminal_id = terminal_config['server_id'] + cwd = terminal_config.get('cwd') + if cwd: + await _set_terminal_cwd(app, terminal_id, user, cwd, chat.id) + + # Build the same payload the frontend sends to /api/chat/completions + form_data = { + 'model': model_id, + 'messages': [{'role': 'user', 'content': prompt}], + 'stream': True, + 'chat_id': chat.id, + 'id': assistant_msg_id, + 'parent_id': user_msg_id, + 'session_id': f'automation:{automation.id}', + 'background_tasks': {}, + } + if tool_ids: + form_data['tool_ids'] = tool_ids + if features: + form_data['features'] = features + if filter_ids: + form_data['filter_ids'] = filter_ids + if terminal_id: + form_data['terminal_id'] = terminal_id + + # Call the full chat completion pipeline (same as POST /api/chat/completions). + # The handler reference is stored on app.state to avoid circular imports. + request = _build_request(app) + await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + + # Notify user + from open_webui.socket.main import sio + + await sio.emit( + 'automation:result', + { + 'automation_id': automation.id, + 'name': automation.name, + 'chat_id': chat.id, + 'status': 'success', + }, + room=f'user:{automation.user_id}', + ) + + _record_run(automation.id, 'success', chat_id=chat.id) + + except Exception as e: + log.exception(f'Automation {automation.id} failed') + _record_run(automation.id, 'error', error=str(e)[:4000]) + + +#################### +# Internals +#################### + + +def _record_run( + automation_id: str, + status: str, + chat_id: str = None, + error: str = None, +): + """Insert a run record into automation_run.""" + with get_db() as db: + AutomationRuns.insert(automation_id, status, chat_id=chat_id, error=error, db=db) diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 5ce6fffec6..9a9e810331 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -316,6 +316,10 @@ async def chat_completed(request: Request, form_data: dict, user: Any): models = request.app.state.MODELS data = form_data + + if not data.get('id'): + raise Exception('Missing message id') + model_id = data['model'] if model_id not in models: raise Exception('Model not found') @@ -327,6 +331,9 @@ async def chat_completed(request: Request, form_data: dict, user: Any): except Exception as e: raise Exception(f'Error: {e}') + if not data.get('id'): + raise Exception('Missing message id') + metadata = { 'chat_id': data['chat_id'], 'message_id': data['id'], diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index fbabb390aa..beb2f15079 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -1,7 +1,10 @@ import asyncio +import logging from typing import Optional from contextlib import AsyncExitStack +log = logging.getLogger(__name__) + import anyio from mcp import ClientSession @@ -9,22 +12,27 @@ from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken import httpx -from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL +from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER -def create_insecure_httpx_client(headers=None, timeout=None, auth=None): - """Create an httpx AsyncClient with SSL verification disabled. +def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True): + """Create an httpx AsyncClient for MCP transport. - Note: verify=False must be passed at construction time because httpx + Falls back to AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER when the caller + (i.e. the MCP SDK) does not supply an explicit timeout. + + Note: verify must be passed at construction time because httpx configures the SSL context during __init__. Setting client.verify = False after construction does not affect the underlying transport's SSL context. """ kwargs = { 'follow_redirects': True, - 'verify': False, + 'verify': verify, } if timeout is not None: kwargs['timeout'] = timeout + elif AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None: + kwargs['timeout'] = float(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) if headers is not None: kwargs['headers'] = headers if auth is not None: @@ -32,6 +40,14 @@ def create_insecure_httpx_client(headers=None, timeout=None, auth=None): return httpx.AsyncClient(**kwargs) +def create_httpx_client(headers=None, timeout=None, auth=None): + return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=True) + + +def create_insecure_httpx_client(headers=None, timeout=None, auth=None): + return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False) + + class MCPClient: def __init__(self): self.session: Optional[ClientSession] = None @@ -40,14 +56,13 @@ class MCPClient: async def connect(self, url: str, headers: Optional[dict] = None): async with AsyncExitStack() as exit_stack: try: - if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL: - self._streams_context = streamablehttp_client(url, headers=headers) - else: - self._streams_context = streamablehttp_client( - url, - headers=headers, - httpx_client_factory=create_insecure_httpx_client, - ) + self._streams_context = streamablehttp_client( + url, + headers=headers, + httpx_client_factory=create_httpx_client + if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL + else create_insecure_httpx_client, + ) transport = await exit_stack.enter_async_context(self._streams_context) read_stream, write_stream, _ = transport @@ -124,8 +139,39 @@ class MCPClient: return result_dict async def disconnect(self): - # Clean up and close the session - await self.exit_stack.aclose() + """Clean up and close the session. + + This method is idempotent — calling it multiple times or on a + client that was never connected is safe. It shields the close + operation from CancelledError and adds a timeout so a hung MCP + server cannot block the event loop indefinitely. + """ + exit_stack = self.exit_stack + if exit_stack is None: + return + + # Prevent double-close from concurrent callers + self.exit_stack = None + self.session = None + + try: + await asyncio.wait_for( + asyncio.shield(exit_stack.aclose()), + timeout=5.0, + ) + except asyncio.TimeoutError: + log.warning('MCPClient.disconnect() timed out after 5 s') + except RuntimeError as exc: + # The MCP SDK's streamable_http transport uses anyio task + # groups and async generators internally. When we close + # a session that was interrupted mid-flight these can + # raise RuntimeError ("aclose(): asynchronous generator is + # already running" or "Attempted to exit cancel scope in a + # different task"). Swallowing the error here prevents the + # orphaned coroutines from spinning at 100 % CPU. + log.debug('MCPClient.disconnect() suppressed RuntimeError: %s', exc) + except Exception as exc: + log.debug('MCPClient.disconnect() error: %s', exc) async def __aenter__(self): await self.exit_stack.__aenter__() diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index b64febd673..54df422c4b 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2370,11 +2370,16 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) else: # Native FC: tool docstring can't be dynamic, so inject - # filesystem context into messages for pyodide engine + # filesystem context into the system message for pyodide + # engine. Appending to the system prompt (instead of the + # user message) keeps it in the stable cached prefix so + # providers with prefix caching don't re-bill the full + # conversation on every turn. if engine != 'jupyter': - form_data['messages'] = add_or_update_user_message( + form_data['messages'] = add_or_update_system_message( CODE_INTERPRETER_PYODIDE_PROMPT, form_data['messages'], + append=True, ) tool_ids = form_data.pop('tool_ids', None) @@ -2412,7 +2417,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) else: # Model-attached: name+description only - skill_descriptions += f'\n{skill.name}\n{skill.description or ""}\n\n' + skill_descriptions += f'\n{skill.id}\n{skill.name}\n{skill.description or ""}\n\n' if skill_descriptions: form_data['messages'] = add_or_update_system_message( @@ -2507,7 +2512,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): oauth_token = extra_params.get('__oauth_token__', None) if oauth_token: headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' - elif auth_type == 'oauth_2.1': + elif auth_type in ('oauth_2.1', 'oauth_2.1_static'): try: splits = server_id.split(':') server_id = splits[-1] if len(splits) > 1 else server_id @@ -2750,16 +2755,18 @@ async def process_chat_payload(request, form_data, user, metadata, model): def get_event_emitter_and_caller(metadata): event_emitter = None event_caller = None - if ( - 'session_id' in metadata - and metadata['session_id'] - and 'chat_id' in metadata - and metadata['chat_id'] - and 'message_id' in metadata - and metadata['message_id'] - ): + + # event_emitter only needs user_id + chat_id + message_id. + # It broadcasts to user:{user_id} room AND persists to DB, + # so it works for backend-initiated calls (automations, API). + if metadata.get('chat_id') and metadata.get('message_id'): event_emitter = get_event_emitter(metadata) + + # event_caller needs session_id — it calls back to a specific + # websocket session (used by direct tools, pyodide code interpreter). + if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'): event_caller = get_event_call(metadata) + return event_emitter, event_caller @@ -3206,7 +3213,9 @@ async def streaming_chat_response_handler(response, ctx): ] # Standard streaming response handler - if event_emitter and event_caller: + # event_caller is optional — only needed for direct (client-side) tools + # and pyodide code interpreter. Server-side tools work without it. + if event_emitter: task_id = str(uuid4()) # Create a unique task ID. model_id = form_data.get('model', '') @@ -3632,6 +3641,16 @@ async def streaming_chat_response_handler(response, ctx): if not choices: error = data.get('error', {}) if error: + try: + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': error}, + }, + ) + except Exception: + pass await event_emitter( { 'type': 'chat:completion', @@ -4607,6 +4626,7 @@ async def streaming_chat_response_handler(response, ctx): 'content': serialize_output(output), 'output': output, 'title': title, + **({'usage': usage} if usage else {}), } if not ENABLE_REALTIME_CHAT_SAVE: diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index bfa934c8b5..787ef4d6e8 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -961,18 +961,15 @@ def stream_chunks_handler(stream: aiohttp.StreamReader): skip_mode = False yield line else: - yield b'data: {}' - yield b'\n' + yield b'data: {}\n' else: # Normal mode: check if line exceeds limit if len(line) > max_buffer_size: skip_mode = True - yield b'data: {}' - yield b'\n' + yield b'data: {}\n' log.info(f'Skip mode triggered, line size: {len(line)}') else: - yield line - yield b'\n' + yield line + b'\n' # Save the last incomplete fragment buffer = lines[-1] @@ -986,7 +983,6 @@ def stream_chunks_handler(stream: aiohttp.StreamReader): # Process remaining buffer data if buffer and not skip_mode: - yield buffer - yield b'\n' + yield buffer + b'\n' return yield_safe_stream_chunks() diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index e4a97327a2..0cbe753b77 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1354,12 +1354,13 @@ class OAuthManager: if provider not in OAUTH_PROVIDERS: raise HTTPException(404) # If the provider has a custom redirect URL, use that, otherwise automatically generate one - redirect_uri = OAUTH_PROVIDERS[provider].get('redirect_uri') or request.url_for( - 'oauth_login_callback', provider=provider - ) client = self.get_client(provider) if client is None: raise HTTPException(404) + redirect_uri = ( + (client.server_metadata or {}).get('redirect_uri') + or request.url_for('oauth_login_callback', provider=provider) + ) kwargs = {} if auth_manager_config.OAUTH_AUDIENCE: @@ -1683,7 +1684,7 @@ class OAuthManager: httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, - **({'max_age': cookie_max_age, 'expires': cookie_expires} if cookie_max_age is not None else {}), + **({'max_age': cookie_max_age} if cookie_max_age is not None else {}), ) log.info(f'Stored OAuth session server-side for user {user.id}, provider {provider}') diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 203c429d22..15213b1f03 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -19,7 +19,7 @@ def get_task_model_id(default_model_id: str, task_model: str, task_model_externa # Set the task model task_model_id = default_model_id # Check if the user has a custom task model and use that model - if models[task_model_id].get('connection_type') == 'local': + if models.get(task_model_id, {}).get('connection_type') == 'local': if task_model and task_model in models: task_model_id = task_model else: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 226830a1fa..377a81d749 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -85,6 +85,7 @@ from open_webui.tools.builtin import ( view_file, view_knowledge_file, view_skill, + tasks, ) import copy @@ -503,6 +504,10 @@ def get_builtin_tools( if extra_params.get('__skill_ids__'): builtin_functions.append(view_skill) + # Task management - break down complex work into trackable steps + if is_builtin_tool_enabled('tasks'): + builtin_functions.append(tasks) + for func in builtin_functions: callable = get_async_tool_function_and_apply_extra_params( func, @@ -1004,6 +1009,13 @@ async def get_terminal_tools( # auth_type == "none": no Authorization header system_prompt = server_data.get('system_prompt') + + # Use chat_id as the per-session key for cwd tracking + metadata = extra_params.get('__metadata__', {}) + session_id = metadata.get('chat_id') + if session_id: + headers['X-Session-Id'] = session_id + terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies) tools_dict = {} diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py index c2064de257..5686f20ec6 100644 --- a/backend/open_webui/utils/validate.py +++ b/backend/open_webui/utils/validate.py @@ -6,6 +6,11 @@ _ALLOWED_STATIC_PATHS = ( '/static/favicon.png', ) +# External URL prefixes that are explicitly trusted for profile images +_ALLOWED_URL_PREFIXES = ( + 'https://www.gravatar.com/avatar/', +) + def validate_profile_image_url(url: str) -> str: """ @@ -15,6 +20,7 @@ def validate_profile_image_url(url: str) -> str: - Empty string (falls back to default avatar) - data:image/* URIs (base64-encoded uploads from the frontend) - Known static asset paths (/user.png, /static/favicon.png) + - Trusted external URLs (e.g. Gravatar) Returns the url unchanged if valid, raises ValueError otherwise. """ @@ -33,4 +39,7 @@ def validate_profile_image_url(url: str) -> str: if url in _ALLOWED_STATIC_PATHS: return url + if any(url.startswith(prefix) for prefix in _ALLOWED_URL_PREFIXES): + return url + raise ValueError('Invalid profile image URL: only data URIs and default avatars are allowed.') diff --git a/src/app.css b/src/app.css index b7b70aeeb3..93417b680a 100644 --- a/src/app.css +++ b/src/app.css @@ -243,6 +243,19 @@ select { animation: smoothFadeIn 0.2s forwards; } +@keyframes fade-in-token { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.fade-in-token { + animation: fade-in-token 100ms ease-out; +} + .katex-mathml { display: none; } diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 1fd22494b5..f8e953f7ca 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -413,6 +413,9 @@ export const updateUserProfile = async (token: string, profile: object) => { .catch((err) => { console.error(err); error = err.detail; + if (Array.isArray(error)) { + error = error.map((e: { msg?: string }) => e.msg).join("; "); + } return null; }); diff --git a/src/lib/apis/automations/index.ts b/src/lib/apis/automations/index.ts new file mode 100644 index 0000000000..a79fe1ddc8 --- /dev/null +++ b/src/lib/apis/automations/index.ts @@ -0,0 +1,300 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type AutomationTerminalConfig = { + server_id: string; + cwd?: string; +}; + +export type AutomationData = { + prompt: string; + model_id: string; + rrule: string; + terminal?: AutomationTerminalConfig; +}; + +export type AutomationForm = { + name: string; + data: AutomationData; + meta?: { + system_prompt?: string; + temperature?: number; + max_tokens?: number; + webhook?: string; + }; + is_active?: boolean; +}; + +export type AutomationRunModel = { + id: string; + automation_id: string; + chat_id: string | null; + status: string; + error: string | null; + created_at: number; +}; + +export type AutomationResponse = { + id: string; + user_id: string; + name: string; + data: AutomationData; + meta: Record | null; + is_active: boolean; + last_run_at: number | null; + next_run_at: number | null; + + created_at: number; + updated_at: number; + last_run: AutomationRunModel | null; + next_runs: number[] | null; +}; + +export const getAutomationItems = async ( + token: string, + query: string | null, + status: string | null, + page: number +): Promise<{ items: AutomationResponse[]; total: number }> => { + let error = null; + + const searchParams = new URLSearchParams(); + if (query) { + searchParams.append('query', query); + } + if (status && status !== 'all') { + searchParams.append('status', status); + } + if (page) { + searchParams.append('page', page.toString()); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/automations/list?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createAutomation = async (token: string, form: AutomationForm) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/automations/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getAutomationById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateAutomationById = async (token: string, id: string, form: AutomationForm) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(form) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const toggleAutomationById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/toggle`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const runAutomationById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/run`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteAutomationById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/delete`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getAutomationRuns = async ( + token: string, + id: string, + skip: number = 0, + limit: number = 50 +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/automations/${id}/runs?skip=${skip}&limit=${limit}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/evaluations/index.ts b/src/lib/apis/evaluations/index.ts index a3af6e80bb..9253295116 100644 --- a/src/lib/apis/evaluations/index.ts +++ b/src/lib/apis/evaluations/index.ts @@ -161,13 +161,42 @@ export const getModelHistory = async (token: string = '', modelId: string, days: return res; }; -export const getFeedbackItems = async (token: string = '', orderBy, direction, page) => { +export const getFeedbackModelIds = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/models`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getFeedbackItems = async (token: string = '', orderBy, direction, page, modelId: string = '') => { let error = null; const searchParams = new URLSearchParams(); if (orderBy) searchParams.append('order_by', orderBy); if (direction) searchParams.append('direction', direction); if (page) searchParams.append('page', page.toString()); + if (modelId) searchParams.append('model_id', modelId); const res = await fetch( `${WEBUI_API_BASE_URL}/evaluations/feedbacks/list?${searchParams.toString()}`, @@ -200,10 +229,13 @@ export const getFeedbackItems = async (token: string = '', orderBy, direction, p return res; }; -export const exportAllFeedbacks = async (token: string = '') => { +export const exportAllFeedbacks = async (token: string = '', modelId: string = '') => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/all/export`, { + const searchParams = new URLSearchParams(); + if (modelId) searchParams.append('model_id', modelId); + + const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/all/export?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index b07d524cba..05475b4b37 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -161,9 +161,12 @@ export const getModels = async ( type ChatCompletedForm = { model: string; - messages: string[]; + messages: Record[]; chat_id: string; - session_id: string; + session_id: string | undefined; + id: string; + filter_ids?: string[]; + model_item?: unknown; }; export const chatCompleted = async (token: string, body: ChatCompletedForm) => { @@ -454,7 +457,8 @@ export const executeToolServer = async ( url: string, name: string, params: Record, - serverData: { openapi: any; info: any; specs: any } + serverData: { openapi: any; info: any; specs: any }, + sessionId?: string ) => { let error = null; @@ -531,6 +535,7 @@ export const executeToolServer = async ( 'Content-Type': 'application/json', ...(token && { authorization: `Bearer ${token}` }) }; + if (sessionId) headers['X-Session-Id'] = sessionId; const requestOptions: RequestInit = { method: httpMethod.toUpperCase(), @@ -556,13 +561,24 @@ export const executeToolServer = async ( responseHeaders[key] = value; }); - const text = await res.text(); let responseData; + const contentType = res.headers.get('Content-Type')?.split(';')[0]?.trim() ?? ''; try { - responseData = JSON.parse(text); + responseData = await res.clone().json(); } catch { - responseData = text; + if (contentType.startsWith('text/') || !contentType) { + responseData = await res.text(); + } else { + const buf = await res.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const b64 = btoa(binary); + responseData = `data:${contentType};base64,${b64}`; + } } return [responseData, responseHeaders]; } catch (err: any) { diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 23567baf8e..69ee2c5a0a 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -45,11 +45,15 @@ export const getTerminalConfig = async ( return res.json().catch(() => null); }; -export const getCwd = async (baseUrl: string, apiKey: string): Promise => { +export const getCwd = async ( + baseUrl: string, + apiKey: string, + sessionId?: string +): Promise => { const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` } - }).catch(() => null); + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }).catch(() => null); if (!res || !res.ok) return null; const json = await res.json().catch(() => null); return json?.cwd ?? null; @@ -58,13 +62,14 @@ export const getCwd = async (baseUrl: string, apiKey: string): Promise => { // The endpoint uses `directory` as the query param name const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` } - }) + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }) .then(async (res) => { if (!res.ok) throw await res.json(); return res.json(); @@ -79,12 +84,13 @@ export const listFiles = async ( export const readFile = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise => { const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` } - }).catch((err) => { + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }).catch((err) => { console.error('open-terminal readFile error:', err); return null; }); @@ -106,12 +112,13 @@ export const readFile = async ( export const downloadFileBlob = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise<{ blob: Blob; filename: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` } - }).catch(() => null); + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }).catch(() => null); if (!res || !res.ok) return null; @@ -123,15 +130,18 @@ export const downloadFileBlob = async ( export const archiveFromTerminal = async ( baseUrl: string, apiKey: string, - paths: string[] + paths: string[], + sessionId?: string ): Promise<{ blob: Blob; filename: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/archive`; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ paths }) }).catch(() => null); @@ -148,14 +158,17 @@ export const uploadToTerminal = async ( baseUrl: string, apiKey: string, directory: string, - file: File + file: File, + sessionId?: string ): Promise<{ path: string; size: number } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`; const body = new FormData(); body.append('file', file); + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { Authorization: `Bearer ${apiKey}` }, + headers, body }) .then(async (res) => { @@ -172,15 +185,18 @@ export const uploadToTerminal = async ( export const createDirectory = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise<{ path: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ path }) }) .then(async (res) => { @@ -197,12 +213,15 @@ export const createDirectory = async ( export const deleteEntry = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise<{ path: string; type: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`; + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'DELETE', - headers: { Authorization: `Bearer ${apiKey}` } + headers }) .then(async (res) => { if (!res.ok) throw await res.json(); @@ -218,15 +237,18 @@ export const deleteEntry = async ( export const setCwd = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise<{ cwd: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ path }) }) .then(async (res) => { @@ -244,15 +266,18 @@ export const moveEntry = async ( baseUrl: string, apiKey: string, source: string, - destination: string + destination: string, + sessionId?: string ): Promise<{ source: string; destination: string } | { error: string }> => { const url = `${baseUrl.replace(/\/$/, '')}/files/move`; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ source, destination }) }) .then(async (res) => { diff --git a/src/lib/apis/utils/index.ts b/src/lib/apis/utils/index.ts index d19f10f948..e5fea091eb 100644 --- a/src/lib/apis/utils/index.ts +++ b/src/lib/apis/utils/index.ts @@ -16,10 +16,14 @@ export const getGravatarUrl = async (token: string, email: string) => { }) .catch((err) => { console.error(err); - error = err; + error = err.detail ?? err; return null; }); + if (error) { + throw error; + } + return res; }; diff --git a/src/lib/components/AutomationModal.svelte b/src/lib/components/AutomationModal.svelte new file mode 100644 index 0000000000..c1843f20e7 --- /dev/null +++ b/src/lib/components/AutomationModal.svelte @@ -0,0 +1,195 @@ + + + +
+ +
+ + +
+ + +
+
{$i18n.t('Instructions')}
+