diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 5726327747..69427ca625 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2045,6 +2045,8 @@ FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '') ENABLE_CHANNELS = os.getenv('ENABLE_CHANNELS', 'False').lower() == 'true' +ENABLE_APPS = os.getenv('ENABLE_APPS', 'False').lower() == 'true' + CHANNEL_MODEL_RESPONSE_MODE = os.getenv('CHANNEL_MODEL_RESPONSE_MODE', 'thread') ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true' @@ -3161,6 +3163,7 @@ DEFAULT_CONFIG = { 'folders.enable': ENABLE_FOLDERS, 'folders.max_file_count': FOLDER_MAX_FILE_COUNT, 'channels.enable': ENABLE_CHANNELS, + 'apps.enable': ENABLE_APPS, 'channels.model_response_mode': CHANNEL_MODEL_RESPONSE_MODE, 'calendar.enable': ENABLE_CALENDAR, 'automations.enable': ENABLE_AUTOMATIONS, diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index f5181fb1b6..7fc9304bce 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -150,6 +150,7 @@ from open_webui.models.models import Models, normalize_model_tags from open_webui.models.groups import Groups, resolve_group_default_models from open_webui.models.users import Users from open_webui.routers import ( + apps, analytics, audio, auths, @@ -885,6 +886,7 @@ app.include_router(mfa.router, prefix='/api/v1/auths/mfa', tags=['auths']) app.include_router(users.router, prefix='/api/v1/users', tags=['users']) +app.include_router(apps.router, prefix='/api/v1/apps', tags=['apps']) app.include_router(channels.router, prefix='/api/v1/channels', tags=['channels']) app.include_router(chats.router, prefix='/api/v1/chats', tags=['chats']) app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes']) @@ -2326,6 +2328,7 @@ async def get_app_config(request: Request): 'folders.enable', 'folders.max_file_count', 'channels.enable', + 'apps.enable', 'calendar.enable', 'automations.enable', 'notes.enable', @@ -2421,6 +2424,7 @@ async def get_app_config(request: Request): 'enable_folders': config.get('folders.enable'), 'folder_max_file_count': config.get('folders.max_file_count'), 'enable_channels': config.get('channels.enable'), + 'enable_apps': config.get('apps.enable'), 'enable_calendar': config.get('calendar.enable'), 'enable_automations': config.get('automations.enable'), 'enable_notes': config.get('notes.enable'), diff --git a/backend/open_webui/migrations/versions/c92da83b017f_add_apps.py b/backend/open_webui/migrations/versions/c92da83b017f_add_apps.py new file mode 100644 index 0000000000..addd71205b --- /dev/null +++ b/backend/open_webui/migrations/versions/c92da83b017f_add_apps.py @@ -0,0 +1,49 @@ +"""Add apps.""" + +import sqlalchemy as sa +from alembic import op + +revision = 'c92da83b017f' +down_revision = 'b8f2c6d91a04' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'app', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('slug', sa.Text(), nullable=False, unique=True), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('data', sa.JSON(), nullable=False), + sa.Column('api_key', sa.Text(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + ) + with op.batch_alter_table('channel_member') as batch: + batch.alter_column('user_id', existing_type=sa.Text(), nullable=True) + batch.add_column(sa.Column('app_id', sa.Text(), nullable=True)) + batch.create_check_constraint('ck_channel_member_identity', '(user_id IS NULL) <> (app_id IS NULL)') + batch.create_unique_constraint('uq_channel_member_app', ['channel_id', 'app_id']) + batch.create_index('ix_channel_member_app_active', ['app_id', 'is_active']) + op.add_column('message', sa.Column('app_id', sa.Text(), nullable=True)) + op.create_index('ix_message_app', 'message', ['app_id']) + + +def downgrade(): + # App-authored history cannot be converted to human messages without inventing an author. + conn = op.get_bind() + if conn.execute(sa.text('SELECT COUNT(*) FROM message WHERE app_id IS NOT NULL')).scalar(): + raise RuntimeError('Export/remove app messages before downgrading apps') + op.drop_index('ix_message_app', table_name='message') + op.drop_column('message', 'app_id') + conn.execute(sa.text('DELETE FROM channel_member WHERE app_id IS NOT NULL')) + with op.batch_alter_table('channel_member') as batch: + batch.drop_index('ix_channel_member_app_active') + batch.drop_constraint('uq_channel_member_app', type_='unique') + batch.drop_constraint('ck_channel_member_identity', type_='check') + batch.drop_column('app_id') + batch.alter_column('user_id', existing_type=sa.Text(), nullable=False) + op.drop_table('app') diff --git a/backend/open_webui/models/apps.py b/backend/open_webui/models/apps.py new file mode 100644 index 0000000000..9f41ce7201 --- /dev/null +++ b/backend/open_webui/models/apps.py @@ -0,0 +1,207 @@ +"""External apps. Credentials never leave the internal model.""" + +import time +from typing import Literal + +from open_webui.internal.db import Base, get_async_db_context +from open_webui.models.access_grants import AccessGrants +from open_webui.models.channels import ChannelMember +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator +from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, select, update +from sqlalchemy.ext.asyncio import AsyncSession + + +class App(Base): + __tablename__ = 'app' + + id = Column(Text, primary_key=True) + slug = Column(Text, nullable=False, unique=True) + name = Column(Text, nullable=False) + user_id = Column(Text, nullable=True) + is_active = Column(Boolean, nullable=False, default=False) + data = Column(JSON, nullable=False) + api_key = Column(Text, nullable=True) + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + +class AppModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + slug: str + name: str + user_id: str | None = None + is_active: bool + data: dict + api_key: str | None = Field(default=None, exclude=True, repr=False) + created_at: int + updated_at: int + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra='forbid') + + +class AppInvitation(StrictModel): + app_id: str + + +Scope = Literal['mentions:read', 'messages:read', 'messages:write', 'channels:read'] + + +class Manifest(StrictModel): + id: str = Field(min_length=1, max_length=100) + name: str = Field(min_length=1, max_length=100) + description: str = Field(default='', max_length=2000) + public_key: str = Field(max_length=2000) + requested_scopes: list[Scope] = Field(max_length=4) + + @field_validator('public_key') + @classmethod + def validate_public_key(cls, value): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + key = serialization.load_pem_public_key(value.encode()) + if not isinstance(key, Ed25519PublicKey): + raise ValueError('An Ed25519 public key is required') + return key.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode() + + +class Action(StrictModel): + action_id: str = Field(min_length=1, max_length=100) + label: str = Field(min_length=1, max_length=200) + context: dict = Field(default_factory=dict) + + +class AppChannelMessage(StrictModel): + content: str = Field(default='', max_length=32000) + actions: list[Action] = Field(default_factory=list, max_length=30) + reply_to_id: str | None = None + parent_id: str | None = None + + +class AppReply(AppChannelMessage): + type: Literal['message', 'update', 'deferred', 'download', 'error'] = 'message' + visibility: Literal['private', 'channel'] = 'private' + title: str = Field(default='', max_length=200) + filename: str = Field(default='report.md', pattern=r'^[a-zA-Z0-9_.-]{1,100}\.md$') + operation_id: str | None = None + + +class AppInteractionForm(StrictModel): + message_id: str + action_id: str = Field(min_length=1, max_length=100) + + +#################### +# Forms +#################### + + +class AppManifestForm(StrictModel): + url: str = Field(max_length=2048) + + +class AppForm(StrictModel): + manifest: Manifest + url: str = Field(max_length=2048) + api_key: SecretStr = Field(min_length=32, max_length=4096) + slug: str = Field(pattern=r'^[a-z][a-z0-9-]{0,39}$') + granted_scopes: list[str] + access_grants: list[dict] = Field( + default_factory=lambda: [{'principal_type': 'user', 'principal_id': '*', 'permission': 'read'}] + ) + + +class AppUpdateForm(StrictModel): + is_active: bool | None = None + name: str | None = Field(default=None, min_length=1, max_length=100) + settings: dict | None = None + manifest: Manifest | None = None + granted_scopes: list[str] | None = None + access_grants: list[dict] | None = None + + +class AppContextForm(StrictModel): + context: str = Field(max_length=100000) + + +class AppConnectionForm(StrictModel): + api_key: SecretStr | None = Field(default=None, min_length=32, max_length=4096) + + +#################### +# Responses +#################### + + +class AppResponse(BaseModel): + id: str + slug: str + name: str + is_active: bool + configured: bool + data: dict + created_at: int + updated_at: int + + +class AppAccessResponse(AppResponse): + access_grants: list[dict] = Field(default_factory=list) + + +class AppsTable: + async def get_app_by_id(self, id: str, db: AsyncSession | None = None) -> AppModel | None: + async with get_async_db_context(db) as db: + app = await db.scalar(select(App).where(App.id == id).execution_options(populate_existing=True)) + return AppModel.model_validate(app) if app else None + + async def get_app_ids_by_channel_id( + self, channel_id: str, joined_before: int, db: AsyncSession | None = None + ) -> list[str]: + async with get_async_db_context(db) as db: + return list( + await db.scalars( + select(App.id) + .join(ChannelMember, ChannelMember.app_id == App.id) + .where( + App.is_active.is_(True), + ChannelMember.channel_id == channel_id, + ChannelMember.is_active.is_(True), + ChannelMember.joined_at <= joined_before, + ) + ) + ) + + async def update_app_by_id( + self, + id: str, + data: dict, + access_grants: list[dict] | None = None, + db: AsyncSession | None = None, + ) -> AppModel | None: + async with get_async_db_context(db) as db: + await db.execute(update(App).where(App.id == id).values(**data)) + if access_grants is not None: + await AccessGrants.replace_access_grants(db, 'app', id, access_grants) + await db.commit() + return await self.get_app_by_id(id, db=db) + + async def uninstall_app_by_id(self, id: str, db: AsyncSession | None = None) -> AppModel | None: + async with get_async_db_context(db) as db: + app = await db.get(App, id) + if not app: + return None + app.is_active = False + app.api_key = None + app.updated_at = max(time.time_ns(), app.updated_at + 1) + await db.execute( + update(ChannelMember).where(ChannelMember.app_id == id).values(is_active=False, left_at=time.time_ns()) + ) + await db.commit() + return AppModel.model_validate(app) + + +Apps = AppsTable() diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 6e368caa6d..e7a9c14730 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -17,6 +17,7 @@ from sqlalchemy import ( BigInteger, Boolean, Column, + CheckConstraint, ForeignKey, String, Text, @@ -99,7 +100,13 @@ class ChannelMember(Base): id = Column(Text, primary_key=True, unique=True) channel_id = Column(Text, nullable=False) - user_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=True) + app_id = Column(Text, nullable=True) + + __table_args__ = ( + CheckConstraint('(user_id IS NULL) <> (app_id IS NULL)', name='ck_channel_member_identity'), + UniqueConstraint('channel_id', 'app_id', name='uq_channel_member_app'), + ) role = Column(Text, nullable=True) status = Column(Text, nullable=True) @@ -129,7 +136,8 @@ class ChannelMemberModel(BaseModel): id: str channel_id: str - user_id: str + user_id: Optional[str] = None + app_id: Optional[str] = None role: Optional[str] = None status: Optional[str] = None @@ -602,7 +610,7 @@ class ChannelTable: self, channel_id: str, db: Optional[AsyncSession] = None ) -> list[ChannelMemberModel]: async with get_async_db_context(db) as db: - result = await db.execute(select(ChannelMember).filter(ChannelMember.channel_id == channel_id)) + result = await db.execute(select(ChannelMember).filter(ChannelMember.channel_id == channel_id, ChannelMember.user_id.is_not(None))) memberships = result.scalars().all() return [ChannelMemberModel.model_validate(membership) for membership in memberships] @@ -688,6 +696,19 @@ class ChannelTable: membership = result.scalars().first() return membership is not None + async def is_app_channel_member(self, channel_id: str, app_id: str, db: AsyncSession | None = None) -> bool: + async with get_async_db_context(db) as db: + membership = await db.scalar( + select(ChannelMember.id) + .where( + ChannelMember.channel_id == channel_id, + ChannelMember.app_id == app_id, + ChannelMember.is_active.is_(True), + ) + .limit(1) + ) + return membership is not None + async def get_channel_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChannelModel]: try: async with get_async_db_context(db) as db: diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 82ac3336dd..490d45e6df 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -6,8 +6,8 @@ from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.channels import ChannelMember, Channels from open_webui.models.tags import Tag, TagModel, Tags from open_webui.models.users import User, UserNameResponse, Users -from pydantic import BaseModel, ConfigDict, field_validator -from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, and_, delete, func, or_, select, text +from pydantic import BaseModel, ConfigDict, computed_field, field_validator +from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, and_, delete, func, or_, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql import exists @@ -40,6 +40,7 @@ class Message(Base): id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) + app_id = Column(Text, nullable=True) channel_id = Column(Text, nullable=True) reply_to_id = Column(Text, nullable=True) @@ -62,7 +63,8 @@ class MessageModel(BaseModel): model_config = ConfigDict(from_attributes=True) id: str - user_id: str + user_id: Optional[str] = None + app_id: Optional[str] = None channel_id: Optional[str] = None reply_to_id: Optional[str] = None @@ -80,6 +82,11 @@ class MessageModel(BaseModel): created_at: int # timestamp in epoch (time_ns) updated_at: int # timestamp in epoch (time_ns) + @computed_field + @property + def etag(self) -> str: + return str(self.updated_at) + #################### # Forms @@ -163,13 +170,68 @@ class MessageTable: 'updated_at': ts, } ) - result = Message(**message.model_dump()) + result = Message(**message.model_dump(exclude={'etag'})) db.add(result) + from open_webui.utils.apps import publish_app_message_event + await db.commit() await db.refresh(result) + await publish_app_message_event(result, 'message.created') return MessageModel.model_validate(result) if result else None + async def insert_new_app_message( + self, app_id: str, channel_id: str, form_data: MessageForm, db: AsyncSession | None = None + ) -> MessageResponse: + async with get_async_db_context(db) as db: + now = time.time_ns() + message = Message( + id=str(uuid.uuid4()), + app_id=app_id, + user_id=None, + channel_id=channel_id, + content=form_data.content, + data=form_data.data, + parent_id=form_data.parent_id, + reply_to_id=form_data.reply_to_id, + created_at=now, + updated_at=now, + ) + db.add(message) + await db.commit() + from open_webui.utils.apps import publish_app_message_event + + await publish_app_message_event(message, 'message.created') + response = await self.get_message_by_id(message.id, db=db) + await db.commit() + return response + + async def update_app_message_by_id( + self, + id: str, + app_id: str, + updated_at: int, + form_data: MessageForm, + db: AsyncSession | None = None, + ) -> MessageResponse | None: + async with get_async_db_context(db) as db: + result = await db.execute( + update(Message) + .where(Message.id == id, Message.app_id == app_id, Message.updated_at == updated_at) + .values(content=form_data.content, data=form_data.data, updated_at=max(time.time_ns(), updated_at + 1)) + ) + if result.rowcount != 1: + await db.rollback() + return None + message = await db.get(Message, id, populate_existing=True) + await db.commit() + from open_webui.utils.apps import publish_app_message_event + + await publish_app_message_event(message, 'message.updated') + response = await self.get_message_by_id(id, db=db) + await db.commit() + return response + async def get_message_by_id( self, id: str, @@ -195,7 +257,9 @@ class MessageTable: # Check if message was sent by webhook (webhook info in meta takes precedence) webhook_info = message.meta.get('webhook') if message.meta else None - if webhook_info and webhook_info.get('id'): + if message.app_id: + user_info = await self._resolve_user_info(message, db) + elif webhook_info and webhook_info.get('id'): # Look up webhook by ID to get current name webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db) if webhook: @@ -228,6 +292,10 @@ class MessageTable: async def _resolve_user_info(self, message: Message, db: AsyncSession) -> Optional[dict]: """Resolve user info from message, handling webhook messages.""" + if message.app_id: + from open_webui.models.apps import App + app = await db.get(App, message.app_id) + return {'id': message.app_id, 'name': app.name if app else 'Removed App', 'role': 'app'} webhook_info = message.meta.get('webhook') if message.meta else None if webhook_info and webhook_info.get('id'): webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db) @@ -276,7 +344,7 @@ class MessageTable: async def get_reply_user_ids_by_message_id(self, id: str, db: Optional[AsyncSession] = None) -> list[str]: async with get_async_db_context(db) as db: result = await db.execute(select(Message.user_id).filter_by(parent_id=id)) - return [row[0] for row in result.all()] + return [row[0] for row in result.all() if row[0] is not None] async def get_messages_by_channel_id( self, @@ -408,8 +476,11 @@ class MessageTable: **(form_data.meta if form_data.meta else {}), } message.updated_at = int(time.time_ns()) + from open_webui.utils.apps import publish_app_message_event + await db.commit() await db.refresh(message) + await publish_app_message_event(message, 'message.updated') return MessageModel.model_validate(message) if message else None async def update_is_pinned_by_id( @@ -442,7 +513,7 @@ class MessageTable: Message.created_at > (last_read_at if last_read_at else 0), ) if user_id: - stmt = stmt.filter(Message.user_id != user_id) + stmt = stmt.filter(or_(Message.user_id != user_id, Message.user_id.is_(None))) result = await db.execute(stmt) return result.scalar() @@ -581,18 +652,28 @@ class MessageTable: async def delete_replies_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: async with get_async_db_context(db) as db: + from open_webui.utils.apps import publish_app_message_event + + messages = (await db.scalars(select(Message).filter_by(parent_id=id))).all() await db.execute(delete(Message).filter_by(parent_id=id)) await db.commit() + for message in messages: + await publish_app_message_event(message, 'message.deleted') return True async def delete_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: async with get_async_db_context(db) as db: + from open_webui.utils.apps import publish_app_message_event + + message = await db.get(Message, id) await db.execute(delete(Message).filter_by(id=id)) # Delete all reactions to this message await db.execute(delete(MessageReaction).filter_by(message_id=id)) await db.commit() + if message: + await publish_app_message_event(message, 'message.deleted') return True async def search_messages_by_channel_ids( diff --git a/backend/open_webui/routers/apps.py b/backend/open_webui/routers/apps.py new file mode 100644 index 0000000000..ff4b6395c7 --- /dev/null +++ b/backend/open_webui/routers/apps.py @@ -0,0 +1,490 @@ +"""Native external apps: administration, channel participation and authenticated interactions.""" + +import time +import uuid +from urllib.parse import urlsplit + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status +from open_webui.internal.db import get_async_session +from open_webui.models.access_grants import AccessGrant, AccessGrants +from open_webui.models.apps import ( + App, + AppAccessResponse, + AppChannelMessage, + AppConnectionForm, + AppContextForm, + AppForm, + AppInteractionForm, + AppManifestForm, + AppResponse, + Apps, + AppUpdateForm, + Manifest, +) +from open_webui.models.channels import Channel, ChannelMember, Channels +from open_webui.models.config import Config +from open_webui.models.messages import Message +from open_webui.models.users import Users +from open_webui.utils.apps import ( + check_app_channel_membership, + check_app_invocation_access, + create_app_message, + decode_app_context_token, + generate_app_response, + get_app_by_id, + get_app_encryption_cipher, + get_app_message_data, + get_app_response, + get_verified_app, + has_app_access, + require_scope, + send_app_event, + send_app_request, + update_app_message, + validate_app_url, + verify_app_token, +) +from open_webui.utils.auth import get_admin_user, get_verified_user +from sqlalchemy import and_, or_, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +router = APIRouter() + + +############################ +# GetApps +############################ + + +@router.get('', response_model=list[AppResponse]) +async def get_apps(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + if not await Config.get('apps.enable'): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Apps are disabled') + apps = (await db.scalars(select(App).order_by(App.name))).all() + return [ + get_app_response(app) + for app in apps + if user.role == 'admin' or (app.is_active and await has_app_access(app, user, db=db)) + ] + + +############################ +# PreviewAppManifest +############################ + + +@router.post('/manifest/preview', response_model=Manifest) +async def preview_app_manifest(form_data: AppManifestForm, user=Depends(get_admin_user)): + if not await Config.get('apps.enable'): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Apps are disabled') + try: + status_code, raw = await send_app_request(form_data.url) + if status_code != 200: + raise ValueError() + manifest = Manifest.model_validate(raw) + return manifest.model_dump() + except Exception: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid or unreachable app manifest' + ) from None + + +############################ +# CreateNewApp +############################ + + +@router.post('', response_model=AppResponse) +async def create_new_app( + form_data: AppForm, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session) +): + if not await Config.get('apps.enable'): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Apps are disabled') + try: + validate_app_url(form_data.url) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid app URL') from None + if not set(form_data.granted_scopes) <= set(form_data.manifest.requested_scopes): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Unrequested scope') + now = time.time_ns() + app = App( + id=str(uuid.uuid4()), + slug=form_data.slug, + name=form_data.manifest.name, + user_id=user.id, + is_active=False, + data={ + 'url': form_data.url, + 'manifest': form_data.manifest.model_dump(), + 'granted_scopes': form_data.granted_scopes, + 'settings': {}, + }, + api_key=get_app_encryption_cipher().encrypt(form_data.api_key.get_secret_value().encode()).decode(), + created_at=now, + updated_at=now, + ) + db.add(app) + try: + await AccessGrants.replace_access_grants(db, 'app', app.id, form_data.access_grants) + await db.commit() + except IntegrityError: + await db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='App slug is already registered') from None + return get_app_response(app) + + +# Fixed runtime routes must precede /{app_id}. +############################ +# GetCurrentApp +############################ + + +@router.get('/runtime/me', response_model=AppResponse) +async def get_current_app(app=Depends(get_verified_app)): + return get_app_response(app) + + +############################ +# GetAppChannels +############################ + + +@router.get('/runtime/channels') +async def get_app_channels(app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session)): + if not await Config.get('channels.enable'): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Channels are disabled') + await get_app_by_id(app.id, require_active=True, db=db) + require_scope(app, 'channels:read') + channels = ( + await db.scalars( + select(Channel) + .join(ChannelMember, Channel.id == ChannelMember.channel_id) + .where( + ChannelMember.app_id == app.id, + ChannelMember.is_active.is_(True), + Channel.deleted_at.is_(None), + Channel.archived_at.is_(None), + ) + ) + ).all() + result = [] + for channel in channels: + channel_data = await Channels.get_channel_by_id(channel.id, db=db) + is_private = ( + channel_data.is_private is True + if channel_data.type == 'group' + else not any(grant.principal_id == '*' for grant in channel_data.access_grants) + ) + result.append({'id': channel.id, 'name': channel.name, 'type': channel.type, 'is_private': is_private}) + return result + + +############################ +# AuthorizeAppExecution +############################ + + +@router.post('/runtime/authorize') +async def authorize_app_execution( + form_data: AppContextForm, app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session) +): + value = decode_app_context_token(app, 'execution', form_data.context) + user = await Users.get_user_by_id(value['actor_id'], db=db) + if not user: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Actor is unavailable') + await check_app_invocation_access(app, value['channel_id'], user, db=db) + require_scope(app, value['scope']) + return { + 'authorized': True, + 'operation_id': value['operation_id'], + 'actor': {'id': user.id, 'is_admin': user.role == 'admin'}, + 'channel_id': value['channel_id'], + 'type': value.get('type'), + 'data': value.get('data'), + } + + +############################ +# VerifyAppEvent +############################ + + +@router.post('/runtime/events/verify') +async def verify_app_event( + form_data: AppContextForm, app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session) +): + value = decode_app_context_token(app, 'event', form_data.context) + await check_app_channel_membership(app, value['channel']['id'], db=db) + require_scope(app, 'messages:read') + return {k: v for k, v in value.items() if k not in ('purpose', 'exp', 'nonce')} + + +############################ +# GetAppChannelMessages +############################ + + +@router.get('/runtime/channels/{channel_id}/messages') +async def get_app_channel_messages( + channel_id: str, + before: str | None = None, + parent_id: str | None = None, + limit: int = Query(default=50, ge=1, le=100), + app=Depends(get_verified_app), + db: AsyncSession = Depends(get_async_session), +): + await check_app_channel_membership(app, channel_id, db=db) + require_scope(app, 'messages:read') + query = select(Message).where(Message.channel_id == channel_id, Message.parent_id == parent_id) + if parent_id: + parent = await db.get(Message, parent_id) + if not parent or parent.channel_id != channel_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Thread not found') + if before: + cursor = await db.get(Message, before) + if not cursor or cursor.channel_id != channel_id or cursor.parent_id != parent_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid message cursor') + query = query.where( + or_( + Message.created_at < cursor.created_at, + and_(Message.created_at == cursor.created_at, Message.id < cursor.id), + ) + ) + rows = (await db.scalars(query.order_by(Message.created_at.desc(), Message.id.desc()).limit(limit + 1))).all() + return { + 'messages': [get_app_message_data(row) for row in rows[:limit]], + 'next_cursor': rows[limit - 1].id if len(rows) > limit else None, + } + + +############################ +# PostAppMessage +############################ + + +@router.post('/runtime/channels/{channel_id}/messages', status_code=status.HTTP_201_CREATED) +async def post_app_message( + channel_id: str, + form_data: AppChannelMessage, + app=Depends(get_verified_app), + db: AsyncSession = Depends(get_async_session), +): + return await create_app_message(db, app, channel_id, form_data) + + +############################ +# UpdateAppMessageById +############################ + + +@router.patch('/runtime/messages/{message_id}') +async def update_app_message_by_id( + message_id: str, + form_data: AppChannelMessage, + if_match: str | None = Header(default=None), + app=Depends(get_verified_app), + db: AsyncSession = Depends(get_async_session), +): + return await update_app_message(db, app, message_id, form_data, if_match) + + +############################ +# GetAppDetailsById +############################ + + +@router.get('/{app_id}', response_model=AppAccessResponse) +async def get_app_details_by_id( + app_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session) +): + result = get_app_response(await get_app_by_id(app_id, db=db)) + result['access_grants'] = [ + {'principal_type': g.principal_type, 'principal_id': g.principal_id, 'permission': g.permission} + for g in ( + await db.scalars( + select(AccessGrant).where(AccessGrant.resource_type == 'app', AccessGrant.resource_id == app_id) + ) + ).all() + ] + return result + + +############################ +# UpdateAppById +############################ + + +@router.patch('/{app_id}', response_model=AppResponse) +async def update_app_by_id( + app_id: str, form_data: AppUpdateForm, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session) +): + app = await get_app_by_id(app_id, db=db) + if form_data.is_active is True: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Connect the app to enable it') + data = {'updated_at': max(time.time_ns(), app.updated_at + 1)} + if form_data.is_active is False: + data['is_active'] = False + if form_data.name is not None: + data['name'] = form_data.name + if form_data.settings is not None: + data['data'] = {**app.data, 'settings': form_data.settings} + if form_data.manifest is not None: + if form_data.manifest.id != app.data['manifest']['id']: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='App identity cannot change') + if form_data.granted_scopes is None or not set(form_data.granted_scopes) <= set( + form_data.manifest.requested_scopes + ): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Review the updated scopes') + data['data'] = { + **data.get('data', app.data), + 'manifest': form_data.manifest.model_dump(), + 'granted_scopes': form_data.granted_scopes, + } + data['is_active'] = False + elif form_data.granted_scopes is not None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='An updated manifest is required') + app = await Apps.update_app_by_id(app_id, data, access_grants=form_data.access_grants, db=db) + return get_app_response(app) + + +############################ +# ConnectAppById +############################ + + +@router.post('/{app_id}/connect', response_model=AppResponse) +async def connect_app_by_id( + app_id: str, + form_data: AppConnectionForm, + request: Request, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + app = await get_app_by_id(app_id, db=db) + webui_url = str(request.base_url).rstrip('/') + try: + validate_app_url(webui_url) + origin = urlsplit(webui_url) + if origin.path or origin.query or origin.fragment: + raise ValueError() + host = f'[{origin.hostname}]' if ':' in origin.hostname else origin.hostname.lower() + port = origin.port or (443 if origin.scheme == 'https' else 80) + suffix = f':{port}' if port != (443 if origin.scheme == 'https' else 80) else '' + webui_url = f'{origin.scheme}://{host}{suffix}' + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail='Connect through your public HTTPS WebUI URL' + ) from None + if form_data.api_key is not None: + app.api_key = get_app_encryption_cipher().encrypt(form_data.api_key.get_secret_value().encode()).decode() + if not app.api_key: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='An app API key is required') + app.data = {**app.data, 'webui_url': webui_url} + app.is_active = False + app.updated_at = max(time.time_ns(), app.updated_at + 1) + checked_at, api_key = app.updated_at, app.api_key + app = await Apps.update_app_by_id( + app_id, {'api_key': api_key, 'data': app.data, 'is_active': False, 'updated_at': checked_at}, db=db + ) + await db.commit() + challenge = str(uuid.uuid4()) + try: + status_code, value = await send_app_event( + app, + {'id': str(uuid.uuid4()), 'type': 'connect', 'data': {'challenge': challenge}}, + timeout=20, + ) + if status_code != 200 or not isinstance(value, dict): + raise ValueError() + claims = verify_app_token(app, value['proof'], 'webui-app-connect+jwt') + if claims.get('challenge') != challenge: + raise ValueError() + except Exception: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail='Could not connect. Check the app API key and installed public key.', + ) from None + result = await db.execute( + update(App) + .where(App.id == app_id, App.updated_at == checked_at, App.api_key == api_key) + .values(is_active=True, updated_at=max(time.time_ns(), checked_at + 1)) + ) + if result.rowcount != 1: + await db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='App changed while connecting; connect again') + await db.commit() + return get_app_response(await get_app_by_id(app_id, db=db)) + + +############################ +# UninstallAppById +############################ + + +@router.delete('/{app_id}', response_model=AppResponse) +async def uninstall_app_by_id(app_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + app = await get_app_by_id(app_id, db=db) + app = await Apps.uninstall_app_by_id(app_id, db=db) + return get_app_response(app) + + +############################ +# CreateAppInteraction +############################ + + +@router.post('/{app_id}/interactions') +async def create_app_interaction( + app_id: str, + form_data: AppInteractionForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + app = await get_app_by_id(app_id, require_active=True, db=db) + message = await db.get(Message, form_data.message_id) + if not message or message.app_id != app.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='App message not found') + await check_app_invocation_access(app, message.channel_id, user, db=db) + actions = (message.data or {}).get('app', {}).get('actions', []) + action = next((item for item in actions if item['action_id'] == form_data.action_id), None) + if not action: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Unknown action') + return await generate_app_response( + app, + user, + message.channel_id, + 'action', + {'message_id': message.id, 'action_id': form_data.action_id, 'context': action['context']}, + source={'id': message.id, 'updated_at': message.updated_at}, + db=db, + ) + + +############################ +# GetAppOperation +############################ + + +@router.get('/{app_id}/operations/{operation_id}') +async def get_app_operation( + app_id: str, + operation_id: str, + x_openwebui_operation_receipt: str = Header(), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + app = await get_app_by_id(app_id, require_active=True, db=db) + value = decode_app_context_token(app, 'operation', x_openwebui_operation_receipt) + if value['actor_id'] != user.id or value['operation_id'] != operation_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Invalid operation receipt') + await check_app_invocation_access(app, value['channel_id'], user, db=db) + return await generate_app_response( + app, + user, + value['channel_id'], + 'operation_status', + {'operation_id': operation_id}, + operation_id=operation_id, + db=db, + ) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 891882bab5..9805b32b7a 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -128,6 +128,7 @@ ADMIN_CONFIG_KEYS = { 'AUTOMATION_MIN_INTERVAL': 'automations.min_interval', 'ENABLE_AUTOMATIONS': 'automations.enable', 'ENABLE_CHANNELS': 'channels.enable', + 'ENABLE_APPS': 'apps.enable', 'CHANNEL_MODEL_RESPONSE_MODE': 'channels.model_response_mode', 'ENABLE_CALENDAR': 'calendar.enable', 'ENABLE_MEMORIES': 'memories.enable', @@ -1177,6 +1178,7 @@ class AdminConfig(MfaConfigForm): AUTOMATION_MIN_INTERVAL: int | str | None = None ENABLE_AUTOMATIONS: bool ENABLE_CHANNELS: bool + ENABLE_APPS: bool = False CHANNEL_MODEL_RESPONSE_MODE: str = 'thread' ENABLE_CALENDAR: bool ENABLE_MEMORIES: bool @@ -1223,6 +1225,8 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep updates.pop(key, None) if 'ENABLE_LOGIN_FORM' not in form_data.model_fields_set: updates.pop('ui.enable_login_form', None) + if 'ENABLE_APPS' not in form_data.model_fields_set: + updates.pop('apps.enable', None) if 'I18N' not in form_data.model_fields_set: updates.pop('ui.i18n', None) updates['ui.default_interface_settings'] = form_data.DEFAULT_INTERFACE_SETTINGS or {} diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 4fa6c221a0..7dd597c6bb 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -1,19 +1,22 @@ import base64 import io import logging +import time +import uuid from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status from fastapi.responses import FileResponse, Response, StreamingResponse from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES -from open_webui.events import EVENTS, publish_event from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, STATIC_DIR -from open_webui.internal.db import get_async_session +from open_webui.events import EVENTS, publish_event +from open_webui.internal.db import get_async_db, get_async_session from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_public_write_access_grant -from open_webui.models.config import Config +from open_webui.models.apps import App, AppInvitation from open_webui.models.channels import ( ChannelForm, + ChannelMember, ChannelModel, ChannelResponse, Channels, @@ -21,8 +24,10 @@ from open_webui.models.channels import ( ChannelWebhookModel, CreateChannelForm, ) +from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.messages import ( + Message, MessageForm, MessageModel, MessageResponse, @@ -43,7 +48,15 @@ from open_webui.socket.main import ( leave_room_for_users, sio, ) +from open_webui.tasks import create_task from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.apps import ( + check_app_channel_access, + generate_app_response, + get_app_by_id, + get_app_response, + has_app_access, +) from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.channels import extract_mentions, replace_mentions from open_webui.utils.files import get_image_base64_from_file_id @@ -52,6 +65,8 @@ from open_webui.utils.models import ( get_filtered_models, ) from pydantic import BaseModel, field_validator +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -815,6 +830,80 @@ async def delete_channel_by_id( ############################ +@router.get('/{channel_id}/apps') +async def get_channel_apps(channel_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + await check_app_channel_access(channel_id, user=user, db=db) + apps = ( + await db.scalars( + select(App) + .join(ChannelMember, ChannelMember.app_id == App.id) + .where( + ChannelMember.channel_id == channel_id, + ChannelMember.is_active.is_(True), + ) + ) + ).all() + return [get_app_response(app) for app in apps] + + +@router.post('/{channel_id}/apps') +async def invite_app( + channel_id: str, form: AppInvitation, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + await check_app_channel_access(channel_id, require_manager=True, user=user, db=db) + app = await get_app_by_id(form.app_id, require_active=True, db=db) + if not await has_app_access(app, user, db=db): + raise HTTPException(403, 'App invocation denied') + member = await db.scalar( + select(ChannelMember).where(ChannelMember.channel_id == channel_id, ChannelMember.app_id == app.id) + ) + now = time.time_ns() + if member: + member.is_active, member.left_at, member.joined_at = True, None, now + member.updated_at, member.invited_by, member.invited_at = now, user.id, now + else: + db.add( + ChannelMember( + id=str(uuid.uuid4()), + channel_id=channel_id, + user_id=None, + app_id=app.id, + role='member', + status='joined', + is_active=True, + invited_by=user.id, + invited_at=now, + joined_at=now, + created_at=now, + updated_at=now, + ) + ) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise HTTPException(409, 'Invitation changed concurrently; reload the app list') from None + return get_app_response(app) + + +@router.delete('/{channel_id}/apps/{app_id}') +async def remove_app( + channel_id: str, app_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + await check_app_channel_access(channel_id, require_manager=True, user=user, db=db) + await db.execute( + update(ChannelMember) + .where(ChannelMember.channel_id == channel_id, ChannelMember.app_id == app_id) + .values( + is_active=False, + left_at=time.time_ns(), + updated_at=time.time_ns(), + ) + ) + await db.commit() + return {'removed': True} + + class MessageUserResponse(MessageResponse): data: bool | None = None @@ -1001,6 +1090,57 @@ async def send_notification(request, channel, message, active_user_ids, db=None) return True +async def app_response_handler(channel_id, message_id, user_id): + """Committed human messages only; errors never cause the human POST to retry.""" + if not await Config.get('apps.enable'): + return + async with get_async_db() as db: + message = await db.get(Message, message_id) + if not message or message.app_id or message.user_id != user_id or message.channel_id != channel_id: + return + text = message.content + parent_id = message.parent_id + app_ids = dict.fromkeys(mention['id'] for mention in extract_mentions(text) if mention['id_type'] == 'A') + for app_id in list(app_ids)[:10]: + app_name = None + try: + async with get_async_db() as db: + app = await get_app_by_id(app_id, require_active=True, db=db) + app_name = app.name + user = await Users.get_user_by_id(user_id, db=db) + if not user: + continue + value = await generate_app_response( + app, + user, + channel_id, + 'mention', + {'text': text, 'message_id': message_id}, + operation_id=message_id, + db=db, + scope='messages:read' if 'messages:read' in app.data['granted_scopes'] else 'mentions:read', + ) + except Exception as exc: + log.warning('App mention delivery failed: %s', type(exc).__name__) + value = { + 'type': 'error', + 'content': 'App could not respond. Check access and connection status before retrying.', + } + if value.get('visibility') != 'channel': + await emit_to_users( + 'events:app', + { + 'app_id': app_id, + 'app_name': app_name, + 'channel_id': channel_id, + 'message_id': message_id, + 'parent_id': parent_id, + 'response': value, + }, + [user_id], + ) + + async def model_response_handler(request, channel, message, user, db=None): MODELS = {model['id']: model for model in await get_filtered_models(await get_all_models(request, user=user), user)} @@ -1269,6 +1409,7 @@ async def post_new_message( # Background tasks should manage their own short-lived sessions to avoid # holding database connections during slow operations (e.g., LLM calls). async def background_handler(): + await create_task(None, app_response_handler(channel.id, message.id, user.id)) await model_response_handler(request, channel, message, user) await send_notification( request, @@ -1334,7 +1475,7 @@ async def get_channel_message( return MessageResponse( **{ **message.model_dump(), - 'user': UserNameResponse(**message_user.model_dump()) if message_user else None, + 'user': UserNameResponse(**message_user.model_dump()) if message_user else getattr(message, 'user', None), } ) @@ -1419,7 +1560,7 @@ async def pin_channel_message( message_data = MessageUserResponse( **{ **message.model_dump(), - 'user': UserNameResponse(**message_user.model_dump()) if message_user else None, + 'user': UserNameResponse(**message_user.model_dump()) if message_user else getattr(message, 'user', None), } ) diff --git a/backend/open_webui/utils/apps.py b/backend/open_webui/utils/apps.py new file mode 100644 index 0000000000..85e6076de9 --- /dev/null +++ b/backend/open_webui/utils/apps.py @@ -0,0 +1,524 @@ +"""App credentials, authenticated events, and access checks.""" + +import base64 +import hashlib +import hmac +import ipaddress +import json +import logging +import os +import time +import uuid +from copy import deepcopy +from typing import Any +from urllib.parse import urlsplit + +import aiohttp +import jwt +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from fastapi import Depends, Header, HTTPException, status +from open_webui.env import WEBUI_SECRET_KEY +from open_webui.internal.db import get_async_db, get_async_session +from open_webui.models.access_grants import AccessGrants +from open_webui.models.apps import AppChannelMessage, AppModel, AppReply, Apps +from open_webui.models.channels import ChannelModel, Channels +from open_webui.models.config import Config +from open_webui.models.messages import MessageForm, Messages +from open_webui.models.users import UserModel, Users +from open_webui.tasks import create_task +from sqlalchemy.ext.asyncio import AsyncSession + +log = logging.getLogger(__name__) + + +def get_app_url_origin(url: str) -> str: + if any(c in url for c in ('\\', '\r', '\n', '\t')): + raise ValueError('Invalid service URL') + parsed_url = urlsplit(url) + if not parsed_url.hostname or parsed_url.username or parsed_url.password or parsed_url.fragment: + raise ValueError('Invalid service URL') + port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80) + host = f'[{parsed_url.hostname}]' if ':' in parsed_url.hostname else parsed_url.hostname.lower() + return f'{parsed_url.scheme}://{host}:{port}' + + +def validate_app_url(url: str) -> bool: + """Validate the endpoint and return whether its private origin is explicitly allowed.""" + origin = get_app_url_origin(url) + private_origins = { + get_app_url_origin(x.strip()) for x in os.getenv('APP_ALLOWED_PRIVATE_ORIGINS', '').split(',') if x.strip() + } + is_private_origin_allowed = origin in private_origins + scheme = urlsplit(url).scheme + if scheme != 'https' and not (is_private_origin_allowed and scheme == 'http'): + raise ValueError('App endpoints require HTTPS') + return is_private_origin_allowed + + +def _is_public_ip_address(ip_address: str) -> bool: + address = ipaddress.ip_address(ip_address) + if not address.is_global or address.is_multicast: + return False + if isinstance(address, ipaddress.IPv6Address): + embedded = [address.ipv4_mapped, address.sixtofour] + if address.teredo: + embedded.extend(address.teredo) + # NAT64 carries an IPv4 destination; reject translation prefixes instead of trusting it. + if address in ipaddress.ip_network('64:ff9b::/96') or address in ipaddress.ip_network('64:ff9b:1::/48'): + return False + return all(x is None or (x.is_global and not x.is_multicast) for x in embedded) + return True + + +class AppTCPConnector(aiohttp.TCPConnector): + def __init__(self, *, allow_private: bool = False): + super().__init__(use_dns_cache=False) + self.allow_private = allow_private + + async def _resolve_host(self, host, port, traces=None): + addresses = await super()._resolve_host(host, port, traces) + if not self.allow_private and any(not _is_public_ip_address(x['host']) for x in addresses): + raise ValueError('App endpoint resolves to a prohibited address') + return addresses + + +async def send_app_request( + url: str, + *, + body: bytes | None = None, + headers: dict[str, str] | None = None, + timeout: float = 10, + max_response_size: int = 262144, +) -> tuple[int, Any]: + allow_private = validate_app_url(url) + async with aiohttp.ClientSession( + connector=AppTCPConnector(allow_private=allow_private), + timeout=aiohttp.ClientTimeout(total=timeout), + trust_env=False, + ) as session: + async with session.request( + 'POST' if body is not None else 'GET', + url, + data=body, + headers=headers, + allow_redirects=False, + ) as response: + response_body = bytearray() + async for chunk in response.content.iter_chunked(16384): + response_body.extend(chunk) + if len(response_body) > max_response_size: + raise ValueError('App response is too large') + if response.status != 200: + return response.status, None + return response.status, json.loads(response_body) if response_body else None + + +def get_app_encryption_cipher() -> Fernet: + if not WEBUI_SECRET_KEY: + raise RuntimeError('A stable WEBUI_SECRET_KEY is required for apps') + key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b'open-webui-app-keys-v1').derive( + WEBUI_SECRET_KEY.encode() + ) + return Fernet(base64.urlsafe_b64encode(key)) + + +def _sign_app_context(app: AppModel, payload: str) -> bytes: + # Key or service-credential replacement invalidates execution contexts and operation receipts. + return hmac.digest( + WEBUI_SECRET_KEY.encode(), + b'open-webui-context-v1\n' + + app.id.encode() + + b'\n' + + hashlib.sha256((app.data['manifest']['public_key'] + (app.api_key or '')).encode()).digest() + + b'\n' + + payload.encode(), + 'sha256', + ) + + +def _serialize_json(value: Any) -> bytes: + return json.dumps(value, separators=(',', ':'), ensure_ascii=False).encode() + + +def create_app_context_token(app: AppModel, purpose: str, data: dict, expires_in: int = 900) -> str: + payload = base64.urlsafe_b64encode( + _serialize_json( + { + **data, + 'app_id': app.id, + 'purpose': purpose, + 'exp': int(time.time()) + expires_in, + 'nonce': str(uuid.uuid4()), + } + ) + ).decode() + signature = _sign_app_context(app, payload) + return payload + '.' + base64.urlsafe_b64encode(signature).decode() + + +def decode_app_context_token(app: AppModel, purpose: str, token: str) -> dict: + try: + payload, signature = token.split('.') + decoded_signature = base64.b64decode(signature, altchars=b'-_', validate=True) + decoded_payload = base64.b64decode(payload, altchars=b'-_', validate=True) + if ( + base64.urlsafe_b64encode(decoded_signature).decode() != signature + or base64.urlsafe_b64encode(decoded_payload).decode() != payload + ): + raise ValueError('Non-canonical context') + if not hmac.compare_digest(decoded_signature, _sign_app_context(app, payload)): + raise ValueError('Invalid context signature') + data = json.loads(decoded_payload) + if data['app_id'] != app.id or data['purpose'] != purpose or data['exp'] <= time.time(): + raise ValueError('Expired context') + return data + except Exception: + raise HTTPException(403, 'Invalid or expired app context') from None + + +def verify_app_token(app: AppModel, token: str, token_type: str = 'webui-app+jwt') -> dict: + header = jwt.get_unverified_header(token) + if header.get('typ') != token_type or any(k in header for k in ('jku', 'jwk', 'x5u', 'crit')): + raise ValueError('Invalid app token type or key source') + claims = jwt.decode( + token, + app.data['manifest']['public_key'], + algorithms=['EdDSA'], + audience=app.data['webui_url'] + '/api/v1/apps/runtime', + options={'require': ['sub', 'aud', 'iat', 'exp'], 'strict_aud': True}, + ) + if ( + claims['sub'] != app.id + or type(claims['iat']) is not int + or type(claims['exp']) is not int + or not 0 < claims['exp'] - claims['iat'] <= 60 + ): + raise ValueError('Invalid app identity or lifetime') + return claims + + +async def send_app_event(app: AppModel, event_data: dict, *, timeout: float = 3) -> tuple[int, Any]: + api_key = get_app_encryption_cipher().decrypt(app.api_key.encode()).decode() + return await send_app_request( + app.data['url'], + body=_serialize_json({**event_data, 'app_id': app.id, 'webui_url': app.data['webui_url']}), + timeout=timeout, + headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {api_key}'}, + ) + + +def get_app_response(app: AppModel) -> dict: + return { + 'id': app.id, + 'slug': app.slug, + 'name': app.name, + 'is_active': app.is_active, + 'configured': bool(app.api_key), + 'data': app.data, + 'created_at': app.created_at, + 'updated_at': app.updated_at, + } + + +async def get_app_by_id(app_id: str, db: AsyncSession, require_active: bool = False) -> AppModel: + if not await Config.get('apps.enable'): + raise HTTPException(404, 'Apps are disabled') + app = await Apps.get_app_by_id(app_id, db=db) + if not app: + raise HTTPException(404, 'App not found') + if require_active and (not app.is_active or not app.data['manifest'].get('public_key') or not app.api_key): + raise HTTPException(403, 'App is inactive') + return app + + +async def get_verified_app( + authorization: str = Header(default=''), db: AsyncSession = Depends(get_async_session) +) -> AppModel: + if not await Config.get('apps.enable'): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Apps are disabled') + try: + if not authorization.startswith('Bearer '): + raise ValueError() + token = authorization.removeprefix('Bearer ') + if len(token) > 4096: + raise ValueError() + claims = jwt.decode(token, options={'verify_signature': False}) + app_id = claims.get('sub') + if not isinstance(app_id, str) or len(app_id) > 100: + raise ValueError() + app = await get_app_by_id(app_id, db=db, require_active=True) + verify_app_token(app, token) + return app + except (ValueError, KeyError, jwt.PyJWTError, HTTPException): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid app credential') from None + + +async def has_app_access(app: AppModel, user: UserModel, db: AsyncSession) -> bool: + return user.role == 'admin' or await AccessGrants.has_access(user.id, 'app', app.id, 'read', db=db) + + +async def check_app_channel_access( + channel_id: str, + db: AsyncSession, + user: UserModel | None = None, + require_manager: bool = False, + require_write: bool = False, +) -> ChannelModel: + if not await Config.get('apps.enable'): + raise HTTPException(404, 'Apps are disabled') + if not await Config.get('channels.enable'): + raise HTTPException(403, 'Channels are disabled') + channel = await Channels.get_channel_by_id(channel_id, db=db) + if not channel or channel.type == 'dm' or channel.deleted_at or channel.archived_at: + raise HTTPException(404, 'Channel unavailable') + if user: + if user.role not in ('user', 'admin'): + raise HTTPException(403, 'User is inactive') + if user.role == 'admin': + return channel + if require_manager: + allowed = await Channels.is_user_channel_manager(channel_id, user.id, db=db) + elif channel.type == 'group': + allowed = await Channels.is_user_channel_member(channel_id, user.id, db=db) + else: + allowed = await AccessGrants.has_access( + user.id, 'channel', channel_id, 'write' if require_write else 'read', db=db + ) + if not allowed: + raise HTTPException(403, 'Channel access denied') + return channel + + +async def check_app_channel_membership(app: AppModel, channel_id: str, db: AsyncSession) -> ChannelModel: + await get_app_by_id(app.id, db=db, require_active=True) + channel = await check_app_channel_access(channel_id, db=db) + if not await Channels.is_app_channel_member(channel_id, app.id, db=db): + raise HTTPException(403, 'Invite the app into this channel first') + return channel + + +async def check_app_invocation_access( + app: AppModel, channel_id: str, user: UserModel, db: AsyncSession +) -> ChannelModel: + if not await has_app_access(app, user, db=db): + raise HTTPException(403, 'App invocation denied') + await check_app_channel_access(channel_id, user=user, db=db) + return await check_app_channel_membership(app, channel_id, db=db) + + +def require_scope(app, scope): + if scope and scope not in app.data.get('granted_scopes', []): + raise HTTPException(403, 'App scope was not granted') + + +def get_app_message_data(message): + # Send channel content, not arbitrary internal message metadata. + meta = message.meta or {} + if message.app_id: + author = {'type': 'app', 'id': message.app_id} + elif meta.get('webhook', {}).get('id'): + author = {'type': 'webhook', 'id': meta['webhook']['id']} + elif meta.get('model_id'): + author = {'type': 'model', 'id': meta['model_id']} + else: + author = {'type': 'user', 'id': message.user_id} + return { + 'id': message.id, + 'channel_id': message.channel_id, + 'author': author, + 'content': message.content, + 'parent_id': message.parent_id, + 'reply_to_id': message.reply_to_id, + 'files': (message.data or {}).get('files', []), + 'actions': (message.data or {}).get('app', {}).get('actions', []), + 'created_at': str(message.created_at), + 'updated_at': str(message.updated_at), + } + + +async def publish_app_message_event(message, kind): + # Model output is streamed into this row; only publish its completed update. + if kind == 'message.updated' and (message.meta or {}).get('model_id') and not (message.meta or {}).get('done'): + return + value = deepcopy(get_app_message_data(message)) + if kind == 'message.deleted': + value = {k: value[k] for k in ('id', 'channel_id', 'parent_id', 'updated_at')} + # shortcut: events are lost on failure/restart; add durable delivery only if replay becomes required. + await create_task(None, send_app_message_event(value, kind, time.time_ns())) + + +async def send_app_message_event(message, kind, created_at): + try: + if not await Config.get('apps.enable') or not await Config.get('channels.enable'): + return + async with get_async_db() as db: + app_ids = await Apps.get_app_ids_by_channel_id(message['channel_id'], created_at, db=db) + for app_id in app_ids: + try: + async with get_async_db() as db: + app = await Apps.get_app_by_id(app_id, db=db) + if not app or 'messages:read' not in app.data.get('granted_scopes', []): + continue + if not app.data.get('webui_url'): + continue + try: + await check_app_channel_membership(app, message['channel_id'], db=db) + except HTTPException: + continue # App or channel access was revoked before sending. + envelope = { + 'id': str(uuid.uuid4()), + 'type': kind, + 'message': message, + 'app_id': app.id, + 'webui_url': app.data['webui_url'], + 'channel': {'id': message['channel_id']}, + } + envelope['event_context'] = create_app_context_token(app, 'event', envelope, expires_in=300) + status, _ = await send_app_event(app, envelope, timeout=10) + if not 200 <= status < 300: + log.warning('App %s did not acknowledge %s: HTTP %s', app_id, kind, status) + except Exception: + log.exception('Failed to send %s to app %s', kind, app_id) + except Exception: + log.exception('Failed to publish %s for message %s', kind, message['id']) + + +async def emit_app_message(message, channel, kind='message'): + from open_webui.socket.main import sio + + try: + await sio.emit( + 'events:channel', + { + 'channel_id': channel.id, + 'message_id': message['id'], + 'data': {'type': kind, 'data': message}, + 'channel': channel.model_dump(), + }, + to=f'channel:{channel.id}', + ) + except Exception: + # The insert is committed; socket delivery must never turn this into a failed POST. + log.warning('App message committed but channel notification failed') + + +async def create_app_message(db, app, channel_id, form): + channel = await check_app_channel_membership(app, channel_id, db=db) + require_scope(app, 'messages:write') + for target in (form.parent_id, form.reply_to_id): + if target: + reference = await Messages.get_message_by_id(target, include_thread_replies=False, db=db) + if not reference or reference.channel_id != channel_id: + raise HTTPException(400, 'Invalid message reference') + message = await Messages.insert_new_app_message( + app.id, + channel_id, + MessageForm( + content=form.content, + data={'app': {'actions': [x.model_dump() for x in form.actions]}}, + parent_id=form.parent_id, + reply_to_id=form.reply_to_id, + ), + db=db, + ) + result = message.model_dump() + await emit_app_message(result, channel) + return result + + +async def update_app_message(db, app, message_id, form, if_match): + if if_match is None: + raise HTTPException(428, 'If-Match is required') + message = await Messages.get_message_by_id(message_id, include_thread_replies=False, db=db) + if not message or message.app_id != app.id: + raise HTTPException(404, 'App message not found') + channel = await check_app_channel_membership(app, message.channel_id, db=db) + require_scope(app, 'messages:write') + if if_match != f'"{message.updated_at}"': + raise HTTPException(412, 'Message changed') + updated = await Messages.update_app_message_by_id( + message_id, + app.id, + message.updated_at, + MessageForm(content=form.content, data={'app': {'actions': [x.model_dump() for x in form.actions]}}), + db=db, + ) + if not updated: + raise HTTPException(412, 'Message changed') + value = updated.model_dump() + await emit_app_message(value, channel, 'message:update') + return value + + +async def generate_app_response( + app, user, channel_id, event_type, data, *, db: AsyncSession, source=None, operation_id=None, scope=None +): + await check_app_invocation_access(app, channel_id, user, db=db) + require_scope(app, scope) + operation_id = operation_id or str(uuid.uuid4()) + context = create_app_context_token( + app, + 'execution', + { + 'actor_id': user.id, + 'channel_id': channel_id, + 'operation_id': operation_id, + 'scope': scope, + 'type': event_type, + 'data': data, + }, + expires_in=86400, + ) + envelope = { + 'id': operation_id, + 'type': event_type, + 'actor': {'id': user.id, 'is_admin': user.role == 'admin'}, + 'channel': {'id': channel_id}, + 'data': data, + 'execution_context': context, + 'source': source, + } + checked_key = (app.api_key, app.data['manifest']['public_key']) + # Release the read transaction before HTTP; recheck access after the response. + await db.commit() + try: + status, raw = await send_app_event(app, envelope) + except Exception: + raise HTTPException(504, 'App response timed out; execution outcome may be unknown') from None + if status != 200: + raise HTTPException(502, 'App rejected the interaction') + app = await get_app_by_id(app.id, require_active=True, db=db) + if (app.api_key, app.data['manifest']['public_key']) != checked_key: + raise HTTPException(403, 'App credentials changed') + user = await Users.get_user_by_id(user.id, db=db) + if not user: + raise HTTPException(403, 'Actor is unavailable') + try: + reply = AppReply.model_validate(raw) + except Exception: + raise HTTPException(502, 'App returned an invalid response') from None + await check_app_invocation_access(app, channel_id, user, db=db) + if event_type == 'operation_status' and (reply.visibility != 'private' or reply.type == 'update'): + raise HTTPException(502, 'Deferred results must be private; apps publish separately through the message API') + if reply.actions and (reply.visibility != 'channel' or reply.type not in ('message', 'update')): + raise HTTPException(502, 'Buttons require a channel message') + value = reply.model_dump() + if reply.type == 'deferred': + if reply.operation_id != operation_id: + raise HTTPException(502, 'App returned an invalid operation ID') + value['receipt'] = create_app_context_token( + app, + 'operation', + {'actor_id': user.id, 'channel_id': channel_id, 'operation_id': operation_id}, + expires_in=86400, + ) + elif reply.type == 'message' and reply.visibility == 'channel': + value['message'] = await create_app_message(db, app, channel_id, reply) + elif reply.type == 'update': + if not source: + raise HTTPException(400, 'Message update requires a source message') + message = AppChannelMessage(**{k: getattr(reply, k) for k in AppChannelMessage.model_fields}) + value['message'] = await update_app_message(db, app, source['id'], message, f'"{source["updated_at"]}"') + return value diff --git a/backend/open_webui/utils/channels.py b/backend/open_webui/utils/channels.py index 6f85dfae1e..b31c6a7ab0 100644 --- a/backend/open_webui/utils/channels.py +++ b/backend/open_webui/utils/channels.py @@ -4,7 +4,7 @@ import re def extract_mentions(message: str, triggerChar: str = '@'): # Escape triggerChar in case it's a regex special character triggerChar = re.escape(triggerChar) - pattern = rf'<{triggerChar}([A-Z]):([^|>]+)' + pattern = rf'<{triggerChar}([A-Z]):([^|>]+)(?:\|[^>]+)?>' matches = re.findall(pattern, message) return [{'id_type': id_type, 'id': id_value} for id_type, id_value in matches] diff --git a/src/lib/apis/apps/index.ts b/src/lib/apis/apps/index.ts new file mode 100644 index 0000000000..658a36340c --- /dev/null +++ b/src/lib/apis/apps/index.ts @@ -0,0 +1,290 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type App = { + id: string; + slug: string; + name: string; + is_active: boolean; + configured: boolean; + data: { + url: string; + manifest: { + description: string; + }; + granted_scopes: string[]; + }; +}; + +export const getApps = async (token: string = ''): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/apps`, { + 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 ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getAppById = async (token: string = '', app_id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/apps/${encodeURIComponent(app_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 ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createNewApp = async (token: string = '', app: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/apps`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(app) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err?.detail ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateAppById = async (token: string = '', app_id: string, app: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/apps/${encodeURIComponent(app_id)}`, { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(app) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err?.detail ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteAppById = async (token: string = '', app_id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/apps/${encodeURIComponent(app_id)}`, { + 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 ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const previewAppManifest = async (token: string = '', url: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/apps/manifest/preview`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ url }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err?.detail ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const connectAppById = async ( + token: string = '', + app_id: string, + data: { api_key?: string } = {} +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/apps/${encodeURIComponent(app_id)}/connect`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(data) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err?.detail ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createAppInteraction = async ( + token: string = '', + app_id: string, + interaction: object +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/apps/${encodeURIComponent(app_id)}/interactions`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(interaction) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err?.detail ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getAppOperationById = async ( + token: string = '', + app_id: string, + operation_id: string, + receipt: string +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/apps/${encodeURIComponent(app_id)}/operations/${encodeURIComponent(operation_id)}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}`, + 'X-OpenWebUI-Operation-Receipt': receipt + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err?.detail ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index e97db2fd28..5b85837404 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -1,4 +1,5 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; +import type { App } from '$lib/apis/apps'; type ChannelForm = { type?: string; @@ -919,3 +920,102 @@ export const deleteChannelWebhook = async ( return res; }; + +export const getChannelAppsById = async ( + token: string = '', + channel_id: string +): Promise => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${encodeURIComponent(channel_id)}/apps`, { + 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 ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const inviteAppToChannel = async ( + token: string = '', + channel_id: string, + app_id: string +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${encodeURIComponent(channel_id)}/apps`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ app_id }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err?.detail ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const removeAppFromChannel = async ( + token: string = '', + channel_id: string, + app_id: string +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${encodeURIComponent(channel_id)}/apps/${encodeURIComponent(app_id)}`, + { + 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 ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/admin/Settings/AdminTabIcon.svelte b/src/lib/components/admin/Settings/AdminTabIcon.svelte index f9a529788f..da0fbdcff3 100644 --- a/src/lib/components/admin/Settings/AdminTabIcon.svelte +++ b/src/lib/components/admin/Settings/AdminTabIcon.svelte @@ -1,4 +1,5 @@ + + +
+

{$i18n.t('Apps')}

+
+ +
+
+
{$i18n.t('Connected apps')}
+ +
+ {#if apps === null}
+ +
+ {:else}
+ {#each apps as app (app.id)} +
+
+ + {app.name} + {app.slug} +
+
+ + + {#key `${app.is_active}-${show}-${busy}`} toggle(app)} + />{/key} + +
+
+ {:else}

+ {$i18n.t('No apps connected.')} +

{/each} +
{/if} +

+ {$i18n.t('Connect external apps to Open WebUI.')} +

+
+
+
+
diff --git a/src/lib/components/admin/Settings/Apps/AppModal.svelte b/src/lib/components/admin/Settings/Apps/AppModal.svelte new file mode 100644 index 0000000000..1dee5a35cf --- /dev/null +++ b/src/lib/components/admin/Settings/Apps/AppModal.svelte @@ -0,0 +1,460 @@ + + + { + grants = value; + }} +/> + + +
+

+ {showScopes ? $i18n.t('Scopes') : app ? $i18n.t('Configure app') : $i18n.t('Add app')} +

+ +
+ {#if showScopes} +
+

+ {manual + ? $i18n.t('Choose the scopes to grant this app.') + : app + ? $i18n.t('Scopes requested by {{APP_NAME}}', { APP_NAME: name }) + : $i18n.t('Review the scopes this app is requesting before adding it.')} +

+ {#if availableScopes.length > 8} +
+ + +
+ {/if} + +
+
+ {#each filteredScopes as scope (scope)} + +
+ {$i18n.t(scopeLabels[scope] ?? scope)} + {#if scopeLabels[scope]}
+ {scope} +
{/if} +
+ {#if app && !manifestUpdated} + {scopes.includes(scope) ? $i18n.t('Granted') : $i18n.t('Not granted')} + {:else} + { + scopes = event.detail ? [...scopes, scope] : scopes.filter((s) => s !== scope); + }} + /> + {/if} +
+ {:else} +

+ {scopeQuery ? $i18n.t('No matching scopes.') : $i18n.t('No scopes requested.')} +

+ {/each} +
+
+
+ +
+
+ {:else} +
+ {#if loading}
+ {:else} + {#if !manifest} + + + {:else} +
+ + +
+ {#if manual} + + + + {/if} + +

{url}

+ {#if app && !manual} + + + {/if} + {#if manifestUpdated} +

+ {$i18n.t('Saving approves the updated app identity and selected scopes.')} +

+ {/if} + + + +
+ + +
+ {$i18n.t(app ? '{{COUNT}} granted' : '{{COUNT}} selected', { + COUNT: scopes.length + })} + +
+
+
+ { + showGrants = true; + }} + /> + {/if} + {#if error}{/if} +
+
+ {#if app?.configured}{:else if manifest && !app}{:else if !manifest}{/if} +
+ +
+ {/if} + + {/if} +
diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index 6bdbb47c00..24e865fbea 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -367,6 +367,13 @@ {/if} + + + + import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { getAppOperationById, createAppInteraction } from '$lib/apis/apps'; + import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; + export let appId: string; + export let channelId: string; + export let response: any; + export let source: any = null; + const dispatch = createEventDispatcher(); + let busy = false, + error = ''; + let timer: ReturnType; + let disposed = false; + let pollDelay = 1000; + + async function action(actionId: string) { + busy = true; + error = ''; + try { + const result = await createAppInteraction(localStorage.token, appId, { + message_id: source.id, + action_id: actionId + }); + if (result.visibility !== 'channel' || result.type === 'error') { + dispatch('response', { + app_id: appId, + app_name: source.user?.name, + channel_id: channelId, + message_id: source.id, + response: result + }); + } + } catch (e) { + error = String(e); + } finally { + busy = false; + } + } + async function poll() { + if (disposed || response?.type !== 'deferred') return; + if (!document.hidden) { + try { + const result = await getAppOperationById( + localStorage.token, + appId, + response.operation_id, + response.receipt + ); + if (disposed) return; + response = result; + } catch (e) { + error = String(e); + return; + } + } + pollDelay = Math.min(pollDelay + 1000, 5000); + timer = setTimeout(poll, pollDelay); + } + function download() { + const url = URL.createObjectURL( + new Blob([response.content], { type: 'text/markdown;charset=utf-8' }) + ); + const link = document.createElement('a'); + link.href = url; + link.download = response.filename; + link.click(); + URL.revokeObjectURL(url); + } + onMount(() => { + if (response?.type === 'deferred') timer = setTimeout(poll, 1000); + }); + onDestroy(() => { + disposed = true; + clearTimeout(timer); + }); + + +
+ {#if response?.title}

{response.title}

{/if} + {#if response?.content}{/if} + {#if response?.type === 'error'}

+ The app could not complete this operation. +

{/if} + {#if error}{/if} + {#if response?.type === 'deferred'}

Working…

{/if} + {#if response?.type === 'download'}{/if} + {#if response?.actions?.length} +
+ {#each response.actions as item, index} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/channel/Apps.svelte b/src/lib/components/channel/Apps.svelte new file mode 100644 index 0000000000..f679f3ad51 --- /dev/null +++ b/src/lib/components/channel/Apps.svelte @@ -0,0 +1,82 @@ + + +
+

Apps

+ {#each joined as app} +
+ {app.name} + {app.is_active ? 'App' : 'Inactive'} + {#if canManage}{/if} +
+ {:else}

+ No apps have been invited. +

{/each} + {#if canManage && available.length} +
+ + +
+ {/if} +
diff --git a/src/lib/components/channel/ChannelInfoModal.svelte b/src/lib/components/channel/ChannelInfoModal.svelte index 8ce3b47484..96fe0bb0b6 100644 --- a/src/lib/components/channel/ChannelInfoModal.svelte +++ b/src/lib/components/channel/ChannelInfoModal.svelte @@ -3,6 +3,8 @@ import { getContext, onMount } from 'svelte'; const i18n = getContext('i18n'); + import Apps from './Apps.svelte'; + import { config } from '$lib/stores'; import { removeMembersById } from '$lib/apis/channels'; import Spinner from '$lib/components/common/Spinner.svelte'; @@ -130,6 +132,7 @@ sort={channel?.type !== 'dm'} /> + {#if $config?.features?.enable_apps && channel.type !== 'dm'}{/if} diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index 48f0a2071c..9cbf86eeed 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -632,6 +632,7 @@ } onMount(() => { + const unsubscribe = channelRequestQueues.subscribe(() => { files = [...files]; }); diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte index 087a7a9381..b8c5dc4fd7 100644 --- a/src/lib/components/channel/MessageInput/MentionList.svelte +++ b/src/lib/components/channel/MessageInput/MentionList.svelte @@ -2,8 +2,11 @@ import { getAbortSignal, getContext, onMount } from 'svelte'; const i18n = getContext('i18n'); - import { channels, models } from '$lib/stores'; + import { getChannelAppsById } from '$lib/apis/channels'; + import { getApps } from '$lib/apis/apps'; + import { channels, models, config } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import Grid from '$lib/components/icons/Grid.svelte'; import Hashtag from '$lib/components/icons/Hashtag.svelte'; import Lock from '$lib/components/icons/Lock.svelte'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; @@ -26,10 +29,11 @@ let _models = []; let _users = []; let _channels = []; + let _apps: { type: string; id: string; label: string }[] = []; type UserSuggestion = { id: string; name: string }; - $: filteredItems = [..._users, ..._models, ..._channels].filter( + $: filteredItems = [..._users, ..._models, ..._channels, ..._apps].filter( (u) => u.label.toLowerCase().includes(query.toLowerCase()) || u.id.toLowerCase().includes(query.toLowerCase()) @@ -90,7 +94,7 @@ // so that the mention renderer can show the label if (item) command({ - id: `${item.type === 'user' ? 'U' : item.type === 'model' ? 'M' : 'C'}:${item.id}|${item.label}`, + id: `${item.type === 'user' ? 'U' : item.type === 'model' ? 'M' : item.type === 'app' ? 'A' : 'C'}:${item.id}|${item.label}`, label: item.label }); }; @@ -132,6 +136,15 @@ } onMount(() => { + if ($config?.features?.enable_apps && channelId && triggerChar === '@') { + Promise.all([getApps(localStorage.token), getChannelAppsById(localStorage.token, channelId)]) + .then(([apps, joined]) => { + _apps = apps + .filter((a) => a.is_active && joined.some((member) => member.id === a.id)) + .map((a) => ({ type: 'app', id: a.id, label: a.name })); + }) + .catch(() => {}); + } const keydownListener = (e: KeyboardEvent) => { // required to prevent the default enter behavior if (e.key === 'Enter') { @@ -197,6 +210,8 @@ {$i18n.t('Users')} {:else if item?.type === 'model'} {$i18n.t('Models')} + {:else if item?.type === 'app'} + {$i18n.t('Apps')} {:else if item?.type === 'channel'} {$i18n.t('Channels')} {/if} @@ -224,6 +239,8 @@ {/if} + {:else if item.type === 'app'} + {:else if item.type === 'model'} {}; let messagesLoading = false; + let appResponses: Record = {}; + let responseLocation = ''; + $: if (responseLocation !== `${channel?.id}:${id}`) { + responseLocation = `${channel?.id}:${id}`; + appResponses = {}; + } + + const receiveAppResponse = (event) => { + if (event.channel_id !== channel?.id || (event.parent_id ?? null) !== (thread ? id : null)) + return; + const key = `${event.message_id}:${event.app_id}`; + appResponses = { ...appResponses, [key]: event }; + }; + + onMount(() => { + const appSocket = $socket; + appSocket?.on('events:app', receiveAppResponse); + return () => appSocket?.off('events:app', receiveAppResponse); + }); const loadMoreMessages = async () => { // scroll slightly down to disable continuous loading @@ -128,6 +148,8 @@ {#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)} + receiveAppResponse({ ...event.detail, parent_id: thread ? id : null })} {message} {channel} {thread} @@ -256,6 +278,47 @@ } }} /> + {#each Object.entries(appResponses).filter(([, event]) => event.message_id === message.id) as [key, event] (key)} +
+ +
+
+ {event.app_name ?? $i18n.t('App')} + {$i18n.t('App')} +
+
+ {#key event.response} + + {/key} +
+
+ {$i18n.t('Only visible to you')} + + +
+
+
+ {/each} {/each}
diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 6c2bb4650e..950d315a72 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -23,6 +23,7 @@ import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { getMessageData } from '$lib/apis/channels'; + import AppResponse from '../AppResponse.svelte'; import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; import StructuredOutputRenderer from '$lib/components/chat/Messages/StructuredOutputRenderer.svelte'; import { buildOutputDisplayItems } from '$lib/components/chat/Messages/structuredOutput'; @@ -399,6 +400,7 @@ e.currentTarget.src = '/favicon.png'; }} /> + {:else if message.app_id}App {:else if message.user?.role === 'webhook'} + {#if message.app_id}App{/if} {#if message.created_at}
{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null} + {#if message.app_id && message?.data?.app} +
+ +
+ {/if} + {#if (message?.reactions ?? []).length > 0}
diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index c20f170cd1..c93113afc4 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -52,6 +52,7 @@ import AdminEvaluations from '$lib/components/admin/Settings/Evaluations.svelte'; import AdminAnalytics from '$lib/components/admin/Analytics.svelte'; import AdminIntegrations from '$lib/components/admin/Settings/Integrations.svelte'; + import AdminApps from '$lib/components/admin/Settings/Apps.svelte'; import AdminDocuments from '$lib/components/admin/Settings/Documents.svelte'; import AdminWebSearch from '$lib/components/admin/Settings/WebSearch.svelte'; import AdminCodeExecution from '$lib/components/admin/Settings/CodeExecution.svelte'; @@ -163,6 +164,7 @@ 'admin:models': $i18n.t('AI'), 'admin:subagents': $i18n.t('AI'), 'admin:integrations': $i18n.t('Tools'), + 'admin:apps': $i18n.t('Tools'), 'admin:documents': $i18n.t('Tools'), 'admin:audio': $i18n.t('Tools'), 'admin:images': $i18n.t('Tools'), @@ -308,6 +310,12 @@ title: $i18n.t('settings.admin.integrations.title'), searchPrefixes: ['settings.admin.integrations.'] }, + { + id: 'admin:apps', + titleKey: 'Apps', + title: $i18n.t('Apps'), + searchPrefixes: [] + }, { id: 'admin:documents', titleKey: 'settings.admin.documents.title', @@ -408,7 +416,9 @@ return ( $user?.role === 'admin' ? [...personalSettings, ...administratorTabs] : personalSettings ).filter( - (tab) => tab.id !== 'admin:analytics' || ($config?.features?.enable_admin_analytics ?? true) + (tab) => + (tab.id !== 'admin:analytics' || ($config?.features?.enable_admin_analytics ?? true)) && + (tab.id !== 'admin:apps' || $config?.features?.enable_apps) ); }; @@ -871,6 +881,8 @@ {:else if selectedTab === 'admin:integrations'} + {:else if selectedTab === 'admin:apps'} + {:else if selectedTab === 'admin:documents'} {:else if selectedTab === 'admin:web'} diff --git a/src/lib/components/workspace/common/AccessControl.svelte b/src/lib/components/workspace/common/AccessControl.svelte index b8169a10ad..28b011c861 100644 --- a/src/lib/components/workspace/common/AccessControl.svelte +++ b/src/lib/components/workspace/common/AccessControl.svelte @@ -34,6 +34,8 @@ export let onChange: Function = () => {}; export let accessRoles = ['read']; + export let labels: Partial> = + {}; export let accessGrants: AccessGrant[] | any = []; export let accessControl: any = undefined; @@ -52,9 +54,9 @@ let showAddAccessModal = false; $: visibility = getVisibility(accessGrants ?? []); $: visibilityOptions = [ - { value: 'private' as const, label: $i18n.t('Private') }, + { value: 'private' as const, label: labels.private ?? $i18n.t('Private') }, ...((share && sharePublic) || hasPublicReadGrant(accessGrants ?? []) - ? [{ value: 'public' as const, label: $i18n.t('Public') }] + ? [{ value: 'public' as const, label: labels.public ?? $i18n.t('Public') }] : []), ...((share && shareOpen) || hasAnyoneReadGrant(accessGrants ?? []) ? [{ value: 'open' as const, label: $i18n.t('Open') }] @@ -500,7 +502,7 @@
{/if}
diff --git a/src/lib/components/workspace/common/AccessControlModal.svelte b/src/lib/components/workspace/common/AccessControlModal.svelte index 69b5e97859..053004c0b9 100644 --- a/src/lib/components/workspace/common/AccessControlModal.svelte +++ b/src/lib/components/workspace/common/AccessControlModal.svelte @@ -18,6 +18,8 @@ export let accessGrants: AccessGrant[] = []; export let accessControl: any = undefined; export let accessRoles = ['read']; + export let labels: Partial> = + {}; export let share = true; export let sharePublic = true; @@ -32,7 +34,7 @@
- {$i18n.t('Access Control')} + {labels.title ?? $i18n.t('Access Control')}