mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-14 04:08:46 +00:00
refac
This commit is contained in:
parent
c9d3ae9999
commit
04bccecb8e
29 changed files with 2848 additions and 31 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
207
backend/open_webui/models/apps.py
Normal file
207
backend/open_webui/models/apps.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
490
backend/open_webui/routers/apps.py
Normal file
490
backend/open_webui/routers/apps.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
524
backend/open_webui/utils/apps.py
Normal file
524
backend/open_webui/utils/apps.py
Normal 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
|
||||
|
|
@ -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
290
src/lib/apis/apps/index.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
117
src/lib/components/admin/Settings/Apps.svelte
Normal file
117
src/lib/components/admin/Settings/Apps.svelte
Normal 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>
|
||||
460
src/lib/components/admin/Settings/Apps/AppModal.svelte
Normal file
460
src/lib/components/admin/Settings/Apps/AppModal.svelte
Normal 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>
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
105
src/lib/components/channel/AppResponse.svelte
Normal file
105
src/lib/components/channel/AppResponse.svelte
Normal 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>
|
||||
82
src/lib/components/channel/Apps.svelte
Normal file
82
src/lib/components/channel/Apps.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -632,6 +632,7 @@
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
const unsubscribe = channelRequestQueues.subscribe(() => {
|
||||
files = [...files];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue