Merge upstream dev into perf/access-check-plumbing

Resolves the conflict with the unregistered base model fix by passing both user_role and user_group_ids to has_base_model_access, keeping the admin-only fallback for unregistered base models while still sharing the single group membership fetch across the whole chain.
This commit is contained in:
Classic298 2026-07-24 09:27:21 +02:00
commit ee145a651f
180 changed files with 5422 additions and 1375 deletions

43
.github/workflows/issue-label.yaml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Issue Labeler
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
label-bug-reports:
runs-on: ubuntu-latest
steps:
- name: Add "bug" label to unlabeled bug reports
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
// Web-form submissions already carry the label from the issue template
if (issue.labels.some((label) => label.name === 'bug')) {
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
const bugLikeTitle = /^\s*\[?(bug|issue|fix)\]?\s*[:/\-]/i.test(title);
// API/CLI-created issues that reproduce the bug report form structure.
// Only headings distinctive to the bug form (both are required fields there) —
// generic headings like "Expected Behavior" also appear in freeform feature requests.
const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body);
if (bugLikeTitle || bugFormBody) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['bug']
});
}

View file

@ -2143,6 +2143,10 @@ CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THR
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None
CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min(
50, max(10, int(os.getenv('CONTEXT_COMPACTION_RETENTION_PERCENTAGE', '40')))
)
CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '')
TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '')
@ -2567,6 +2571,24 @@ if _oauth_authorize_params:
log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
def oauth_client_kwargs(scope: str, **kwargs):
client_kwargs = {
'scope': scope,
**kwargs,
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
}
if OAUTH_CODE_CHALLENGE_METHOD == 'S256':
client_kwargs['code_challenge_method'] = 'S256'
elif OAUTH_CODE_CHALLENGE_METHOD:
raise Exception(
'Code challenge methods other than "%s" not supported. Given: "%s"'
% ('S256', OAUTH_CODE_CHALLENGE_METHOD)
)
return client_kwargs
def load_oauth_providers():
OAUTH_PROVIDERS.clear()
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
@ -2577,10 +2599,7 @@ def load_oauth_providers():
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': GOOGLE_OAUTH_SCOPE,
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
},
client_kwargs=oauth_client_kwargs(GOOGLE_OAUTH_SCOPE),
redirect_uri=GOOGLE_REDIRECT_URI,
**({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}),
)
@ -2598,10 +2617,7 @@ def load_oauth_providers():
client_id=MICROSOFT_CLIENT_ID,
client_secret=MICROSOFT_CLIENT_SECRET,
server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL}/{MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID}',
client_kwargs={
'scope': MICROSOFT_OAUTH_SCOPE,
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
},
client_kwargs=oauth_client_kwargs(MICROSOFT_OAUTH_SCOPE),
redirect_uri=MICROSOFT_REDIRECT_URI,
)
return client
@ -2622,10 +2638,7 @@ def load_oauth_providers():
authorize_url='https://github.com/login/oauth/authorize',
api_base_url='https://api.github.com',
userinfo_endpoint='https://api.github.com/user',
client_kwargs={
'scope': GITHUB_CLIENT_SCOPE,
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
},
client_kwargs=oauth_client_kwargs(GITHUB_CLIENT_SCOPE),
redirect_uri=GITHUB_CLIENT_REDIRECT_URI,
)
return client
@ -2638,30 +2651,19 @@ def load_oauth_providers():
if OAUTH_CLIENT_ID and (OAUTH_CLIENT_SECRET or OAUTH_CODE_CHALLENGE_METHOD) and OPENID_PROVIDER_URL:
def oidc_oauth_register(oauth: OAuth):
client_kwargs = {
'scope': OAUTH_SCOPES,
**(
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
else {}
),
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
}
if OAUTH_CODE_CHALLENGE_METHOD and OAUTH_CODE_CHALLENGE_METHOD == 'S256':
client_kwargs['code_challenge_method'] = 'S256'
elif OAUTH_CODE_CHALLENGE_METHOD:
raise Exception(
'Code challenge methods other than "%s" not supported. Given: "%s"'
% ('S256', OAUTH_CODE_CHALLENGE_METHOD)
)
client = oauth.register(
name='oidc',
client_id=OAUTH_CLIENT_ID,
client_secret=OAUTH_CLIENT_SECRET,
server_metadata_url=OPENID_PROVIDER_URL,
client_kwargs=client_kwargs,
client_kwargs=oauth_client_kwargs(
OAUTH_SCOPES,
**(
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
else {}
),
),
redirect_uri=OPENID_REDIRECT_URI,
)
return client
@ -3056,6 +3058,7 @@ DEFAULT_CONFIG = {
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE,
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,

View file

@ -594,6 +594,15 @@ try:
except (ValueError, TypeError):
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
AIOHTTP_FILE_STREAM_CHUNK_SIZE = os.getenv('AIOHTTP_FILE_STREAM_CHUNK_SIZE', str(1024 * 1024))
try:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = int(AIOHTTP_FILE_STREAM_CHUNK_SIZE)
except Exception:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
if AIOHTTP_FILE_STREAM_CHUNK_SIZE <= 0:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
# SSL verification for tool server connections specifically.
# Accepts "True", "False", or a path to a CA bundle file.
@ -1093,15 +1102,19 @@ except ValueError:
MAX_BODY_LOG_SIZE = 2048
# Comma separated list for urls to exclude from audit
AUDIT_EXCLUDED_PATHS = os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [
path
for path in (
path.strip().lstrip('/') for path in os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
)
if path
]
# Comma separated list of urls to include in audit (whitelist mode)
# When set, only these paths are audited and AUDIT_EXCLUDED_PATHS is ignored
AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')
AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS]
AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path]
AUDIT_INCLUDED_PATHS = [
path for path in (path.strip().lstrip('/') for path in os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')) if path
]
# When enabled, GET requests are also audited (disabled by default to avoid log noise)
ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true'

View file

@ -370,6 +370,8 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
# Generous default — async coroutines + no session sharing = high connection demand.
# No pool_pre_ping: a local SQLite file cannot drop connections, and the
# ping costs a worker-thread hop plus a SELECT 1 on every checkout.
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
@ -377,7 +379,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
pool_size=_sqlite_pool_size,
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
)
@event.listens_for(async_engine.sync_engine, 'connect')

View file

@ -220,6 +220,9 @@ from open_webui.utils.chat import (
from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler,
)
from open_webui.utils.chat_variables import (
normalize_chat_variables,
)
from open_webui.utils.embeddings import generate_embeddings
from open_webui.utils.logger import start_logger
from open_webui.utils.middleware import (
@ -717,6 +720,25 @@ app.state.speech_speaker_embeddings_dataset = None
app.state.MODELS = MODELS
# Add the middleware to the app
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
except ValueError as e:
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
audit_level = AuditLevel.NONE
# Added before CompressMiddleware so audit sits inside compression and
# captures response bodies before they are compressed (last added runs
# outermost).
if audit_level != AuditLevel.NONE:
app.add_middleware(
AuditLoggingMiddleware,
audit_level=audit_level,
excluded_paths=AUDIT_EXCLUDED_PATHS,
included_paths=AUDIT_INCLUDED_PATHS,
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
max_body_size=MAX_BODY_LOG_SIZE,
)
if ENABLE_COMPRESSION_MIDDLEWARE:
app.add_middleware(CompressMiddleware)
@ -795,21 +817,6 @@ if ENABLE_SCIM:
app.include_router(scim.router, prefix='/api/v1/scim/v2', tags=['scim'])
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
except ValueError as e:
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
audit_level = AuditLevel.NONE
if audit_level != AuditLevel.NONE:
app.add_middleware(
AuditLoggingMiddleware,
audit_level=audit_level,
excluded_paths=AUDIT_EXCLUDED_PATHS,
included_paths=AUDIT_INCLUDED_PATHS,
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
max_body_size=MAX_BODY_LOG_SIZE,
)
##################################
#
# Chat Endpoints
@ -822,12 +829,18 @@ if audit_level != AuditLevel.NONE:
async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)):
all_models = await get_all_models(request, refresh=refresh, user=user)
models = []
for model in all_models:
# Filter out filter pipelines
if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter':
continue
# Filter out filter pipelines
models = [model for model in all_models if not ('pipeline' in model and model['pipeline'].get('type', None) == 'filter')]
# Chat requests resolve models by ID from request.app.state.MODELS, where
# duplicate IDs collapse to the last model. Return the same effective list.
models = list({model['id']: model for model in models}.values())
# Access-filter first so the per-model payload work below only runs for
# models the caller can actually see.
models = await get_filtered_models(models, user)
for model in models:
# Remove profile image URL to reduce payload size
if model.get('info', {}).get('meta', {}).get('profile_image_url'):
model['info']['meta'].pop('profile_image_url', None)
@ -841,13 +854,6 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
except Exception as e:
log.debug(f'Error processing model tags: {e}')
model['tags'] = []
pass
models.append(model)
# Chat requests resolve models by ID from request.app.state.MODELS, where
# duplicate IDs collapse to the last model. Return the same effective list.
models = list({model['id']: model for model in models}.values())
model_order_list = await Config.get('ui.model_order_list')
if model_order_list:
@ -860,11 +866,10 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
)
)
models = await get_filtered_models(models, user)
log.debug(
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
)
if log.isEnabledFor(logging.DEBUG):
log.debug(
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
)
return {'data': models}
@ -1133,6 +1138,13 @@ async def chat_completion(
message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}]
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
chat_id = form_data.get('chat_id') or ''
chat_variables = form_data.pop('chat_variables', None)
if chat_variables is None:
existing_chat = await Chats.get_chat_by_id(chat_id) if chat_id else None
chat_variables = existing_chat.variables if existing_chat else {}
chat_variables = normalize_chat_variables(chat_variables)
# Drop tool_servers if caller lacks features.direct_tool_servers —
# mirrors the storage-side strip in user/settings/update.
@ -1164,6 +1176,7 @@ async def chat_completion(
'files': form_data.get('files', None),
'features': form_data.get('features', {}),
'variables': form_data.get('variables', {}),
'chat_variables': chat_variables,
'model': model,
'direct': model_item.get('direct', False),
'params': {
@ -1278,6 +1291,7 @@ async def chat_completion(
'tags': [],
'timestamp': int(time.time() * 1000),
},
variables=chat_variables,
folder_id=metadata.get('folder_id'),
),
)
@ -1383,6 +1397,8 @@ async def chat_completion(
updated['models'] = selected_chat_models
await Chats.update_chat_by_id(chat_id, updated)
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
# Save user message to DB
if user_message and user_message.get('id'):
await Chats.upsert_message_to_chat_by_id_and_message_id(

View file

@ -0,0 +1,36 @@
"""Add memory (id, user_id) covering index
Revision ID: 55f1302ac17c
Revises: b0018471bbbe
Create Date: 2026-07-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '55f1302ac17c'
down_revision: Union[str, None] = 'b0018471bbbe'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_id_user_id' not in indexes:
op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id'])
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_id_user_id' in indexes:
op.drop_index('ix_memory_id_user_id', table_name='memory')

View file

@ -0,0 +1,31 @@
"""add user variables
Revision ID: b0018471bbbe
Revises: c49178636c78
Create Date: 2026-07-24 01:21:46.457057
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b0018471bbbe'
down_revision: Union[str, None] = 'c49178636c78'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('user')]
if 'variables' not in columns:
op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'variables')

View file

@ -0,0 +1,31 @@
"""add chat variables
Revision ID: c49178636c78
Revises: 9a1b2c3d4e5f
Create Date: 2026-07-23 23:33:45.497453
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c49178636c78'
down_revision: Union[str, None] = '9a1b2c3d4e5f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'variables' not in columns:
op.add_column('chat', sa.Column('variables', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('chat', 'variables')

View file

@ -316,7 +316,6 @@ class AccessGrantsTable:
)
db.add(grant)
await db.commit()
await db.refresh(grant)
return AccessGrantModel.model_validate(grant)
async def revoke_access(

View file

@ -133,9 +133,8 @@ class AuthsTable:
oauth=oauth,
db=session,
)
# persist both records and reload generated defaults
# persist both records
await session.commit()
await session.refresh(credential)
return created_user if credential and created_user else None
async def authenticate_user(

View file

@ -139,7 +139,6 @@ class AutomationTable:
)
db.add(row)
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int:
@ -224,7 +223,6 @@ class AutomationTable:
row.next_run_at = next_run_at
row.updated_at = int(time.time_ns())
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def toggle(
@ -241,7 +239,6 @@ class AutomationTable:
row.next_run_at = next_run_at if row.is_active else None
row.updated_at = int(time.time_ns())
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool:
@ -324,7 +321,6 @@ class AutomationRunTable:
)
db.add(row)
await db.commit()
await db.refresh(row)
return AutomationRunModel.model_validate(row)
async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]:

View file

@ -500,9 +500,12 @@ class CalendarEventTable:
# Filter to requested calendars only
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
# Also get event IDs where user is an attendee
# Also get event IDs where the user is an attendee, excluding invites they declined
attendee_event_ids_result = await db.execute(
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
select(CalendarEventAttendee.event_id).filter(
CalendarEventAttendee.user_id == user_id,
CalendarEventAttendee.status != 'declined',
)
)
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
@ -764,22 +767,34 @@ class CalendarEventAttendeeTable:
async def set_attendees(
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
"""Replace all attendees for an event.
"""Replace all attendees for an event ({user_id, meta?} per dict).
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
RSVP status is the attendee's alone to set (via update_rsvp): an existing
attendee keeps their status, a newly added one starts 'pending'. A
caller-supplied status is ignored so an organiser cannot set it for others.
"""
async with get_async_db_context(db) as db:
existing_status = {
row.user_id: row.status
for row in (
await db.execute(
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)
)
).scalars()
}
# Remove existing
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
now = int(time.time_ns())
models = []
for att in attendees:
user_id = att['user_id']
row = CalendarEventAttendee(
id=str(uuid4()),
event_id=event_id,
user_id=att['user_id'],
status=att.get('status', 'pending'),
user_id=user_id,
status=existing_status.get(user_id, 'pending'),
meta=att.get('meta'),
created_at=now,
updated_at=now,

View file

@ -869,7 +869,6 @@ class ChannelTable:
result = ChannelFile(**channel_file.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return ChannelFileModel.model_validate(result)
else:

View file

@ -259,7 +259,6 @@ class ChatMessageTable:
existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
existing.updated_at = now
await db.commit()
await db.refresh(existing)
return ChatMessageModel.model_validate(existing)
else:
# Insert new
@ -288,7 +287,6 @@ class ChatMessageTable:
)
db.add(message)
await db.commit()
await db.refresh(message)
return ChatMessageModel.model_validate(message)
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:

View file

@ -13,8 +13,8 @@ from open_webui.models.automations import AutomationRun
from open_webui.models.chat_messages import ChatMessage, ChatMessages
from open_webui.models.folders import Folders
from open_webui.models.tags import Tag, TagModel, Tags
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
from pydantic import BaseModel, ConfigDict
from open_webui.utils.misc import get_output_text, sanitize_data_for_db, sanitize_text_for_db
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import (
JSON,
BigInteger,
@ -58,6 +58,7 @@ class Chat(Base): # database table mapping for chat entity
pinned = Column(Boolean, default=False, nullable=True)
meta = Column(JSON, server_default='{}')
variables = Column(JSON, nullable=True)
folder_id = Column(Text, nullable=True)
tasks = Column(JSON, nullable=True)
@ -95,6 +96,7 @@ class ChatModel(BaseModel):
pinned: bool | None = False
meta: dict = {}
variables: dict = {}
folder_id: str | None = None
tasks: list | None = None
@ -103,6 +105,11 @@ class ChatModel(BaseModel):
last_read_at: int | None = None
@field_validator('variables', mode='before')
@classmethod
def normalize_variables(cls, value):
return value if isinstance(value, dict) else {}
class ChatFile(Base):
__tablename__ = 'chat_file'
@ -141,6 +148,7 @@ class ChatFileModel(BaseModel):
class ChatForm(BaseModel):
chat: dict
variables: dict | None = None
folder_id: str | None = None
@ -176,6 +184,7 @@ class ChatResponse(BaseModel):
archived: bool
pinned: bool | None = False
meta: dict = {}
variables: dict = {}
folder_id: str | None = None
tasks: list | None = None
@ -183,6 +192,11 @@ class ChatResponse(BaseModel):
current_message_id: str | None = None
context_usage: dict | None = None
@field_validator('variables', mode='before')
@classmethod
def normalize_variables(cls, value):
return value if isinstance(value, dict) else {}
class ChatTitleIdResponse(BaseModel):
id: str
@ -394,6 +408,7 @@ class ChatTable:
'chat': self._clean_null_bytes(form_data.chat),
'folder_id': form_data.folder_id,
'meta': internal_meta or {},
'variables': form_data.variables or {},
'current_message_id': self.get_current_message_id(form_data.chat),
'created_at': int(time.time()),
'updated_at': int(time.time()),
@ -404,7 +419,6 @@ class ChatTable:
chat_item = Chat(**chat.model_dump())
session.add(chat_item)
await session.commit()
await session.refresh(chat_item)
# Dual-write initial messages to chat_message table
try:
@ -482,6 +496,7 @@ class ChatTable:
'title': self._clean_null_bytes(form_data.chat['title'] if 'title' in form_data.chat else 'New Chat'),
'chat': self._clean_null_bytes(form_data.chat),
'meta': form_data.meta,
'variables': form_data.variables or {},
'pinned': form_data.pinned,
'folder_id': form_data.folder_id,
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
@ -575,6 +590,29 @@ class ChatTable:
except Exception:
return
async def update_chat_variables_by_id(
self,
id: str,
variables: dict | None,
db: AsyncSession | None = None,
*,
touch: bool = True,
) -> ChatModel | None:
try:
async with get_async_db_context(db) as session:
chat_item = await session.get(Chat, id)
if chat_item is None:
return None
chat_item.variables = variables if isinstance(variables, dict) else {}
if touch:
chat_item.updated_at = int(time.time())
await session.commit()
return ChatModel.model_validate(chat_item)
except Exception:
return None
async def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
@ -597,7 +635,6 @@ class ChatTable:
chat_item.title = clean_title
chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title}
await session.commit()
await session.refresh(chat_item)
return ChatModel.model_validate(chat_item)
except Exception:
return None
@ -615,7 +652,6 @@ class ChatTable:
# Single meta update
chat.meta = {**chat.meta, 'tags': new_tag_ids}
await session.commit()
await session.refresh(chat)
# Batch-create any missing tag rows
await Tags.ensure_tags_exist(new_tags, user.id, db=session)
@ -804,6 +840,11 @@ class ChatTable:
if chat is None:
return None
if not message.get('content'):
output_text = get_output_text(message.get('output'))
if output_text:
message['content'] = output_text
# Sanitize message content for null characters before upserting
if isinstance(message.get('content'), str):
message['content'] = sanitize_text_for_db(message['content'])
@ -947,7 +988,6 @@ class ChatTable:
# Set share_id on the original chat
chat.share_id = shared.id
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat) # return the updated original
# refresh helper
@ -994,7 +1034,6 @@ class ChatTable:
chat = await session.get(Chat, id)
chat.share_id = share_id
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
@ -1007,7 +1046,6 @@ class ChatTable:
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
@ -1021,7 +1059,6 @@ class ChatTable:
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
@ -1327,7 +1364,6 @@ class ChatTable:
flag_modified(chat_item, 'chat')
if self._sanitize_chat_row(chat_item) or repaired_history:
await session.commit()
await session.refresh(chat_item)
return ChatModel.model_validate(chat_item)
except Exception:
@ -1369,7 +1405,6 @@ class ChatTable:
flag_modified(chat, 'chat')
if self._sanitize_chat_row(chat) or repaired_history:
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
@ -1851,7 +1886,6 @@ class ChatTable:
chat.last_read_at = int(time.time())
chat.pinned = False
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
@ -1931,7 +1965,6 @@ class ChatTable:
'tags': list(set(chat.meta.get('tags', []) + [tag_id])),
}
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
@ -2231,7 +2264,6 @@ class ChatTable:
return None
chat.tasks = tasks
await session.commit()
await session.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None

