This commit is contained in:
Timothy Jaeryang Baek 2026-10-12 17:29:04 +04:00
parent c9d3ae9999
commit 04bccecb8e
29 changed files with 2848 additions and 31 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

290
src/lib/apis/apps/index.ts Normal file
View file

@ -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<App[]> => {
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;
};

View file

@ -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<App[]> => {
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;
};

View file

@ -1,4 +1,5 @@
<script lang="ts">
import Grid from '$lib/components/icons/Grid.svelte';
import CodeBracket from '$lib/components/icons/CodeBracket.svelte';
import BarsArrowUp from '$lib/components/icons/BarsArrowUp.svelte';
import Computer from '$lib/components/icons/Computer.svelte';
@ -71,6 +72,8 @@
<BarsArrowUp {className} {strokeWidth} />
{:else if id === 'integrations'}
<WrenchAlt {className} {strokeWidth} />
{:else if id === 'apps'}
<Grid {className} {strokeWidth} />
{:else if id === 'documents'}
<Document {className} {strokeWidth} />
{:else if id === 'web'}

View file

@ -0,0 +1,117 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { getApps, updateAppById, type App } from '$lib/apis/apps';
import AdminSettingSection from './AdminSettingSection.svelte';
import AppModal from './Apps/AppModal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Grid from '$lib/components/icons/Grid.svelte';
const i18n = getContext<any>('i18n');
let apps: App[] | null = null;
let selected: App | null = null;
let show = false;
let busy = '';
async function load() {
try {
apps = await getApps(localStorage.token);
} catch (error) {
toast.error(String(error));
apps = [];
}
}
function configure(app: App | null) {
selected = app;
show = true;
}
async function toggle(app: App) {
if (!app.is_active) {
configure(app);
return;
}
busy = app.id;
try {
await updateAppById(localStorage.token, app.id, { is_active: false });
await load();
} catch (error) {
toast.error(String(error));
} finally {
busy = '';
}
}
onMount(load);
</script>
<AppModal bind:show app={selected} onChange={load} />
<div class="flex h-full flex-col text-sm">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$i18n.t('Apps')}</h2>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<AdminSettingSection first>
<div>
<div class="mb-2 flex items-center justify-between">
<div class="text-xs text-gray-600 dark:text-gray-400">{$i18n.t('Connected apps')}</div>
<Tooltip content={$i18n.t('Add app')}
><button
type="button"
aria-label={$i18n.t('Add app')}
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
on:click={() => configure(null)}><Plus /></button
></Tooltip
>
</div>
{#if apps === null}<div class="flex justify-center py-4">
<Spinner className="size-5" />
</div>
{:else}<div class="flex flex-col gap-1.5">
{#each apps as app (app.id)}
<div class="flex w-full items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<div
class="flex min-w-0 flex-1 items-center gap-1.5 {app.is_active
? ''
: 'opacity-50'}"
>
<Grid className="size-4 shrink-0" strokeWidth="1.5" />
<span class="truncate"
>{app.name}
<span class="text-gray-400 dark:text-gray-600">{app.slug}</span></span
>
</div>
<div class="flex shrink-0 items-center gap-1">
<Tooltip content={$i18n.t('Configure')}
><button
type="button"
aria-label={`${$i18n.t('Configure')} ${app.name}`}
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
on:click={() => configure(app)}><Cog6 /></button
></Tooltip
>
<Tooltip
content={app.is_active
? $i18n.t('Enabled')
: app.configured
? $i18n.t('Disabled')
: $i18n.t('Uninstalled')}
>
{#key `${app.is_active}-${show}-${busy}`}<Switch
state={app.is_active}
disabled={busy === app.id}
ariaLabel={`${$i18n.t('Enable')} ${app.name}`}
on:change={() => toggle(app)}
/>{/key}
</Tooltip>
</div>
</div>
{:else}<p class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('No apps connected.')}
</p>{/each}
</div>{/if}
<p class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Connect external apps to Open WebUI.')}
</p>
</div>
</AdminSettingSection>
</div>
</div>

View file

@ -0,0 +1,460 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import {
createNewApp,
updateAppById,
getAppById,
deleteAppById,
previewAppManifest,
connectAppById,
type App
} from '$lib/apis/apps';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import AccessButton from '$lib/components/common/AccessButton.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
import AdminSettingSection from '../AdminSettingSection.svelte';
import AdminSettingRow from '../AdminSettingRow.svelte';
import AdminSettingField from '../AdminSettingField.svelte';
const i18n = getContext<any>('i18n');
export let show = false;
export let app: App | null = null;
export let onChange: () => Promise<void>;
let url = '',
apiKey = '',
slug = app?.slug ?? '',
name = app?.name ?? '',
error = '';
let manifest: any = app?.data.manifest ?? null;
let scopes = [...(app?.data.granted_scopes ?? [])];
let grants: any[] = [];
let busy = false,
loading = !!app,
showGrants = false,
confirm = false;
let showScopes = false;
let manifestUpdated = false;
let manual = false;
let scopeQuery = '';
$: availableScopes = manual ? Object.keys(scopeLabels) : (manifest?.requested_scopes ?? []);
$: filteredScopes = availableScopes.filter((scope: string) =>
`${scope} ${$i18n.t(scopeLabels[scope] ?? scope)}`
.toLowerCase()
.includes(scopeQuery.trim().toLowerCase())
);
const inputClass =
'w-full min-w-0 bg-transparent text-sm outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 disabled:text-gray-400 dark:disabled:text-gray-600';
const buttonClass =
'flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white';
const scopeLabels: Record<string, string> = {
'mentions:read': 'Read app mentions',
'messages:read': 'Read messages and history in joined channels',
'messages:write': 'Send and update messages',
'channels:read': 'Read joined channels'
};
async function run(task: () => Promise<void>) {
busy = true;
error = '';
try {
await task();
} catch (e) {
error = String(e);
} finally {
busy = false;
}
}
function editDetailsManually() {
manual = true;
manifest = manifest ?? { id: '', name: '', public_key: '', requested_scopes: [] };
manifestUpdated = !!app;
}
async function submit() {
await run(async () => {
if (!manifest) {
manifest = await previewAppManifest(localStorage.token, url);
manual = false;
name = manifest.name;
slug =
manifest.id
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/^[^a-z]+/, '')
.slice(0, 40) || 'app';
scopes = [...manifest.requested_scopes];
scopeQuery = '';
showScopes = true;
} else {
if (manual) {
manifest = { ...manifest, id: manifest.id || slug, name, requested_scopes: scopes };
}
if (!app) {
app = await createNewApp(localStorage.token, {
manifest,
url,
api_key: apiKey,
slug,
granted_scopes: scopes,
access_grants: grants
});
apiKey = '';
} else {
app = await updateAppById(localStorage.token, app.id, {
name,
access_grants: grants,
...(manifestUpdated ? { manifest, granted_scopes: scopes } : {})
});
manifestUpdated = false;
}
try {
if (!app!.is_active || apiKey) {
app = await connectAppById(
localStorage.token,
app!.id,
apiKey ? { api_key: apiKey } : {}
);
apiKey = '';
}
} finally {
await onChange();
}
toast.success($i18n.t('App settings updated'));
show = false;
}
});
}
async function destructive() {
await run(async () => {
await deleteAppById(localStorage.token, app!.id);
show = false;
await onChange();
});
}
let opened = false;
async function initialize() {
manifestUpdated = false;
manual = false;
showScopes = false;
scopeQuery = '';
url = app?.data.url ?? '';
apiKey = '';
slug = app?.slug ?? '';
name = app?.name ?? '';
error = '';
manifest = app?.data.manifest ? { ...app.data.manifest } : null;
scopes = [...(app?.data.granted_scopes ?? [])];
grants = app ? [] : [{ principal_type: 'user', principal_id: '*', permission: 'read' }];
apiKey = '';
loading = !!app;
if (app)
await run(async () => {
grants = (await getAppById(localStorage.token, app!.id)).access_grants;
});
loading = false;
}
$: if (show && !opened) {
opened = true;
initialize();
} else if (!show) {
opened = false;
apiKey = '';
}
</script>
<AccessControlModal
bind:show={showGrants}
bind:accessGrants={grants}
accessRoles={['read']}
labels={{
title: $i18n.t('Who can use this app?'),
public: $i18n.t('Everyone'),
private: $i18n.t('Restricted'),
read: $i18n.t('Use'),
empty: $i18n.t('Only admins can use this app. Add users or groups to allow others.')
}}
onChange={(value) => {
grants = value;
}}
/>
<ConfirmDialog
bind:show={confirm}
title={$i18n.t('Uninstall app')}
message={$i18n.t(
'Credentials and channel memberships will be removed. Existing messages will remain.'
)}
on:confirm={destructive}
/>
<Modal size="sm" bind:show>
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<h1 class="text-lg font-medium self-center font-primary">
{showScopes ? $i18n.t('Scopes') : app ? $i18n.t('Configure app') : $i18n.t('Add app')}
</h1>
<button
type="button"
aria-label={$i18n.t('Close app settings')}
class="self-center"
on:click={() => {
show = false;
}}><XMark className="size-5" /></button
>
</div>
{#if showScopes}
<div class="px-5 pb-4 text-sm dark:text-gray-200">
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{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.')}
</p>
{#if availableScopes.length > 8}
<div class="mb-3 flex items-center gap-2 text-gray-500">
<Search className="size-4 shrink-0" />
<input
type="search"
aria-label={$i18n.t('Search scopes')}
placeholder={$i18n.t('Search scopes')}
class={inputClass}
bind:value={scopeQuery}
/>
</div>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The scrollable scope list must support keyboard scrolling.) -->
<div
class="max-h-[45dvh] overflow-y-auto overscroll-contain scrollbar-hover pr-1.5"
role="region"
aria-label={$i18n.t('App scopes')}
tabindex="0"
>
<div class="flex flex-col gap-3">
{#each filteredScopes as scope (scope)}
<AdminSettingRow label={$i18n.t(scopeLabels[scope] ?? scope)} let:labelId>
<div slot="label" class="break-words">
{$i18n.t(scopeLabels[scope] ?? scope)}
{#if scopeLabels[scope]}<div
class="mt-0.5 break-all font-mono text-[0.6875rem] text-gray-400 dark:text-gray-600"
>
{scope}
</div>{/if}
</div>
{#if app && !manifestUpdated}
<span class="whitespace-nowrap text-[0.6875rem] text-gray-400 dark:text-gray-600"
>{scopes.includes(scope) ? $i18n.t('Granted') : $i18n.t('Not granted')}</span
>
{:else}
<Switch
state={scopes.includes(scope)}
ariaLabelledbyId={labelId}
on:change={(event) => {
scopes = event.detail ? [...scopes, scope] : scopes.filter((s) => s !== scope);
}}
/>
{/if}
</AdminSettingRow>
{:else}
<p class="py-3 text-xs text-gray-400 dark:text-gray-600">
{scopeQuery ? $i18n.t('No matching scopes.') : $i18n.t('No scopes requested.')}
</p>
{/each}
</div>
</div>
<div class="mt-5 flex justify-start">
<button
type="button"
class="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
on:click={() => {
showScopes = false;
}}><ChevronLeft className="size-3" />{$i18n.t('Back')}</button
>
</div>
</div>
{:else}
<form class="px-5 pb-4 text-sm dark:text-gray-200" on:submit|preventDefault={submit}>
{#if loading}<div class="flex justify-center py-4"><Spinner className="size-5" /></div>
{:else}
{#if !manifest}<AdminSettingField
label={$i18n.t('App URL')}
forId="app-url"
description={$i18n.t(
'Manifest discovery is recommended. You can also enter app details manually.'
)}
>
<input
id="app-url"
type="url"
required
bind:value={url}
class={inputClass}
placeholder="https://app.example.com/open-webui"
/>
</AdminSettingField>
{:else}
<div class="flex gap-3">
<AdminSettingField label={$i18n.t('Name')} forId="app-name" className="min-w-0 flex-1"
><input
id="app-name"
class={inputClass}
bind:value={name}
disabled={!app && !manual}
required
maxlength="100"
/></AdminSettingField
>
<AdminSettingField
label={$i18n.t('App handle')}
forId="app-handle"
className="min-w-0 flex-1"
><input
id="app-handle"
class={`${inputClass} font-mono`}
bind:value={slug}
disabled={!!app}
required
pattern={'[a-z][a-z0-9-]{0,39}'}
/></AdminSettingField
>
</div>
{#if manual}
<AdminSettingField
label={$i18n.t('Public key')}
forId="app-public-key"
className="mt-4"
description={$i18n.t('Enter the Ed25519 public key provided by the app.')}
>
<textarea
id="app-public-key"
class={`${inputClass} font-mono`}
rows="3"
required
maxlength="2000"
spellcheck="false"
bind:value={manifest.public_key}
placeholder="-----BEGIN PUBLIC KEY-----"
></textarea>
</AdminSettingField>
{/if}
<AdminSettingSection title={$i18n.t('Connection')}>
<p class="break-all text-xs text-gray-600 dark:text-gray-400">{url}</p>
{#if app && !manual}
<button
type="button"
disabled={busy}
class="mt-2 text-xs text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white hover:underline"
on:click={() =>
run(async () => {
manifest = await previewAppManifest(localStorage.token, url);
scopes = scopes.filter((scope) => manifest.requested_scopes.includes(scope));
manifestUpdated = true;
showScopes = true;
})}>{$i18n.t('Update app')}</button
>
<button
type="button"
disabled={busy}
class="ml-3 mt-2 text-xs text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white hover:underline"
on:click={editDetailsManually}>{$i18n.t('Edit details manually')}</button
>
{/if}
{#if manifestUpdated}
<p class="mt-2 text-xs text-gray-500">
{$i18n.t('Saving approves the updated app identity and selected scopes.')}
</p>
{/if}
<AdminSettingField
label={$i18n.t('API Key')}
forId="app-api-key"
description={app?.configured
? $i18n.t('Leave empty to keep the current API key.')
: $i18n.t('Enter the API key provided by the app.')}
>
<input
id="app-api-key"
type="password"
autocomplete="new-password"
class={inputClass}
bind:value={apiKey}
required={!app?.configured}
minlength="32"
maxlength="4096"
placeholder={app?.configured ? '••••••••' : ''}
/>
</AdminSettingField>
</AdminSettingSection>
<AdminSettingSection>
<AdminSettingRow label={$i18n.t('Scopes')}>
<div class="flex items-center gap-3 text-xs">
<span class="text-gray-400 dark:text-gray-600"
>{$i18n.t(app ? '{{COUNT}} granted' : '{{COUNT}} selected', {
COUNT: scopes.length
})}</span
>
<button
type="button"
class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white hover:underline"
on:click={() => {
scopeQuery = '';
showScopes = true;
}}
>
{app && !manifestUpdated ? $i18n.t('View') : $i18n.t('Review')}
</button>
</div>
</AdminSettingRow>
</AdminSettingSection>
<AccessButton
label={$i18n.t('Who can use this app?')}
className="mt-5"
on:click={() => {
showGrants = true;
}}
/>
{/if}
{#if error}<p role="alert" class="mt-3 text-xs text-red-600 dark:text-red-400">
{error}
</p>{/if}
<div class="mt-5 flex items-center justify-between gap-2">
<div>
{#if app?.configured}<button
type="button"
disabled={busy}
class="px-1 py-1.5 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:underline transition disabled:opacity-50"
on:click={() => {
confirm = true;
}}>{$i18n.t('Uninstall')}</button
>{:else if manifest && !app}<button
type="button"
disabled={busy}
class="text-xs text-gray-500 hover:text-gray-900 dark:hover:text-white"
on:click={() => {
manifest = null;
manual = false;
}}>{$i18n.t('Back')}</button
>{:else if !manifest}<button
type="button"
disabled={busy}
class="text-xs text-gray-500 hover:text-gray-900 dark:hover:text-white"
on:click={(event) => {
if (event.currentTarget.form?.reportValidity()) editDetailsManually();
}}>{$i18n.t('Enter details manually')}</button
>{/if}
</div>
<button type="submit" disabled={busy} class={buttonClass}>
{#if busy}<Spinner className="size-3" />{/if}
{!manifest
? $i18n.t('Continue')
: !app
? $i18n.t('Connect')
: app.is_active && !apiKey
? $i18n.t('Save')
: $i18n.t('Connect')}
</button>
</div>
{/if}
</form>
{/if}
</Modal>

View file

@ -367,6 +367,13 @@
</SettingsSelect>
</AdminSettingRow>
{/if}
<AdminSettingRow
label={$i18n.t('settings.admin.general.apps.label')}
description={$i18n.t('settings.admin.general.apps.description')}
let:labelId
>
<Switch bind:state={adminConfig.ENABLE_APPS} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('settings.admin.general.calendar.label')}
description={$i18n.t('settings.admin.general.calendar.description')}

View file

@ -0,0 +1,105 @@
<script lang="ts">
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<typeof setTimeout>;
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);
});
</script>
<div class="space-y-3 text-sm" aria-busy={busy}>
{#if response?.title}<h3 class="font-medium">{response.title}</h3>{/if}
{#if response?.content}<Markdown content={response.content} allowEmbeds={false} />{/if}
{#if response?.type === 'error'}<p role="alert">
The app could not complete this operation.
</p>{/if}
{#if error}<p role="alert" class="text-red-600">{error}</p>{/if}
{#if response?.type === 'deferred'}<p role="status" class="text-gray-500">Working…</p>{/if}
{#if response?.type === 'download'}<button
class="focus-ring flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={download}>Download {response.filename}</button
>{/if}
{#if response?.actions?.length}
<div class="flex flex-wrap items-center gap-1.5">
{#each response.actions as item, index}
<button
type="button"
disabled={busy}
class="focus-ring flex h-7 max-w-full shrink-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-normal transition disabled:cursor-not-allowed disabled:opacity-60 {index ===
0
? 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
on:click={() => action(item.action_id)}>{item.label}</button
>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { user } from '$lib/stores';
import { getApps, type App } from '$lib/apis/apps';
import { getChannelAppsById, inviteAppToChannel, removeAppFromChannel } from '$lib/apis/channels';
export let channel: any;
let joined: App[] = [];
let available: App[] = [];
let selected = '';
let busy = false;
$: canManage = channel?.is_manager || $user?.role === 'admin' || channel?.user_id === $user?.id;
async function load() {
try {
joined = await getChannelAppsById(localStorage.token, channel.id);
available = (await getApps(localStorage.token)).filter(
(a: App) => a.is_active && !joined.some((j) => j.id === a.id)
);
} catch (error) {
toast.error(String(error));
}
}
async function change(appId: string, remove = false) {
busy = true;
try {
if (remove) {
await removeAppFromChannel(localStorage.token, channel.id, appId);
} else {
await inviteAppToChannel(localStorage.token, channel.id, appId);
}
selected = '';
await load();
} catch (error) {
toast.error(String(error));
} finally {
busy = false;
}
}
onMount(load);
</script>
<section class="space-y-2 px-1 mt-3" aria-label="Apps">
<h3 class="text-[0.6875rem] text-gray-400 dark:text-gray-600 dark:text-gray-400">Apps</h3>
{#each joined as app}
<div class="flex items-center justify-between gap-2 text-xs text-gray-600 dark:text-gray-400">
<span
>{app.name}
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600"
>{app.is_active ? 'App' : 'Inactive'}</span
></span
>
{#if canManage}<button
type="button"
disabled={busy}
class="text-xs text-gray-400 hover:text-red-600 transition disabled:opacity-50"
on:click={() => change(app.id, true)}>Remove</button
>{/if}
</div>
{:else}<p class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
No apps have been invited.
</p>{/each}
{#if canManage && available.length}
<div class="flex gap-2">
<select
aria-label="App to invite"
bind:value={selected}
class="min-w-0 flex-1 bg-transparent text-xs text-gray-600 dark:text-gray-400 outline-hidden"
>
<option value="">Choose an app</option>{#each available as app}<option value={app.id}
>{app.name}</option
>{/each}
</select>
<button
type="button"
disabled={!selected || busy}
on:click={() => change(selected)}
class="h-7 rounded-lg px-2.5 text-xs bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 transition hover:bg-black dark:hover:bg-white disabled:opacity-50"
>Invite</button
>
</div>
{/if}
</section>

View file

@ -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'}
/>
</div>
{#if $config?.features?.enable_apps && channel.type !== 'dm'}<Apps {channel} />{/if}
</form>
</div>
</div>

View file

@ -632,6 +632,7 @@
}
onMount(() => {
const unsubscribe = channelRequestQueues.subscribe(() => {
files = [...files];
});

View file

@ -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 @@
<Lock className="size-[0.9375rem]" strokeWidth="2" />
{/if}
</div>
{:else if item.type === 'app'}
<Grid className="size-4 mr-2" />
{:else if item.type === 'model'}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${item.id}&lang=${$i18n.language}`}
@ -259,6 +276,8 @@
{$i18n.t('User')}
{:else if item.type === 'model'}
{$i18n.t('Model')}
{:else if item.type === 'app'}
{$i18n.t('App')}
{:else if item.type === 'channel'}
{$i18n.t('Channel')}
{/if}

View file

@ -9,11 +9,12 @@
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
import { tick, getContext, onMount } from 'svelte';
import { settings, user } from '$lib/stores';
import { settings, socket, user } from '$lib/stores';
import Message from './Messages/Message.svelte';
import AppResponse from './AppResponse.svelte';
import Loader from '../common/Loader.svelte';
import Spinner from '../common/Spinner.svelte';
import {
@ -40,6 +41,25 @@
export let onPin: Function = () => {};
let messagesLoading = false;
let appResponses: Record<string, any> = {};
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)}
<Message
on:response={(event) =>
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)}
<div
class="flex gap-3 px-4 pt-3 pb-2"
data-app-response={event.app_id}
dir={$settings.chatDirection}
>
<span
class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gray-100 text-[0.625rem] text-gray-500 dark:bg-gray-800 dark:text-gray-400"
aria-hidden="true">App</span
>
<div class="min-w-0 flex-1">
<div class="mb-0.5 flex items-baseline gap-1.5 leading-5">
<span class="truncate text-[0.8125rem] font-medium text-gray-900 dark:text-gray-100"
>{event.app_name ?? $i18n.t('App')}</span
>
<span class="text-[0.65rem] text-gray-500">{$i18n.t('App')}</span>
</div>
<div class="[&_.markdown-prose]:text-sm! [&_.markdown-prose]:leading-[1.375rem]!">
{#key event.response}
<AppResponse
appId={event.app_id}
channelId={channel.id}
response={event.response}
/>
{/key}
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span>{$i18n.t('Only visible to you')}</span>
<span aria-hidden="true">·</span>
<button
type="button"
class="focus-ring rounded hover:text-gray-800 hover:underline dark:hover:text-gray-200"
on:click={() => {
delete appResponses[key];
appResponses = { ...appResponses };
}}>{$i18n.t('Dismiss')}</button
>
</div>
</div>
</div>
{/each}
{/each}
<div class="pb-4"></div>

View file

@ -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}<span class="flex size-8 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800 text-xs" aria-label="App">App</span>
{:else if message.user?.role === 'webhook'}
<ProfileImage
src={`${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}
@ -442,6 +444,7 @@
{/if}
</div>
{#if message.app_id}<span class="text-[0.65rem] text-gray-500">App</span>{/if}
{#if message.created_at}
<div
class=" shrink-0 translate-y-px text-[0.6875rem] leading-4 text-gray-500 dark:text-gray-400 font-normal first-letter:capitalize"
@ -574,7 +577,7 @@
<Markdown
id={renderedMessageId}
content={message.content}
paragraphTag="span"
paragraphTag={message.app_id ? 'p' : 'span'}
allowEmbeds={false}
/>
</span>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
@ -584,6 +587,12 @@
{/if}
</div>
{#if message.app_id && message?.data?.app}
<div class="mt-2">
<AppResponse on:response appId={message.app_id} channelId={channel.id} source={message} response={{ actions: message.data.app.actions }} />
</div>
{/if}
{#if (message?.reactions ?? []).length > 0}
<div>
<div class="flex items-center flex-wrap gap-1 mt-1 mb-1">

View file

@ -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 @@
<AdminAnalytics />
{:else if selectedTab === 'admin:integrations'}
<AdminIntegrations {saveSettings} />
{:else if selectedTab === 'admin:apps'}
<AdminApps />
{:else if selectedTab === 'admin:documents'}
<AdminDocuments on:save={adminConfigSaveHandler} />
{:else if selectedTab === 'admin:web'}

View file

@ -34,6 +34,8 @@
export let onChange: Function = () => {};
export let accessRoles = ['read'];
export let labels: Partial<Record<'title' | 'public' | 'private' | 'read' | 'empty', string>> =
{};
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 @@
<Dropdown closeOnSelect>
<button
type="button"
aria-label={$i18n.t('Visibility')}
aria-label={labels.title ?? $i18n.t('Visibility')}
class="flex h-6 items-center gap-2 rounded-md text-[0.8125rem] font-medium text-gray-800 transition hover:text-gray-950 dark:text-gray-200 dark:hover:text-white"
>
{#if visibility === 'private'}
@ -622,7 +624,7 @@
<option value="write">{$i18n.t('Write')}</option>
</select>
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
<Badge type={'info'} content={labels.read ?? $i18n.t('Read')} />
{/if}
<button
@ -676,7 +678,7 @@
<option value="write">{$i18n.t('Write')}</option>
</select>
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
<Badge type={'info'} content={labels.read ?? $i18n.t('Read')} />
{/if}
<button
@ -695,7 +697,7 @@
{#if getVisibility(accessGrants ?? []) === 'private' && accessGroups.length === 0 && selectedUsers.length === 0}
<div class="text-xs text-gray-500 text-center py-3">
{$i18n.t('No access grants. Private to you.')}
{labels.empty ?? $i18n.t('No access grants. Private to you.')}
</div>
{/if}
</div>

View file

@ -18,6 +18,8 @@
export let accessGrants: AccessGrant[] = [];
export let accessControl: any = undefined;
export let accessRoles = ['read'];
export let labels: Partial<Record<'title' | 'public' | 'private' | 'read' | 'empty', string>> =
{};
export let share = true;
export let sharePublic = true;
@ -32,7 +34,7 @@
<div>
<div class="flex justify-between dark:text-gray-100 px-4 pt-3 pb-2">
<div class="text-sm font-medium self-center">
{$i18n.t('Access Control')}
{labels.title ?? $i18n.t('Access Control')}
</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
@ -50,6 +52,7 @@
bind:accessControl
{onChange}
{accessRoles}
{labels}
{share}
{sharePublic}
{shareOpen}

View file

@ -1,4 +1,8 @@
{
"Only admins can use this app. Add users or groups to allow others.": "",
"Restricted": "",
"Everyone": "",
"Who can use this app?": "",
"-1 for no limit, or a positive integer for a specific limit": "",
"(latest)": "",
"(leave blank for to use commercial endpoint)": "",
@ -748,6 +752,7 @@
"Discover, download, and explore model presets": "",
"Discussion channel where access is based on groups and permissions": "",
"Dismiss": "",
"Only visible to you": "",
"Dismiss notification": "",
"Display": "",
"Display text": "Display text",
@ -2638,6 +2643,8 @@
"settings.admin.general.banners.label": "Banners",
"settings.admin.general.calendar.description": "Allow users to access calendar features.",
"settings.admin.general.calendar.label": "Calendar",
"settings.admin.general.apps.description": "Allow admins to connect external apps to Open WebUI.",
"settings.admin.general.apps.label": "Apps",
"settings.admin.general.channels.description": "Allow users to use channels for shared conversations.",
"settings.admin.general.channels.label": "Channels",
"settings.admin.general.checkForUpdates.label": "Check for updates",
@ -3872,5 +3879,14 @@
"YouTube": "",
"YouTube video player": "",
"Zoom in": "",
"Zoom out": ""
"Zoom out": "",
"Read messages and history in joined channels": "Read messages and history in joined channels",
"Update app": "Update app",
"Saving approves the updated app identity and selected scopes.": "Saving approves the updated app identity and selected scopes.",
"Manifest discovery is recommended. You can also enter app details manually.": "",
"Choose the scopes to grant this app.": "",
"Public key": "",
"Enter the Ed25519 public key provided by the app.": "",
"Edit details manually": "",
"Enter details manually": ""
}

View file

@ -381,6 +381,7 @@ type Config = {
enable_tools?: boolean;
enable_functions?: boolean;
enable_tool_servers?: boolean;
enable_apps?: boolean;
enable_autocomplete_generation: boolean;
enable_direct_connections: boolean;
enable_direct_integrations?: boolean;