View file

@ -248,6 +248,10 @@ class Config(Base):
now = int(time.time())
new_count = 0
for key, value in defaults.items():
# Skip keys the DB is not authoritative for (e.g. oauth.* while
# ENABLE_OAUTH_PERSISTENT_CONFIG is off), matching the read paths.
if not Config.persistent_enabled_for(key):
continue
if key not in existing_keys:
value = _json_value(value)
db.add(Config(key=key, value=value, updated_at=now))

View file

@ -165,7 +165,6 @@ class FeedbackTable:
result = Feedback(**feedback.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FeedbackModel.model_validate(result)
else:

View file

@ -142,7 +142,6 @@ class FilesTable:
result = File(**file.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FileModel.model_validate(result)
else:

View file

@ -42,7 +42,7 @@ class FunctionMeta(BaseModel):
class FunctionModel(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
name: str
type: str
content: str
@ -58,7 +58,7 @@ class FunctionModel(BaseModel):
# --- form / schema definitions ---
class FunctionWithValvesModel(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
name: str
type: str
content: str
@ -79,7 +79,7 @@ class FunctionWithValvesModel(BaseModel):
class FunctionResponse(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
type: str
name: str
meta: FunctionMeta
@ -129,7 +129,6 @@ class FunctionsTable:
result = Function(**function.model_dump())
db.add(result)
await db.commit()
await db.refresh(result)
if result:
return FunctionModel.model_validate(result)
else:
@ -275,11 +274,17 @@ class FunctionsTable:
result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True))
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
async def get_active_function_ids_by_type(
self, type: str, db: AsyncSession | None = None
) -> list[tuple[str, bool]]:
"""Return (id, is_global) for active functions without fetching plugin source."""
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.is_global).filter_by(type=type, is_active=True))
return [(id, bool(is_global)) for id, is_global in result.all()]
async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]:
"""Return (id, is_global) for active filters without fetching plugin source."""
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.is_global).filter_by(type='filter', is_active=True))
return [(id, bool(is_global)) for id, is_global in result.all()]
return await self.get_active_function_ids_by_type('filter', db=db)
async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]:
async with get_async_db_context(db) as db:
@ -320,7 +325,6 @@ class FunctionsTable:
function.valves = encrypt_valves(valves)
function.updated_at = int(time.time())
await db.commit()
await db.refresh(function)
return FunctionModel.model_validate(function)
except Exception:
return None
@ -340,7 +344,6 @@ class FunctionsTable:
function.updated_at = int(time.time())
await db.commit()
await db.refresh(function)
return FunctionModel.model_validate(function)
else:
return None

View file

@ -479,20 +479,16 @@ class KnowledgeTable:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
result = []
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user_id:
result.append(knowledge_base)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(knowledge_base)
return result
# One grants query for all non-owned knowledge bases instead of one each
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='knowledge',
resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id],
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids]
async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
try:
@ -675,9 +671,25 @@ class KnowledgeTable:
async def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[AsyncSession] = None
) -> list[FileMetadataResponse]:
"""Column-only listing: File.data holds each file's full extracted
text, which metadata views must never load."""
try:
files = await self.get_files_by_id(knowledge_id, db=db)
return [FileMetadataResponse(**file.model_dump()) for file in files]
async with get_async_db_context(db) as db:
result = await db.execute(
select(File.id, File.hash, File.meta, File.created_at, File.updated_at)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
return [
FileMetadataResponse(
id=row.id,
hash=row.hash,
meta=row.meta,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in result.all()
]
except Exception:
return []

View file

@ -8,7 +8,7 @@ from typing import Literal
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, select
from sqlalchemy import JSON, BigInteger, Column, Index, String, Text, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -16,6 +16,7 @@ class Memory(Base): # user memory store
"""Stores user-created memory entries linked to a vector collection."""
__tablename__ = 'memory'
__table_args__ = (Index('ix_memory_id_user_id', 'id', 'user_id'),)
id = Column(String, primary_key=True, unique=True)
user_id = Column(String, index=True)
@ -70,7 +71,6 @@ class MemoriesTable:
)
db.add(record)
await db.commit()
await db.refresh(record)
return MemoryModel.model_validate(record) if record else None
async def update_memory_by_id_and_user_id(
@ -101,7 +101,6 @@ class MemoriesTable:
memory.updated_at = int(time.time())
await db.commit()
await db.refresh(memory)
return MemoryModel.model_validate(memory)
except Exception:
return None

View file

@ -220,7 +220,6 @@ class ModelsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db)
if result:
@ -303,26 +302,26 @@ class ModelsTable:
]
async def get_models_by_user_id(
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
self,
user_id: str,
permission: str = 'write',
db: AsyncSession | None = None,
user_group_ids: set[str] | None = None,
) -> list[ModelUserResponse]:
models = await self.get_models(db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
result = []
for model in models:
if model.user_id == user_id:
result.append(model)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='model',
resource_id=model.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(model)
return result
# One grants query for all non-owned models instead of one per model
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='model',
resource_ids=[model.id for model in models if model.user_id != user_id],
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
return [model for model in models if model.user_id == user_id or model.id in accessible_ids]
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
@ -530,7 +529,6 @@ class ModelsTable:
model.is_active = not model.is_active
model.updated_at = int(time.time())
await db.commit()
await db.refresh(model)
return await self._to_model_model(model, db=db)
except Exception:
@ -562,7 +560,6 @@ class ModelsTable:
return None
model_obj.updated_at = int(time.time())
await db.commit()
await db.refresh(model_obj)
return await self._to_model_model(model_obj, db=db)
except Exception as e:
log.exception(f'Failed to update the model updated_at by id {id}: {e}')

View file

@ -128,7 +128,6 @@ class OAuthSessionTable:
db.add(result)
await db.commit()
await db.refresh(result)
if result:
# Make a copy of the model data before closing session

View file

@ -70,7 +70,6 @@ class PromptHistoryTable:
)
db.add(history)
await db.commit()
await db.refresh(history)
return PromptHistoryModel.model_validate(history)
async def get_history_by_prompt_id(

View file

@ -132,7 +132,6 @@ class PromptsTable:
)
session.add(record)
await session.commit()
await session.refresh(record) # populate generated defaults
await AccessGrants.set_access_grants(
'prompt',
@ -169,7 +168,6 @@ class PromptsTable:
if history_entry:
record.version_id = history_entry.id
await session.commit()
await session.refresh(record) # re-read version_id
return await self._to_prompt_model(record, db=session)
except Exception as e:
@ -637,7 +635,6 @@ class PromptsTable:
prompt.is_active = not prompt.is_active
prompt.updated_at = int(time.time())
await session.commit()
await session.refresh(prompt)
return await self._to_prompt_model(prompt, db=session)
return None
except Exception:

View file

@ -137,7 +137,6 @@ class SkillsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db)
if result:
return await self._to_skill_model(result, db=db)
@ -326,8 +325,8 @@ class SkillsTable:
if access_grants is not None:
await AccessGrants.set_access_grants('skill', id, access_grants, db=db)
skill = await db.get(Skill, id)
await db.refresh(skill)
# populate_existing: the Core update above bypasses any identity-map copy
skill = await db.get(Skill, id, populate_existing=True)
return await self._to_skill_model(skill, db=db)
except Exception:
return None
@ -343,7 +342,6 @@ class SkillsTable:
skill.is_active = not skill.is_active
skill.updated_at = int(time.time())
await db.commit()
await db.refresh(skill)
return await self._to_skill_model(skill, db=db)
except Exception:

View file

@ -63,7 +63,6 @@ class TagTable:
record = Tag(id=tag_id, user_id=user_id, name=name)
db.add(record)
await db.commit()
await db.refresh(record)
return TagModel.model_validate(record) if record else None
except Exception as e:
log.exception('Error inserting tag %r: %s', name, e)

View file

@ -41,9 +41,10 @@ class ToolMeta(BaseModel):
class ToolModel(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
name: str
content: str
# None when listed with defer_content=True (source skipped for listings)
content: str | None = None
specs: list[dict]
meta: ToolMeta
access_grants: list[AccessGrantModel] = Field(default_factory=list)
@ -65,7 +66,7 @@ class ToolUserModel(ToolModel):
class ToolResponse(BaseModel):
id: str
user_id: str
user_id: str | None = None # may be null for legacy/malformed records
name: str
meta: ToolMeta
access_grants: list[AccessGrantModel] = Field(default_factory=list)
@ -131,7 +132,6 @@ class ToolsTable:
)
db.add(result)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db)
if result:
return await self._to_tool_model(result, db=db)
@ -171,11 +171,18 @@ class ToolsTable:
async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]:
async with get_async_db_context(db) as db:
stmt = select(Tool).order_by(Tool.updated_at.desc())
if defer_content:
stmt = stmt
result = await db.execute(stmt)
all_tools = result.scalars().all()
# Skip Tool.content (plugin source, potentially large) via a
# column select; Row attributes satisfy from_attributes.
result = await db.execute(
select(
Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at
).order_by(Tool.updated_at.desc())
)
all_tools = result.all()
else:
result = await db.execute(select(Tool).order_by(Tool.updated_at.desc()))
all_tools = result.scalars().all()
user_ids = list(set(tool.user_id for tool in all_tools))
tool_ids = [tool.id for tool in all_tools]
@ -214,20 +221,16 @@ class ToolsTable:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
result = []
for tool in tools:
if tool.user_id == user_id:
result.append(tool)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type='tool',
resource_id=tool.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
):
result.append(tool)
return result
# One grants query for all non-owned tools instead of one per tool
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user_id,
resource_type='tool',
resource_ids=[tool.id for tool in tools if tool.user_id != user_id],
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids]
async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
try:
@ -301,8 +304,8 @@ class ToolsTable:
if access_grants is not None:
await AccessGrants.set_access_grants('tool', id, access_grants, db=db)
tool = await db.get(Tool, id)
await db.refresh(tool)
# populate_existing: the Core update above bypasses any identity-map copy
tool = await db.get(Tool, id, populate_existing=True)
return await self._to_tool_model(tool, db=db)
except Exception:
return None

View file

@ -9,7 +9,7 @@ from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.utils.misc import throttle
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sqlalchemy import (
JSON,
BigInteger,
@ -69,6 +69,7 @@ class User(Base): # identity & profile
# Metadata
info = Column(JSON, nullable=True)
variables = Column(JSON, nullable=True)
settings = Column(JSON, nullable=True)
oauth = Column(JSON, nullable=True)
scim = Column(JSON, nullable=True)
@ -105,6 +106,7 @@ class UserModel(BaseModel):
status_expires_at: int | None = None
info: dict | None = None
variables: dict = Field(default_factory=dict, exclude=True)
settings: UserSettings | None = None
oauth: dict | None = None
@ -126,6 +128,11 @@ class UserModel(BaseModel):
self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id)
return self
@field_validator('variables', mode='before')
@classmethod
def normalize_variables(cls, value):
return value if isinstance(value, dict) else {}
class UserStatusModel(UserModel):
is_active: bool = False
@ -302,7 +309,6 @@ class UsersTable:
result = User(**user.model_dump())
session.add(result)
await session.commit()
await session.refresh(result)
return user if result else None
# database read methods
@ -582,7 +588,6 @@ class UsersTable:
return None
user.role = role
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_status_by_id(
@ -595,7 +600,6 @@ class UsersTable:
for key, value in form_data.model_dump(exclude_none=True).items():
setattr(user, key, value)
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_profile_image_url_by_id(
@ -615,7 +619,6 @@ class UsersTable:
return None
user.profile_image_url = profile_image_url
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
@ -636,7 +639,6 @@ class UsersTable:
oauth[provider] = {'sub': sub}
user.oauth = oauth
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_scim_by_id(
@ -655,7 +657,6 @@ class UsersTable:
scim[provider] = {'external_id': external_id}
user.scim = scim
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def update_user_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> UserModel | None:
@ -666,7 +667,6 @@ class UsersTable:
for key, value in updated.items():
setattr(user, key, value)
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
# settings update helper
@ -681,7 +681,6 @@ class UsersTable:
user_settings.update(updated)
user.settings = user_settings
await session.commit()
await session.refresh(user)
return UserModel.model_validate(user)
async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
@ -728,8 +727,8 @@ class UsersTable:
async def get_valid_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[str]:
async with get_async_db_context(db) as session:
result = await session.execute(select(User).where(User.id.in_(user_ids)))
return [u.id for u in result.scalars().all()]
result = await session.execute(select(User.id).where(User.id.in_(user_ids)))
return list(result.scalars().all())
async def get_super_admin_user(self, db: AsyncSession | None = None) -> UserModel | None:
async with get_async_db_context(db) as session:

View file

@ -51,7 +51,7 @@ from open_webui.retrieval.web.utils import get_web_loader
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.misc import get_message_list
from open_webui.utils.misc import get_content_from_message, get_message_list
log = logging.getLogger(__name__)
@ -73,6 +73,20 @@ LOADER_CONFIG_KEYS = {
'web_loader_ssl_verification': 'web.loader.ssl_verification',
'web_loader_concurrent_requests': 'web.loader.concurrent_requests',
'web_search_trust_env': 'web.search.trust_env',
'web_loader_engine': 'web.loader.engine',
'web_loader_timeout': 'web.loader.timeout',
'playwright_ws_url': 'web.loader.playwright_ws_url',
'playwright_timeout': 'web.loader.playwright_timeout',
'firecrawl_api_key': 'web.loader.firecrawl_api_key',
'firecrawl_api_url': 'web.loader.firecrawl_api_url',
'firecrawl_timeout': 'web.loader.firecrawl_timeout',
'tavily_api_key': 'web.search.tavily_api_key',
'tavily_extract_depth': 'web.search.tavily_extract_depth',
'microsoft_web_iq_api_base_url': 'web.search.microsoft_web_iq_api_base_url',
'microsoft_web_iq_api_key': 'web.search.microsoft_web_iq_api_key',
'microsoft_web_iq_language': 'web.search.microsoft_web_iq_language',
'external_web_loader_url': 'web.loader.external_web_loader_url',
'external_web_loader_api_key': 'web.loader.external_web_loader_api_key',
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key',
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
@ -128,6 +142,7 @@ def get_loader(request, url: str, config: dict):
verify_ssl=config.get('web_loader_ssl_verification'),
requests_per_second=config.get('web_loader_concurrent_requests'),
trust_env=config.get('web_search_trust_env'),
loader_config=config,
)
@ -1409,7 +1424,10 @@ async def get_sources_from_items(
# Reconstruct the message list in order
message_list = get_message_list(messages_map, message_id)
message_history = '\n'.join(
[f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list]
[
f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n'
for m in message_list
]
)
# User has access to the chat

View file

@ -158,6 +158,11 @@ def _ssrf_safe_new_conn(self):
if getattr(self, 'source_address', None):
sock.bind(self.source_address)
for opt in getattr(self, 'socket_options', None) or ():
if len(opt) == 4 and isinstance(opt[3], str):
# urllib3-future per-protocol form: (level, optname, value, "tcp"/"udp")
if opt[3].lower() == 'tcp':
sock.setsockopt(*opt[:3])
continue
sock.setsockopt(*opt)
sock.connect(sa)
return sock
@ -785,13 +790,11 @@ class SafeWebBaseLoader(WebBaseLoader):
final_results = []
for i, result in enumerate(results):
url = urls[i]
if parser is None:
if url.endswith('.xml'):
parser = 'xml'
else:
parser = self.default_parser
self._check_parser(parser)
final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs))
url_parser = parser
if url_parser is None:
url_parser = 'xml' if url.endswith('.xml') else self.default_parser
self._check_parser(url_parser)
final_results.append(BeautifulSoup(result, url_parser, **self.bs_kwargs))
return final_results
async def ascrape_all(self, urls: List[str], parser: Union[str, None] = None) -> List[Any]:
@ -838,6 +841,7 @@ def get_web_loader(
verify_ssl: bool = True,
requests_per_second: int = 2,
trust_env: bool = False,
loader_config: Optional[dict] = None,
):
# Check if the URLs are valid
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
@ -846,6 +850,16 @@ def get_web_loader(
log.warning(f'All provided URLs were blocked or invalid: {urls}')
raise ValueError(ERROR_MESSAGES.INVALID_URL)
loader_config = loader_config or {}
def cfg(key, env_value):
# Admin-saved DB value wins; env constant covers keys never saved.
value = loader_config.get(key)
return env_value if value is None else value
engine = cfg('web_loader_engine', WEB_LOADER_ENGINE)
web_loader_timeout = cfg('web_loader_timeout', WEB_LOADER_TIMEOUT)
web_loader_args = {
'web_paths': safe_urls,
'verify_ssl': verify_ssl,
@ -854,13 +868,15 @@ def get_web_loader(
'trust_env': trust_env,
}
if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web':
WebLoaderClass = None
if engine == '' or engine == 'safe_web':
WebLoaderClass = SafeWebBaseLoader
request_kwargs = {}
if WEB_LOADER_TIMEOUT:
if web_loader_timeout:
try:
timeout_value = float(WEB_LOADER_TIMEOUT)
timeout_value = float(web_loader_timeout)
except ValueError:
timeout_value = None
@ -870,42 +886,44 @@ def get_web_loader(
if request_kwargs:
web_loader_args['requests_kwargs'] = request_kwargs
if WEB_LOADER_ENGINE == 'playwright':
if engine == 'playwright':
WebLoaderClass = SafePlaywrightURLLoader
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT
if PLAYWRIGHT_WS_URL:
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL
web_loader_args['playwright_timeout'] = cfg('playwright_timeout', PLAYWRIGHT_TIMEOUT)
playwright_ws_url = cfg('playwright_ws_url', PLAYWRIGHT_WS_URL)
if playwright_ws_url:
web_loader_args['playwright_ws_url'] = playwright_ws_url
if WEB_LOADER_ENGINE == 'firecrawl':
if engine == 'firecrawl':
WebLoaderClass = SafeFireCrawlLoader
web_loader_args['api_key'] = FIRECRAWL_API_KEY
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL
if FIRECRAWL_TIMEOUT:
web_loader_args['api_key'] = cfg('firecrawl_api_key', FIRECRAWL_API_KEY)
web_loader_args['api_url'] = cfg('firecrawl_api_url', FIRECRAWL_API_BASE_URL)
firecrawl_timeout = cfg('firecrawl_timeout', FIRECRAWL_TIMEOUT)
if firecrawl_timeout:
try:
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT)
web_loader_args['timeout'] = int(firecrawl_timeout)
except ValueError:
pass
if WEB_LOADER_ENGINE == 'tavily':
if engine == 'tavily':
WebLoaderClass = SafeTavilyLoader
web_loader_args['api_key'] = TAVILY_API_KEY
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH
web_loader_args['api_key'] = cfg('tavily_api_key', TAVILY_API_KEY)
web_loader_args['extract_depth'] = cfg('tavily_extract_depth', TAVILY_EXTRACT_DEPTH)
if WEB_LOADER_ENGINE == 'microsoft_web_iq':
if engine == 'microsoft_web_iq':
WebLoaderClass = SafeMicrosoftWebIQLoader
web_loader_args['api_base_url'] = MICROSOFT_WEB_IQ_API_BASE_URL
web_loader_args['api_key'] = MICROSOFT_WEB_IQ_API_KEY
web_loader_args['language'] = MICROSOFT_WEB_IQ_LANGUAGE
if WEB_LOADER_TIMEOUT:
web_loader_args['api_base_url'] = cfg('microsoft_web_iq_api_base_url', MICROSOFT_WEB_IQ_API_BASE_URL)
web_loader_args['api_key'] = cfg('microsoft_web_iq_api_key', MICROSOFT_WEB_IQ_API_KEY)
web_loader_args['language'] = cfg('microsoft_web_iq_language', MICROSOFT_WEB_IQ_LANGUAGE)
if web_loader_timeout:
try:
web_loader_args['timeout'] = int(WEB_LOADER_TIMEOUT)
web_loader_args['timeout'] = int(web_loader_timeout)
except ValueError:
pass
if WEB_LOADER_ENGINE == 'external':
if engine == 'external':
WebLoaderClass = ExternalWebLoader
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY
web_loader_args['external_url'] = cfg('external_web_loader_url', EXTERNAL_WEB_LOADER_URL)
web_loader_args['external_api_key'] = cfg('external_web_loader_api_key', EXTERNAL_WEB_LOADER_API_KEY)
if WebLoaderClass:
web_loader = WebLoaderClass(**web_loader_args)
@ -919,6 +937,6 @@ def get_web_loader(
return web_loader
else:
raise ValueError(
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. '
f'Invalid WEB_LOADER_ENGINE: {engine}. '
"Please set it to 'safe_web', 'playwright', 'firecrawl', 'tavily', 'external', or 'microsoft_web_iq'."
)

View file

@ -49,6 +49,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
BYPASS_PYDUB_PREPROCESSING,
DEVICE_TYPE,
ENABLE_FORWARD_USER_INFO_HEADERS,
@ -678,15 +679,19 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
for key, value in payload.items():
form_data.add_field(key, str(value))
with open(file_path, 'rb') as audio_file:
form_data.add_field('file', audio_file, filename=filename)
async def audio_chunks():
async with aiofiles.open(file_path, 'rb') as audio_file:
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
yield chunk
r = await session.post(
url=f'{api_base_url}/audio/transcriptions',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
form_data.add_field('file', audio_chunks(), filename=filename)
r = await session.post(
url=f'{api_base_url}/audio/transcriptions',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
if r.status == 200:
break
@ -823,13 +828,18 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
base_url or f'https://{region}.api.cognitive.microsoft.com'
) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15'
form_data = aiohttp.FormData()
form_data.add_field('definition', definition)
form_data.add_field('audio', open(file_path, 'rb'), filename=filename)
r = None
try:
session = await get_session()
form_data = aiohttp.FormData()
form_data.add_field('definition', definition)
async def audio_chunks():
async with aiofiles.open(file_path, 'rb') as audio_file:
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
yield chunk
form_data.add_field('audio', audio_chunks(), filename=filename)
r = await session.post(
url=endpoint,
data=form_data,
@ -1002,7 +1012,12 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
if language:
form_data.add_field('language', language)
form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type)
async def audio_chunks():
async with aiofiles.open(file_path, 'rb') as audio_file:
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
yield chunk
form_data.add_field('file', audio_chunks(), filename=filename, content_type=mime_type)
r = await session.post(
url=f'{api_base_url}/audio/transcriptions',
@ -1094,7 +1109,7 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict]
for chunk_path in chunk_paths:
if chunk_path != file_path and os.path.isfile(chunk_path):
try:
os.remove(chunk_path)
await asyncio.to_thread(os.remove, chunk_path)
except Exception:
pass
@ -1208,12 +1223,8 @@ async def transcription(
if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)):
raise ValueError('Invalid file path detected')
def _write_upload():
with open(file_path, 'wb') as f:
f.write(contents)
# Audio uploads can be large; write to disk off the event loop.
await asyncio.to_thread(_write_upload)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(contents)
try:
metadata = None

View file

@ -1500,12 +1500,13 @@ async def update_message_by_id(
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
else:
if (
user.role != 'admin'
and message.user_id != user.id
and not await channel_has_access(user.id, channel, permission='write', strict=False, db=db)
if user.role != 'admin' and not await channel_has_access(
user.id, channel, permission='write', strict=False, db=db
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
# Write access is not authorship — block cross-member edits.
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
await Messages.update_message_by_id(message_id, form_data, db=db)
@ -1725,18 +1726,17 @@ async def delete_message_by_id(
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
else:
if (
user.role != 'admin'
and message.user_id != user.id
and not await channel_has_access(
user.id,
channel,
permission='write',
strict=False,
db=db,
)
if user.role != 'admin' and not await channel_has_access(
user.id,
channel,
permission='write',
strict=False,
db=db,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
# Write access is not authorship — block cross-member deletes.
if user.role != 'admin' and message.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
await Messages.delete_message_by_id(message_id, db=db)

View file

@ -55,6 +55,7 @@ CHAT_CONFIG_KEYS = {
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage',
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
}
@ -76,6 +77,7 @@ class ChatConfigForm(BaseModel):
ENABLE_CONTEXT_COMPACTION: bool
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
@ -125,6 +127,8 @@ async def get_chat_config_values() -> dict:
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None:
config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000)
if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None:
config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40
return config
@ -738,12 +742,14 @@ async def get_chat_config(user=Depends(get_admin_user)):
async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold))
retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE)))
await Config.upsert(
chat_config_updates(
{
**form_data.model_dump(),
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage,
}
)
)
@ -1203,30 +1209,31 @@ async def compact_chat_by_id(
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
# Check if user has access via access grants (shared_chat grants)
if user.role == 'admin':
candidate = await Chats.get_chat_by_id(id, db=db)
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
chat = candidate
else:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=id,
permission='read',
db=db,
)
if has_grant:
chat = await Chats.get_chat_by_id(id, db=db)
if not chat and user.role == 'admin':
candidate = await Chats.get_chat_by_id(id, db=db)
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
chat = candidate
# Check folder-based access (shared folders)
if not chat:
candidate = await Chats.get_chat_by_id(id, db=db)
if candidate and candidate.folder_id:
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
if folder and await has_folder_access(user.id, folder, 'read', db):
chat = candidate
# Access explicitly granted to this user applies to admins too, so an admin
# does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off.
if not chat:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=id,
permission='read',
db=db,
)
if has_grant:
chat = await Chats.get_chat_by_id(id, db=db)
# Check folder-based access (shared folders)
if not chat:
candidate = await Chats.get_chat_by_id(id, db=db)
if candidate and candidate.folder_id:
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
if folder and await has_folder_access(user.id, folder, 'read', db):
chat = candidate
if chat:
data = ChatResponse(**chat.model_dump()).model_dump()
@ -1259,6 +1266,8 @@ async def update_chat_by_id(
)
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
if form_data.variables is not None:
chat = await Chats.update_chat_variables_by_id(id, form_data.variables, db=db) or chat
# Reconcile chat_message rows without inferring deletes from missing IDs.
# Message deletion has its own endpoint below.
@ -1642,6 +1651,9 @@ async def fork_chat_by_id(
internal_meta=meta,
)
if fork and chat.variables:
fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork
if not fork:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
@ -1684,6 +1696,7 @@ async def clone_chat_by_id(
**{
'chat': updated_chat,
'meta': chat.meta,
'variables': chat.variables or {},
'pinned': chat.pinned,
'folder_id': chat.folder_id,
}
@ -1767,6 +1780,7 @@ async def clone_shared_chat_by_id(
**{
'chat': updated_chat,
'meta': chat.meta,
'variables': chat.variables or {},
'pinned': chat.pinned,
'folder_id': chat.folder_id,
}

View file

@ -424,7 +424,7 @@ async def create_feedback(
EVENTS.FEEDBACK_CREATED,
actor=user,
subject_id=feedback.id,
data={'rating': getattr(feedback, 'rating', None)},
data={'rating': (feedback.data or {}).get('rating')},
)
return feedback
@ -463,7 +463,7 @@ async def update_feedback_by_id(
EVENTS.FEEDBACK_UPDATED,
actor=user,
subject_id=feedback.id,
data={'rating': getattr(feedback, 'rating', None)},
data={'rating': (feedback.data or {}).get('rating')},
)
return feedback

View file

@ -76,11 +76,12 @@ async def get_folders(
await check_folders_permission(request, user, db=db)
folders = await Folders.get_folders_by_user_id(user.id, db=db)
folder_ids = {folder.id for folder in folders}
# Verify folder data integrity
folder_list = []
for folder in folders:
if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db):
if folder.parent_id and folder.parent_id not in folder_ids:
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
if folder.data and 'files' in folder.data:

View file

@ -13,6 +13,7 @@ from types import SimpleNamespace
from typing import Optional
from urllib.parse import quote, urlparse
import aiofiles
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
@ -940,10 +941,10 @@ async def image_edits(
if isinstance(file_response, FileResponse):
file_path = file_response.path
with open(file_path, 'rb') as f:
file_bytes = f.read()
image_data = base64.b64encode(file_bytes).decode('utf-8')
mime_type, _ = mimetypes.guess_type(file_path)
async with aiofiles.open(file_path, 'rb') as f:
file_bytes = await f.read()
image_data = base64.b64encode(file_bytes).decode('utf-8')
mime_type, _ = mimetypes.guess_type(file_path)
return f'data:{mime_type};base64,{image_data}'
return data

View file

@ -40,6 +40,7 @@ from open_webui.models.models import (
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.files import has_access_to_file
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.chat_variables import get_chat_variables_schema
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -48,6 +49,14 @@ log = logging.getLogger(__name__)
router = APIRouter()
def add_chat_variables_schema(model_dict: dict) -> dict:
system = ((model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None)
schema = get_chat_variables_schema(system)
if schema:
model_dict.setdefault('meta', {})['chat_variables_schema'] = schema
return model_dict
def _safe_static_redirect_path(url: str) -> str | None:
"""
If url is a same-origin static asset path, return a normalized path safe for
@ -177,7 +186,7 @@ async def get_models(
# Strip profile_image_url from meta — images are served via /model/profile/image.
items = []
for model in result.items:
data = model.model_dump()
data = add_chat_variables_schema(model.model_dump())
if data.get('meta'):
data['meta'].pop('profile_image_url', None)
items.append(
@ -527,6 +536,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
db=db,
):
model_dict = model.model_dump()
model_dict = add_chat_variables_schema(model_dict)
# Strip params (system prompt and other admin-curated config)
# for read-only callers — matches the params strip already
# enforced on /api/models in utils/models.py. Owners, admins

View file

@ -11,6 +11,7 @@ from datetime import datetime
from typing import Optional, Union
from urllib.parse import urlparse
import aiofiles
import aiohttp
from aiocache import cached
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
@ -25,6 +26,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
@ -1529,7 +1531,7 @@ async def download_file_stream(
file_url: str,
file_path: str,
file_name: str,
chunk_size: int = 1024 * 1024,
chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE,
):
"""Stream a model file download from *file_url*, then push the blob to Ollama."""
current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
@ -1544,37 +1546,38 @@ async def download_file_stream(
) as response:
total_size = int(response.headers.get('content-length', 0)) + current_size
with open(file_path, 'ab+') as f:
async with aiofiles.open(file_path, 'ab') as f:
async for data in response.content.iter_chunked(chunk_size):
current_size += len(data)
f.write(data)
await f.write(data)
done = current_size == total_size
progress = round((current_size / total_size) * 100, 2)
progress_total = total_size or current_size
progress = round((current_size / progress_total) * 100, 2)
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
if done:
f.close()
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
done = True
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
def _read_blob():
with open(file_path, 'rb') as blob_f:
return blob_f.read()
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
blob_size = await asyncio.to_thread(os.path.getsize, file_path)
blob_data = await asyncio.to_thread(_read_blob)
async def blob_chunks():
async with aiofiles.open(file_path, 'rb') as blob_file:
while chunk := await blob_file.read(chunk_size):
yield chunk
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
async with session.post(
blob_url,
data=blob_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=30),
) as blob_resp:
if blob_resp.ok:
os.remove(file_path)
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
else:
raise RuntimeError('Ollama: Could not create blob, Please try again.')
async with session.post(
blob_url,
data=blob_chunks(),
headers={'Content-Length': str(blob_size)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=30),
) as blob_resp:
if blob_resp.ok:
await asyncio.to_thread(os.remove, file_path)
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
else:
raise RuntimeError('Ollama: Could not create blob, Please try again.')
@router.post('/models/download')
@ -1621,17 +1624,11 @@ async def upload_model(
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Stage 1: persist the uploaded file to disk
chunk_size = 1024 * 1024 * 2 # 2 MiB
chunk_size = AIOHTTP_FILE_STREAM_CHUNK_SIZE
def _persist_upload():
with open(file_path, 'wb') as out_f:
while True:
chunk = file.file.read(chunk_size)
if not chunk:
break
out_f.write(chunk)
await asyncio.to_thread(_persist_upload)
async with aiofiles.open(file_path, 'wb') as out_f:
while chunk := await file.read(chunk_size):
await out_f.write(chunk)
async def file_process_stream():
nonlocal ollama_url
@ -1643,25 +1640,26 @@ async def upload_model(
log.info(f'Model Hash: {file_hash}')
try:
with open(file_path, 'rb') as f:
bytes_read = 0
while chunk := f.read(chunk_size):
bytes_read = 0
async with aiofiles.open(file_path, 'rb') as f:
while chunk := await f.read(chunk_size):
bytes_read += len(chunk)
progress = round(bytes_read / total_size * 100, 2)
yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n'
# Stage 3: push blob to Ollama
def _read_blob():
with open(file_path, 'rb') as f:
return f.read()
blob_data = await asyncio.to_thread(_read_blob)
event = json.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read})
yield f'data: {event}\n\n'
session = await get_session()
blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}'
async def blob_chunks():
async with aiofiles.open(file_path, 'rb') as blob_file:
while chunk := await blob_file.read(chunk_size):
yield chunk
async with session.post(
blob_url,
data=blob_data,
data=blob_chunks(),
headers={'Content-Length': str(total_size)},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as resp:
@ -1669,7 +1667,7 @@ async def upload_model(
raise Exception('Ollama: Could not create blob, Please try again.')
log.info('Uploaded to /api/blobs')
os.remove(file_path)
await asyncio.to_thread(os.remove, file_path)
# Stage 4: create the model
model, _ext = os.path.splitext(filename)
@ -1690,7 +1688,10 @@ async def upload_model(
) as create_resp:
if create_resp.ok:
log.info('API SUCCESS!')
yield f'data: {json.dumps({"done": True, "blob": f"sha256:{file_hash}", "name": filename, "model_created": model})}\n\n'
event = json.dumps(
{'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model}
)
yield f'data: {event}\n\n'
else:
resp_text = await create_resp.text()
raise Exception(f'Failed to create model in Ollama. {resp_text}')

View file

@ -8,6 +8,7 @@ import re
from typing import Optional
from urllib.parse import quote, urlparse
import aiofiles
import aiohttp
from aiocache import cached
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
@ -491,13 +492,12 @@ async def speech(request: Request, user=Depends(get_verified_user)):
r.raise_for_status()
# Save the streaming content to a file
with open(file_path, 'wb') as f:
async with aiofiles.open(file_path, 'wb') as f:
async for chunk in r.content.iter_chunked(8192):
f.write(chunk)
await f.write(chunk)
with open(file_body_path, 'w') as f:
json.dump(json.loads(body.decode('utf-8')), f)
async with aiofiles.open(file_body_path, 'w') as f:
await f.write(json.dumps(json.loads(body.decode('utf-8'))))
# Return the saved file
return FileResponse(file_path)

View file

@ -1,9 +1,9 @@
import asyncio
import logging
import os
import shutil
from typing import Optional
import aiofiles
import aiohttp
from fastapi import (
APIRouter,
@ -18,7 +18,7 @@ from fastapi import (
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_FILE_STREAM_CHUNK_SIZE
from open_webui.events import EVENTS, publish_event
from open_webui.models.config import Config
from open_webui.routers.openai import get_all_models_responses
@ -237,35 +237,37 @@ async def upload_pipeline(
response = None
try:
# Save the uploaded file off the event loop (uploads can be large).
def _save_upload():
with open(file_path, 'wb') as buffer:
shutil.copyfileobj(file.file, buffer)
await asyncio.to_thread(_save_upload)
async with aiofiles.open(file_path, 'wb') as buffer:
while chunk := await file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
await buffer.write(chunk)
url, key = await get_openai_connection(urlIdx)
headers = {'Authorization': f'Bearer {key}'}
async with aiohttp.ClientSession(trust_env=True) as session:
with open(file_path, 'rb') as f:
form_data = aiohttp.FormData()
form_data.add_field(
'file',
f,
filename=filename,
content_type='application/octet-stream',
)
form_data = aiohttp.FormData()
async with session.post(
f'{url}/pipelines/upload',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
async def pipeline_chunks():
async with aiofiles.open(file_path, 'rb') as pipeline_file:
while chunk := await pipeline_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
yield chunk
form_data.add_field(
'file',
pipeline_chunks(),
filename=filename,
content_type='application/octet-stream',
)
async with session.post(
f'{url}/pipelines/upload',
headers=headers,
data=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
await publish_event(
request,
@ -297,7 +299,7 @@ async def upload_pipeline(
finally:
# Ensure the file is deleted after the upload is completed or on failure
if os.path.exists(file_path):
os.remove(file_path)
await asyncio.to_thread(os.remove, file_path)
class AddPipelineForm(BaseModel):

View file

@ -127,6 +127,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
TIKTOKEN_DISALLOWED_SPECIAL = ()
##########################################
#
# Utility functions
@ -1582,7 +1584,7 @@ def get_splitter_length_function(
) -> Callable[[str], int]:
if config.TEXT_SPLITTER == 'token':
encoding = tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME))
return lambda text: len(encoding.encode(text, disallowed_special=()))
return lambda text: len(encoding.encode(text, disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL))
if config.TEXT_SPLITTER == 'token_transformers':
tokenizer = get_transformers_tokenizer(request, config)
@ -1689,6 +1691,7 @@ def save_docs_to_vector_db(
chunk_size=config.CHUNK_SIZE,
chunk_overlap=config.CHUNK_OVERLAP,
add_start_index=True,
disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL,
)
docs = text_splitter.split_documents(docs)
elif config.TEXT_SPLITTER == 'token_transformers':
@ -2609,6 +2612,7 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
verify_ssl=config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=config.WEB_LOADER_CONCURRENT_REQUESTS,
trust_env=config.WEB_SEARCH_TRUST_ENV,
loader_config=await get_loader_config(),
)
docs = await loader.aload()
@ -2910,21 +2914,24 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo
folder = f'{UPLOAD_DIR}'
try:
# Check if the directory exists
if os.path.exists(folder):
if await asyncio.to_thread(os.path.exists, folder):
# Iterate over all the files and directories in the specified directory
for filename in os.listdir(folder):
for filename in await asyncio.to_thread(os.listdir, folder):
file_path = os.path.join(folder, filename)
try:
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path) # Remove the file or link
elif os.path.isdir(file_path):
shutil.rmtree(file_path) # Remove the directory
if await asyncio.to_thread(os.path.isfile, file_path) or await asyncio.to_thread(
os.path.islink, file_path
):
await asyncio.to_thread(os.unlink, file_path) # Remove the file or link
elif await asyncio.to_thread(os.path.isdir, file_path):
await asyncio.to_thread(shutil.rmtree, file_path) # Remove the directory
except Exception as e:
log.exception(f'Failed to delete {file_path}. Reason: {e}')
else:
log.warning(f'The directory {folder} does not exist')
except Exception as e:
log.exception(f'Failed to process the directory {folder}. Reason: {e}')
await publish_event(
request,
EVENTS.RETRIEVAL_UPLOADS_RESET,

View file

@ -44,6 +44,7 @@ from open_webui.utils.auth import (
get_verified_user,
validate_password,
)
from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
@ -207,6 +208,7 @@ class SharingPermissions(BaseModel):
class AccessGrantsPermissions(BaseModel):
allow_users: bool = True
allow_groups: bool = True
class ChatPermissions(BaseModel):
@ -573,6 +575,52 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
return user.info
class UserVariablesForm(BaseModel):
variables: dict = Field(default_factory=dict)
class UserVariablesResponse(BaseModel):
variables: dict[str, str] = Field(default_factory=dict)
############################
# GetUserVariablesBySessionUser
############################
@router.get('/user/variables', response_model=UserVariablesResponse)
async def get_user_variables_by_session_user(user=Depends(get_verified_user)):
return UserVariablesResponse(variables=normalize_user_variables(user.variables))
############################
# UpdateUserVariablesBySessionUser
############################
@router.post('/user/variables/update', response_model=UserVariablesResponse)
async def update_user_variables_by_session_user(
form_data: UserVariablesForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
try:
variables = validate_user_variables(form_data.variables)
except ChatVariablesError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db)
if not updated:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
return UserVariablesResponse(variables=variables)
############################
# UpdateUserInfoBySessionUser
############################

View file

@ -5,7 +5,6 @@ import logging
import random
import sys
import time
from typing import Dict
import pycrdt as Y
import socketio
@ -16,7 +15,6 @@ from open_webui.env import (
ENABLE_WEBSOCKET_SUPPORT,
GLOBAL_LOG_LEVEL,
REDIS_KEY_PREFIX,
VERSION,
WEBSOCKET_EVENT_CALLER_TIMEOUT,
WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_CLUSTER,
@ -44,7 +42,6 @@ from open_webui.utils.redis import (
get_redis_connection,
get_sentinels_from_env,
)
from redis import asyncio as aioredis
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -58,6 +55,11 @@ REDIS = None
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
def get_room_sid_map(manager, namespace: str, room: str):
"""Return this process's Socket.IO sid map for a room, without copying it."""
return manager.rooms.get(namespace, {}).get(room)
class LocalFilteredRedisManager(socketio.AsyncRedisManager):
"""AsyncRedisManager that drops pub/sub emits with no local recipients.
@ -76,7 +78,7 @@ class LocalFilteredRedisManager(socketio.AsyncRedisManager):
room = message.get('room')
if isinstance(room, str):
namespace = message.get('namespace') or '/'
if next(self.get_participants(namespace, room), None) is None:
if not get_room_sid_map(self, namespace, room):
return
await super()._handle_emit(message)
@ -229,17 +231,19 @@ async def periodic_usage_pool_cleanup():
try:
while True:
if not renew_func():
log.error(f'Unable to renew cleanup lock. Exiting usage pool cleanup.')
log.error('Unable to renew cleanup lock. Exiting usage pool cleanup.')
raise Exception('Unable to renew usage pool cleanup lock.')
now = int(time.time())
send_usage = False
for model_id, connections in list(USAGE_POOL.items()):
# Creating a list of sids to remove if they have timed out
expired_sids = [
sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION
]
if connections and not expired_sids:
continue
for sid in expired_sids:
del connections[sid]
@ -248,8 +252,6 @@ async def periodic_usage_pool_cleanup():
del USAGE_POOL[model_id]
else:
USAGE_POOL[model_id] = connections
send_usage = True
await asyncio.sleep(TIMEOUT_DURATION)
finally:
release_func()
@ -276,24 +278,21 @@ def get_user_id_from_session_pool(sid):
def get_session_ids_from_room(room):
"""Get all session IDs from a specific room."""
active_session_ids = sio.manager.get_participants(
namespace='/',
room=room,
)
return [session_id[0] for session_id in active_session_ids]
members = get_room_sid_map(sio.manager, '/', room)
return list(members) if members else []
def get_user_ids_from_room(room):
active_session_ids = get_session_ids_from_room(room)
# Single pool lookup per session (each .get is a Redis round trip
# when the session pool is Redis-backed).
active_user_ids = list(
set(
[
SESSION_POOL.get(session_id)['id']
for session_id in active_session_ids
if SESSION_POOL.get(session_id) is not None
]
)
{
entry['id']
for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids)
if entry is not None
}
)
return active_user_ids
@ -357,7 +356,7 @@ async def usage(sid, data):
# Store the new usage data and task
USAGE_POOL[model_id] = {
**(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}),
**(USAGE_POOL.get(model_id) or {}),
sid: {'updated_at': current_time},
}
@ -404,33 +403,38 @@ async def user_join(sid, data):
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
return
user = await Users.get_user_by_id(token_data['id'])
if not user:
return
existing = SESSION_POOL.get(sid)
if existing and existing.get('id') == token_data['id']:
SESSION_POOL[sid] = {**existing, 'last_seen_at': int(time.time())}
user_id, user_name, user_role = existing['id'], existing['name'], existing['role']
else:
user = await Users.get_user_by_id(token_data['id'])
if not user:
return
SESSION_POOL[sid] = {
**user.model_dump(
exclude=[
'profile_image_url',
'profile_banner_image_url',
'date_of_birth',
'bio',
'gender',
]
),
'last_seen_at': int(time.time()),
}
await sio.enter_room(sid, f'user:{user.id}')
SESSION_POOL[sid] = {
**user.model_dump(
exclude=[
'profile_image_url',
'profile_banner_image_url',
'date_of_birth',
'bio',
'gender',
]
),
'last_seen_at': int(time.time()),
}
await sio.enter_room(sid, f'user:{user.id}')
user_id, user_name, user_role = user.id, user.name, user.role
# Join all the channels only if user has channels permission
if user.role == 'admin' or await has_permission(user.id, 'features.channels'):
channels = await Channels.get_channels_by_user_id(user.id)
if user_role == 'admin' or await has_permission(user_id, 'features.channels'):
channels = await Channels.get_channels_by_user_id(user_id)
log.debug(f'{channels=}')
for channel in channels:
await sio.enter_room(sid, f'channel:{channel.id}')
return {'id': user.id, 'name': user.name}
return {'id': user_id, 'name': user_name}
@sio.on('heartbeat')
@ -510,13 +514,7 @@ async def join_note(sid, data):
@sio.on('events:channel')
async def channel_events(sid, data):
room = f'channel:{data["channel_id"]}'
participants = sio.manager.get_participants(
namespace='/',
room=room,
)
sids = [sid for sid, _ in participants]
if sid not in sids:
if sid not in (get_room_sid_map(sio.manager, '/', room) or {}):
return
event_data = data['data']
@ -859,7 +857,6 @@ async def yjs_awareness_update(sid, data):
@sio.event
async def disconnect(sid, reason=None):
if sid in SESSION_POOL:
user = SESSION_POOL[sid]
del SESSION_POOL[sid]
# Clean up USAGE_POOL entries for this session
@ -960,16 +957,19 @@ async def get_event_emitter(request_info, update_db=True):
if internal and event_data.get('type') == 'notification':
return
await sio.emit(
'events',
{
'chat_id': chat_id,
'message_id': message_id,
**({'internal': True} if internal else {}),
'data': event_data,
},
room=f'user:{user_id}',
)
room = f'user:{user_id}'
# Local rooms are authoritative; Redis may have listeners on another instance.
if WEBSOCKET_MANAGER == 'redis' or room in sio.manager.rooms.get('/', {}):
await sio.emit(
'events',
{
'chat_id': chat_id,
'message_id': message_id,
**({'internal': True} if internal else {}),
'data': event_data,
},
room=room,
)
if update_db and message_id and not (request_info.get('chat_id') or '').startswith('local:'):
event_type = event_data.get('type')

View file

@ -102,12 +102,18 @@ class RedisDict:
# Serialize values once — reused for both the fingerprint and the write.
serialized = {k: json.dumps(v) for k, v in mapping.items()}
digest = hashlib.sha256()
for key in sorted(serialized):
digest.update(key.encode())
digest.update(b'\0')
digest.update(serialized[key].encode())
digest.update(b'\0')
signature = digest.hexdigest()
# Skip the write when the prepared mapping is identical to the last one
# this process wrote. The check is per-instance (not distributed), but
# still eliminates the majority of redundant writes because each pod
# typically produces the same model list on consecutive refreshes.
signature = hashlib.sha256(json.dumps(serialized, sort_keys=True).encode()).hexdigest()
if signature == self._last_signature:
return

View file

@ -33,9 +33,9 @@ MAX_GREP_MATCHES = 50
def is_regex_pattern(pattern: str) -> bool:
"""Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...])."""
"""Detect if a pattern looks like regex (|, .*, .+, \d, \w, \s, [...])."""
return (
'\|' in pattern
'|' in pattern
or '.*' in pattern
or '.+' in pattern
or '.?' in pattern
@ -691,6 +691,10 @@ async def _kb_grep(
for i, line in enumerate(lines, 1):
if _matches(line):
matched.append(f'{i}: {line}')
if count_only:
return str(len(matched))
if filenames_only:
return '(standard input)' if matched else f'No matches for "{pattern}"'
return '\n'.join(matched) if matched else f'No matches for "{pattern}"'
# Single file grep

View file

@ -276,6 +276,7 @@ async def has_base_model_access(
user_id: str,
model_info,
*,
user_role: str | None = None,
user_group_ids: set[str] | None = None,
db=None,
) -> bool:
@ -283,9 +284,11 @@ async def has_base_model_access(
Walk the ``base_model_id`` chain and verify the caller has read access
at every hop.
Returns ``True`` when access is granted (or the chain ends at a raw
provider model that has no per-model ACL). Returns ``False`` the
moment a registered base model denies access.
A base model without a ``model`` table row is admin-only, matching how
unregistered models are treated for direct use (``get_filtered_models``
hides them from non-admins and ``check_model_access`` rejects them), so
a shared preset cannot be used to reach a base model the caller could
not use directly. Returns ``False`` the moment any hop denies access.
"""
from open_webui.models.access_grants import AccessGrants
from open_webui.models.models import Models
@ -296,7 +299,7 @@ async def has_base_model_access(
seen.add(base_model_id)
base_model_info = await Models.get_model_by_id(base_model_id, db=db)
if base_model_info is None:
break # Raw provider model — no per-model ACL
return user_role == 'admin'
if not (
user_id == base_model_info.user_id
or await AccessGrants.has_access(
@ -355,7 +358,9 @@ async def check_model_access(
raise HTTPException(status_code=403, detail='Model not found')
# Enforce access on chained base models
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids):
if not await has_base_model_access(
user.id, model_info, user_role=user.role, user_group_ids=user_group_ids
):
raise HTTPException(status_code=403, detail='Model not found')
else:
if user.role != 'admin':

View file

@ -18,6 +18,7 @@ async def has_access_to_file(
access_type: str,
user: UserModel,
db: AsyncSession | None = None,
user_group_ids: set[str] | None = None,
) -> bool:
"""
Check if a user has the specified access to a file through any of:
@ -43,7 +44,8 @@ async def has_access_to_file(
# the object's OWNER owns that file; otherwise a read-only file laundered into an object
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
if user_group_ids is None:
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
for knowledge_base in knowledge_bases:
if (
knowledge_base.user_id == user.id
@ -60,12 +62,25 @@ async def has_access_to_file(
knowledge_base_id = file.meta.get('collection_name') if file.meta else None
if knowledge_base_id:
knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id and (
access_type == 'read' or knowledge_base.user_id == file.user_id
):
return True
# Fetch the one referenced knowledge base instead of listing every
# knowledge base the user can access just to scan for this id.
knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id, db=db)
if (
knowledge_base
and (access_type == 'read' or knowledge_base.user_id == file.user_id)
and (
knowledge_base.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
)
)
):
return True
# Check if the file is associated with any channels the user has access to
channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
@ -88,7 +103,9 @@ async def has_access_to_file(
# Check if the file is directly attached to a shared workspace model (per the ownership
# note above, model write is conferred only for files the model owner owns).
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
for model in await Models.get_models_by_user_id(
user.id, permission=access_type, db=db, user_group_ids=user_group_ids
):
knowledge_items = getattr(model.meta, 'knowledge', None) or []
for item in knowledge_items:
if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:
@ -105,14 +122,18 @@ async def get_accessible_folder_files(
) -> list[dict]:
"""Filter folder.data['files'] entries to those the caller can read.
Each entry is expected to have 'type' ('file' or 'collection') and 'id'.
Admins bypass all checks. Unknown types are kept as-is.
Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. File, collection and
note ids are each access-checked against the caller; admins bypass all checks and
genuinely unknown types are kept as-is.
"""
if not entries:
return []
if user.role == 'admin':
return list(entries)
# One group-membership fetch for the whole folder listing
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
accessible: list[dict] = []
for entry in entries:
if not isinstance(entry, dict):
@ -123,11 +144,27 @@ async def get_accessible_folder_files(
accessible.append(entry)
continue
if entry_type == 'file':
if await has_access_to_file(entry_id, 'read', user, db=db):
if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids):
accessible.append(entry)
elif entry_type == 'collection':
if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db):
accessible.append(entry)
elif entry_type == 'note':
# Owner has no self-grant (notes are private by default), so check ownership too.
from open_webui.models.notes import Notes
note = await Notes.get_note_by_id(entry_id, db=db)
if note and (
note.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=entry_id,
permission='read',
db=db,
)
):
accessible.append(entry)
else:
accessible.append(entry)
return accessible

View file

@ -100,14 +100,20 @@ class CommitSessionMiddleware:
# Downstream did not complete successfully. Roll back any
# pending sync writes, release the connection, and let the
# exception propagate.
try:
ScopedSession.rollback()
except Exception:
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
finally:
ScopedSession.remove()
if ScopedSession.registry.has():
try:
ScopedSession.rollback()
except Exception:
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
finally:
ScopedSession.remove()
raise
# Nothing in this request touched the sync session: committing would
# only instantiate one to run an empty transaction.
if not ScopedSession.registry.has():
return
# Downstream completed. Commit pending sync work.
try:
ScopedSession.commit()
@ -166,9 +172,9 @@ class AuthTokenMiddleware:
async def send_with_timing(message: Message) -> None:
if message['type'] == 'http.response.start':
process_time = int(time.monotonic() - start_time)
process_time = time.monotonic() - start_time
headers = MutableHeaders(scope=message)
headers['X-Process-Time'] = str(process_time)
headers['X-Process-Time'] = f'{process_time:.6f}'
await send(message)
await self.app(scope, receive, send_with_timing)

View file

@ -132,14 +132,27 @@ class AuditLoggingMiddleware:
) -> None:
self.app = app
self.audit_logger = AuditLogger(logger)
self.excluded_paths = excluded_paths or []
self.included_paths = included_paths or []
def normalize_paths(paths: Optional[list[str]]) -> list[str]:
return [path for path in (path.strip().lstrip('/') for path in paths or []) if path]
self.excluded_paths = normalize_paths(excluded_paths)
self.included_paths = normalize_paths(included_paths)
self.max_body_size = max_body_size
self.audited_methods = set(self.DEFAULT_AUDITED_METHODS)
if audit_get_requests:
self.audited_methods.add('GET')
self.audit_level = audit_level
# Paths are fixed for the process lifetime; compile once instead of
# per request. None means the corresponding mode has nothing to match.
self._included_pattern = (
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') if self.included_paths else None
)
self._excluded_pattern = (
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') if self.excluded_paths else None
)
if self.included_paths and self.excluded_paths:
logger.warning(
'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. '
@ -196,6 +209,13 @@ class AuditLoggingMiddleware:
await self._log_audit_entry(request, context)
async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
# get_current_user stashes the resolved user on the scope-backed state;
# reuse it instead of running the full auth pipeline (JWT decode, Redis
# revocation checks, DB fetch, last-active write) a second time.
user = getattr(request.state, 'user', None)
if isinstance(user, UserModel):
return user
auth_header = request.headers.get('Authorization')
try:
@ -206,6 +226,12 @@ class AuditLoggingMiddleware:
return None
ALWAYS_LOG_ENDPOINTS = (
'/api/v1/auths/signin',
'/api/v1/auths/signout',
'/api/v1/auths/signup',
)
def _should_skip_auditing(self, request: Request) -> bool:
if AUDIT_LOG_LEVEL == 'NONE':
return True
@ -213,13 +239,8 @@ class AuditLoggingMiddleware:
if request.method not in self.audited_methods:
return True
ALWAYS_LOG_ENDPOINTS = {
'/api/v1/auths/signin',
'/api/v1/auths/signout',
'/api/v1/auths/signup',
}
path = request.url.path.lower()
for endpoint in ALWAYS_LOG_ENDPOINTS:
for endpoint in self.ALWAYS_LOG_ENDPOINTS:
if path.startswith(endpoint):
return False # Do NOT skip logging for auth endpoints
@ -229,15 +250,11 @@ class AuditLoggingMiddleware:
return True
# Whitelist mode: only log paths that match included_paths
if self.included_paths:
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b')
if not pattern.match(request.url.path):
return True # Skip: path not in whitelist
return False # Do NOT skip: path is in whitelist
if self._included_pattern:
return not self._included_pattern.match(request.url.path)
# Blacklist mode: skip paths that match excluded_paths
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b')
if pattern.match(request.url.path):
if self._excluded_pattern and self._excluded_pattern.match(request.url.path):
return True
return False

View file

@ -356,6 +356,8 @@ async def get_current_user(
current_span.set_attribute('client.user.role', user.role)
current_span.set_attribute('client.auth.type', 'api_key')
# Scope-backed, so outer middleware (audit) can reuse the resolved user
request.state.user = user
return user
# auth by jwt token
@ -404,6 +406,9 @@ async def get_current_user(
# Refresh the user's last active timestamp
# Fire-and-forget via asyncio.create_task to avoid blocking
asyncio.create_task(Users.update_last_active_by_id(user.id))
# Scope-backed, so outer middleware (audit) can reuse the resolved user
request.state.user = user
return user
else:
raise HTTPException(

View file

@ -179,8 +179,10 @@ async def generate_chat_completion(
# Merge the direct connection model into server models so that
# task functions (title, tags, etc.) can resolve a server-side
# task model while still having the direct model available.
# dict(...items()) is one HGETALL on a Redis-backed pool; ``{**pool}``
# would issue HKEYS plus one HGET per model.
models = {
**request.app.state.MODELS,
**dict(request.app.state.MODELS.items()),
request.state.model['id']: request.state.model,
}
log.debug(f'direct connection to model: {request.state.model["id"]}')
@ -188,11 +190,12 @@ async def generate_chat_completion(
models = request.app.state.MODELS
model_id = form_data['model']
if model_id not in models:
# Single lookup — membership check plus getitem would be two Redis
# round trips on a Redis-backed model pool.
model = models.get(model_id)
if model is None:
raise Exception('Model not found')
model = models[model_id]
if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'):
return await generate_direct_chat_completion(request, form_data, user=user, models=models)
else:

View file

@ -0,0 +1,288 @@
from __future__ import annotations
import json
import re
from typing import Any
CHAT_VARIABLE_KEY_RE = re.compile(r'^[a-z][a-z0-9_]*$')
CHAT_VARIABLE_ANY_RE = re.compile(r'{{\s*chat\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}')
USER_VARIABLE_ANY_RE = re.compile(r'{{\s*user\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}')
MAX_VARIABLE_VALUE_LENGTH = 20_000
MAX_VARIABLES_JSON_LENGTH = 100_000
class ChatVariablesError(ValueError):
pass
def split_properties(value: str, delimiter: str) -> list[str]:
result: list[str] = []
current = ''
depth = 0
in_string = False
escape_next = False
for char in value:
if escape_next:
current += char
escape_next = False
continue
if char == '\\':
current += char
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
current += char
continue
if not in_string:
if char in ('{', '['):
depth += 1
elif char in ('}', ']'):
depth -= 1
if char == delimiter and depth == 0:
result.append(current.strip())
current = ''
continue
current += char
if current.strip():
result.append(current.strip())
return result
def parse_json_value(value: str) -> Any:
if value.startswith('"') and value.endswith('"'):
return value[1:-1]
if re.match(r'^[\[{]', value):
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return value
def parse_variable_definition(definition: str) -> dict[str, Any]:
parts = split_properties(definition, ':')
if not parts:
return {'type': 'text'}
first_part, *property_parts = parts
field_type = first_part[5:] if first_part.startswith('type=') else first_part
field_type = field_type.strip() or 'text'
properties: dict[str, Any] = {}
for part in property_parts:
trimmed = part.strip()
if not trimmed:
continue
equals_parts = split_properties(trimmed, '=')
if len(equals_parts) == 1:
properties[equals_parts[0].strip()] = True
continue
property_name, *value_parts = equals_parts
properties[property_name.strip()] = parse_json_value('='.join(value_parts).strip())
return {'type': field_type, **properties}
def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]:
allowed_keys = {
'default',
'label',
'max',
'maxlength',
'min',
'minlength',
'options',
'placeholder',
'required',
'step',
'type',
}
field = {'key': key}
for field_key in allowed_keys:
if field_key in definition:
field[field_key] = definition[field_key]
field.setdefault('type', 'text')
if field.get('type') == 'select' and not isinstance(field.get('options'), list):
field['options'] = []
field['required'] = bool(field.get('required', False))
return field
def get_chat_variables_schema(system_prompt: str | None) -> dict[str, list[dict[str, Any]]] | None:
if not system_prompt:
return None
try:
fields_by_key = collect_chat_variable_fields(system_prompt)
except ChatVariablesError:
fields_by_key = {}
if not fields_by_key:
return None
return {'fields': list(fields_by_key.values())}
def collect_chat_variable_fields(system_prompt: str | None) -> dict[str, dict[str, Any]]:
fields_by_key: dict[str, dict[str, Any]] = {}
if not system_prompt:
return fields_by_key
typed_fields_by_key: dict[str, dict[str, Any]] = {}
for match in CHAT_VARIABLE_ANY_RE.finditer(system_prompt):
key = match.group(1).strip()
definition = match.group(2)
if not CHAT_VARIABLE_KEY_RE.match(key):
raise ChatVariablesError(f'Invalid chat variable key: {key}')
if definition is None or not definition.strip():
fields_by_key.setdefault(key, _safe_field(key, {'type': 'text'}))
continue
field = _safe_field(key, parse_variable_definition(definition.strip()))
if field.get('type') == 'select' and not field.get('options'):
raise ChatVariablesError(f'Chat variable {key} select needs options.')
previous = typed_fields_by_key.get(key)
if previous and previous != field:
raise ChatVariablesError(f'Chat variable {key} has conflicting definitions.')
typed_fields_by_key[key] = field
fields_by_key[key] = field
return fields_by_key
def normalize_chat_variables(variables: Any) -> dict[str, Any]:
if not isinstance(variables, dict):
return {}
return variables
def normalize_user_variables(variables: Any) -> dict[str, str]:
if not isinstance(variables, dict):
return {}
return {key: value for key, value in variables.items() if isinstance(key, str) and isinstance(value, str)}
def validate_user_variables(variables: Any) -> dict[str, str]:
if not isinstance(variables, dict):
raise ChatVariablesError('User variables must be an object.')
try:
if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
raise ChatVariablesError('User variables are too large.')
except TypeError:
raise ChatVariablesError('User variables must be JSON serializable.')
validated: dict[str, str] = {}
for key, value in variables.items():
if not isinstance(key, str) or not CHAT_VARIABLE_KEY_RE.match(key):
raise ChatVariablesError(f'Invalid user variable key: {key}')
if not isinstance(value, str):
raise ChatVariablesError(f'User variable must be a string: {key}')
value = value.replace('\r\n', '\n')
if len(value) > MAX_VARIABLE_VALUE_LENGTH:
raise ChatVariablesError(f'User variable is too long: {key}')
validated[key] = value
return validated
def validate_chat_variables(
system_prompt: str | None,
variables: Any,
*,
required: bool = True,
) -> dict[str, Any]:
field_map = collect_chat_variable_fields(system_prompt)
variables = normalize_chat_variables(variables)
try:
if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
raise ChatVariablesError('Chat variables are too large.')
except TypeError:
raise ChatVariablesError('Chat variables must be JSON serializable.')
validated: dict[str, Any] = {}
for key, field in field_map.items():
has_value = key in variables and variables[key] not in (None, '')
value = variables.get(key)
if not has_value:
if field.get('default') not in (None, ''):
value = field.get('default')
has_value = True
elif required and field.get('required'):
label = field.get('label') or key
raise ChatVariablesError(f'Missing required chat variable: {label}')
else:
value = ''
if field.get('type') == 'select':
options = field.get('options') or []
if has_value and value not in options:
label = field.get('label') or key
raise ChatVariablesError(f'Invalid value for chat variable: {label}')
if isinstance(value, str):
value = value.replace('\r\n', '\n')
if len(value) > MAX_VARIABLE_VALUE_LENGTH:
label = field.get('label') or key
raise ChatVariablesError(f'Chat variable is too long: {label}')
validated[key] = value
return validated
def render_chat_variables(
system_prompt: str | None,
variables: Any,
*,
required: bool = True,
) -> str | None:
if not system_prompt:
return system_prompt
try:
validated = validate_chat_variables(system_prompt, variables, required=required)
except ChatVariablesError:
validated = {}
def replace(match: re.Match) -> str:
key = match.group(1).strip()
value = validated.get(key, '')
return '' if value is None else str(value)
return CHAT_VARIABLE_ANY_RE.sub(replace, system_prompt)
def render_user_variables(system_prompt: str | None, variables: Any) -> str | None:
if not system_prompt:
return system_prompt
variables = normalize_user_variables(variables)
def replace(match: re.Match) -> str:
key = match.group(1).strip()
if not CHAT_VARIABLE_KEY_RE.match(key):
return ''
return variables.get(key, '')
return USER_VARIABLE_ANY_RE.sub(replace, system_prompt)

View file

@ -56,7 +56,7 @@ async def compact_messages_for_request(
if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3:
return messages, previous_summary, False
boundary = _find_compaction_boundary(messages)
boundary = _find_compaction_boundary(messages, config['retention_percentage'])
compacted_messages = messages[:boundary]
recent_messages = messages[boundary:]
if not compacted_messages or not recent_messages:
@ -196,6 +196,7 @@ async def _load_config() -> dict:
'chat.context_compaction.enable',
'chat.context_compaction.token_threshold',
'chat.context_compaction.token_cap',
'chat.context_compaction.retention_percentage',
'chat.context_compaction.prompt_template',
)
token_threshold = _parse_positive_int(values.get('chat.context_compaction.token_threshold')) or 80000
@ -203,6 +204,9 @@ async def _load_config() -> dict:
'enable': bool(values.get('chat.context_compaction.enable', False)),
'token_threshold': token_threshold,
'token_cap': _parse_positive_int(values.get('chat.context_compaction.token_cap')) or token_threshold,
'retention_percentage': _clamp_retention_percentage(
values.get('chat.context_compaction.retention_percentage')
),
'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '',
}
@ -215,6 +219,14 @@ def _parse_positive_int(value: Any) -> int | None:
return parsed if parsed > 0 else None
def _clamp_retention_percentage(value: Any) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
parsed = 40
return min(50, max(10, parsed))
def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: dict) -> int:
configured_threshold = _parse_positive_int((metadata.get('params') or {}).get('compact_token_threshold'))
return min(configured_threshold or global_threshold, global_cap)
@ -297,8 +309,9 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary:
return estimated > threshold
def _find_compaction_boundary(messages: list[dict]) -> int:
keep_count = max(2, len(messages) * 2 // 5)
def _find_compaction_boundary(messages: list[dict], retention_percentage: int = 40) -> int:
retention_percentage = _clamp_retention_percentage(retention_percentage)
keep_count = max(2, len(messages) * retention_percentage // 100)
target = max(1, len(messages) - keep_count)
boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:]
return next((idx for idx in reversed(boundaries) if idx <= target), 0)

View file

@ -6,6 +6,7 @@ import re
from pathlib import Path
from typing import Optional
import aiofiles
from fastapi import (
APIRouter,
Depends,
@ -75,7 +76,7 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
# file-ID resolver which enforces ownership/access checks.
return await get_image_base64_from_file_id(url, user=user)
except Exception as e:
except Exception:
return None
@ -200,15 +201,15 @@ async def get_image_base64_from_file_id(id: str, user=None) -> Optional[str]:
# Check if the file already exists in the cache
if file_path.is_file():
with open(file_path, 'rb') as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
if not content_type:
return None
return f'data:{content_type};base64,{encoded_string}'
async with aiofiles.open(file_path, 'rb') as image_file:
encoded_string = base64.b64encode(await image_file.read()).decode('utf-8')
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
if not content_type:
return None
return f'data:{content_type};base64,{encoded_string}'
else:
return None
except Exception as e:
except Exception:
return None

View file

@ -57,6 +57,7 @@ from open_webui.routers.images import (
image_generations,
)
from open_webui.routers.pipelines import (
get_sorted_filters,
process_pipeline_inlet_filter,
process_pipeline_outlet_filter,
)
@ -106,6 +107,7 @@ from open_webui.utils.misc import (
get_last_user_message,
get_last_user_message_item,
get_message_list,
get_output_text,
get_system_message,
is_string_allowed,
merge_system_messages,
@ -136,17 +138,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
def _last_output_text(output: list | None) -> str:
for item in reversed(output or []):
if item.get('type') != 'message':
continue
parts = item.get('content') or []
text = ''.join(str(part.get('text') or '') for part in parts if part.get('type') == 'output_text')
if text:
return text
return ''
async def publish_chat_finished_event(
request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None
):
@ -154,7 +145,7 @@ async def publish_chat_finished_event(
if getattr(request.state, 'internal', False) is True or not chat_id or chat_id.startswith(('channel:', 'local:')):
return
content = content or _last_output_text(output)
content = content or get_output_text(output)
webui_url = await Config.get('webui.url')
await publish_event(
request,
@ -2157,23 +2148,25 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
return ids
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
def strip_skill_mentions(messages: list[dict]) -> None:
"""Replace <$skillId|label> and </skillId|label> mention tags with the label in-place."""
strip_re = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
def label(match):
return match.group(1) or match.group(2) or ''
for message in messages:
content = message.get('content')
if isinstance(content, str) and strip_re.search(content):
message['content'] = strip_re.sub(label, content).strip()
if isinstance(content, str) and SKILL_MENTION_STRIP_RE.search(content):
message['content'] = SKILL_MENTION_STRIP_RE.sub(label, content).strip()
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get('type') == 'text':
text = part.get('text', '')
if strip_re.search(text):
part['text'] = strip_re.sub(label, text).strip()
if SKILL_MENTION_STRIP_RE.search(text):
part['text'] = SKILL_MENTION_STRIP_RE.sub(label, text).strip()
async def connect_mcp_server(
@ -2572,7 +2565,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Mentioned skills get full content; selected/default skills can be loaded through view_skill.
mentioned_skill_ids = extract_skill_ids_from_messages(form_data.get('messages', []))
skill_ids = (
skill_ids = sorted(
set(form_data.pop('skill_ids', None) or [])
| set(model.get('info', {}).get('meta', {}).get('skillIds', []))
| mentioned_skill_ids
@ -2675,7 +2668,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{
'model_id': form_data.get('model'),
'tool_ids': tool_ids,
'skill_ids': list(skill_ids),
'skill_ids': skill_ids,
'terminal_id': terminal_id,
'files': files,
'features': features,
@ -3395,7 +3388,7 @@ async def outlet_filter_handler(ctx):
message_list = [
{
'role': m.get('role'),
'content': m.get('content', ''),
'content': m.get('content') or get_output_text(m.get('output')),
}
for m in form_messages
]
@ -3428,7 +3421,7 @@ async def outlet_filter_handler(ctx):
{
'id': m.get('id'),
'role': m.get('role'),
'content': m.get('content', ''),
'content': m.get('content') or get_output_text(m.get('output')),
'info': m.get('info'),
'timestamp': m.get('timestamp'),
**({'output': m['output']} if m.get('output') else {}),
@ -3480,17 +3473,21 @@ async def outlet_filter_handler(ctx):
outlet_message_id = message.get('id')
if outlet_message_id and outlet_message_id in messages_map:
original_message = messages_map[outlet_message_id]
content_changed = original_message.get('content') != message.get('content')
original_content = original_message.get('content') or get_output_text(
original_message.get('output')
)
message_content = message.get('content') or get_output_text(message.get('output'))
content_changed = original_content != message_content
output_changed = message.get('output') and message.get('output') != original_message.get(
'output'
)
if content_changed or output_changed:
message_update = {
'originalContent': original_message.get('content'),
'originalContent': original_content,
**({'output': message['output']} if output_changed else {}),
}
if content_changed:
message_update['content'] = message.get('content', '')
message_update['content'] = message_content or ''
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
outlet_message_id,
@ -3972,9 +3969,10 @@ async def streaming_chat_response_handler(response, ctx):
except Exception as e:
pass
content = (
initial_content = (
message.get('content', '') if message else last_assistant_message if last_assistant_message else ''
)
content_parts = [initial_content] if initial_content else []
# Initialize output: use existing from message if continuing, else create new
existing_output = message.get('output') if message else None
@ -3982,14 +3980,14 @@ async def streaming_chat_response_handler(response, ctx):
output = existing_output
else:
# Only create an initial message item if there is content to initialize with
if content:
if initial_content:
output = [
{
'type': 'message',
'id': output_id('msg'),
'status': 'in_progress',
'role': 'assistant',
'content': [{'type': 'output_text', 'text': content}],
'content': [{'type': 'output_text', 'text': initial_content}],
}
]
else:
@ -4051,7 +4049,7 @@ async def streaming_chat_response_handler(response, ctx):
)
async def stream_body_handler(response, form_data):
nonlocal content
nonlocal content_parts
nonlocal usage
nonlocal output
nonlocal prior_output
@ -4116,7 +4114,7 @@ async def streaming_chat_response_handler(response, ctx):
raw_error = raw_obj.get('error') if isinstance(raw_obj, dict) else None
if raw_error:
try:
Chats.upsert_message_to_chat_by_id_and_message_id(
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
@ -4502,12 +4500,8 @@ async def streaming_chat_response_handler(response, ctx):
user,
)
if isinstance(value, str):
# In-place append — avoids copying the full
# accumulated response on every chunk.
content += value
else:
content = f'{content}{value}'
# closure-cell str += recopies per chunk; append + join once at read is O(n)
content_parts.append(value if isinstance(value, str) else f'{value}')
# Check if we're inside a tag-based block
# (reasoning, code_interpreter, or solution).
@ -4789,12 +4783,12 @@ async def streaming_chat_response_handler(response, ctx):
params = {}
if tool_args and tool_args.strip():
try:
params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
params = json.loads(tool_args)
except Exception:
try:
params = json.loads(tool_args)
except Exception:
params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
return None
tool_call.setdefault('function', {})['arguments'] = json.dumps(params)
return params
@ -5372,7 +5366,7 @@ async def streaming_chat_response_handler(response, ctx):
{'done': True},
)
await publish_chat_finished_event(request, user, metadata, title, content, output)
await publish_chat_finished_event(request, user, metadata, title, ''.join(content_parts), output)
await event_emitter(
{
@ -5438,6 +5432,16 @@ async def streaming_chat_response_handler(response, ctx):
return f'data: {item}\n\n'
assistant_message = {}
has_api_outlet_filters = ENABLE_API_OUTLET_FILTERS and bool(filter_functions)
if ENABLE_API_OUTLET_FILTERS and not has_api_outlet_filters:
try:
model_id = model.get('id') if isinstance(model, dict) else model
has_api_outlet_filters = bool(
(isinstance(model, dict) and 'pipeline' in model)
or get_sorted_filters(model_id, request.app.state.MODELS)
)
except Exception:
has_api_outlet_filters = True
for event in events:
event, _ = await process_filter_functions(
@ -5461,11 +5465,11 @@ async def streaming_chat_response_handler(response, ctx):
)
if data:
if ENABLE_API_OUTLET_FILTERS:
if has_api_outlet_filters:
update_assistant_message_from_stream(assistant_message, data)
yield data
if ENABLE_API_OUTLET_FILTERS and assistant_message:
if has_api_outlet_filters and assistant_message:
ctx['assistant_message'] = assistant_message
await outlet_filter_handler(ctx)

View file

@ -17,6 +17,7 @@ import mimeparse
from open_webui.env import CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE
log = logging.getLogger(__name__)
SURROGATE_RE = re.compile('[\ud800-\udfff]')
def deep_update(d, u):
@ -28,18 +29,26 @@ def deep_update(d, u):
return d
def _strip_filter_entry(entry):
# Compose list-form env syntax passes surrounding quotes through verbatim
return (entry or '').strip().strip('"\'').strip()
def get_allow_block_lists(filter_list):
allow_list = []
block_list = []
if filter_list:
for d in filter_list:
if d.startswith('!'):
# Domains starting with "!" → blocked
block_list.append(d[1:].strip())
else:
# Domains starting without "!" → allowed
allow_list.append(d.strip())
for raw_entry in filter_list or []:
entry = _strip_filter_entry(raw_entry)
is_blocked = entry.startswith('!')
if is_blocked:
entry = _strip_filter_entry(entry[1:])
if not entry:
continue
if is_blocked:
block_list.append(entry)
else:
allow_list.append(entry)
return allow_list, block_list
@ -159,13 +168,40 @@ def get_last_user_message_item(messages: list[dict]) -> dict | None:
def get_content_from_message(message: dict) -> str | None:
if isinstance(message.get('content'), list):
for item in message['content']:
if item['type'] == 'text':
return item['text']
else:
return message.get('content')
return None
content = message.get('content')
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get('type') == 'text':
return item.get('text')
elif content:
return content
output_text = get_output_text(message.get('output'))
return output_text or (content if isinstance(content, str) else None)
def get_output_text(output: list | None) -> str:
if not isinstance(output, list):
return ''
texts = []
for item in output:
if not isinstance(item, dict) or item.get('type') != 'message':
continue
parts = item.get('content') or []
if not isinstance(parts, list):
continue
text = ''.join(
str(part.get('text'))
for part in parts
if isinstance(part, dict) and part.get('text') is not None
)
if text.strip():
texts.append(text)
return '\n'.join(texts)
def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
@ -212,7 +248,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
# All tool_calls were orphans — keep the message only if it
# carries meaningful text or reasoning content.
content = message.get('content', '')
content = get_content_from_message(message) or ''
has_meaningful_content = content.strip() if isinstance(content, str) else content
if has_meaningful_content or message.get('reasoning_content'):
reconciled_messages.append({key: value for key, value in message.items() if key != 'tool_calls'})
@ -715,18 +751,10 @@ def sanitize_text_for_db(text: str) -> str:
"""Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage."""
if not isinstance(text, str):
return text
# Fast path: skip work when there are no null bytes (the common case)
if '\x00' not in text:
# Fast path: skip work when there are no null bytes or surrogate code points.
if '\x00' not in text and not SURROGATE_RE.search(text):
return text
# Remove null bytes
text = text.replace('\x00', '').replace('\u0000', '')
# Remove invalid UTF-8 surrogate characters that can cause encoding errors
# This handles cases where binary data or encoding issues introduced surrogates
try:
text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore')
except (UnicodeEncodeError, UnicodeDecodeError):
pass
return text
return SURROGATE_RE.sub('', text.replace('\x00', ''))
def _strip_null_bytes_deep(obj):
@ -734,7 +762,10 @@ def _strip_null_bytes_deep(obj):
if isinstance(obj, str):
return sanitize_text_for_db(obj)
elif isinstance(obj, dict):
return {k: _strip_null_bytes_deep(v) for k, v in obj.items()}
cleaned = {}
for k, v in obj.items():
cleaned[sanitize_text_for_db(k) if isinstance(k, str) else k] = _strip_null_bytes_deep(v)
return cleaned
elif isinstance(obj, list):
return [_strip_null_bytes_deep(v) for v in obj]
return obj
@ -744,19 +775,21 @@ def sanitize_data_for_db(obj):
"""Recursively sanitize all strings in a data structure for database storage.
Performs a fast pre-check: serializes the structure once and scans for
null bytes. If none are found (the overwhelmingly common case), the
null bytes or invalid UTF-8 surrogates. If none are found, the
original object is returned immediately, skipping the expensive
recursive walk.
"""
if isinstance(obj, str):
return sanitize_text_for_db(obj)
# Fast path: check for null bytes in the serialized form.
# Fast path: check for null bytes and surrogate code points in the serialized form.
# json.dumps is implemented in C and much faster than a Python-level
# recursive walk over every leaf string.
try:
if '\\u0000' not in json.dumps(obj, ensure_ascii=False):
serialized = json.dumps(obj, ensure_ascii=False)
if '\\u0000' not in serialized:
serialized.encode('utf-8')
return obj
except (TypeError, ValueError):
except (TypeError, ValueError, UnicodeEncodeError):
pass
return _strip_null_bytes_deep(obj)

View file

@ -16,6 +16,7 @@ from open_webui.models.config import Config
from open_webui.models.functions import Functions
from open_webui.models.groups import Groups
from open_webui.models.models import Models
from open_webui.utils.chat_variables import get_chat_variables_schema
from open_webui.models.users import UserModel
from open_webui.routers import ollama, openai
from open_webui.socket.utils import RedisDict
@ -129,13 +130,13 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
# One query per type: the global sets are subsets of the active sets, so
# deriving them from the same rows halves the function-table queries.
if ENABLE_PLUGINS:
active_actions = await Functions.get_functions_by_type('action', active_only=True)
global_action_ids = {function.id for function in active_actions if function.is_global}
enabled_action_ids = {function.id for function in active_actions}
active_actions = await Functions.get_active_function_ids_by_type('action')
global_action_ids = {function_id for function_id, is_global in active_actions if is_global}
enabled_action_ids = {function_id for function_id, _ in active_actions}
active_filters = await Functions.get_functions_by_type('filter', active_only=True)
global_filter_ids = {function.id for function in active_filters if function.is_global}
enabled_filter_ids = {function.id for function in active_filters}
active_filters = await Functions.get_active_function_ids_by_type('filter')
global_filter_ids = {function_id for function_id, is_global in active_filters if is_global}
enabled_filter_ids = {function_id for function_id, _ in active_filters}
else:
global_action_ids = set()
enabled_action_ids = set()
@ -162,6 +163,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if custom_model.is_active:
model['name'] = custom_model.name
model['info'] = custom_model.model_dump()
schema = get_chat_variables_schema(custom_model.params.model_dump().get('system'))
if schema:
model['info'].setdefault('meta', {})['chat_variables_schema'] = schema
action_ids = []
filter_ids = []
@ -211,6 +215,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
}
info = custom_model.model_dump()
schema = get_chat_variables_schema(custom_model.params.model_dump().get('system'))
if schema:
info.setdefault('meta', {})['chat_variables_schema'] = schema
if 'params' in info:
# Remove params to avoid exposing sensitive info
del info['params']
@ -328,16 +335,27 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
all_function_valves = await Functions.get_function_valves_by_ids(list(all_function_ids))
functions_cache = get_functions_cache(request)
# Global actions and filters appear in every model, so priorities and item
# lists are memoized across the loop instead of rebuilt per model.
action_priorities = {}
def get_action_priority(action_id):
if action_id in action_priorities:
return action_priorities[action_id]
priority = 0
try:
function_module = functions_cache.get(action_id)
if function_module and hasattr(function_module, 'Valves'):
valves_db = all_function_valves.get(action_id)
valves = function_module.Valves(**(valves_db if valves_db else {}))
return getattr(valves, 'priority', 0)
priority = getattr(valves, 'priority', 0)
except Exception:
pass
return 0
priority = 0
action_priorities[action_id] = priority
return priority
action_items_by_id = {}
filter_items_by_id = {}
for model in models:
action_ids = [
@ -355,30 +373,45 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
model['actions'] = []
for action_id in action_ids:
action_function = functions_by_id.get(action_id)
if action_function is None:
log.info(f'Action not found: {action_id}')
continue
items = action_items_by_id.get(action_id)
if items is None:
action_function = functions_by_id.get(action_id)
if action_function is None:
log.info(f'Action not found: {action_id}')
action_items_by_id[action_id] = []
continue
function_module = functions_cache.get(action_id)
if function_module is None:
log.info(f'Failed to load action module: {action_id}')
continue
model['actions'].extend(get_action_items_from_module(action_function, function_module))
function_module = functions_cache.get(action_id)
if function_module is None:
log.info(f'Failed to load action module: {action_id}')
action_items_by_id[action_id] = []
continue
items = get_action_items_from_module(action_function, function_module)
action_items_by_id[action_id] = items
# Shallow copies keep per-model item dicts independent, as before
model['actions'].extend({**item} for item in items)
model['filters'] = []
for filter_id in filter_ids:
filter_function = functions_by_id.get(filter_id)
if filter_function is None:
log.info(f'Filter not found: {filter_id}')
continue
items = filter_items_by_id.get(filter_id)
if items is None:
filter_function = functions_by_id.get(filter_id)
if filter_function is None:
log.info(f'Filter not found: {filter_id}')
filter_items_by_id[filter_id] = []
continue
function_module = functions_cache.get(filter_id)
if function_module is None:
log.info(f'Failed to load filter module: {filter_id}')
continue
if getattr(function_module, 'toggle', None):
model['filters'].extend(get_filter_items_from_module(filter_function, function_module))
function_module = functions_cache.get(filter_id)
if function_module is None:
log.info(f'Failed to load filter module: {filter_id}')
filter_items_by_id[filter_id] = []
continue
if getattr(function_module, 'toggle', None):
items = get_filter_items_from_module(filter_function, function_module)
else:
items = []
filter_items_by_id[filter_id] = items
model['filters'].extend({**item} for item in items)
log.debug(f'get_all_models() returned {len(models)} models')
@ -433,7 +466,9 @@ async def check_model_access(user, model, model_info=None, db=None):
raise Exception('Model not found')
# Enforce access on chained base models
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids, db=db):
if not await has_base_model_access(
user.id, model_info, user_role=user.role, user_group_ids=user_group_ids, db=db
):
raise Exception('Model not found')

View file

@ -16,7 +16,6 @@ from typing import Literal, Optional
import aiohttp
from authlib.integrations.starlette_client import OAuth
from authlib.jose.errors import BadSignatureError
from authlib.oauth2.rfc6749.errors import OAuth2Error
from authlib.oidc.core import UserInfo
from cryptography.fernet import Fernet
@ -24,6 +23,7 @@ from fastapi import (
HTTPException,
status,
)
from joserfc.errors import BadSignatureError
from joserfc.jws import JWSRegistry
from joserfc.registry import HeaderParameter
from mcp.shared.auth import (

View file

@ -7,6 +7,7 @@ from open_webui.utils.misc import (
deep_update,
replace_system_message_content,
)
from open_webui.utils.chat_variables import render_chat_variables, render_user_variables
from open_webui.utils.task import prompt_template, prompt_variables_template
@ -18,6 +19,15 @@ async def resolve_system_prompt(
if not system:
return ''
if metadata:
system = render_chat_variables(
system,
metadata.get('chat_variables', {}),
required=False,
)
system = render_user_variables(system, getattr(user, 'variables', {}) if user else {})
# Metadata (WebUI Usage)
if metadata:
variables = metadata.get('variables', {})

View file

@ -207,6 +207,7 @@ async def load_tool_module_by_id(tool_id, content=None):
if not ENABLE_PLUGINS:
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
frontmatter = None
if content is None:
tool = await Tools.get_tool_by_id(tool_id)
if not tool:
@ -238,7 +239,8 @@ async def load_tool_module_by_id(tool_id, content=None):
# Executing the modified content in the created module's namespace
exec(content, module.__dict__)
frontmatter = extract_frontmatter(content)
if frontmatter is None:
frontmatter = extract_frontmatter(content)
log.info(f'Loaded module: {module.__name__}')
# Create and return the object if the class 'Tools' is found in the module
@ -258,6 +260,7 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non
if not ENABLE_PLUGINS:
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
frontmatter = None
if content is None:
function = await Functions.get_function_by_id(function_id)
if not function:
@ -286,7 +289,8 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non
# Execute the modified content in the created module's namespace
exec(content, module.__dict__)
frontmatter = extract_frontmatter(content)
if frontmatter is None:
frontmatter = extract_frontmatter(content)
log.info(f'Loaded module: {module.__name__}')
# Create appropriate object based on available class type in the module

View file

@ -14,7 +14,6 @@ from typing import Any
from urllib.parse import ParseResult, urlparse
import redis as _redis_sync
from open_webui.env import (
REDIS_CLUSTER,
REDIS_HEALTH_CHECK_INTERVAL,
@ -33,6 +32,7 @@ _ACCEPTED_SCHEMES = frozenset({'redis', 'rediss'})
_SENTINEL_RETRYABLE = (
_redis_sync.exceptions.ConnectionError,
_redis_sync.exceptions.ReadOnlyError,
_redis_sync.exceptions.TimeoutError,
)
_FACTORY_METHODS = frozenset({'pipeline', 'pubsub', 'monitor', 'client', 'transaction'})
_CONNECTION_POOL: dict[tuple, Any] = {}
@ -126,10 +126,11 @@ class SentinelRedisProxy:
self._sentinel = sentinel
self._service_name = service_name
self._async_mode = async_mode
self._master: Any | None = None
def __getattr__(self, name: str) -> Any:
"""Proxy attribute access with automatic Sentinel failover retry."""
current_master = self._sentinel.master_for(self._service_name)
current_master = self._resolve_master()
original = getattr(current_master, name)
# Non-callable or factory attributes pass through without wrapping.
@ -143,7 +144,12 @@ class SentinelRedisProxy:
def _resolve_master(self) -> Any:
"""Ask Sentinel for the current master connection."""
return self._sentinel.master_for(self._service_name)
if self._master is None:
self._master = self._sentinel.master_for(self._service_name)
return self._master
def _clear_master(self) -> None:
self._master = None
def _should_retry(self, attempt: int) -> bool:
return attempt < REDIS_SENTINEL_MAX_RETRY_COUNT - 1
@ -184,6 +190,7 @@ class SentinelRedisProxy:
except _SENTINEL_RETRYABLE as exc:
if proxy._should_retry(attempt):
proxy._log_retry(exc, attempt)
proxy._clear_master()
if REDIS_RECONNECT_DELAY:
await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000)
continue
@ -208,6 +215,7 @@ class SentinelRedisProxy:
except _SENTINEL_RETRYABLE as exc:
if proxy._should_retry(attempt):
proxy._log_retry(exc, attempt)
proxy._clear_master()
if REDIS_RECONNECT_DELAY:
await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000)
continue
@ -229,6 +237,7 @@ class SentinelRedisProxy:
except _SENTINEL_RETRYABLE as exc:
if proxy._should_retry(attempt):
proxy._log_retry(exc, attempt)
proxy._clear_master()
if REDIS_RECONNECT_DELAY:
time.sleep(REDIS_RECONNECT_DELAY / 1000)
continue

View file

@ -4,7 +4,7 @@
fastapi==0.136.3
uvicorn[standard]==0.41.0
pydantic==2.13.4
python-multipart==0.0.27
python-multipart==0.0.32
itsdangerous==2.2.0
python-socketio==5.16.2
@ -14,6 +14,7 @@ bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.13.0
authlib==1.7.2
joserfc==1.7.4
requests==2.34.2
aiohttp==3.13.5 # do not update to 3.13.3 - broken

View file

@ -1,7 +1,7 @@
fastapi==0.136.3
uvicorn[standard]==0.41.0
pydantic==2.13.4
python-multipart==0.0.27
python-multipart==0.0.32
itsdangerous==2.2.0
python-socketio==5.16.2
@ -11,6 +11,7 @@ bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.13.0
authlib==1.7.2
joserfc==1.7.4
requests==2.34.2
aiohttp==3.13.5 # do not update to 3.13.3 - broken
@ -89,7 +90,7 @@ soundfile==0.13.1
pillow==12.2.0
opencv-python-headless==4.13.0.92
rapidocr-onnxruntime==1.4.4
rapidocr==3.9.2
rank-bm25==0.2.2
onnxruntime==1.26.0

View file

@ -9,7 +9,7 @@ dependencies = [
"fastapi==0.136.3",
"uvicorn[standard]==0.41.0",
"pydantic==2.13.4",
"python-multipart==0.0.27",
"python-multipart==0.0.32",
"itsdangerous==2.2.0",
"python-socketio==5.16.2",
@ -19,6 +19,7 @@ dependencies = [
"argon2-cffi==25.1.0",
"PyJWT[crypto]==2.13.0",
"authlib==1.7.2",
"joserfc==1.7.4",
"requests==2.34.2",
"aiohttp==3.13.5", # do not update to 3.13.3 - broken
@ -97,7 +98,7 @@ dependencies = [
"pillow==12.2.0",
"opencv-python-headless==4.13.0.92",
"rapidocr-onnxruntime==1.4.4",
"rapidocr==3.9.2",
"rank-bm25==0.2.2",
"onnxruntime==1.26.0",

View file

@ -6,18 +6,6 @@
font-display: swap;
}
@font-face {
font-family: 'Mona Sans';
src: url('/assets/fonts/Mona-Sans.woff2');
font-display: swap;
}
@font-face {
font-family: 'InstrumentSerif';
src: url('/assets/fonts/InstrumentSerif-Regular.ttf');
font-display: swap;
}
@font-face {
font-family: 'Vazirmatn';
src: url('/assets/fonts/Vazirmatn-Variable.ttf');
@ -64,10 +52,6 @@ code {
border-radius: 2px;
}
.font-secondary {
font-family: 'InstrumentSerif', sans-serif;
}
.app-muted {
@apply text-gray-500 dark:text-gray-400;
}

View file

@ -58,7 +58,12 @@ export const updateChatConfig = async (token: string, config: object) => {
return res;
};
export const createNewChat = async (token: string, chat: object, folderId: string | null) => {
export const createNewChat = async (
token: string,
chat: object,
folderId: string | null,
variables: object | null = null
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
@ -70,6 +75,7 @@ export const createNewChat = async (token: string, chat: object, folderId: strin
},
body: JSON.stringify({
chat: chat,
...(variables !== null ? { variables } : {}),
folder_id: folderId ?? null
})
})
@ -1197,7 +1203,12 @@ export const getChatAccessGrants = async (token: string, id: string) => {
return res;
};
export const updateChatById = async (token: string, id: string, chat: object) => {
export const updateChatById = async (
token: string,
id: string,
chat: object,
variables: object | null = null
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
@ -1208,7 +1219,8 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
chat: chat
chat: chat,
...(variables !== null ? { variables } : {})
})
})
.then(async (res) => {

View file

@ -163,7 +163,14 @@ export const getModels = async (
// Remove duplicates
const modelsMap = {};
for (const model of models) {
modelsMap[model.id] = model;
const existing = modelsMap[model.id];
modelsMap[model.id] = existing
? {
...existing,
...model,
info: existing.info ?? model.info
}
: model;
}
models = Object.values(modelsMap);

View file

@ -440,6 +440,62 @@ export const updateUserInfo = async (token: string, info: object) => {
return res;
};
export const getUserVariables = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateUserVariables = async (token: string, variables: Record<string, string>) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
variables
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAndUpdateUserLocation = async (token: string) => {
const location = await getUserPosition().catch((err) => {
console.error(err);

View file

@ -251,14 +251,14 @@
type="date"
bind:value={customStart}
max={customEnd || undefined}
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none dark:scheme-dark"
/>
<span class="text-xs text-gray-400">–</span>
<input
type="date"
bind:value={customEnd}
min={customStart || undefined}
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none dark:scheme-dark"
/>
{/if}
<select

View file

@ -55,7 +55,7 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
{#if loaded}
<div class="flex flex-col w-full">
<div class="flex flex-col w-full mb-2">

View file

@ -29,7 +29,7 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<ManageOllama {urlIdx} />
</div>
</div>

View file

@ -44,6 +44,7 @@
ENABLE_CONTEXT_COMPACTION: false,
CONTEXT_COMPACTION_TOKEN_THRESHOLD: 80000,
CONTEXT_COMPACTION_TOKEN_CAP: 80000,
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: 40,
CONTEXT_COMPACTION_PROMPT_TEMPLATE: ''
};
@ -273,6 +274,22 @@
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Retained Messages')}
description={$i18n.t(
'Percentage of recent messages to keep after older messages are summarized.'
)}
>
<input
type="number"
min="10"
max="50"
step="1"
class={inputClass}
bind:value={chatConfig.CONTEXT_COMPACTION_RETENTION_PERCENTAGE}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('Context Compaction Prompt')}
description={$i18n.t(

View file

@ -911,7 +911,7 @@
: ''}"
id="model-item-{model.id}"
>
<div class="self-center pr-1 text-gray-400 dark:text-gray-600">
<div class="self-center pr-1 -ml-1 text-gray-400 dark:text-gray-600">
<Tooltip
content={canReorderModels
? $i18n.t('Drag to reorder')
@ -1094,7 +1094,7 @@
{/if}
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
class="hidden sm:flex self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedModelId = model.id;

View file

@ -230,7 +230,7 @@
type="date"
bind:value={onceDate}
min={new Date().toISOString().split('T')[0]}
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
class="bg-transparent outline-hidden text-xs dark:scheme-dark"
on:click={(e) => e.stopPropagation()}
on:input={onChange}
/>
@ -239,7 +239,7 @@
<input
type="time"
bind:value={onceTime}
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
class="bg-transparent outline-hidden text-xs dark:scheme-dark"
on:click={(e) => e.stopPropagation()}
on:input={onChange}
/>
@ -256,7 +256,7 @@
minute = m;
onChange();
}}
class="bg-transparent text-center outline-hidden text-xs dark:color-scheme-dark"
class="bg-transparent text-center outline-hidden text-xs dark:scheme-dark"
on:click={(e) => e.stopPropagation()}
/>
</div>

View file

@ -214,11 +214,11 @@
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('When')}</div>
<div class="flex items-center gap-2 text-sm flex-wrap">
<input type="date" class="bg-transparent outline-hidden" bind:value={startDate} />
<input type="date" class="bg-transparent outline-hidden dark:scheme-dark" bind:value={startDate} />
{#if !allDay}
<input type="time" class="bg-transparent outline-hidden" bind:value={startTime} />
<input type="time" class="bg-transparent outline-hidden dark:scheme-dark" bind:value={startTime} />
<span class="text-gray-300 dark:text-gray-600">–</span>
<input type="time" class="bg-transparent outline-hidden" bind:value={endTime} />
<input type="time" class="bg-transparent outline-hidden dark:scheme-dark" bind:value={endTime} />
{/if}
<label class="flex items-center gap-1.5 cursor-pointer text-xs text-gray-400 ml-auto">
<input type="checkbox" class="accent-blue-500" bind:checked={allDay} />

View file

@ -111,12 +111,14 @@
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import NotificationToast from '../NotificationToast.svelte';
import Spinner from '../common/Spinner.svelte';
import Modal from '../common/Modal.svelte';
import { isEmbedWindow } from '../common/FullHeightIframe.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte';
import Image from '../common/Image.svelte';
import XMark from '../icons/XMark.svelte';
import EmbeddedChatHistoryDropdown from './EmbeddedChatHistoryDropdown.svelte';
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
export let chatIdProp = '';
export let embedded = false;
@ -367,9 +369,110 @@
let chatFiles = [];
let files = [];
let params = {};
let chatVariables = {};
let showChatVariablesModal = false;
let loadedChatIdProp = '';
let currentDraftKey = '';
const mergeChatVariableSchemas = (modelIds = [], availableModels = []) => {
const byKey: Record<string, any> = {};
const conflicts: any[] = [];
for (const modelId of modelIds.filter(Boolean)) {
const fields =
availableModels.find((model) => model.id === modelId)?.info?.meta?.chat_variables_schema
?.fields ?? [];
for (const rawField of fields) {
const field = {
...rawField,
type: rawField?.type ?? 'text',
required: Boolean(rawField?.required)
};
if (!field?.key) continue;
const { required, ...shape } = field;
const existing = byKey[field.key];
if (!existing) {
byKey[field.key] = {
field,
modelIds: [modelId],
shape
};
continue;
}
if (!equal(existing.shape, shape)) {
conflicts.push({
key: field.key,
modelIds: [...existing.modelIds, modelId]
});
continue;
}
existing.field = {
...existing.field,
required: existing.field.required || field.required
};
existing.modelIds.push(modelId);
}
}
return {
fields: Object.values(byKey).map((item: any) => item.field),
conflicts
};
};
const hasValue = (value) => value !== undefined && value !== null && value !== '';
const getChatVariablesForm = (modelIds = [], values = {}, availableModels = []) => {
const { fields, conflicts } = mergeChatVariableSchemas(modelIds, availableModels);
const empty =
fields.length > 0 &&
fields.every((field) => !hasValue(values?.[field.key]) && !hasValue(field.default));
const missing = fields.some(
(field) => field.required && !hasValue(values?.[field.key]) && !hasValue(field.default)
);
const variables = fields.reduce(
(acc, field) => {
const { key, ...inputField } = field;
acc[key] = {
...inputField,
default: hasValue(values?.[key]) ? values[key] : inputField.default
};
return acc;
},
{} as Record<string, any>
);
return { conflicts, empty, missing, variables };
};
const saveChatVariables = async (values) => {
chatVariables = { ...chatVariables, ...values };
if ($chatId && !$temporaryChatEnabled && !$chatId.startsWith('local:')) {
const res = await updateChatById(localStorage.token, $chatId, {}, chatVariables).catch(
(err) => {
console.error('[chat variables save]', err);
toast.error($i18n.t('Failed to save chat variables'));
return null;
}
);
if (res) chat = res;
}
};
let oldSelectedModelIds = [''];
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
onSelectedModelIdsChange();
}
const onSelectedModelIdsChange = () => {
resetInput();
oldSelectedModelIds = structuredClone(selectedModelIds);
};
const mergeFiles = (current, incoming) => {
const seen = new Set();
return [...(incoming ?? []), ...(current ?? [])].filter((file) => {
@ -514,6 +617,7 @@
currentId: null
};
params = {};
chatVariables = {};
chatFiles = [];
files = [];
selectedToolIds = [];
@ -585,16 +689,6 @@
initiateOAuthRedirect(nextTool);
};
let oldSelectedModelIds = [''];
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
onSelectedModelIdsChange();
}
const onSelectedModelIdsChange = () => {
resetInput();
oldSelectedModelIds = structuredClone(selectedModelIds);
};
const resetInput = async () => {
selectedToolIds = [];
selectedSkillIds = [];
@ -897,7 +991,7 @@
} else if (type === 'chat:message:follow_ups') {
message.followUps = data.follow_ups;
if (autoScroll) {
if (shouldAutoScrollResponse()) {
scrollToBottom('smooth');
}
} else if (type === 'chat:outlet') {
@ -1268,6 +1362,13 @@
pageSubscribe();
showControlsSubscribe();
selectedFolderSubscribe();
// Clear the selected chat when leaving the chat surface (e.g. navigating
// to the admin panel), otherwise the previously-viewed chat stays selected
// in the sidebar and deleting/archiving it wrongly navigates away.
chatId.set('');
chatTitle.set('');
window.removeEventListener('message', onMessageHandler);
$socket?.off('events', chatEventHandler);
$socket?.off('connect', handleSocketConnect);
@ -1703,6 +1804,7 @@
chatFiles = [];
params = {};
chatVariables = {};
taskIds = null;
chatTasks = [];
@ -1854,6 +1956,7 @@
noteChatDebug('getTagsById completed', { tagCount: tags?.length ?? 0 });
const chatContent = chat.chat;
chatVariables = chat?.variables ?? {};
if (chatContent) {
noteChatDebug('chat payload found', {
@ -2009,9 +2112,14 @@
await messagesRef?.scrollToTop();
};
const shouldAutoScrollResponse = () =>
autoScroll && ($settings?.scrollOnResponseGeneration ?? true);
let scrollRAF = null;
let contentsRAF = null;
const scheduleScrollToBottom = () => {
const scheduleResponseScrollToBottom = () => {
if (!shouldAutoScrollResponse()) return;
if (!scrollRAF) {
scrollRAF = requestAnimationFrame(async () => {
scrollRAF = null;
@ -2060,7 +2168,7 @@
messages: messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
content: getOutputText(m.output) || m.content,
info: m.info ? m.info : undefined,
timestamp: m.timestamp,
...(m.sources ? { sources: m.sources } : {})
@ -2330,7 +2438,7 @@
history.messages[message.id] = message;
await tick();
if (autoScroll) {
if (shouldAutoScrollResponse()) {
scrollToBottom();
}
@ -2351,9 +2459,7 @@
console.log(data);
await tick();
if (autoScroll) {
scheduleScrollToBottom();
}
scheduleResponseScrollToBottom();
};
//////////////////////////
@ -2552,6 +2658,16 @@
toast.error($i18n.t('Model not selected'));
return;
}
const form = getChatVariablesForm(selectedModelIds, chatVariables, $models);
if (form.conflicts.length > 0) {
showChatVariablesModal = true;
toast.error($i18n.t('Chat Variables have conflicting model definitions'));
return;
}
if (form.missing || form.empty) {
showChatVariablesModal = true;
return;
}
if (
files.length > 0 &&
@ -2968,6 +3084,8 @@
// Only send terminal_id if the model has terminal capability enabled
const terminalEnabled = model.info?.meta?.capabilities?.terminal ?? true;
const useChatVariablesFallback =
!_chatId || $temporaryChatEnabled || _chatId.startsWith('local:');
const res = await generateOpenAIChatCompletion(
localStorage.token,
@ -3002,6 +3120,7 @@
$user?.email
)
},
...(useChatVariablesFallback ? { chat_variables: chatVariables } : {}),
model_item: $models.find((m) => m.id === model.id),
session_id: $socket?.id,
@ -3100,7 +3219,9 @@
}
await tick();
scrollToBottom();
if (shouldAutoScrollResponse()) {
scrollToBottom();
}
};
const handleOpenAIError = async (error, responseMessage) => {
@ -3173,7 +3294,7 @@
history.messages[history.currentId] = responseMessage;
if (autoScroll) {
if (shouldAutoScrollResponse()) {
scrollToBottom();
}
}
@ -3321,9 +3442,7 @@
history.messages[messageId] = message;
}
if (autoScroll) {
scheduleScrollToBottom();
}
scheduleResponseScrollToBottom();
}
await saveChatHandler(_chatId, history);
@ -3352,7 +3471,8 @@
tags: [],
timestamp: Date.now()
},
$selectedFolder?.id
$selectedFolder?.id,
chatVariables
);
_chatId = chat.id;
@ -3515,6 +3635,47 @@
<audio id="audioElement" style="display: none;"></audio>
{#if getChatVariablesForm(selectedModelIds, chatVariables, $models).conflicts.length > 0}
<Modal bind:show={showChatVariablesModal} size="md">
<div>
<div class="flex justify-between px-4 pt-3 pb-1 dark:text-gray-300">
<div class="self-center text-sm font-medium">{$i18n.t('Chat Variables')}</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"
on:click={() => {
showChatVariablesModal = false;
}}
>
<XMark className="size-4" />
</button>
</div>
<div class="px-5 pb-4 text-sm text-gray-600 dark:text-gray-300">
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('Selected models define incompatible Chat Variables.')}
</div>
<div class="flex flex-col gap-1">
{#each getChatVariablesForm(selectedModelIds, chatVariables, $models).conflicts as conflict}
<div class="rounded-lg border border-red-200 px-3 py-2 text-xs dark:border-red-900/60">
<div class="font-medium text-red-600 dark:text-red-400">{conflict.key}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">
{conflict.modelIds.join(', ')}
</div>
</div>
{/each}
</div>
</div>
</div>
</Modal>
{:else}
<InputVariablesModal
bind:show={showChatVariablesModal}
title={$i18n.t('Chat Variables')}
variables={getChatVariablesForm(selectedModelIds, chatVariables, $models).variables}
onSave={saveChatVariables}
/>
{/if}
<WebSearchConfirmDialog
bind:show={showWebSearchConfirm}
title={$i18n.t('Use Web Search?')}
@ -3673,7 +3834,8 @@
messages: messages,
timestamp: Date.now()
},
null
null,
chatVariables
);
if (savedChat) {
@ -3726,6 +3888,7 @@
{mergeResponses}
{chatActionHandler}
{addMessages}
allowDelete={!(generating || taskIds?.length)}
forkHandler={generating || taskIds?.length ? null : handleForkChat}
topPadding={!embedded}
bottomPadding={files.length > 0}
@ -3819,6 +3982,9 @@
}
}}
onWebSearchToggle={handleWebSearchToggle}
on:chatVariables={() => {
showChatVariablesModal = true;
}}
on:submit={async (e) => {
clearDraft($chatId);
if (e.detail || files.length > 0) {
@ -3895,6 +4061,9 @@
messageQueue={$chatRequestQueues[$chatId] ?? []}
{chatTasks}
onWebSearchToggle={handleWebSearchToggle}
on:chatVariables={() => {
showChatVariablesModal = true;
}}
on:submit={async (e) => {
clearDraft($chatId);
if (e.detail || files.length > 0) {
@ -3930,6 +4099,9 @@
{onSelect}
{onUpload}
onWebSearchToggle={handleWebSearchToggle}
on:chatVariables={() => {
showChatVariablesModal = true;
}}
onChange={(data) => {
if (!$temporaryChatEnabled) {
saveDraft(data);

View file

@ -132,6 +132,11 @@
let selectedModelIds = [];
$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
$: hasChatVariables = selectedModelIds.some(
(modelId) =>
($models.find((model) => model.id === modelId)?.info?.meta?.chat_variables_schema?.fields
?.length ?? 0) > 0
);
export let history;
export let taskIds = null;
@ -1095,7 +1100,10 @@
shiftKey = true;
}
if (matchKeybinding(e) === Shortcut.TOGGLE_DICTATION) {
if (
$settings?.keyboardShortcuts !== false &&
matchKeybinding(e) === Shortcut.TOGGLE_DICTATION
) {
e.preventDefault();
if (recording) {
// Confirm and stop recording
@ -2233,6 +2241,22 @@
/>
</div>
{#if hasChatVariables}
<Tooltip content={$i18n.t('Chat Variables')} placement="top">
<button
type="button"
id="chat-variables-button"
class="flex size-[1.875rem] shrink-0 items-center justify-center rounded-full bg-transparent text-gray-500 transition-colors hover:text-gray-800 focus:outline-hidden dark:text-gray-400 dark:hover:text-gray-100"
aria-label={$i18n.t('Chat Variables')}
on:click={() => {
dispatch('chatVariables');
}}
>
<Knobs className="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
{#if isActive && prompt === '' && files.length === 0}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Stop')}>

View file

@ -14,11 +14,16 @@
export let show = false;
export let variables = {};
export let title = $i18n.t('Input Variables');
export let onSave = (e) => {};
let loading = true;
let variableValues = {};
let variablesKey = '';
const getVariableLabel = (variable) => variables[variable]?.label ?? variable;
const getVariablesKey = (value) => JSON.stringify(value ?? {});
const submitHandler = async () => {
// Normalize Windows CRLF (\r\n) to LF (\n) for all string values
@ -59,15 +64,25 @@
}
};
$: if (!show) {
variablesKey = '';
}
$: if (show) {
init();
const key = getVariablesKey(variables);
if (key !== variablesKey) {
variablesKey = key;
init();
}
}
</script>
<Modal bind:show size="md">
<div>
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">{$i18n.t('Input Variables')}</div>
<div class=" text-sm font-medium self-center">
{title}
</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"
on:click={() => {
@ -78,7 +93,7 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
@ -95,10 +110,10 @@
<div class=" py-0.5 w-full justify-between">
<div class="flex w-full justify-between mb-1.5">
<div class=" self-center text-xs font-normal">
{variable}
{getVariableLabel(variable)}
{#if variables[variable]?.required ?? false}
<span class=" text-gray-500">*{$i18n.t('required')}</span>
<span class="ml-1 text-gray-500">* {$i18n.t('required')}</span>
{/if}
</div>
</div>
@ -111,13 +126,11 @@
bind:value={variableValues[variable]}
id="input-variable-{idx}"
>
{#if variables[variable]?.placeholder}
<option value="" disabled selected>
{variables[variable].placeholder}
</option>
{/if}
<option value="" disabled>
{variables[variable]?.placeholder ?? $i18n.t('Select an option')}
</option>
{#each variables[variable]?.options ?? [] as option}
<option value={option} selected={option === variableValues[variable]}>
<option value={option}>
{option}
</option>
{/each}
@ -175,7 +188,7 @@
{:else if variables[variable]?.type === 'date'}
<input
type="date"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30 dark:scheme-dark"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
@ -186,7 +199,7 @@
{:else if variables[variable]?.type === 'datetime-local'}
<input
type="datetime-local"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30 dark:scheme-dark"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
@ -208,7 +221,7 @@
{:else if variables[variable]?.type === 'month'}
<input
type="month"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30 dark:scheme-dark"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
@ -283,7 +296,7 @@
{:else if variables[variable]?.type === 'time'}
<input
type="time"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30 dark:scheme-dark"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"

View file

@ -300,6 +300,9 @@
if (transcribe) {
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
// reset accumulated transcription from previous sessions
transcription = '';
// Create a SpeechRecognition object
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
@ -353,13 +356,21 @@
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
onCancel();
stopRecording();
cancelRecording();
};
}
}
}
};
const cancelRecording = async () => {
if (speechRecognition) {
// detach onend so cancelling does not confirm the transcription
speechRecognition.onend = null;
}
await stopRecording();
};
const stopRecording = async () => {
if (recording && mediaRecorder) {
await mediaRecorder.stop();
@ -411,7 +422,7 @@
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
stopRecording();
cancelRecording();
onCancel();
}
};
@ -468,7 +479,7 @@
rounded-full"
on:click={async () => {
stopRecording();
cancelRecording();
onCancel();
}}
>

View file

@ -43,6 +43,7 @@
export let forkHandler: Function | null = null;
export let readOnly = false;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
@ -89,7 +90,7 @@
let message = history.messages[history.currentId];
const visitedMessageIds = new Set();
while (message && (messagesCount !== null ? _messages.length <= messagesCount : true)) {
while (message && (messagesCount !== null ? _messages.length < messagesCount : true)) {
if (visitedMessageIds.has(message.id)) {
console.warn('Circular dependency detected in message history', message.id);
break;
@ -555,6 +556,7 @@
{mergeResponses}
{addMessages}
{forkHandler}
{allowDelete}
{triggerScroll}
{readOnly}
{compactPreview}

View file

@ -42,17 +42,22 @@
export let forkHandler: Function | null = null;
export let triggerScroll;
export let readOnly = false;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
export let topPadding = false;
export let onInsertToNote: ((content: string) => void) | null = null;
// Safari's content-visibility implementation has paint bugs that leave
// on-screen messages blank (#26712), so skip virtualization there
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
</script>
<div
role="listitem"
class="flex flex-col justify-between px-5 mb-3 w-full {($settings?.widescreenMode ?? null)
class="flex flex-col justify-between px-3.5 mb-3 w-full {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-[58rem]'} mx-auto rounded-lg group message-listitem"
: 'max-w-[58rem]'} mx-auto rounded-lg group {isSafari ? '' : 'message-listitem'}"
>
{#if history.messages[messageId]}
{#if history.messages[messageId].role === 'user'}
@ -72,6 +77,7 @@
{showNextMessage}
{editMessage}
{deleteMessage}
{allowDelete}
{readOnly}
{compactPreview}
{editCodeBlock}
@ -97,6 +103,7 @@
{actionMessage}
{submitMessage}
{deleteMessage}
{allowDelete}
{continueResponse}
{regenerateResponse}
{addMessages}
@ -122,6 +129,7 @@
{actionMessage}
{submitMessage}
{deleteMessage}
{allowDelete}
{continueResponse}
{regenerateResponse}
{mergeResponses}

View file

@ -28,6 +28,7 @@
export let isLastMessage;
export let readOnly = false;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
@ -377,6 +378,7 @@
{saveMessage}
{rateMessage}
{deleteMessage}
{allowDelete}
{actionMessage}
{submitMessage}
{continueResponse}

View file

@ -130,14 +130,14 @@
</button>
</div>
<div class="w-full flex justify-center">
<div class=" relative w-fit overflow-x-auto scrollbar-none">
<div class="mt-1.5 w-fit flex gap-1 pb-2">
<div class="w-full overflow-x-auto">
<div class="relative mx-auto min-w-[19rem] max-w-80">
<div class="mt-1.5 grid grid-cols-10 gap-1 pb-2">
<!-- 1-10 scale -->
{#each Array.from({ length: 10 }).map((_, i) => i + 1) as rating}
<button
aria-label={$i18n.t('Rate {{rating}} out of 10', { rating })}
class="size-7 text-sm border border-gray-100/30 dark:border-gray-850/30 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating ===
class="aspect-square w-full text-sm border border-gray-100/30 dark:border-gray-850/30 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating ===
rating
? 'bg-gray-100 dark:bg-gray-800'
: ''} transition rounded-full disabled:cursor-not-allowed disabled:text-gray-500 disabled:bg-white dark:disabled:bg-gray-900"

View file

@ -165,6 +165,7 @@
export let isLastMessage = true;
export let readOnly = false;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
export let topPadding = false;
@ -1074,37 +1075,6 @@
</button>
</Tooltip>
{#if message.done && !readOnly && forkHandler}
<Tooltip content="Fork chat" placement="bottom">
<button
aria-label="Fork chat"
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
forkHandler?.(message.id);
}}
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 12H9" />
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
<path d="M17 4L20 7L17 10" />
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
<path d="M17 14L20 17L17 20" />
</svg>
</button>
</Tooltip>
{/if}
{#if onInsertToNote && visibleResponseContent}
<Tooltip content={$i18n.t('Insert into note')} placement="bottom">
<button
@ -1369,6 +1339,37 @@
</Tooltip>
{/if}
{#if message.done && !readOnly && forkHandler}
<Tooltip content="Fork chat" placement="bottom">
<button
aria-label="Fork chat"
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
forkHandler?.(message.id);
}}
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 12H9" />
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
<path d="M17 4L20 7L17 10" />
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
<path d="M17 14L20 17L17 20" />
</svg>
</button>
</Tooltip>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? true)}
{#if $settings?.regenerateMenu ?? true}
<button
@ -1482,7 +1483,7 @@
{/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete_message ?? true)}
{#if siblings.length > 1}
{#if allowDelete && siblings.length > 1}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
type="button"

View file

@ -39,6 +39,7 @@
export let isFirstMessage: boolean;
export let readOnly: boolean;
export let allowDelete = true;
export let compactPreview = false;
export let editCodeBlock = true;
export let topPadding = false;
@ -578,7 +579,7 @@
{/if}
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.delete_message ?? false)}
{#if !compactPreview && !readOnly && (!isFirstMessage || siblings.length > 1)}
{#if !compactPreview && !readOnly && allowDelete && (!isFirstMessage || siblings.length > 1)}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
class="{($settings?.highContrastMode ?? false)

View file

@ -13,6 +13,7 @@
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
import { deleteModelById } from '$lib/apis/models';
import { unloadModel } from '$lib/apis';
import {
@ -629,12 +630,25 @@
const model = deleteModelTarget;
if (!model) return;
const res = await deleteModel(localStorage.token, model.id).catch((error) => {
toast.error($i18n.t('Error deleting model: {{error}}', { error }));
});
let success = false;
if (res) {
// $i18n.t('Model {{modelId}} not found')
if (model?.info?.base_model_id) {
// Workspace model: only delete the workspace model record, not the underlying base model
const res = await deleteModelById(localStorage.token, model.id).catch((error) => {
toast.error($i18n.t('Error deleting model: {{error}}', { error }));
return null;
});
success = !!res;
} else {
// Base Ollama model: delete from Ollama directly
const res = await deleteModel(localStorage.token, model.id).catch((error) => {
toast.error($i18n.t('Error deleting model: {{error}}', { error }));
return null;
});
success = !!res;
}
if (success) {
toast.success(
$i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id })
);

View file

@ -237,6 +237,7 @@
{onChange}
{onUpload}
{onWebSearchToggle}
on:chatVariables
on:submit={(e) => {
dispatch('submit', e.detail);
}}

View file

@ -4,6 +4,7 @@
import { user, config } from '$lib/stores';
import { updateUserProfile, createAPIKey, getAPIKey, getSessionUser } from '$lib/apis/auths';
import { getUserVariables, updateUserVariables } from '$lib/apis/users';
import { WEBUI_BASE_URL } from '$lib/constants';
import UpdatePassword from './Account/UpdatePassword.svelte';
@ -14,6 +15,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import UserProfileImage from './Account/UserProfileImage.svelte';
import UserSettingField from './UserSettingField.svelte';
import UserSettingRow from './UserSettingRow.svelte';
@ -38,15 +40,96 @@
let APIKey = '';
let APIKeyCopied = false;
let variableRows: { key: string; value: string }[] = [];
let variableModalOpen = false;
let variableFormIndex: number | null = null;
let variableFormKey = '';
let variableFormValue = '';
const textareaClass =
'w-full resize-y rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const inputClass =
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const variableValueClass =
'w-full resize-none rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
const variableRowClass = 'flex w-full items-center gap-1.5';
const variableKeyRegex = /^[a-z][a-z0-9_]*$/;
const setVariableRows = (variables = {}) => {
variableRows = Object.entries(variables).map(([key, value]) => ({
key,
value: String(value ?? '')
}));
};
const openVariableModal = (idx: number | null = null) => {
const row = idx === null ? null : variableRows[idx];
variableFormIndex = idx;
variableFormKey = row?.key ?? '';
variableFormValue = row?.value ?? '';
variableModalOpen = true;
};
const removeVariable = (idx: number) => {
variableRows = variableRows.filter((_, rowIdx) => rowIdx !== idx);
};
const saveVariableForm = () => {
const key = variableFormKey.trim();
if (!variableKeyRegex.test(key)) {
toast.error($i18n.t('Variable keys must use lowercase snake case.'));
return;
}
if (variableRows.some((row, idx) => idx !== variableFormIndex && row.key === key)) {
toast.error($i18n.t('Variable keys must be unique.'));
return;
}
const row = { key, value: variableFormValue ?? '' };
variableRows =
variableFormIndex === null
? [...variableRows, row]
: variableRows.map((current, idx) => (idx === variableFormIndex ? row : current));
variableModalOpen = false;
};
const deleteVariableForm = () => {
if (variableFormIndex !== null) {
removeVariable(variableFormIndex);
}
variableModalOpen = false;
};
const getVariablesPayload = () => {
const variables: Record<string, string> = {};
for (const row of variableRows) {
const key = row.key.trim();
if (!key && !row.value) {
continue;
}
if (!variableKeyRegex.test(key)) {
throw $i18n.t('Variable keys must use lowercase snake case.');
}
if (Object.prototype.hasOwnProperty.call(variables, key)) {
throw $i18n.t('Variable keys must be unique.');
}
variables[key] = row.value ?? '';
}
return variables;
};
const submitHandler = async () => {
let variables: Record<string, string>;
try {
variables = getVariablesPayload();
} catch (error) {
toast.error(`${error}`);
return false;
}
if (name !== $user?.name) {
if (profileImageUrl === generateInitialsImage($user?.name) || profileImageUrl === '') {
profileImageUrl = generateInitialsImage(name);
@ -63,7 +146,13 @@
toast.error(`${error}`);
});
if (updatedUser) {
const variablesRes = await updateUserVariables(localStorage.token, variables).catch((error) => {
toast.error(`${error}`);
return null;
});
if (updatedUser && variablesRes) {
setVariableRows(variablesRes.variables ?? {});
// Get Session User Info
const sessionUser = await getSessionUser(localStorage.token).catch((error) => {
toast.error(`${error}`);
@ -102,6 +191,12 @@
dateOfBirth = user?.date_of_birth ?? '';
}
const userVariables = await getUserVariables(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
setVariableRows(userVariables?.variables ?? {});
// Only fetch API key if the feature is enabled and user has permission
if (
user &&
@ -197,7 +292,7 @@
description={$i18n.t('Set the birth date saved with your profile.')}
>
<input
class={inputClass}
class="{inputClass} dark:scheme-dark"
type="date"
aria-label={$i18n.t('Birth Date')}
bind:value={dateOfBirth}
@ -206,6 +301,47 @@
</UserSettingField>
</UserSettingSection>
<section class="mt-4 w-full">
<div
class="mb-0.5 flex items-center justify-between gap-2 text-xs text-gray-600 dark:text-gray-400"
>
<div class="flex min-w-0 items-center gap-1.5">
{$i18n.t('User Variables')}
<span class="text-gray-400 dark:text-gray-600">{variableRows.length}</span>
</div>
<button class={actionButtonClass} type="button" on:click={() => openVariableModal()}>
{$i18n.t('Add')}
</button>
</div>
<div class="flex flex-col gap-1 py-1">
{#each variableRows as row, idx}
<div class={variableRowClass}>
<div class="min-w-0 truncate font-mono text-xs text-gray-700 dark:text-gray-300">
{row.key || $i18n.t('key_name')}
</div>
<div class="min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-gray-500">
{row.value || $i18n.t('Empty')}
</div>
<button class={actionButtonClass} type="button" on:click={() => openVariableModal(idx)}>
{$i18n.t('Edit')}
</button>
</div>
{/each}
{#if variableRows.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('No user variables configured.')}
</div>
{/if}
</div>
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Use these in model system prompts as {{example}}.', {
example: '{{user.variables.key_name}}'
})}
</div>
</section>
{#if $config?.features.enable_login_form && $config?.features.enable_password_change_form}
<UserSettingSection title={$i18n.t('Password')}>
<UpdatePassword />
@ -397,3 +533,68 @@
</button>
</div>
</div>
<Modal size="sm" bind:show={variableModalOpen}>
<form class="p-4" on:submit|preventDefault={saveVariableForm}>
<h2 class="mb-3 text-sm font-medium text-gray-900 dark:text-white">
{variableFormIndex === null ? $i18n.t('Add User Variable') : $i18n.t('Edit User Variable')}
</h2>
<div class="mb-1 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Key')}
</div>
<input
class={inputClass}
type="text"
bind:value={variableFormKey}
aria-label={$i18n.t('Variable key')}
placeholder={$i18n.t('key_name')}
autocomplete="off"
spellcheck="false"
/>
<div class="mb-1 mt-3 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Value')}
</div>
<Textarea
className={variableValueClass}
rows="6"
minSize={132}
bind:value={variableFormValue}
ariaLabel={$i18n.t('Variable value')}
placeholder={$i18n.t('Value')}
/>
<div class="mt-4 flex items-center justify-between gap-2">
<div>
{#if variableFormIndex !== null}
<button
class="text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
type="button"
on:click={deleteVariableForm}
>
{$i18n.t('Delete')}
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
class="text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
type="button"
on:click={() => {
variableModalOpen = false;
}}
>
{$i18n.t('Cancel')}
</button>
<button
class="rounded-full bg-black px-3.5 py-1.5 text-sm font-normal text-white transition hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100"
type="submit"
>
{$i18n.t('Done')}
</button>
</div>
</div>
</form>
</Modal>

View file

@ -55,7 +55,7 @@
}}>{show ? $i18n.t('Hide') : $i18n.t('Show')}</button
>
</div>
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
<p class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Update the password used for email and password sign-in.')}
</p>
@ -113,10 +113,8 @@
</div>
</div>
<div class="mt-3 flex justify-end">
<button
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
>
<div class="flex justify-end">
<button class={actionButtonClass}>
{$i18n.t('Update password')}
</button>
</div>

View file

@ -32,6 +32,7 @@
let widescreenMode = false;
let splitLargeChunks = false;
let scrollOnBranchChange = true;
let scrollOnResponseGeneration = true;
let showFilesOnTerminalSelect = true;
let userLocation = false;
@ -250,6 +251,7 @@
widescreenMode = $settings?.widescreenMode ?? false;
splitLargeChunks = $settings?.splitLargeChunks ?? false;
scrollOnBranchChange = $settings?.scrollOnBranchChange ?? true;
scrollOnResponseGeneration = $settings?.scrollOnResponseGeneration ?? true;
showFilesOnTerminalSelect = $settings?.showFilesOnTerminalSelect ?? true;
temporaryChatByDefault = $settings?.temporaryChatByDefault ?? false;
@ -929,6 +931,28 @@
</p>
</div>
<div>
<div class={settingRowClass}>
<div id="response-auto-scroll-label" class={settingLabelClass}>
{$i18n.t('Response Auto-Scroll')}
</div>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="response-auto-scroll-label"
tooltip={true}
bind:state={scrollOnResponseGeneration}
on:change={() => {
saveSettings({ scrollOnResponseGeneration });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('Follow assistant responses as they are generated.')}
</p>
</div>
<div>
<div class={settingRowClass}>
<div id="insert-suggestion-prompt-label" class={settingLabelClass}>

View file

@ -66,7 +66,7 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form class="flex flex-col w-full" on:submit|preventDefault={submitHandler}>
<div class="px-1">

View file

@ -14,6 +14,7 @@
} from '$lib/shortcuts';
import type { KeybindingsMap, ShortcutDefinition } from '$lib/shortcuts';
import { settings } from '$lib/stores';
import Switch from '$lib/components/common/Switch.svelte';
import ShortcutItem from '../ShortcutItem.svelte';
type ShortcutEntry = {
@ -29,12 +30,16 @@
const i18n: I18nStore = getContext('i18n');
export let saveSettings: (updated: Record<string, unknown>) => Promise<void>;
let categorizedShortcuts: CategorizedShortcuts = {};
let isMac = false;
let recordingShortcut: Shortcut | null = null;
let enableKeyboardShortcuts = true;
onMount(() => {
isMac = /Mac/i.test(navigator.userAgent);
enableKeyboardShortcuts = $settings?.keyboardShortcuts ?? true;
});
$: {
@ -112,6 +117,31 @@
</button>
</div>
<div class="mb-3">
<div class="flex items-center justify-between gap-2.5">
<div
id="enable-keyboard-shortcuts-label"
class="min-w-0 text-xs text-gray-600 dark:text-gray-400"
>
{$i18n.t('Enable Keyboard Shortcuts')}
</div>
<div class="flex shrink-0 items-center justify-end gap-1.5">
<Switch
ariaLabelledbyId="enable-keyboard-shortcuts-label"
tooltip={true}
bind:state={enableKeyboardShortcuts}
on:change={() => {
saveSettings({ keyboardShortcuts: enableKeyboardShortcuts });
}}
/>
</div>
</div>
<p class="mt-1.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('When disabled, keyboard shortcuts will not trigger any actions.')}
</p>
</div>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<div class="flex items-center gap-2 px-1 pb-1">
<span class="flex-1 text-[0.625rem] text-gray-400 dark:text-gray-600">

View file

@ -6,7 +6,7 @@
<section class="w-full {first ? '' : 'mt-4'} {className}">
{#if title}
<h3 class="mb-2 text-xs text-gray-400 dark:text-gray-600">
<h3 class="mb-2 text-xs text-gray-600 dark:text-gray-400">
{title}
</h3>
{/if}

View file

@ -259,8 +259,13 @@
'pastelargetextasfile',
'reset background',
'resetbackground',
'disable auto scroll',
'disableautoscroll',
'response auto copy',
'response auto scroll',
'response auto-scroll',
'responseautocopy',
'responseautoscroll',
'rich text input for chat',
'richtextinputforchat',
'right to left',
@ -1171,7 +1176,7 @@
{:else if selectedTab === 'notifications'}
<Notifications {saveSettings} />
{:else if selectedTab === 'shortcuts'}
<Shortcuts />
<Shortcuts {saveSettings} />
{:else if selectedTab === 'connections'}
<Connections
saveSettings={async (updated: Record<string, any>) => {

View file

@ -2,13 +2,15 @@
import { onMount } from 'svelte';
import { formatChord, isConfigurableShortcut, keybindings, shortcuts } from '$lib/shortcuts';
import type { Shortcut } from '$lib/shortcuts';
import { settings } from '$lib/stores';
export let name: string;
export let className = '';
let isMac = false;
let mounted = false;
let isVisible = true;
$: isVisible = $settings?.keyboardShortcuts !== false;
onMount(() => {
isMac = /Mac/i.test(navigator.userAgent);

View file

@ -1,201 +0,0 @@
<script lang="ts">
// @ts-ignore
import fileSaver from 'file-saver';
import type { Writable } from 'svelte/store';
const { saveAs } = fileSaver;
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import {
archiveChatById,
getAllArchivedChats,
getArchivedChatCount,
getArchivedChatList,
unarchiveAllChats
} from '$lib/apis/chats';
import ChatsModal from './ChatsModal.svelte';
import UnarchiveAllConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '../common/Spinner.svelte';
const i18n: Writable<any> = getContext('i18n');
export let show = false;
export let onUpdate = () => {};
export let onDelete: (id: string) => void = () => {};
let loading = false;
let chatList: any[] | null = null;
let chatCount: number | null = null;
let page = 1;
let query = '';
let orderBy = 'updated_at';
let direction = 'desc';
let allChatsLoaded = false;
let chatListLoading = false;
let searchDebounceTimeout: any;
let showUnarchiveAllConfirmDialog = false;
let filter: any = {};
$: filter = {
...(query ? { query } : {}),
...(orderBy ? { order_by: orderBy } : {}),
...(direction ? { direction } : {})
};
$: if (filter !== null) {
searchHandler();
}
const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
page = 1;
chatList = null;
if (query === '') {
chatList = await getArchivedChatList(localStorage.token, page, filter);
} else {
searchDebounceTimeout = setTimeout(async () => {
chatList = await getArchivedChatList(localStorage.token, page, filter);
}, 500);
}
if ((chatList ?? []).length === 0) {
allChatsLoaded = true;
} else {
allChatsLoaded = false;
}
};
const loadMoreChats = async () => {
chatListLoading = true;
page += 1;
let newChatList = [];
if (query) {
newChatList = await getArchivedChatList(localStorage.token, page, filter);
} else {
newChatList = await getArchivedChatList(localStorage.token, page, filter);
}
// once the bottom of the list has been reached (no results) there is no need to continue querying
allChatsLoaded = newChatList.length === 0;
if (newChatList.length > 0) {
chatList = [...(chatList || []), ...newChatList];
}
chatListLoading = false;
};
const exportChatsHandler = async () => {
const chats = await getAllArchivedChats(localStorage.token);
let blob = new Blob([JSON.stringify(chats)], {
type: 'application/json'
});
saveAs(blob, `${$i18n.t('archived-chat-export')}-${Date.now()}.json`);
};
const unarchiveHandler = async (chatId: string) => {
const res = await archiveChatById(localStorage.token, chatId).catch((error) => {
toast.error(`${error}`);
});
chatList = chatList?.filter((c) => c.id !== chatId) ?? null;
if (chatCount !== null) chatCount--;
onUpdate();
};
const unarchiveAllHandler = async () => {
loading = true;
try {
await unarchiveAllChats(localStorage.token);
toast.success($i18n.t('All chats have been unarchived.'));
onUpdate();
await init();
} catch (error) {
toast.error(`${error}`);
} finally {
loading = false;
}
};
const init = async () => {
chatList = await getArchivedChatList(localStorage.token);
chatCount = await getArchivedChatCount(localStorage.token);
};
$: if (show) {
init();
}
</script>
<UnarchiveAllConfirmDialog
bind:show={showUnarchiveAllConfirmDialog}
message={$i18n.t('Are you sure you want to unarchive all archived chats?')}
confirmLabel={$i18n.t('Unarchive All')}
on:confirm={() => {
unarchiveAllHandler();
}}
/>
<ChatsModal
bind:show
bind:query
bind:orderBy
bind:direction
title={$i18n.t('Archived Chats')}
emptyPlaceholder={$i18n.t('You have no archived conversations.')}
count={chatCount}
{chatList}
{allChatsLoaded}
{chatListLoading}
onUpdate={() => {
onUpdate();
}}
onDelete={(id) => {
if (chatCount !== null) chatCount--;
onDelete(id);
}}
loadHandler={loadMoreChats}
{unarchiveHandler}
>
<div slot="footer">
<div class="flex flex-wrap text-sm font-normal gap-1.5 mt-2 m-1 justify-end w-full">
<button
class=" px-3.5 py-1.5 font-normal hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-800 rounded-3xl"
disabled={loading}
on:click={() => {
showUnarchiveAllConfirmDialog = true;
}}
>
{#if loading}
<Spinner className="size-4" />
{:else}
{$i18n.t('Unarchive All Archived Chats')}
{/if}
</button>
<button
class="px-3.5 py-1.5 font-normal hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-800 rounded-3xl"
disabled={loading}
on:click={() => {
exportChatsHandler();
}}
>
{$i18n.t('Export All Archived Chats')}
</button>
</div>
</div>
</ChatsModal>

View file

@ -271,6 +271,8 @@
let selectedModels = [''];
let history = null;
let messages = null;
let messagesContainerElement: HTMLElement | null = null;
const messagesContainerId = 'chat-preview';
const searchFilterPrefixes = ['tag:', 'folder:', 'pinned:', 'archived:', 'shared:'];
@ -311,6 +313,26 @@
loadChatPreview(selectedIdx);
}
const scrollPreviewToBottom = async () => {
await tick();
requestAnimationFrame(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
requestAnimationFrame(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
});
}
});
setTimeout(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
}, 80);
};
const loadChatPreview = async (selectedIdx) => {
if (!chatList || chatList.length === 0 || selectedIdx === null) {
selectedChat = null;
@ -336,6 +358,8 @@
});
if (chat) {
selectedChat = chat;
if (chat?.chat?.history) {
selectedModels =
(chat?.chat?.models ?? undefined) !== undefined
@ -343,14 +367,8 @@
: [chat?.chat?.models ?? ''];
history = chat?.chat?.history;
messages = createMessagesList(chat?.chat?.history, chat?.chat?.history?.currentId);
// scroll to the bottom of the messages container
await tick();
const messagesContainerElement = document.getElementById('chat-preview');
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
messages = [];
await scrollPreviewToBottom();
} else {
messages = [];
}
@ -853,7 +871,8 @@
{/if}
</div>
<div
id="chat-preview"
id={messagesContainerId}
bind:this={messagesContainerElement}
class="hidden md:flex md:flex-1 w-full overflow-y-auto h-96 md:h-[40rem] scrollbar-hidden @container"
>
{#if messages === null}
@ -871,8 +890,9 @@
readOnly={true}
{selectedModels}
bind:history
bind:messages
autoScroll={true}
{messagesContainerId}
messagesCount={8}
sendMessage={() => {}}
continueResponse={() => {}}
regenerateResponse={() => {}}

View file

@ -90,6 +90,15 @@
let shiftKey = false;
let selectedChatId = null;
// Keep the optimistic sidebar highlight in sync with the active chat. Leaving the
// chat view (e.g. navigating to an admin page) clears chatId, and programmatic
// navigation such as cloning moves chatId to a different chat — in both cases the
// previously-selected item must not stay highlighted. The optimistic on-click
// highlight is preserved because a click sets selectedChatId without changing
// chatId, so this reactive only re-runs once chatId catches up to the same value.
$: selectedChatId = $chatId || null;
let showCreateChannel = false;
// Pagination variables

View file

@ -158,7 +158,7 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"

Some files were not shown because too many files have changed in this diff Show more