Merge pull request #32796 from open-webui/dev
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Release / publish (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled

0.12.4
This commit is contained in:
Tim Baek 2026-10-25 17:00:02 +09:00 • committed by GitHub
commit 33c1978d5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
364 changed files with 26924 additions and 16988 deletions

View file

@ -26,7 +26,7 @@ jobs:
node-version: 22
- uses: actions/setup-python@v6
with:
python-version: 3.11
python-version: 3.12
- name: Build
env:
APP_BUILD_CHANNEL: main

File diff suppressed because one or more lines are too long

View file

@ -56,7 +56,7 @@ RUN chown -R $UID:$GID /app/backend && \
chmod -R g=u /app/backend/open_webui/static
######## WebUI backend ########
FROM python:3.11-slim-bookworm AS base
FROM python:3.12-slim-trixie AS base
# Use args
ARG USE_CUDA

View file

@ -14,7 +14,7 @@ Open WebUI is **a home for AI**, a self-hosted AI platform that's **[extensible]
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
![Open WebUI Demo](./demo.png)
![Open WebUI Demo](https://community.s3.openwebui.com/uploads/users/6d849fdd-b93f-411e-a611-eb25c73f1154/2026/10/25aebd3e-1d1c-4177-98b3-317ce60f75e9.gif)
> [!TIP]
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)**
@ -109,7 +109,7 @@ We are incredibly grateful for the generous support of our sponsors. Their contr
### Installation via Python pip 🐍
Open WebUI can be installed using pip, the Python package installer. Before proceeding, ensure you're using **Python 3.11** to avoid compatibility issues.
Open WebUI can be installed using pip, the Python package installer. Before proceeding, ensure you're using **Python 3.12** to avoid compatibility issues.
1. **Install Open WebUI**:
Open your terminal and run the following command to install Open WebUI:

View file

@ -57,8 +57,8 @@ def serve(
os.environ['LD_LIBRARY_PATH'] = ':'.join(
LD_LIBRARY_PATH
+ [
'/usr/local/lib/python3.11/site-packages/torch/lib',
'/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib',
'/usr/local/lib/python3.12/site-packages/torch/lib',
'/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib',
]
)
try:

View file

@ -14,7 +14,7 @@ from urllib.parse import urlparse
import redis
import requests
from authlib.integrations.starlette_client import OAuth
from pydantic import BaseModel
from pydantic import BaseModel, StrictBool, TypeAdapter
from open_webui.env import (
USE_SLIM,
@ -1745,6 +1745,12 @@ except Exception as e:
DEFAULT_INTERFACE_SETTINGS = default_interface_settings if isinstance(default_interface_settings, dict) else {}
try:
UI_VISIBILITY = TypeAdapter(dict[str, StrictBool]).validate_json(os.getenv('UI_VISIBILITY', '{}'))
except Exception as e:
log.exception(f'Error loading UI_VISIBILITY: {e}')
UI_VISIBILITY = {}
DEFAULT_USER_ROLE = os.getenv('DEFAULT_USER_ROLE', 'pending')
DEFAULT_GROUP_ID = os.getenv('DEFAULT_GROUP_ID', '')
@ -1786,6 +1792,10 @@ USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT = (
os.getenv('USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT', 'False').lower() == 'true'
)
USER_PERMISSIONS_WORKSPACE_MODELS_HIDDEN = (
os.getenv('USER_PERMISSIONS_WORKSPACE_MODELS_HIDDEN', 'False').lower() == 'true'
)
USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT = (
os.getenv('USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT', 'False').lower() == 'true'
)
@ -1978,6 +1988,7 @@ DEFAULT_USER_PERMISSIONS = {
'skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS,
'models_import': USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT,
'models_export': USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT,
'models_hidden': USER_PERMISSIONS_WORKSPACE_MODELS_HIDDEN,
'prompts_import': USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT,
'prompts_export': USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT,
'tools_import': USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT,
@ -2058,6 +2069,8 @@ ENABLE_FOLDERS = os.getenv('ENABLE_FOLDERS', 'True').lower() == 'true'
FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '')
ENABLE_SKILL_LAZY_LOADING = os.getenv('ENABLE_SKILL_LAZY_LOADING', 'False').lower() == 'true'
SKILL_CONTENT_MAX_CHARS = int(os.environ['SKILL_CONTENT_MAX_CHARS']) if os.getenv('SKILL_CONTENT_MAX_CHARS') else None
if SKILL_CONTENT_MAX_CHARS is not None and SKILL_CONTENT_MAX_CHARS <= 0:
raise ValueError('SKILL_CONTENT_MAX_CHARS must be a positive integer or unset')
@ -2875,6 +2888,8 @@ LDAP_SERVER_PORT = int(os.getenv('LDAP_SERVER_PORT', '389'))
LDAP_ATTRIBUTE_FOR_MAIL = os.getenv('LDAP_ATTRIBUTE_FOR_MAIL', 'mail')
LDAP_DEFAULT_EMAIL_DOMAIN = os.getenv('LDAP_DEFAULT_EMAIL_DOMAIN', '')
LDAP_ATTRIBUTE_FOR_USERNAME = os.getenv('LDAP_ATTRIBUTE_FOR_USERNAME', 'uid')
LDAP_APP_DN = os.getenv('LDAP_APP_DN', '')
@ -2900,6 +2915,7 @@ ENABLE_LDAP_GROUP_CREATION = os.getenv('ENABLE_LDAP_GROUP_CREATION', 'False').lo
LDAP_ATTRIBUTE_FOR_GROUPS = os.getenv('LDAP_ATTRIBUTE_FOR_GROUPS', 'memberOf')
DEFAULT_CONFIG = {
'skills.lazy_loading.enable': ENABLE_SKILL_LAZY_LOADING,
'skills.content_max_chars': SKILL_CONTENT_MAX_CHARS,
'skills.preview_max_chars': SKILL_PREVIEW_MAX_CHARS,
'direct.enable': ENABLE_DIRECT_CONNECTIONS,
@ -3181,6 +3197,7 @@ DEFAULT_CONFIG = {
'ui.default_models': DEFAULT_MODELS,
'ui.default_pinned_models': DEFAULT_PINNED_MODELS,
'ui.default_interface_settings': DEFAULT_INTERFACE_SETTINGS,
'ui.visibility': UI_VISIBILITY,
'ui.i18n': {},
'ui.prompt_suggestions': DEFAULT_PROMPT_SUGGESTIONS,
'ui.prompt_suggestions_i18n': DEFAULT_PROMPT_SUGGESTIONS_I18N,
@ -3316,6 +3333,7 @@ DEFAULT_CONFIG = {
'ldap.server.host': LDAP_SERVER_HOST,
'ldap.server.port': LDAP_SERVER_PORT,
'ldap.server.attribute_for_mail': LDAP_ATTRIBUTE_FOR_MAIL,
'ldap.server.default_email_domain': LDAP_DEFAULT_EMAIL_DOMAIN,
'ldap.server.attribute_for_username': LDAP_ATTRIBUTE_FOR_USERNAME,
'ldap.server.app_dn': LDAP_APP_DN,
'ldap.server.app_password': LDAP_APP_PASSWORD,

View file

@ -699,9 +699,11 @@ else:
except Exception:
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT
# Timeout (in seconds) for the MCP session.initialize() handshake.
# The handshake performs a list-tools round-trip and can take tens of
# seconds on cold-start servers or servers exposing many tools.
# Timeout (in seconds) for each MCP protocol request other than a tool call:
# the legacy initialize handshake, tool listing and resource reads. Raise it
# for cold-start servers or servers exposing many tools. The SDK bounds its
# server/discover probe separately (10s), and tool calls use
# AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER.
MCP_INITIALIZE_TIMEOUT = os.getenv('MCP_INITIALIZE_TIMEOUT', '10')
try:
MCP_INITIALIZE_TIMEOUT = int(MCP_INITIALIZE_TIMEOUT)
@ -857,6 +859,16 @@ MINERU_MAX_MARKDOWN_BYTES = (
int(os.getenv('MINERU_MAX_MARKDOWN_BYTES')) if os.getenv('MINERU_MAX_MARKDOWN_BYTES') else None
)
rapidocr_params = os.getenv('RAPIDOCR_PARAMS', '{"EngineConfig.onnxruntime.intra_op_num_threads": 1}')
try:
rapidocr_params = json.loads(rapidocr_params)
except json.JSONDecodeError:
rapidocr_params = {}
if not isinstance(rapidocr_params, dict):
rapidocr_params = {}
RAPIDOCR_PARAMS = rapidocr_params
# When enabled, skips pydub-based preprocessing (format conversion, compression,
# and chunked splitting) before sending files to processing engines. Useful when
# the upstream provider handles these steps or when ffmpeg is unavailable.

View file

@ -1128,10 +1128,6 @@ class SocketSessionEventSink:
EVENTS.FOLDER_UPDATED.name,
EVENTS.FOLDER_PARENT_UPDATED.name,
EVENTS.FOLDER_DELETED.name,
EVENTS.GROUP_MEMBER_REMOVED.name,
EVENTS.GROUP_MEMBER_ADDED.name,
EVENTS.GROUP_UPDATED.name,
EVENTS.GROUP_DELETED.name,
EVENTS.CHAT_DELETED_ALL.name,
}:
await refresh_chat_access()
@ -1140,8 +1136,11 @@ class SocketSessionEventSink:
EVENTS.CHAT_UNSHARED.name,
EVENTS.CHAT_FOLDER_UPDATED.name,
EVENTS.CHAT_DELETED.name,
EVENTS.CHAT_ARCHIVED.name,
}:
await refresh_chat_access((event.subject or {}).get('id'))
subject = event.subject or {}
# User-scoped events (archive all, unshare all) recheck every chat.
await refresh_chat_access(subject.get('id') if subject.get('type') == 'chat' else None)
if event.event not in {EVENTS.USER_DELETED.name, EVENTS.USER_ROLE_UPDATED.name}:
return

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import logging
import os
import re
@ -476,6 +477,12 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
@event.listens_for(async_engine.sync_engine, 'connect')
def _set_sqlite_pragmas(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
@event.listens_for(async_engine.sync_engine, 'handle_error')
def _keep_connection_on_cancel(context):
# invalidating here would close the connection inside the cancelled task, and that close is lost
if isinstance(context.original_exception, asyncio.CancelledError):
context.is_disconnect = False
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
@ -514,20 +521,14 @@ AsyncSessionLocal = async_sessionmaker(
async def get_async_session():
"""Async session generator for FastAPI Depends()."""
async with AsyncSessionLocal() as db:
try:
yield db
finally:
await db.close()
yield db
@asynccontextmanager
async def get_async_db():
"""Async context manager for use outside of FastAPI dependency injection."""
async with AsyncSessionLocal() as db:
try:
yield db
finally:
await db.close()
yield db
@asynccontextmanager

View file

@ -1217,12 +1217,6 @@ async def chat_completion(
if not isinstance(model_controls, dict):
raise HTTPException(400, 'Model control options must be keyed by model.')
model_controls = {} if form_data.get('automation_id') else model_controls
if any(model_controls.values()) and user.role != 'admin':
permissions = await Config.get('user.permissions')
for permission in ('chat.controls', 'chat.params'):
if not await has_permission(user.id, permission, permissions):
model_controls = {}
break
if missing_base_model and model_controls.get(model_id):
raise HTTPException(400, 'Model control options cannot be applied to the fallback model.')
if model_info_params or request_params:
@ -1304,6 +1298,8 @@ async def chat_completion(
)
):
tool_servers = None
if tool_servers and not await Config.get('direct.integrations.enable', False):
tool_servers = None
automation_id = form_data.pop('automation_id', None)
tool_approval_mode = (
@ -1329,6 +1325,7 @@ async def chat_completion(
'user_message': user_message,
'user_message_id': user_message.get('id') if user_message else None,
'assistant_message_id': form_data.pop('assistant_message_id', None),
'regeneration_message_id': form_data.pop('regeneration_message_id', None),
'session_id': form_data.pop('session_id', None),
'automation_id': automation_id,
'folder_id': form_data.pop('folder_id', None),
@ -2337,6 +2334,7 @@ async def get_app_config(request: Request):
'ui.enable_password_change_form',
'direct.enable',
'direct.integrations.enable',
'terminal_server.connections',
'folders.enable',
'folders.max_file_count',
'channels.enable',
@ -2354,6 +2352,9 @@ async def get_app_config(request: Request):
'code_interpreter.enable',
'image_generation.enable',
'task.autocomplete.enable',
'task.title.enable',
'task.follow_up.enable',
'task.tags.enable',
'ui.enable_community_sharing',
'ui.enable_message_rating',
'ui.enable_user_webhooks',
@ -2361,9 +2362,11 @@ async def get_app_config(request: Request):
'google_drive.enable',
'onedrive.enable',
'memories.enable',
'subagents.enable',
'ui.default_models',
'ui.default_pinned_models',
'ui.default_interface_settings',
'ui.visibility',
'ui.i18n',
'ui.prompt_suggestions',
'ui.prompt_suggestions_i18n',
@ -2434,6 +2437,11 @@ async def get_app_config(request: Request):
'enable_tools': ENABLE_TOOLS,
'enable_functions': ENABLE_FUNCTIONS,
'enable_tool_servers': ENABLE_TOOL_SERVERS,
'enable_terminal_servers': ENABLE_TOOL_SERVERS
and any(
connection.get('enabled', True)
for connection in config.get('terminal_server.connections') or []
),
'enable_folders': config.get('folders.enable'),
'folder_max_file_count': config.get('folders.max_file_count'),
'enable_channels': config.get('channels.enable'),
@ -2451,6 +2459,9 @@ async def get_app_config(request: Request):
'enable_code_interpreter': config.get('code_interpreter.enable'),
'enable_image_generation': config.get('image_generation.enable'),
'enable_autocomplete_generation': config.get('task.autocomplete.enable'),
'enable_title_generation': config.get('task.title.enable'),
'enable_follow_up_generation': config.get('task.follow_up.enable'),
'enable_tags_generation': config.get('task.tags.enable'),
'enable_community_sharing': config.get('ui.enable_community_sharing'),
'enable_message_rating': config.get('ui.enable_message_rating'),
'enable_user_webhooks': config.get('ui.enable_user_webhooks'),
@ -2461,6 +2472,7 @@ async def get_app_config(request: Request):
'enable_google_drive_integration': config.get('google_drive.enable'),
'enable_onedrive_integration': config.get('onedrive.enable'),
'enable_memories': config.get('memories.enable'),
'enable_subagents': config.get('subagents.enable'),
**(
{
'enable_onedrive_personal': ENABLE_ONEDRIVE_PERSONAL,
@ -2521,6 +2533,7 @@ async def get_app_config(request: Request):
},
'ui': {
'default_interface_settings': config.get('ui.default_interface_settings'),
'visibility': config.get('ui.visibility'),
'pending_user_overlay_title': config.get('ui.pending_user_overlay_title'),
'pending_user_overlay_content': config.get('ui.pending_user_overlay_content'),
'response_watermark': config.get('ui.watermark'),
@ -3117,6 +3130,8 @@ async def check_db_health():
mimetypes.add_type('text/javascript', '.js')
mimetypes.add_type('text/javascript', '.mjs')
mimetypes.add_type('application/wasm', '.wasm')
# Python before 3.13 only knows .webp from /etc/mime.types, which the Docker image does not ship
mimetypes.add_type('image/webp', '.webp')
# Serve build-time static assets (CSS, JS, images, favicon, etc.)
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')

View file

@ -3,8 +3,8 @@ import time
from typing import Optional
from uuid import uuid4
from open_webui.internal.db import Base, get_async_db_context
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse
@ -176,7 +176,6 @@ class CalendarUpdateForm(BaseModel):
color: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
async def validate_calendar_rrule(value: Optional[str]) -> None:
@ -362,8 +361,6 @@ class CalendarTable:
cal.data = {**(cal.data or {}), **update_data['data']}
if 'meta' in update_data:
cal.meta = {**(cal.meta or {}), **update_data['meta']}
if 'access_grants' in update_data:
await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db)
cal.updated_at = int(time.time_ns())
await db.commit()

View file

@ -16,8 +16,8 @@ from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
CheckConstraint,
Column,
ForeignKey,
String,
Text,
@ -243,12 +243,12 @@ class ChannelForm(BaseModel):
is_private: Optional[bool] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
group_ids: Optional[list[str]] = None
user_ids: Optional[list[str]] = None
class CreateChannelForm(ChannelForm):
access_grants: Optional[list[dict]] = None
type: Optional[str] = None
@ -896,8 +896,6 @@ class ChannelTable:
channel.data = form_data.data
channel.meta = form_data.meta
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('channel', id, form_data.access_grants, db=db)
channel.updated_at = int(time.time_ns())
await db.commit()

View file

@ -1,8 +1,10 @@
from contextlib import nullcontext
import os
import time
import uuid
from collections import Counter
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@ -17,12 +19,15 @@ from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Float,
Index,
Integer,
Text,
cast,
case,
delete,
func,
literal,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
@ -88,6 +93,36 @@ def _token_columns(dialect: str):
)
def _daily_usage_bucket(dialect: str, bucket_offset: int):
"""Match Python's server-local day and legacy timestamp normalization in SQL."""
if dialect not in {'sqlite', 'postgresql'}:
return None
if dialect == 'postgresql':
# The database server/session can use a different timezone from the application.
zone = os.environ.get('TZ')
if zone is None:
localtime = str(Path('/etc/localtime').resolve())
if '/zoneinfo/' in localtime:
zone = localtime.split('/zoneinfo/', 1)[1]
elif time.timezone == 0 and not time.daylight:
zone = 'UTC'
try:
ZoneInfo(zone or '')
except (ValueError, ZoneInfoNotFoundError):
# Retain Python bucketing when the OS timezone has no portable IANA name.
return None
now = time.time()
seconds = case(
(ChatMessage.created_at > 10_000_000_000, cast(ChatMessage.created_at, Float) / 1000),
else_=ChatMessage.created_at,
)
timestamp = case((seconds.between(1577836800, now + 86400), seconds), else_=now) + bucket_offset
if dialect == 'sqlite':
return func.strftime('%Y-%m-%d', timestamp, 'unixepoch', 'localtime')
return func.to_char(func.timezone(zone, func.to_timestamp(timestamp)), 'YYYY-MM-DD')
def _extract_tool_names(value: Any) -> list[str]:
names: list[str] = []
@ -519,6 +554,37 @@ class ChatMessageTable:
return True
# Analytics methods
async def get_analytics_summary(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, int]:
"""Return dashboard totals without materializing every model, user, and chat group."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import group_user_memberships
stmt = select(
func.count(ChatMessage.model_id).label('total_messages'),
func.count(distinct(ChatMessage.chat_id)).label('total_chats'),
func.count(distinct(ChatMessage.model_id)).label('total_models'),
# GROUP BY user_id included the anonymous/null group in the old summary.
(
func.count(distinct(ChatMessage.user_id))
+ case((func.count() > func.count(ChatMessage.user_id), 1), else_=0)
).label('total_users'),
).where(ChatMessage.role == 'assistant')
if start_date:
stmt = stmt.where(ChatMessage.created_at >= start_date)
if end_date:
stmt = stmt.where(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.where(ChatMessage.user_id.in_(group_users))
return dict((await db.execute(stmt)).mappings().one())
async def get_message_count_by_model(
self,
start_date: Optional[int] = None,
@ -546,19 +612,20 @@ class ChatMessageTable:
result = await db.execute(stmt)
return {row.model_id: row.count for row in result.all()}
async def get_unique_counts_by_model(
async def get_model_analytics(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, dict]:
"""Count distinct users and chats per model."""
"""Count messages, distinct users, and chats per model in one pass."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import group_user_memberships
stmt = select(
ChatMessage.model_id,
func.count(ChatMessage.id).label('count'),
func.count(distinct(ChatMessage.user_id)).label('unique_users'),
func.count(distinct(ChatMessage.chat_id)).label('unique_chats'),
).filter(
@ -578,6 +645,7 @@ class ChatMessageTable:
result = await db.execute(stmt)
return {
row.model_id: {
'count': row.count,
'unique_users': row.unique_users,
'unique_chats': row.unique_chats,
}
@ -634,14 +702,14 @@ class ChatMessageTable:
for row in result.all()
}
async def get_token_usage_by_user(
async def get_user_analytics(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, dict]:
"""Aggregate token usage by user using database-level aggregation."""
"""Aggregate all message counts and token usage per user in one pass."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import group_user_memberships
@ -650,6 +718,10 @@ class ChatMessageTable:
input_tokens, output_tokens = _token_columns(dialect)
# The previous token query excluded anonymous users; keep those totals at zero.
input_tokens = case((ChatMessage.user_id.isnot(None), input_tokens), else_=0)
output_tokens = case((ChatMessage.user_id.isnot(None), output_tokens), else_=0)
stmt = select(
ChatMessage.user_id,
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
@ -657,8 +729,6 @@ class ChatMessageTable:
func.count(ChatMessage.id).label('message_count'),
).filter(
ChatMessage.role == 'assistant',
ChatMessage.user_id.isnot(None),
ChatMessage.usage.isnot(None),
)
if start_date:
@ -961,20 +1031,43 @@ class ChatMessageTable:
result = await db.execute(stmt)
return {row.chat_id: row.count for row in result.all()}
async def get_daily_message_counts_by_model(
async def get_usage_over_time_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
granularity: str = 'daily',
metric: str = 'messages',
bucket_offset: int = 0,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by day and model."""
async with get_async_db_context(db) as db:
from datetime import datetime, timedelta
"""Get message counts or total tokens grouped by time bucket and model."""
bucket_minutes = {'10m': 10, 'hourly': 60, '3h': 180, '6h': 360, 'daily': 1440}[granularity]
date_format = '%Y-%m-%d' if granularity == 'daily' else '%Y-%m-%d %H:%M'
step = timedelta(minutes=bucket_minutes)
def bucket_start(timestamp):
# Shift comparison events before bucketing so partial intervals align exactly.
date = datetime.fromtimestamp(_normalize_timestamp(timestamp) + bucket_offset)
minutes = date.hour * 60 + date.minute
minutes = minutes // bucket_minutes * bucket_minutes
return date.replace(hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0)
async with get_async_db_context(db) as db:
from open_webui.models.groups import group_user_memberships
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
bind = await db.connection()
day = _daily_usage_bucket(bind.dialect.name, bucket_offset) if granularity == 'daily' else None
value = literal(1)
if metric == 'tokens':
input_tokens, output_tokens = _token_columns(bind.dialect.name)
value = func.coalesce(input_tokens, 0) + func.coalesce(output_tokens, 0)
stmt = select(
day if day is not None else ChatMessage.created_at,
ChatMessage.model_id,
func.sum(value) if day is not None else value,
).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
@ -987,79 +1080,23 @@ class ChatMessageTable:
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
if day is not None:
stmt = stmt.group_by(day, ChatMessage.model_id)
result = await db.execute(stmt)
results = result.all()
counts: dict[str, dict[str, int]] = {}
for timestamp, model_id, value in result.all():
date_str = timestamp if day is not None else bucket_start(timestamp).strftime(date_format)
bucket = counts.setdefault(date_str, {})
bucket[model_id] = bucket.get(model_id, 0) + int(value)
# Group by date -> model -> count
daily_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d')
if date_str not in daily_counts:
daily_counts[date_str] = {}
daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1
# Fill in missing days
if start_date and end_date:
current = datetime.fromtimestamp(_normalize_timestamp(start_date))
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
current = bucket_start(start_date)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date) + bucket_offset)
while current <= end_dt:
date_str = current.strftime('%Y-%m-%d')
if date_str not in daily_counts:
daily_counts[date_str] = {}
current += timedelta(days=1)
counts.setdefault(current.strftime(date_format), {})
current += step
return daily_counts
async def get_hourly_message_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by hour and model."""
async with get_async_db_context(db) as db:
from datetime import datetime, timedelta
from open_webui.models.groups import group_user_memberships
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
result = await db.execute(stmt)
results = result.all()
# Group by hour -> model -> count
hourly_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d %H:00')
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1
# Fill in missing hours
if start_date and end_date:
current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace(
minute=0, second=0, microsecond=0
)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
while current <= end_dt:
hour_str = current.strftime('%Y-%m-%d %H:00')
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
current += timedelta(hours=1)
return hourly_counts
return counts
ChatMessages = ChatMessageTable()

View file

@ -421,7 +421,7 @@ class ChatTable:
{
ChatMessages.DB_TO_JSON_KEY_MAP.get(column.key, column.key): getattr(row, column.key)
for column in ChatMessage.__table__.columns
if column.key not in ChatMessages.EXCLUDED_COLUMNS
if column.key not in ChatMessages.EXCLUDED_COLUMNS and getattr(row, column.key) is not None
}
)
message['id'] = message_id
@ -852,6 +852,18 @@ class ChatTable:
except Exception:
return
async def update_chat_params_by_id(self, id: str, params: dict) -> ChatModel | None:
async with self._chat_transaction(id) as (_, chat):
if chat is None:
return None
stored = chat.chat or {}
chat.chat = {
**stored,
'params': self._clean_null_bytes({**(stored.get('params') or {}), **params}),
}
return ChatModel.model_validate(chat)
async def update_chat_variables_by_id(
self,
id: str,
@ -1565,7 +1577,7 @@ class ChatTable:
async def archive_all_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> bool:
try:
async with get_async_db_context(db) as session:
await session.execute(update(Chat).filter_by(user_id=user_id).values(archived=True))
await session.execute(update(Chat).filter_by(user_id=user_id).values(archived=True, folder_id=None))
await session.commit()
return True
except Exception:
@ -1781,6 +1793,13 @@ class ChatTable:
result = await session.execute(stmt)
return [meta for meta in result.scalars().all() if isinstance(meta, dict)]
async def get_owner_ids_by_chat_ids(self, chat_ids: list[str], db: AsyncSession | None = None) -> dict[str, str]:
if not chat_ids:
return {}
async with get_async_db_context(db) as session:
result = await session.execute(select(Chat.id, Chat.user_id).filter(Chat.id.in_(chat_ids)))
return dict(result.all())
async def get_chats_by_model_id(
self,
model_id: str,
@ -2519,7 +2538,8 @@ class ChatTable:
chat = await session.get(Chat, id)
chat.folder_id = folder_id
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
if chat.user_id == user_id:
chat.last_read_at = int(time.time())
chat.pinned = False
if folder_id is not None:
# Folder listings only show unarchived chats, so moving an archived

View file

@ -253,13 +253,12 @@ async def refresh_group_sessions(user_ids):
return
# Import lazily to avoid models/socket import cycles. A committed write must not
# be reported as failed just because a client has already disconnected.
from open_webui.socket.main import disconnect_user_sessions
from open_webui.socket.main import disconnect_sessions_for_users
for user_id in set(user_ids):
try:
await disconnect_user_sessions(user_id, refresh_access=True)
except Exception:
log.exception('Unable to refresh group access for user %s', user_id)
try:
await disconnect_sessions_for_users(user_ids, refresh_access=True)
except Exception:
log.exception('Unable to refresh group access for %s user(s)', len(user_ids))
async def validate_parent(db, group_id, parent_id):

View file

@ -792,8 +792,6 @@ class KnowledgeTable:
)
)
await db.commit()
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
return await self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)

View file

@ -7,10 +7,10 @@ from copy import deepcopy
from typing import Annotated, Any, Literal
from fastapi import HTTPException
from open_webui.models.model_history import ModelHistory, ModelHistories, model_snapshot
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.model_history import ModelHistories, ModelHistory, model_snapshot
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.utils.misc import json_text_variants
from open_webui.utils.validate import validate_image_url
@ -280,6 +280,7 @@ class ModelsTable:
**data, user_id=user_id, version_id=entry.id, created_at=int(time.time()), updated_at=int(time.time())
)
session.add_all([current, entry])
await AccessGrants.replace_access_grants(session, 'model', form.id, form.access_grants)
else:
if production_version_id is not None:
# Serialize with history deletion before reading the selected snapshot.
@ -322,8 +323,6 @@ class ModelsTable:
)
if result.rowcount != 1:
raise HTTPException(409, {'code': 'version_conflict'})
if form.access_grants is not None or current in session.new:
await AccessGrants.replace_access_grants(session, 'model', form.id, form.access_grants)
return current
async def _written_model(self, session, model):

View file

@ -109,7 +109,6 @@ class NoteUpdateForm(BaseModel):
title: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
@field_validator('data', mode='before')
@classmethod
@ -376,12 +375,9 @@ class NoteTable:
if 'meta' in form_data:
note.meta = {**(note.meta or {}), **(form_data['meta'] or {})}
if not db.is_modified(note) and 'access_grants' not in form_data:
if not db.is_modified(note):
return await self._to_note_model(note, db=db)
if 'access_grants' in form_data:
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)
note.updated_at = int(time.time_ns())
await db.commit()

View file

@ -434,11 +434,7 @@ class PromptsTable:
current_access_grants = await self._get_access_grants(prompt.id, db=session)
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.content != form_data.content
or form_data.access_grants is not None
)
content_changed = prompt.name != form_data.name or prompt.content != form_data.content
# Update prompt fields
prompt.name = form_data.name
@ -446,10 +442,6 @@ class PromptsTable:
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
prompt.updated_at = int(time.time())
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=session)
current_access_grants = await self._get_access_grants(prompt.id, db=session)
await session.commit()
# Create history entry only if content changed
@ -504,7 +496,6 @@ class PromptsTable:
prompt.name != form_data.name
or prompt.command != form_data.command
or prompt.content != form_data.content
or form_data.access_grants is not None
or (form_data.tags is not None and prompt.tags != form_data.tags)
)
@ -520,10 +511,6 @@ class PromptsTable:
if form_data.tags is not None:
prompt.tags = form_data.tags
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=session)
current_access_grants = await self._get_access_grants(prompt.id, db=session)
prompt.updated_at = int(time.time())
await session.commit()

View file

@ -433,8 +433,6 @@ class SkillsTable:
)
if result.rowcount != 1:
raise HTTPException(409, {'code': 'version_conflict'})
if updated.get('access_grants') is not None:
await AccessGrants.replace_access_grants(session, 'skill', id, updated['access_grants'])
await session.commit()
await session.refresh(skill)
grants = (

View file

@ -5,6 +5,8 @@ from __future__ import annotations
import logging
import time
from fastapi import HTTPException
# local imports
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants
@ -12,7 +14,6 @@ from open_webui.models.groups import Groups
from open_webui.models.tool_history import ToolHistories, ToolHistory, tool_snapshot
from open_webui.models.users import UserResponse, Users
from open_webui.utils.valves import decrypt_valves, encrypt_valves, validate_valves
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, String, Text, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -375,10 +376,8 @@ class ToolsTable:
if not allow_code_changes and updated.get('content', tool.content) != tool.content:
raise HTTPException(401, 'You do not have permission to change executable Tool code')
updated = dict(updated)
grants = updated.pop('access_grants', None)
updated.pop('access_grants', None)
await self._write_tool(session, tool, updated, user_id, version_id, module)
if grants is not None:
await AccessGrants.replace_access_grants(session, 'tool', id, grants)
await session.flush()
grants = (
(await session.execute(select(AccessGrant).filter_by(resource_type='tool', resource_id=id)))

View file

@ -273,8 +273,12 @@ class MinerULoader:
"""
Send one request to the MinerU V1 API and raise an HTTPException on failure.
"""
# A 4.x server started with --api-key rejects every V1 request without it
headers = {'Authorization': f'Bearer {self.api_key}'} if self.api_key else None
try:
response = requests.request(method, f'{self.api_url}{path}', timeout=self.timeout, **kwargs)
response = requests.request(
method, f'{self.api_url}{path}', headers=headers, timeout=self.timeout, **kwargs
)
response.raise_for_status()
except requests.Timeout:
raise HTTPException(

View file

@ -6,6 +6,8 @@ from pathlib import Path
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.env import RAPIDOCR_PARAMS
log = logging.getLogger(__name__)
@ -94,7 +96,7 @@ class PDFLoader(BaseLoader):
if self.ocr is None:
from rapidocr import RapidOCR
self.ocr = RapidOCR()
self.ocr = RapidOCR(params=RAPIDOCR_PARAMS)
result = self.ocr(pixels)
if result and result.txts:
texts.append('\n'.join(result.txts).strip())

View file

@ -37,9 +37,9 @@ class ColBERT(BaseReranker):
# This is a workaround for the issue with the docker container
# where the torch extension is not loaded properly
# and the following error is thrown:
# /root/.cache/torch_extensions/py311_cpu/segmented_maxsim_cpp/segmented_maxsim_cpp.so: cannot open shared object file: No such file or directory
# /root/.cache/torch_extensions/py312_cpu/segmented_maxsim_cpp/segmented_maxsim_cpp.so: cannot open shared object file: No such file or directory
lock_file = '/root/.cache/torch_extensions/py311_cpu/segmented_maxsim_cpp/lock'
lock_file = '/root/.cache/torch_extensions/py312_cpu/segmented_maxsim_cpp/lock'
if os.path.exists(lock_file):
os.remove(lock_file)

View file

@ -1288,6 +1288,45 @@ def _is_safe_collection_name(name: str) -> bool:
return isinstance(name, str) and bool(_SAFE_COLLECTION_NAME_RE.match(name))
def resolve_collection_names(collection_name: str) -> set[str]:
"""Names that may share a vector collection after backend normalization."""
normalized_name = collection_name.lower().replace('_', '-').strip('-')
collection_names = {normalized_name}
# Weaviate prepends C to digit-leading names, so both spellings can reach the same collection.
if normalized_name[:1].isdigit():
collection_names.add(f'c{normalized_name}')
elif normalized_name.startswith('c') and normalized_name[1:2].isdigit():
collection_names.add(normalized_name[1:])
return collection_names
async def has_access_to_collection(collection_name: str, user: UserModel, access_type: str) -> bool:
"""Require access to every recognized collection, preserving the policy for unknown names."""
has_authorized_collection = False
for resolved_name in resolve_collection_names(collection_name):
if resolved_name == 'knowledge-bases':
return False
if resolved_name.startswith('file-'):
has_access = await has_access_to_file(
file_id=resolved_name[len('file-') :], access_type=access_type, user=user
)
elif resolved_name.startswith('user-memory-'):
has_access = resolved_name == f'user-memory-{user.id}'
elif resolved_name.startswith('web-search-'):
has_access = resolved_name.startswith(f'web-search-{user.id}-')
else:
has_access = await Knowledges.check_access_by_user_id(resolved_name, user.id, permission=access_type)
if not has_access and not await Knowledges.get_knowledge_by_id(resolved_name):
continue
# A denied collection must win even when another name is authorized or unscoped names are enabled.
if not has_access:
return False
has_authorized_collection = True
return has_authorized_collection or ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS
async def filter_accessible_collections(
collection_names: set[str],
user: UserModel,
@ -1308,6 +1347,8 @@ async def filter_accessible_collections(
ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS is True,
the name is treated as a legacy/ephemeral
collection and allowed.
Access to every recognized collection is required; unknown names do not override a denial.
"""
# Applied before the admin bypass — malformed names should never reach the vector store.
safe_names = {n for n in collection_names if _is_safe_collection_name(n)}
@ -1323,36 +1364,9 @@ async def filter_accessible_collections(
return safe_names
validated = set()
for name in safe_names:
# Some vector stores fold case and _/- together, so authorize a name as the collection it can resolve to.
canonical = name.lower().replace('_', '-')
if canonical == 'knowledge-bases':
# System meta-collection — never exposed to non-admins.
continue
elif canonical.startswith('file-'):
file_id = canonical[len('file-') :]
if await has_access_to_file(file_id=file_id, access_type=access_type, user=user):
validated.add(name)
elif canonical.startswith('user-memory-'):
if canonical == f'user-memory-{user.id}':
validated.add(name)
elif canonical.startswith('web-search-'):
# Ephemeral per-query collections, owner-bound: process_web_search mints
# them as web-search-{user.id}-<hash>, so only the creator may read/write.
if canonical.startswith(f'web-search-{user.id}-'):
validated.add(name)
else:
# May be a knowledge-base ID or a legacy/ephemeral collection.
# If it IS a KB, enforce access control. If no such KB
# exists, the behaviour depends on
# ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS:
# False (default) — deny (closes the unscoped namespace)
# True — allow (preserves legacy behaviour)
if await Knowledges.check_access_by_user_id(canonical, user.id, permission=access_type):
validated.add(name)
elif ENABLE_RETRIEVAL_UNSCOPED_COLLECTIONS and not await Knowledges.get_knowledge_by_id(canonical):
# Not a KB at all — legacy/ephemeral collection, allow
validated.add(name)
for collection_name in safe_names:
if await has_access_to_collection(collection_name, user, access_type):
validated.add(collection_name)
return validated

View file

@ -1,9 +1,9 @@
import logging
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Optional
from typing import Literal, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from open_webui.internal.db import get_async_session
from open_webui.models.chat_messages import ChatMessageModel, ChatMessages
from open_webui.models.chats import Chats
@ -64,20 +64,10 @@ async def get_model_analytics(
db: AsyncSession = Depends(get_async_session),
):
"""Get message counts per model."""
counts = await ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
unique_counts = await ChatMessages.get_unique_counts_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
counts = await ChatMessages.get_model_analytics(start_date=start_date, end_date=end_date, group_id=group_id, db=db)
models = [
ModelAnalyticsEntry(
model_id=model_id,
count=count,
unique_users=unique_counts.get(model_id, {}).get('unique_users', 0),
unique_chats=unique_counts.get(model_id, {}).get('unique_chats', 0),
)
for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
ModelAnalyticsEntry(model_id=model_id, **data)
for model_id, data in sorted(counts.items(), key=lambda x: -x[1]['count'])
]
return ModelAnalyticsResponse(models=models)
@ -88,31 +78,33 @@ async def get_user_analytics(
end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
group_id: Optional[str] = Query(None, description='Filter by user group ID'),
limit: int = Query(50, description='Max users to return'),
metric: Literal['messages', 'tokens'] = Query('messages'),
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
"""Get message counts and token usage per user with user info."""
counts = await ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
token_usage = await ChatMessages.get_token_usage_by_user(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
usage = await ChatMessages.get_user_analytics(start_date=start_date, end_date=end_date, group_id=group_id, db=db)
# Get user info for top users
top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]]
# Rank by the selected metric before limiting the leaderboard.
top_user_ids = sorted(
usage,
key=lambda uid: (
-usage[uid]['total_tokens'] if metric == 'tokens' else -usage[uid]['message_count'],
-usage[uid]['message_count'],
),
)[:limit]
user_info = {u.id: u for u in await Users.get_users_by_user_ids(top_user_ids, db=db)}
users = []
for user_id in top_user_ids:
u = user_info.get(user_id)
tokens = token_usage.get(user_id, {})
tokens = usage[user_id]
users.append(
UserAnalyticsEntry(
user_id=user_id,
name=u.name if u else None,
email=u.email if u else None,
count=counts[user_id],
count=tokens['message_count'],
input_tokens=tokens.get('input_tokens', 0),
output_tokens=tokens.get('output_tokens', 0),
total_tokens=tokens.get('total_tokens', 0),
@ -169,27 +161,25 @@ async def get_summary(
db: AsyncSession = Depends(get_async_session),
):
"""Get summary statistics for the dashboard."""
model_counts = await ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
user_counts = await ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
chat_counts = await ChatMessages.get_message_count_by_chat(
summary = await ChatMessages.get_analytics_summary(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
return SummaryResponse(
total_messages=sum(model_counts.values()),
total_chats=len(chat_counts),
total_models=len(model_counts),
total_users=len(user_counts),
)
return SummaryResponse(**summary)
class ComparisonStatsEntry(BaseModel):
models: dict[str, int]
start: str
end: str
class DailyStatsEntry(BaseModel):
date: str
models: dict[str, int]
start: Optional[str] = None
end: Optional[str] = None
previous: Optional[ComparisonStatsEntry] = None
class DailyStatsResponse(BaseModel):
@ -201,22 +191,49 @@ async def get_daily_stats(
start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'),
end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
group_id: Optional[str] = Query(None, description='Filter by user group ID'),
granularity: str = Query('daily', description="Granularity: 'hourly' or 'daily'"),
granularity: Literal['10m', 'hourly', '3h', '6h', 'daily'] = Query('daily'),
metric: Literal['messages', 'tokens'] = Query('messages'),
compare: bool = Query(False),
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
"""Get message counts grouped by model for time-series chart."""
if granularity == 'hourly':
counts = await ChatMessages.get_hourly_message_counts_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
else:
counts = await ChatMessages.get_daily_message_counts_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
return DailyStatsResponse(
data=[DailyStatsEntry(date=date, models=models) for date, models in sorted(counts.items())]
"""Get message counts or total tokens grouped by model for the time-series chart."""
if compare and (start_date is None or end_date is None or start_date > end_date):
raise HTTPException(status_code=400, detail='Comparison requires a bounded date range')
counts = await ChatMessages.get_usage_over_time_by_model(
start_date=start_date,
end_date=end_date,
group_id=group_id,
db=db,
granularity=granularity,
metric=metric,
)
entries = [DailyStatsEntry(date=date, models=models) for date, models in sorted(counts.items())]
if compare:
# API bounds are inclusive: equal windows must not share an endpoint.
duration = end_date - start_date + 1
previous = await ChatMessages.get_usage_over_time_by_model(
start_date=start_date - duration,
end_date=start_date - 1,
group_id=group_id,
db=db,
granularity=granularity,
metric=metric,
bucket_offset=duration,
)
step = timedelta(minutes={'10m': 10, 'hourly': 60, '3h': 180, '6h': 360, 'daily': 1440}[granularity])
for entry in entries:
bucket = datetime.fromisoformat(entry.date)
start = max(start_date, int(bucket.timestamp()))
end = min(end_date + 1, int((bucket + step).timestamp()))
entry.start = datetime.fromtimestamp(start).isoformat()
entry.end = datetime.fromtimestamp(end).isoformat()
entry.previous = ComparisonStatsEntry(
models=previous.get(entry.date, {}),
start=datetime.fromtimestamp(start - duration).isoformat(),
end=datetime.fromtimestamp(end - duration).isoformat(),
)
return DailyStatsResponse(data=entries)
class TokenUsageEntry(BaseModel):

View file

@ -9,13 +9,13 @@ from urllib.parse import urlencode, urlsplit, urlunsplit
import aiohttp
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, DEFAULT_REALTIME_CALL_PROMPT_TEMPLATE
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, BYPASS_MODEL_ACCESS_CONTROL
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, BYPASS_MODEL_ACCESS_CONTROL, ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.models import Models, ModelVoice
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user_by_token
from open_webui.utils.headers import get_microsoft_entra_id_access_token
from open_webui.utils.headers import get_microsoft_entra_id_access_token, include_user_info_headers
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.models import check_model_access, get_all_models
from open_webui.utils.session_pool import get_session
@ -424,6 +424,8 @@ async def realtime_call(ws: WebSocket):
async with asyncio.timeout(30):
async with asyncio.timeout(15):
ws_url, headers = await _get_realtime_connection(config)
if ENABLE_FORWARD_USER_INFO_HEADERS:
headers = include_user_info_headers(headers, user)
session = await get_session()
try:
upstream = await session.ws_connect(
@ -547,6 +549,9 @@ async def realtime_call(ws: WebSocket):
detail = (
str(exc)
if type(exc) is ValueError
# A send can still fail on the provider connection that just closed.
else 'Voice provider connection closed'
if isinstance(exc, aiohttp.ClientConnectionResetError)
else ('Voice connection timed out' if isinstance(exc, TimeoutError) else 'Voice connection failed')
)
log.info('Bridge closed: user_id=%s error_type=%s', user.id if user else None, type(exc).__name__)

View file

@ -134,6 +134,7 @@ ADMIN_CONFIG_KEYS = {
'ENABLE_CALENDAR': 'calendar.enable',
'ENABLE_MEMORIES': 'memories.enable',
'ENABLE_MEMORY_SYSTEM_CONTEXT': 'memories.system_context.enable',
'ENABLE_SKILL_LAZY_LOADING': 'skills.lazy_loading.enable',
'SKILL_CONTENT_MAX_CHARS': 'skills.content_max_chars',
'SKILL_PREVIEW_MAX_CHARS': 'skills.preview_max_chars',
'ENABLE_NOTES': 'notes.enable',
@ -149,6 +150,7 @@ LDAP_SERVER_CONFIG_KEYS = {
'host': 'ldap.server.host',
'port': 'ldap.server.port',
'attribute_for_mail': 'ldap.server.attribute_for_mail',
'default_email_domain': 'ldap.server.default_email_domain',
'attribute_for_username': 'ldap.server.attribute_for_username',
'app_dn': 'ldap.server.app_dn',
'app_dn_password': 'ldap.server.app_password',
@ -435,6 +437,7 @@ async def ldap_auth(
LDAP_SERVER_HOST = await Config.get('ldap.server.host')
LDAP_SERVER_PORT = await Config.get('ldap.server.port')
LDAP_ATTRIBUTE_FOR_MAIL = await Config.get('ldap.server.attribute_for_mail')
LDAP_DEFAULT_EMAIL_DOMAIN = await Config.get('ldap.server.default_email_domain')
LDAP_ATTRIBUTE_FOR_USERNAME = await Config.get('ldap.server.attribute_for_username')
LDAP_SEARCH_BASE = await Config.get('ldap.server.users_dn')
LDAP_SEARCH_FILTERS = await Config.get('ldap.server.search_filter')
@ -500,6 +503,10 @@ async def ldap_auth(
entry = connection_app.entries[0]
entry_username = entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'].value
email = entry[f'{LDAP_ATTRIBUTE_FOR_MAIL}'].value # retrieve the Attribute value
if not email and LDAP_DEFAULT_EMAIL_DOMAIN:
default_email = f'{form_data.user}@{LDAP_DEFAULT_EMAIL_DOMAIN}'
if validate_email_format(default_email):
email = default_email
username_list = [] # list of usernames from LDAP attribute
if isinstance(entry_username, list):
@ -1187,6 +1194,7 @@ class AdminConfig(MfaConfigForm):
ENABLE_CALENDAR: bool
ENABLE_MEMORIES: bool
ENABLE_MEMORY_SYSTEM_CONTEXT: bool
ENABLE_SKILL_LAZY_LOADING: bool = False
SKILL_CONTENT_MAX_CHARS: int | None = Field(default=None, gt=0, strict=True)
SKILL_PREVIEW_MAX_CHARS: int | None = Field(default=None, gt=0, strict=True)
ENABLE_NOTES: bool
@ -1237,6 +1245,8 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep
updates.pop('apps.enable', None)
if 'I18N' not in form_data.model_fields_set:
updates.pop('ui.i18n', None)
if 'ENABLE_SKILL_LAZY_LOADING' not in form_data.model_fields_set:
updates.pop('skills.lazy_loading.enable', None)
if 'SKILL_CONTENT_MAX_CHARS' not in form_data.model_fields_set:
updates.pop('skills.content_max_chars', None)
if 'SKILL_PREVIEW_MAX_CHARS' not in form_data.model_fields_set:
@ -1269,6 +1279,7 @@ class LdapServerConfig(BaseModel):
host: str
port: int | None = None
attribute_for_mail: str = 'mail'
default_email_domain: str = ''
attribute_for_username: str = 'uid'
app_dn: str
app_dn_password: str

View file

@ -23,10 +23,11 @@ from open_webui.models.calendar import (
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.auth import get_verified_user
from open_webui.utils.calendar import expand_recurring_event
from open_webui.utils.recurrence import schedule_start_ns
from pydantic import BaseModel
log = logging.getLogger(__name__)
@ -123,7 +124,7 @@ async def create_calendar(request: Request, form_data: CalendarForm, user: UserM
user.id,
user.role,
form_data.access_grants,
'sharing.public_calendars',
resource_type='calendar',
)
calendar = await Calendars.insert_new_calendar(user.id, form_data)
await publish_event(
@ -415,25 +416,7 @@ async def update_calendar(
request: Request, calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user)
):
await check_calendar_permission(request, user)
cal = await _check_calendar_access(calendar_id, user, 'write')
# Only owner/admin can change access grants
if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
# Strip public/user grants the requesting user is not permitted to assign
# (matches the channel/notes/models pattern). The owner-only check above
# only restricts WHO can set grants; this filter restricts WHICH grants
# they may set, so a non-admin owner cannot make their calendar
# publicly readable/writable without the corresponding sharing permission.
if form_data.access_grants is not None:
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_calendars',
)
await _check_calendar_access(calendar_id, user, 'write')
updated = await Calendars.update_calendar_by_id(calendar_id, form_data)
if not updated:
@ -448,6 +431,30 @@ async def update_calendar(
return updated
class CalendarAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post('/{calendar_id}/access/update', response_model=CalendarModel)
async def update_calendar_access_by_id(
request: Request,
calendar_id: str,
form_data: CalendarAccessGrantsForm,
user: UserModel = Depends(get_verified_user),
):
await check_calendar_permission(request, user)
cal = await _check_calendar_access(calendar_id, user, 'write')
if cal.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
grants = await validate_access_grants(
await Config.get('user.permissions'), user.id, user.role, form_data.access_grants, resource_type='calendar'
)
cal.access_grants = await AccessGrants.set_access_grants('calendar', calendar_id, grants)
await publish_event(request, EVENTS.CALENDAR_UPDATED, actor=user, subject_id=cal.id, data={'name': cal.name})
return cal
@router.delete('/{calendar_id}/delete')
async def delete_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)):
await check_calendar_permission(request, user)

View file

@ -42,15 +42,15 @@ from open_webui.models.users import (
Users,
)
from open_webui.socket.main import (
encryption,
emit_to_users,
encryption,
enter_room_for_users,
get_user_ids_from_room,
leave_room_for_users,
sio,
)
from open_webui.tasks import create_task
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.apps import (
check_app_channel_access,
create_app_message,
@ -334,7 +334,7 @@ async def create_new_channel(
user.id,
user.role,
form_data.access_grants,
'sharing.public_channels',
resource_type='channel',
)
try:
@ -795,27 +795,9 @@ async def update_channel_by_id(
if channel.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_channels',
)
previous_access_grants = channel.access_grants
try:
await encryption.invalidate(id)
channel = await Channels.update_channel_by_id(id, form_data, db=db)
# Group and DM channels use membership instead of access grants.
if form_data.access_grants is not None and channel.type not in ['group', 'dm']:
revoked_user_ids = await AccessGrants.get_revoked_user_ids_by_resource(
'channel', id, previous_access_grants, db=db
)
revoked_user_ids.discard(channel.user_id)
await leave_room_for_users(f'channel:{id}', list(revoked_user_ids))
await publish_event(
request,
EVENTS.CHANNEL_UPDATED,
@ -829,6 +811,48 @@ async def update_channel_by_id(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
class ChannelAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post('/{id}/access/update', response_model=ChannelModel)
async def update_channel_access_by_id(
request: Request,
id: str,
form_data: ChannelAccessGrantsForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
await check_channels_access(request, user)
channel = await Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND)
if channel.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
# Group and DM channels manage access through membership instead.
if channel.type in ['group', 'dm']:
raise HTTPException(status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
resource_type='channel',
db=db,
)
previous_access_grants = channel.access_grants
await encryption.invalidate(id)
channel.access_grants = await AccessGrants.set_access_grants('channel', id, grants, db=db)
revoked_user_ids = await AccessGrants.get_revoked_user_ids_by_resource('channel', id, previous_access_grants, db=db)
revoked_user_ids.discard(channel.user_id)
await leave_room_for_users(f'channel:{id}', list(revoked_user_ids))
await publish_event(
request, EVENTS.CHANNEL_UPDATED, actor=user, subject_id=id, data={'name': channel.name, 'type': channel.type}
)
return channel
############################
# DeleteChannelById
############################
@ -1481,12 +1505,11 @@ async def post_new_message(
except Exception as e:
log.debug(e)
active_user_ids = await get_user_ids_from_room(f'channel:{channel.id}')
# NOTE: We intentionally do NOT pass db to background_handler.
# Background tasks should manage their own short-lived sessions to avoid
# holding database connections during slow operations (e.g., LLM calls).
async def background_handler():
active_user_ids = await get_user_ids_from_room(f'channel:{channel.id}')
await create_task(None, app_channel_reply_handler(channel.id, message.id, user.id))
await model_response_handler(request, channel, message, user)
await send_notification(
@ -2013,6 +2036,7 @@ async def delete_message_by_id(
try:
await Messages.delete_message_by_id(message_id, db=db)
await Messages.delete_replies_by_id(message_id, db=db)
await sio.emit(
'events:channel',
{

View file

@ -37,10 +37,11 @@ from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.shared_chats import ChatShareMode, ShareChatForm, SharedChatResponse, SharedChats
from open_webui.models.tags import TagModel, Tags
from open_webui.models.users import UserModel
from open_webui.socket.main import get_event_emitter
from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.folders import has_folder_write_access
from open_webui.utils.access_control import has_permission, validate_access_grants
from open_webui.utils.access_control.folders import has_chat_move_access, has_folder_write_access
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
from open_webui.utils.chat_fork import build_fork_history
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
@ -167,6 +168,16 @@ async def shared_chat_response(chat, user=None, db=None):
return data
def set_message_authors(chat: dict, user: UserModel) -> None:
"""Attribute client-supplied messages to the user."""
messages = list((chat.get('history', {}).get('messages') or {}).values())
messages.extend(chat.get('messages') or [])
for message in messages:
message['user_id'] = user.id
if message.get('role') == 'user':
message['user'] = {'id': user.id, 'name': user.name}
async def add_active_state_to_chat_list(
request: Request, chat_list: list[ChatTitleIdResponse]
) -> list[ChatTitleIdResponse]:
@ -832,6 +843,7 @@ async def create_new_chat(
)
try:
set_message_authors(form_data.chat, user)
chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db)
await publish_event(
request,
@ -863,6 +875,8 @@ async def import_chats(
await require_chat_import_permission(request, user, db)
try:
for chat_form in form_data.chats:
set_message_authors(chat_form.chat, user)
chats = await Chats.import_chats(user.id, form_data.chats, db=db)
await Tags.ensure_tags_exist(
list({tag_id for chat in chats for tag_id in (chat.meta or {}).get('tags', [])}), user.id, db=db
@ -1452,8 +1466,22 @@ async def update_chat_by_id(
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)
chat = await Chats.get_chat_by_id(id, db=db)
if chat and chat.user_id != user.id:
folder = await Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id, db=db) if chat.folder_id else None
if (
not folder
or set(form_data.chat) != {'title'}
or form_data.variables is not None
or form_data.folder_id is not None
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
title = form_data.chat['title']
if not isinstance(title, str) or not title.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT('Invalid title'))
form_data.chat['title'] = title.strip()
if chat:
set_message_authors(form_data.chat, user)
touch = 'history' in form_data.chat or 'messages' in form_data.chat
chat = await Chats.update_chat_by_id(id, form_data.chat, db=db, touch=touch)
if form_data.variables is not None:
@ -1474,6 +1502,8 @@ async def update_chat_by_id(
subject_id=id,
data={'title': chat.title},
)
if chat.user_id != user.id:
return await shared_chat_response(chat, user, db=db)
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(
@ -1482,6 +1512,39 @@ async def update_chat_by_id(
)
############################
# UpdateChatParams
############################
@router.patch('/{id}/params', response_model=dict)
async def update_chat_params(
request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if not await Chats.get_chat_by_id_and_user_id(id, user.id, db=db):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = await Chats.update_chat_params_by_id(id, form_data)
if chat is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
await publish_event(
request,
EVENTS.CHAT_UPDATED,
actor=user,
subject_id=id,
data={'title': chat.title},
)
return chat.chat['params']
############################
# UpdateChatMessageById
############################
@ -2166,13 +2229,12 @@ async def update_shared_chat_access_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_chats',
'sharing.open_chats',
resource_type='shared_chat',
db=db,
)
@ -2258,7 +2320,10 @@ async def update_chat_folder_id_by_id(
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)
chat = await Chats.get_chat_by_id(id, db=db)
if chat and chat.user_id != user.id:
if not await has_chat_move_access(user.id, chat.folder_id, form_data.folder_id, db=db):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
if chat:
# None is allowed: it moves the chat out of any folder.
if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db):
@ -2275,6 +2340,8 @@ async def update_chat_folder_id_by_id(
subject_id=id,
data={'folder_id': form_data.folder_id},
)
if chat.user_id != user.id:
return await shared_chat_response(chat, user, db=db)
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())

View file

@ -31,7 +31,7 @@ from open_webui.utils.tools import (
set_terminal_servers,
set_tool_servers,
)
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, StrictBool
router = APIRouter()
@ -127,6 +127,24 @@ async def get_config_namespace(namespace: str, user=Depends(get_admin_user)):
return await Config.get_namespace(namespace)
class InterfaceVisibilityForm(BaseModel):
visibility: dict[str, StrictBool]
@router.post('/interface/visibility', response_model=InterfaceVisibilityForm)
async def set_interface_visibility(request: Request, form_data: InterfaceVisibilityForm, user=Depends(get_admin_user)):
await Config.upsert({'ui.visibility': form_data.visibility})
await publish_event(
request,
EVENTS.CONFIG_UPDATED,
actor=user,
subject_id='interface',
subject_type='config',
data={'keys': ['ui.visibility']},
)
return form_data
############################
# Connections Config
############################

View file

@ -41,7 +41,7 @@ from open_webui.models.knowledge import Knowledges
from open_webui.models.users import Users
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.routers.audio import transcribe
from open_webui.routers.retrieval import ProcessFileForm, process_file
from open_webui.routers.retrieval import ProcessFileForm, index_file, process_file
from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.misc import strict_match_mime_type
@ -136,6 +136,7 @@ async def process_uploaded_file(
):
async def _process_handler(db_session):
try:
await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session)
content_type = file.content_type
# Detect mis-labeled text files (e.g. .ts → video/mp2t)
@ -158,7 +159,7 @@ async def process_uploaded_file(
file_metadata,
user,
)
await process_file(
await index_file(
request,
ProcessFileForm(file_id=file_item.id, content=result.get('text', '')),
user=user,
@ -178,11 +179,6 @@ async def process_uploaded_file(
# extraction causes "Timeout reached while detecting
# encoding" errors.
log.info('Video file detected (%s), skipping text extraction', content_type)
await Files.update_file_data_by_id(
file_item.id,
{'status': 'completed'},
db=db_session,
)
else:
raise Exception(f'File type {content_type} is not supported for processing')
@ -191,7 +187,7 @@ async def process_uploaded_file(
# configured content extraction engine.
if not content_type:
log.info('File type %s is not provided, but trying to process anyway', file.content_type)
await process_file(
await index_file(
request,
ProcessFileForm(file_id=file_item.id),
user=user,
@ -233,10 +229,7 @@ async def process_uploaded_file(
)
directory_id = None
# Keep the generic file status stream open until the
# KB-specific vector write and durable link both finish.
await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session)
await process_file(
await index_file(
request,
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
user=user,
@ -256,6 +249,9 @@ async def process_uploaded_file(
log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}')
raise
# Completion includes the durable knowledge link, when requested.
await Files.update_file_data_by_id(file_item.id, {'status': 'completed', 'error': None}, db=db_session)
except Exception as e:
log.error(f'Error processing file: {file_item.id}')
await Files.update_file_data_by_id(
@ -511,6 +507,15 @@ async def list_files(
############################
async def _search_files(filename: str, content: bool, skip: int, limit: int, user_id: str | None, db: AsyncSession):
files = await Files.search_files(user_id=user_id, filename=filename, skip=skip, limit=limit, db=db)
if not content:
for file in files:
if file.data and 'content' in file.data:
del file.data['content']
return files
@router.get('/search', response_model=list[FileModelResponse])
async def search_files(
filename: str = Query(
@ -523,36 +528,28 @@ async def search_files(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""
Search for files by filename with support for wildcard patterns.
Uses SQL-based filtering with pagination for better performance.
"""
# Determine user_id: null for admin with bypass (search all), user.id otherwise
user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id
# Use optimized database query with pagination
files = await Files.search_files(
user_id=user_id,
filename=filename,
skip=skip,
limit=limit,
db=db,
)
files = await _search_files(filename, content, skip, limit, user_id, db)
if not files:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No files found matching the pattern.',
)
if not content:
for file in files:
if file.data and 'content' in file.data:
del file.data['content']
return files
@router.get('/user/search', response_model=list[FileModelResponse])
async def search_user_files(
filename: str = Query('*'),
content: bool = Query(False),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=1000),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
return await _search_files(filename, content, skip, limit, user.id, db)
############################
# Count Files
############################
@ -567,6 +564,14 @@ async def count_files(
return await Files.count_files_by_user_id(user_id=user_id, db=db)
@router.get('/user/count', response_model=int)
async def count_user_files(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
return await Files.count_files_by_user_id(user_id=user.id, db=db)
############################
# Delete All Files
############################
@ -732,6 +737,9 @@ async def update_file_data_content_by_id(
)
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'write', user, db=db):
if not form_data.content.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.EMPTY_CONTENT)
max_size = await Config.get('rag.file.max_size')
if max_size and len(form_data.content.encode('utf-8')) > int(max_size) * 1024 * 1024:
raise HTTPException(

View file

@ -12,10 +12,11 @@ from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.shared_chats import ChatShareMode
from open_webui.models.access_grants import AccessGrants
from open_webui.models.automations import Automations
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.config import Config
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.folders import (
FolderForm,
FolderModel,
@ -23,17 +24,16 @@ from open_webui.models.folders import (
Folders,
FolderUpdateForm,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.automations import Automations
from open_webui.models.groups import Groups
from open_webui.models.shared_chats import ChatShareMode
from open_webui.models.users import Users
from open_webui.utils.access_control import has_permission
from open_webui.tasks import has_active_tasks
from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_permission,
validate_access_grants,
)
from open_webui.utils.access_control.files import can_read_all_folder_files, get_accessible_folder_files
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.tasks import has_active_tasks
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -555,12 +555,12 @@ async def update_folder_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
None,
resource_type='folder',
db=db,
)

View file

@ -43,7 +43,7 @@ from open_webui.routers.retrieval import (
process_files_batch,
)
from open_webui.storage.provider import Storage
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
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.json_codec import JSONCodec
@ -338,7 +338,7 @@ async def create_new_knowledge(
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
resource_type='knowledge',
)
knowledge = await Knowledges.insert_new_knowledge(user.id, form_data)
@ -910,7 +910,7 @@ async def create_external_knowledge(
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
resource_type='knowledge',
)
knowledge = await Knowledges.insert_new_knowledge(
@ -969,7 +969,7 @@ async def create_external_knowledge_source(
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
resource_type='knowledge',
)
connections = await _get_external_connections()
@ -1040,14 +1040,6 @@ async def update_external_knowledge_source(
if not test_result.get('documents'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Test query returned no results.')
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
)
connections[idx] = connection
await _set_external_connections(connections)
@ -1176,14 +1168,6 @@ async def update_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
)
knowledge = await Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
# Re-embed knowledge base for semantic search
@ -1252,12 +1236,12 @@ async def update_knowledge_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_knowledge',
resource_type='knowledge',
)
knowledge.access_grants = await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)

View file

@ -28,7 +28,7 @@ from open_webui.env import (
)
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants, normalize_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.files import Files
from open_webui.models.groups import Groups
@ -45,17 +45,22 @@ from open_webui.models.models import (
Models,
)
from open_webui.storage.provider import Storage
from open_webui.utils.access_control import filter_allowed_access_grants, has_access, has_permission
from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_access,
has_permission,
validate_access_grants,
)
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 open_webui.utils.models import get_all_models
from open_webui.utils.validate import BACKGROUND_IMAGE_MAX_BYTES, validate_background_image
from open_webui.utils.voice_avatar import (
AVATAR_MAX_BYTES,
ANIMATION_MAX_BYTES,
validate_voice_avatar,
AVATAR_MAX_BYTES,
validate_voice_animation,
validate_voice_avatar,
)
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@ -410,7 +415,7 @@ async def create_new_model(
user.id,
user.role,
form_data.access_grants,
'sharing.public_models',
resource_type='model',
)
await _check_model_controls(form_data, None, user, request)
@ -580,12 +585,10 @@ async def import_models(
and existing_model.user_id != user.id
and model_id not in writable_model_ids
):
log.warning(
'import_models: user %s skipped model %s (no write access)',
user.id,
model_id,
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
)
continue
if (
user.role != 'admin'
@ -607,17 +610,6 @@ async def import_models(
model_data['params'] = model_data.get('params', {})
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
# Only filter access_grants when explicitly provided
# in the payload to avoid altering existing ACLs on
# metadata-only imports.
if 'access_grants' in model_data:
updated_model.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
updated_model.access_grants,
'sharing.public_models',
)
imported_model = updated_model
else:
# Insert new model
@ -648,19 +640,17 @@ async def import_models(
base_model_ids.add(base_model_id.split(':', 1)[0])
if model_id in base_model_ids:
log.warning(
'import_models: user %s skipped model %s (id belongs to a base model)',
user.id,
model_id,
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
)
continue
new_model.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
new_model.access_grants,
'sharing.public_models',
resource_type='model',
)
imported_model = new_model
@ -1209,30 +1199,6 @@ async def update_model_by_id(
form_data.meta.background_image_url = model.meta.background_image_url
await _verify_background_image(form_data.meta.background_image_url, user, db, model.meta.background_image_url)
if form_data.access_grants is not None:
# The editor resends every stored grant, so re-checking them would strip sharing this user cannot re-create.
existing_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in model.access_grants
}
submitted_access_grants_map = {
(grant['principal_type'], grant['principal_id'], grant['permission']): grant
for grant in normalize_access_grants(form_data.access_grants)
}
preserved_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
]
new_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
]
form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
new_access_grants,
'sharing.public_models',
)
await _check_model_controls(form_data, model, user, request)
model = await Models.update_model_by_id(form_data.id, form_data, db=db, user_id=user.id)
if model:
@ -1306,12 +1272,12 @@ async def update_model_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_models',
resource_type='model',
)
await AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db)

View file

@ -11,7 +11,7 @@ from open_webui.config import (
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.access_grants import AccessGrantModel, AccessGrants, has_public_write_access_grant
from open_webui.models.chats import ChatForm, ChatResponse, Chats
from open_webui.models.config import Config
from open_webui.models.groups import Groups
@ -20,6 +20,7 @@ from open_webui.models.notes import (
NoteListResponse,
NoteModel,
Notes,
NoteUpdateForm,
NoteUserResponse,
)
from open_webui.models.users import UserModel, UserResponse, Users
@ -27,9 +28,9 @@ from open_webui.socket.main import leave_room_for_users, sio
from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_permission,
has_public_read_access_grant,
has_public_write_access_grant,
validate_access_grants,
)
from open_webui.utils.access_control.files import has_access_to_note_files
from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -232,7 +233,7 @@ async def create_new_note(
user.id,
user.role,
form_data.access_grants,
'sharing.public_notes',
resource_type='note',
db=db,
)
@ -511,7 +512,7 @@ async def create_note_chat_by_id(
async def update_note_by_id(
request: Request,
id: str,
form_data: NoteForm,
form_data: NoteUpdateForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -533,22 +534,15 @@ async def update_note_by_id(
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
if form_data.access_grants is not None:
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_notes',
db=db,
)
previous_access_grants = note.access_grants
if (
form_data.data
and 'files' in form_data.data
and not await has_access_to_note_files(note, form_data.data['files'], user, db=db)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
note = await Notes.update_note_by_id(id, form_data, db=db)
if form_data.access_grants is not None:
await leave_note_rooms_for_revoked_users(note, previous_access_grants, db=db)
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
note.is_pinned = note.id in pinned_note_ids
@ -613,12 +607,12 @@ async def update_note_access_by_id(
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_notes',
resource_type='note',
)
await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db)

View file

@ -1173,22 +1173,20 @@ RESPONSES_ALLOWED_FIELDS: dict[str, set[str]] = {
'message': {'type', 'role', 'content'},
'function_call': {'type', 'call_id', 'name', 'arguments', 'id'},
'function_call_output': {'type', 'call_id', 'output'},
'reasoning': {'type', 'id', 'summary', 'encrypted_content', 'content'},
}
def _normalize_stored_item(item: dict) -> dict:
"""Strip local-only fields from a stored output item before replaying it.
"""Keep replayable fields for known output item types.
Open WebUI stores extra bookkeeping fields (``id``, ``status``,
``started_at``, ``ended_at``, ``duration``, ``_tag_type``,
``attributes``, ``summary``, etc.) that the Responses API does
not accept. This helper returns a copy containing only the
fields the API understands.
Stored items also contain UI bookkeeping and output-only metadata.
In particular, Azure rejects ``status`` on reasoning input items.
"""
item_type = item.get('type', '')
allowed = RESPONSES_ALLOWED_FIELDS.get(item_type)
if allowed is None:
# Unknown type — pass through as-is (e.g. reasoning, extension items).
# Unknown type — pass through as-is for provider extensions.
return item
if item_type == 'function_call':
item = {**item, **get_provider_function(item)}
@ -1695,8 +1693,6 @@ async def embeddings(request: Request, form_data: dict, user):
dict: OpenAI-compatible embeddings response.
"""
idx = 0
# Prepare payload/body
body = JSONCodec.dumps(form_data)
# Find correct backend url/key based on model
model_id = form_data.get('model')
# Check if model is already in app state cache to avoid expensive get_all_models() call
@ -1709,6 +1705,10 @@ async def embeddings(request: Request, form_data: dict, user):
url, key, api_config = await get_openai_connection(idx)
form_data['model'] = strip_provider_model_prefix(form_data['model'], api_config.get('prefix_id'))
# Prepare payload/body
body = JSONCodec.dumps(form_data)
r = None
streaming = False

View file

@ -7,7 +7,7 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants, normalize_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
@ -23,7 +23,7 @@ from open_webui.models.prompts import (
Prompts,
PromptUserResponse,
)
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@ -171,7 +171,7 @@ async def create_new_prompt(
user.id,
user.role,
form_data.access_grants,
'sharing.public_prompts',
resource_type='prompt',
)
prompt = await Prompts.get_prompt_by_command(form_data.command, db=db)
@ -289,29 +289,6 @@ async def update_prompt_by_id(
detail=ERROR_MESSAGES.COMMAND_TAKEN,
)
if form_data.access_grants is not None:
existing_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in prompt.access_grants
}
submitted_access_grants_map = {
(grant['principal_type'], grant['principal_id'], grant['permission']): grant
for grant in normalize_access_grants(form_data.access_grants)
}
preserved_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
]
new_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
]
form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
new_access_grants,
'sharing.public_prompts',
)
# Use the ID from the found prompt
updated_prompt = await Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db)
if updated_prompt:
@ -484,12 +461,12 @@ async def update_prompt_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_prompts',
resource_type='prompt',
)
await AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db)

View file

@ -37,7 +37,6 @@ from langchain_text_splitters import (
from open_webui.config import (
Config,
DEFAULT_LOCALE,
ENV,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
@ -1575,8 +1574,26 @@ async def process_file(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Standalone processing endpoint: index the file, then mark it completed.
Uploads call index_file directly so they can finish knowledge linking before
reporting completion.
"""
Process a file and save its content to the vector database.
result = await index_file(request, form_data, user, db)
async with get_async_db() as session:
await Files.update_file_data_by_id(form_data.file_id, {'status': 'completed', 'error': None}, db=session)
return result
async def index_file(
request: Request,
form_data: ProcessFileForm,
user,
db: AsyncSession,
):
"""Extract or reuse file content and index it without marking completion.
The caller marks completion after all its work, including any knowledge linking.
Note: granular session management is used to prevent connection pool exhaustion.
The session is committed before external API calls, and updates use a fresh session.
"""
@ -1714,7 +1731,6 @@ async def process_file(
hash = calculate_sha256_string(text_content)
if config['rag.bypass_embedding_and_retrieval']:
await Files.update_file_data_by_id(file.id, {'status': 'completed', 'error': None}, db=db)
await Files.update_file_hash_by_id(file.id, hash, db=db)
await publish_event(
request,
@ -1771,11 +1787,6 @@ async def process_file(
db=session,
)
await Files.update_file_data_by_id(
file.id,
{'status': 'completed', 'error': None},
db=session,
)
await Files.update_file_hash_by_id(file.id, hash, db=session)
await publish_event(
@ -2990,13 +3001,6 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo
return True
if ENV == 'dev':
@router.get('/ef/{text}')
async def get_embeddings(request: Request, text: str | None = 'Hello World!'):
return {'result': await request.app.state.EMBEDDING_FUNCTION(text, prefix=RAG_EMBEDDING_QUERY_PREFIX)}
class BatchProcessFilesForm(BaseModel):
files: list[FileModel]
collection_name: str

View file

@ -28,7 +28,7 @@ from open_webui.models.skills import (
SkillUserResponse,
get_skill_snapshot,
)
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission, validate_access_grants
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.skill_files import (
MAX_IMPORT_BYTES,
@ -261,7 +261,7 @@ async def create_new_skill(
user.id,
user.role,
form_data.access_grants,
'sharing.public_skills',
resource_type='skill',
)
try:
@ -373,22 +373,9 @@ async def update_skill_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public/user grants the requesting user is not permitted to assign
# (matches the channel/notes/calendar pattern). The access check above only
# restricts WHO can write to the skill; this filter restricts WHICH grants
# they may set, so a non-admin owner cannot make their own skill publicly
# readable/writable without sharing.public_skills permission.
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_skills',
)
try:
updated = {
**form_data.model_dump(exclude={'id'}, exclude_unset=True),
**form_data.model_dump(exclude={'id', 'access_grants'}, exclude_unset=True),
}
skill = await Skills.update_skill_by_id(id, updated, db=db, user_id=user.id)
@ -456,12 +443,12 @@ async def update_skill_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_skills',
resource_type='skill',
)
await AccessGrants.set_access_grants('skill', id, form_data.access_grants, db=db)

View file

@ -19,7 +19,7 @@ from open_webui.env import (
)
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants, normalize_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
@ -36,6 +36,7 @@ from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_connection_access,
has_permission,
validate_access_grants,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.plugin import (
@ -420,7 +421,7 @@ async def create_new_tools(
user.id,
user.role,
form_data.access_grants,
'sharing.public_tools',
resource_type='tool',
)
form_data.content = replace_imports(form_data.content)
@ -571,37 +572,11 @@ async def _update_tool(request, id, form_data, user, db, version_id=None):
specs = get_local_tool_specs(tool_module)
if version_id is None and form_data.access_grants is not None:
existing_access_grants = {
(grant.principal_type, grant.principal_id, grant.permission) for grant in tools.access_grants
}
submitted_access_grants_map = {
(grant['principal_type'], grant['principal_id'], grant['permission']): grant
for grant in normalize_access_grants(form_data.access_grants)
}
preserved_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key in existing_access_grants
]
new_access_grants = [
grant for key, grant in submitted_access_grants_map.items() if key not in existing_access_grants
]
form_data.access_grants = preserved_access_grants + await filter_allowed_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
new_access_grants,
'sharing.public_tools',
)
updated = {
**form_data.model_dump(exclude={'id'}),
**form_data.model_dump(exclude={'id', 'access_grants'}),
'specs': specs,
}
if version_id is not None:
updated.pop('access_grants', None)
tools = await Tools.update_tool_by_id(
id,
updated,
@ -677,12 +652,12 @@ async def update_tool_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.access_grants = await filter_allowed_access_grants(
form_data.access_grants = await validate_access_grants(
await Config.get('user.permissions'),
user.id,
user.role,
form_data.access_grants,
'sharing.public_tools',
resource_type='tool',
)
await AccessGrants.set_access_grants('tool', id, form_data.access_grants, db=db)

View file

@ -202,6 +202,7 @@ class WorkspacePermissions(BaseModel):
skills: bool = False
models_import: bool = False
models_export: bool = False
models_hidden: bool = False
prompts_import: bool = False
prompts_export: bool = False
tools_import: bool = False
@ -514,16 +515,6 @@ async def update_user_settings_by_session_user(
):
ui_settings.pop('system', None)
if (
user.role != 'admin'
and 'params' in ui_settings
and (
not await has_permission(user.id, 'chat.controls', await Config.get('user.permissions'), db=db)
or not await has_permission(user.id, 'chat.params', await Config.get('user.permissions'), db=db)
)
):
ui_settings.pop('params', None)
if (
user.role != 'admin'
and ui_settings is not None

View file

@ -54,6 +54,7 @@ from open_webui.tasks import (
stop_item_tasks,
)
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control.files import has_access_to_note_files
from open_webui.utils.auth import get_verified_user_by_token
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.json_codec import SOCKETIO_JSON, JSONCodec, dumps_bytes
@ -440,11 +441,6 @@ def get_session_ids_from_room(room):
return list(members) if members else []
def get_session_ids_by_user_id(user_id: str) -> list[str]:
"""Get known session IDs for a user across the local rooms and shared session pool."""
return get_session_ids_by_user_ids([user_id])
def get_session_ids_by_user_ids(user_ids: list[str]) -> list[str]:
"""Get known session IDs for users across the local rooms and shared session pool."""
if not user_ids:
@ -517,7 +513,14 @@ async def disconnect_user_sessions(user_id: str, *, refresh_access: bool = False
The client will automatically reconnect and re-authenticate with
fresh data from the database.
"""
session_ids = get_session_ids_by_user_id(user_id)
session_ids = await disconnect_sessions_for_users([user_id], refresh_access=refresh_access)
if session_ids:
log.info('Requested disconnect of %s session(s) for user %s', len(session_ids), user_id)
async def disconnect_sessions_for_users(user_ids: list[str], *, refresh_access: bool = False) -> list[str]:
"""Disconnect all Socket.IO sessions belonging to these users and return their session IDs."""
session_ids = get_session_ids_by_user_ids(user_ids)
for sid in session_ids:
if refresh_access:
try:
@ -527,10 +530,8 @@ async def disconnect_user_sessions(user_id: str, *, refresh_access: bool = False
try:
await sio.disconnect(sid)
except Exception:
log.exception('Failed to disconnect session %s for user %s', sid, user_id)
if session_ids:
log.info('Requested disconnect of %s session(s) for user %s', len(session_ids), user_id)
log.exception('Failed to disconnect session %s', sid)
return session_ids
@sio.on('usage')
@ -622,12 +623,23 @@ async def refresh_chat_access(chat_id=None):
access = {}
chat_ids = set()
for batch in get_session_pool_batches():
chat_owner_ids: dict[str, str] = {}
if not chat_id:
# Owners keep access; role changes and deletion disconnect them separately.
open_chat_ids: set[str] = set()
for _, session in batch:
if not session:
continue
open_chat_ids.update(session.get('chat_ids') or [])
chat_owner_ids = await Chats.get_owner_ids_by_chat_ids(list(open_chat_ids))
for sid, session in batch:
if not session:
continue
for cid in session.get('chat_ids') or []:
if chat_id and cid != chat_id:
continue
if chat_owner_ids.get(cid) == session['id']:
continue
key = (cid, session['id'])
if key not in access:
user = await Users.get_user_by_id(session['id'])
@ -939,8 +951,6 @@ async def ydoc_document_join(sid, data):
# Join Socket.IO room
await sio.enter_room(sid, f'doc_{document_id}')
active_session_ids = get_session_ids_from_room(f'doc_{document_id}')
# Get the Yjs document state
ydoc = Y.Doc()
updates = await YDOC_MANAGER.get_updates(document_id)
@ -955,7 +965,6 @@ async def ydoc_document_join(sid, data):
'document_id': document_id,
'state': list(state_update), # Convert bytes to list for JSON
'content': note.data.get('content') if document_id.startswith('note:') and note.data else None,
'sessions': active_session_ids,
},
room=sid,
)
@ -1089,8 +1098,25 @@ async def yjs_document_update(sid, data):
log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.')
return
snapshot = data.get('data')
if isinstance(snapshot, dict) and 'files' in snapshot:
editor = await Users.get_user_by_id(user['id'])
if not editor or not await has_access_to_note_files(note, snapshot['files'], editor):
await sio.emit('error', {'message': 'Cannot attach files you cannot access'}, room=sid)
return
update = data.get('update') # List of bytes from frontend
# The document lock makes initialization first-write-wins across workers.
# Return the stored state so losing clients never apply their own seed.
if data.get('initialize'):
updates = await YDOC_MANAGER.get_updates(document_id)
if updates:
ydoc = Y.Doc()
for stored_update in updates:
ydoc.apply_update(bytes(stored_update))
return list(ydoc.get_update())
if update:
user_id = data.get('user_id', sid)
@ -1114,6 +1140,9 @@ async def yjs_document_update(sid, data):
else:
log.warning(f'Update for document {document_id} is invalid or over the size limit. Rejecting update.')
if data.get('initialize'):
return update if stored else None
async def debounced_save():
await asyncio.sleep(0.5)
async with YDOC_MANAGER.lock(document_id):
@ -1574,6 +1603,20 @@ async def get_event_emitter(request_info, update_db=True):
return None
def get_user_chat_session_id(user_id: str, chat_id: str) -> str | None:
now = time.time()
for batch in get_session_pool_batches():
for session_id, session in batch:
if (
session
and session.get('id') == user_id
and chat_id in (session.get('chat_ids') or [])
and now - session.get('last_seen_at', 0) <= SESSION_POOL_TIMEOUT
):
return session_id
return None
async def get_event_call(request_info):
async def __event_caller__(event_data):
session_id = request_info['session_id']
@ -1584,12 +1627,17 @@ async def get_event_call(request_info):
log.warning(f'Event caller: session {session_id} not owned by requesting user or disconnected')
return {'error': 'Client session disconnected.'}
interaction_id = None
is_input_request = event_data.get('type') in {
'confirmation',
'input',
'request:user_input',
'request:elicitation',
}
interaction_id = str(uuid4()) if is_input_request else None
timeout = WEBSOCKET_EVENT_CALLER_TIMEOUT
if event_data.get('type') in ('request:user_input', 'request:elicitation') or (
event_data.get('type') == 'confirmation' and (event_data.get('data') or {}).get('tool_call')
):
interaction_id = str(uuid4())
data = dict(event_data.get('data') or {})
timeout_ms = data.get('timeout_ms', 120_000)
if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int):
@ -1597,19 +1645,56 @@ async def get_event_call(request_info):
timeout = min(max(timeout_ms / 1000, 60), 240)
if WEBSOCKET_EVENT_CALLER_TIMEOUT is not None and WEBSOCKET_EVENT_CALLER_TIMEOUT > 0:
timeout = min(timeout, WEBSOCKET_EVENT_CALLER_TIMEOUT)
event_data = {**event_data, 'data': {**data, 'interaction_id': interaction_id}}
if interaction_id:
event_data = {**event_data, 'data': {**(event_data.get('data') or {}), 'interaction_id': interaction_id}}
can_resume = (
is_input_request
and 'session_id' not in (event_data.get('data') or {})
and is_saved_chat_id(request_info['chat_id'])
)
try:
return await sio.call(
'events',
{
'chat_id': request_info.get('chat_id', None),
'message_id': request_info.get('message_id', None),
'data': event_data,
},
to=session_id,
timeout=timeout,
)
async with asyncio.timeout(timeout):
while True:
call_task = asyncio.create_task(
sio.call(
'events',
{
'chat_id': request_info['chat_id'],
'message_id': request_info['message_id'],
'data': event_data,
},
to=session_id,
timeout=None,
)
)
try:
while (
not call_task.done()
and get_user_id_from_session_pool(session_id) == request_info['user_id']
):
await asyncio.wait({call_task}, timeout=1)
if call_task.done():
return call_task.result()
finally:
call_task.cancel()
await asyncio.gather(call_task, return_exceptions=True)
log.warning('Event caller: session %s disconnected before answering', session_id)
if not can_resume:
return {'error': 'Client session disconnected.'}
try:
async with asyncio.timeout(60):
while True:
# Redis session scans are synchronous; keep them off the event loop.
next_session_id = await asyncio.to_thread(
get_user_chat_session_id, request_info['user_id'], request_info['chat_id']
)
if next_session_id is not None:
session_id = request_info['session_id'] = next_session_id
break
await asyncio.sleep(1)
except TimeoutError:
return {'error': 'Client session disconnected.'}
except (TimeoutError, socketio.exceptions.TimeoutError):
log.warning(f'Event caller timed out for session {session_id}')
return {'error': 'Event call timed out. The browser tab may be inactive or closed.'}

View file

@ -111,6 +111,9 @@ class AsyncRedisRoomChannelManager(AsyncRedisManager):
if data.get('type') == 'subscribe':
# redis-py can reconnect internally and restore a pending removal.
self._subscription_queue.put_nowait(asyncio.Event())
# Ping once per (re)subscribe, not per channel: a silent reconnect drops the pending PONG.
if data['channel'] == self.channel.encode():
await pubsub.ping(str(serial).encode())
elif data.get('type') == 'pong' and data['data'] == str(serial).encode():
for ready in waiters:
ready.set()

View file

@ -539,7 +539,9 @@ async def ask_user(
:return: JSON with status and answers keyed by question id
"""
try:
if not isinstance(questions, list) or not 1 <= len(questions) <= 3:
if not isinstance(questions, list):
raise ValueError('ask_user questions must be an array of question objects.')
if not 1 <= len(questions) <= 3:
raise ValueError('ask_user requires 1-3 questions.')
normalized_questions = []
@ -1348,21 +1350,26 @@ async def replace_note_content(
__user__: dict = None,
) -> str:
"""
Update an existing note by replacing the whole markdown content or applying range operations.
Update an existing note by replacing the whole markdown content, exact text, or character ranges.
Prefer "replace_range" when only part of the note changes.
Prefer "replace_text" when only part of the note changes. Supply either find or start/end, not both.
find is non-empty, literal, and case-sensitive.
Text matches are non-overlapping. Multiple matches require a 0-indexed occurrence or replace_all=true;
otherwise nothing is changed and match offsets and context are returned.
A "replace" operation must be the only operation in the request.
start and end are 0-indexed character offsets into the markdown content from view_note.
end is exclusive.
Offsets never shift as operations are applied.
Ranges must not overlap.
expected is optional. When set, the request is rejected if the range's current text does not match it.
"replace_range" is a deprecated alias for the start/end form of "replace_text".
:param note_id: The ID of the note to update
:param content: The new markdown content for a whole-note update
:param operations: Optional note operations:
- {"action": "replace", "content": "..."}
- {"action": "replace_range", "start": 0, "end": 10, "content": "...", "expected": "..."}
- {"action": "replace_text", "start": 0, "end": 10, "content": "...", "expected": "..."}
- {"action": "replace_text", "find": "old text", "content": "new text", "occurrence": 0, "replace_all": false}
:param title: Optional new title for the note
:return: JSON with success status and updated note info
"""
@ -1386,11 +1393,11 @@ async def replace_note_content(
current_content = ((note.data or {}).get('content') or {}).get('md') or ''
applied_operation_count = 0
range_operations = []
if operations is not None:
if not isinstance(operations, list) or len(operations) == 0:
return JSONCodec.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'})
range_operations = []
for idx, operation in enumerate(operations):
if not isinstance(operation, dict):
return JSONCodec.dumps(
@ -1421,11 +1428,78 @@ async def replace_note_content(
applied_operation_count = 1
break
if action != 'replace_range':
if action not in ('replace_range', 'replace_text'):
return JSONCodec.dumps(
{'error': 'unknown operation action', 'code': 'invalid_action', 'index': idx, 'action': action}
)
if not isinstance(replacement, str):
return JSONCodec.dumps(
{'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx}
)
if action == 'replace_text' and 'find' in operation:
if 'start' in operation or 'end' in operation:
return JSONCodec.dumps(
{
'error': 'use either find or start/end, not both',
'code': 'invalid_operation',
'index': idx,
}
)
find = operation.get('find')
occurrence = operation.get('occurrence')
replace_all = operation.get('replace_all', False)
if not isinstance(find, str) or not find:
return JSONCodec.dumps(
{'error': 'find must be a non-empty string', 'code': 'invalid_find', 'index': idx}
)
if (
type(replace_all) is not bool
or (occurrence is not None and (type(occurrence) is not int or occurrence < 0))
or (replace_all and occurrence is not None)
):
return JSONCodec.dumps(
{
'error': 'use a non-negative occurrence or replace_all boolean',
'code': 'invalid_occurrence',
'index': idx,
}
)
matches = []
start = current_content.find(find)
while start != -1:
end = start + len(find)
matches.append({'start': start, 'end': end})
start = current_content.find(find, end)
if not matches:
return JSONCodec.dumps({'error': 'Text not found', 'code': 'text_not_found', 'index': idx})
if occurrence is not None and occurrence >= len(matches):
return JSONCodec.dumps(
{'error': 'occurrence is out of bounds', 'code': 'invalid_occurrence', 'index': idx}
)
if len(matches) > 1 and occurrence is None and not replace_all:
return JSONCodec.dumps(
{
'error': 'Multiple matches; specify occurrence or replace_all',
'code': 'ambiguous_match',
'index': idx,
'matches': [
{
**match,
'context': current_content[max(0, match['start'] - 50) : match['end'] + 50],
}
for match in matches
],
},
ensure_ascii=False,
)
if occurrence is not None:
matches = [matches[occurrence]]
range_operations.extend({**match, 'content': replacement} for match in matches)
continue
start = operation.get('start')
end = operation.get('end')
expected = operation.get('expected')
@ -1433,10 +1507,6 @@ async def replace_note_content(
return JSONCodec.dumps(
{'error': 'operation start and end must be integers', 'code': 'invalid_range', 'index': idx}
)
if not isinstance(replacement, str):
return JSONCodec.dumps(
{'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx}
)
if start < 0 or end < start or end > len(current_content):
return JSONCodec.dumps(
{'error': 'operation range is out of bounds', 'code': 'range_out_of_bounds', 'index': idx}
@ -1503,6 +1573,7 @@ async def replace_note_content(
'title': updated_note.title,
'updated_at': updated_note.updated_at,
'applied_operation_count': applied_operation_count,
'applied_ranges': [{'start': op['start'], 'end': op['end']} for op in range_operations],
},
ensure_ascii=False,
)
@ -2773,7 +2844,7 @@ async def view_file(
:param max_chars: Maximum characters to return (a server-side hard cap applies)
:param line_numbers: If true, prefix each line with its 1-indexed line number
:param start_line: Optional 1-indexed start line (overrides offset/max_chars when set)
:param end_line: Optional 1-indexed end line (inclusive)
:param end_line: Optional 1-indexed end line (inclusive; reads from line 1 if no start line is given)
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
"""
if __request__ is None:
@ -2815,10 +2886,10 @@ async def view_file(
total_chars = len(content)
# Line-based addressing (overrides char-based offset/max_chars)
if start_line is not None:
if start_line is not None or end_line is not None:
all_lines = content.split('\n')
total_lines = len(all_lines)
s = max(1, int(start_line)) - 1 # 1-indexed to 0-indexed
s = max(1, int(start_line or 1)) - 1 # 1-indexed to 0-indexed
e = min(total_lines, int(end_line) if end_line else s + 100)
selected = all_lines[s:e]
sliced = '\n'.join(f'{s + i + 1}: {line}' for i, line in enumerate(selected))
@ -2838,7 +2909,8 @@ async def view_file(
return JSONCodec.dumps(result, ensure_ascii=False)
sliced = content[offset : offset + max_chars]
is_truncated = (offset + len(sliced)) < total_chars
returned_chars = len(sliced)
is_truncated = (offset + returned_chars) < total_chars
if line_numbers:
start_ln = content[:offset].count('\n') + 1
@ -2856,10 +2928,10 @@ async def view_file(
if is_truncated or offset > 0:
result['truncated'] = is_truncated
result['total_chars'] = total_chars
result['returned_chars'] = len(sliced)
result['returned_chars'] = returned_chars
result['offset'] = offset
if is_truncated:
result['next_offset'] = offset + len(sliced)
result['next_offset'] = offset + returned_chars
return JSONCodec.dumps(result, ensure_ascii=False)
except Exception as e:
@ -2885,7 +2957,7 @@ async def view_knowledge_file(
:param max_chars: Maximum characters to return (a server-side hard cap applies)
:param line_numbers: If true, prefix each line with its 1-indexed line number
:param start_line: Optional 1-indexed start line (overrides offset/max_chars when set)
:param end_line: Optional 1-indexed end line (inclusive)
:param end_line: Optional 1-indexed end line (inclusive; reads from line 1 if no start line is given)
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
"""
if __request__ is None:
@ -2955,10 +3027,10 @@ async def view_knowledge_file(
total_chars = len(content)
# Line-based addressing (overrides char-based offset/max_chars)
if start_line is not None:
if start_line is not None or end_line is not None:
all_lines = content.split('\n')
total_lines = len(all_lines)
s = max(1, int(start_line)) - 1
s = max(1, int(start_line or 1)) - 1
e = min(total_lines, int(end_line) if end_line else s + 100)
selected = all_lines[s:e]
sliced = '\n'.join(f'{s + i + 1}: {line}' for i, line in enumerate(selected))
@ -2981,7 +3053,8 @@ async def view_knowledge_file(
return JSONCodec.dumps(result, ensure_ascii=False)
sliced = content[offset : offset + max_chars]
is_truncated = (offset + len(sliced)) < total_chars
returned_chars = len(sliced)
is_truncated = (offset + returned_chars) < total_chars
if line_numbers:
start_ln = content[:offset].count('\n') + 1
@ -3002,10 +3075,10 @@ async def view_knowledge_file(
if is_truncated or offset > 0:
result['truncated'] = is_truncated
result['total_chars'] = total_chars
result['returned_chars'] = len(sliced)
result['returned_chars'] = returned_chars
result['offset'] = offset
if is_truncated:
result['next_offset'] = offset + len(sliced)
result['next_offset'] = offset + returned_chars
return JSONCodec.dumps(result, ensure_ascii=False)
except Exception as e:

View file

@ -2,14 +2,11 @@ import logging
from copy import deepcopy
from typing import Any
from fastapi import HTTPException
from open_webui.config import DEFAULT_USER_PERMISSIONS
from open_webui.models.access_grants import (
has_anyone_read_access_grant,
has_public_read_access_grant,
has_public_write_access_grant,
has_user_access_grant,
normalize_access_grants,
strip_anyone_access_grants,
strip_user_access_grants,
)
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
@ -218,85 +215,91 @@ def migrate_access_control(data: dict, ac_key: str = 'access_control', grants_ke
data.pop(ac_key, None)
# General sharing, all authenticated users, and anonymous readers, respectively.
_SHARING_PERMISSION_KEYS = {
'model': ('sharing.models', 'sharing.public_models', None),
'knowledge': ('sharing.knowledge', 'sharing.public_knowledge', None),
'prompt': ('sharing.prompts', 'sharing.public_prompts', None),
'tool': ('sharing.tools', 'sharing.public_tools', None),
'skill': ('sharing.skills', 'sharing.public_skills', None),
'note': ('sharing.notes', 'sharing.public_notes', None),
'folder': ('sharing.folders', None, None),
'channel': (None, 'sharing.public_channels', None),
'calendar': (None, 'sharing.public_calendars', None),
'shared_chat': (None, 'sharing.public_chats', 'sharing.open_chats'),
}
async def filter_allowed_access_grants(
default_permissions: dict[str, Any],
user_id: str,
user_role: str,
access_grants: list,
public_permission_key: str,
anyone_permission_key: str | None = None,
access_grants: list | None,
resource_type: str,
*,
db: AsyncSession | None = None,
) -> list:
"""
Checks if the user has the required permissions to grant access to a resource.
Returns the filtered list of access grants if permissions are missing.
"""
) -> list | None:
"""Filter initial sharing by the resource policy when creating an item."""
if resource_type not in _SHARING_PERMISSION_KEYS:
raise ValueError(f'Unsupported sharing resource type: {resource_type}')
sharing_permission_key, public_permission_key, anyone_permission_key = _SHARING_PERMISSION_KEYS[resource_type]
if not access_grants:
return access_grants
if has_anyone_read_access_grant(access_grants) and (
not anyone_permission_key
or (
user_role != 'admin'
and not await has_permission(
user_id,
anyone_permission_key,
default_permissions,
db=db,
)
)
):
access_grants = normalize_access_grants(access_grants)
# Unsupported audiences are forbidden even for admins.
if anyone_permission_key is None:
access_grants = strip_anyone_access_grants(access_grants)
if public_permission_key is None:
access_grants = [
grant for grant in access_grants if (grant['principal_type'], grant['principal_id']) != ('user', '*')
]
if user_role == 'admin':
if user_role == 'admin' or not access_grants:
return access_grants
# Check if user can share publicly
if (
has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants)
) and not await has_permission(
user_id,
public_permission_key,
default_permissions,
db=db,
):
access_grants = [
grant
for grant in access_grants
if not (
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
== 'user'
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None))
== '*'
)
]
permissions = await get_permissions(user_id, default_permissions, db=db)
# Strip individual user sharing if user lacks permission
if has_user_access_grant(access_grants) and not await has_permission(
user_id,
'access_grants.allow_users',
default_permissions,
db=db,
):
access_grants = strip_user_access_grants(access_grants)
def allowed(key: str | None) -> bool:
if key is None:
return False
section, name = key.split('.')
return bool(permissions.get(section, {}).get(name, False))
if any(
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group'
if sharing_permission_key and not allowed(sharing_permission_key):
return []
allowed_audiences = {
'anyone': allowed(anyone_permission_key),
'public': allowed(public_permission_key),
'user': allowed('access_grants.allow_users'),
'group': allowed('access_grants.allow_groups'),
}
return [
grant
for grant in access_grants
) and not await has_permission(
user_id,
'access_grants.allow_groups',
default_permissions,
db=db,
):
access_grants = [
grant
for grant in access_grants
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
!= 'group'
if allowed_audiences[
'public' if (grant['principal_type'], grant['principal_id']) == ('user', '*') else grant['principal_type']
]
]
return access_grants
async def validate_access_grants(
default_permissions: dict[str, Any],
user_id: str,
user_role: str,
access_grants: list,
resource_type: str,
*,
db: AsyncSession | None = None,
) -> list:
"""Reject unauthorized sharing changes before replacing any stored grants."""
grants = normalize_access_grants(access_grants)
allowed = await filter_allowed_access_grants(default_permissions, user_id, user_role, grants, resource_type, db=db)
if len(allowed) != len(grants):
raise HTTPException(403, 'You do not have permission to share with the requested audience')
return allowed
async def has_base_model_access(

View file

@ -8,7 +8,7 @@ from open_webui.models.folders import FolderModel
from open_webui.models.groups import Groups
from open_webui.models.knowledge import Knowledges
from open_webui.models.models import Models
from open_webui.models.notes import Notes
from open_webui.models.notes import NoteModel, Notes
from open_webui.models.users import UserModel, Users
from sqlalchemy.ext.asyncio import AsyncSession
@ -17,6 +17,35 @@ log = logging.getLogger(__name__)
FOLDER_FILE_TYPES = {'file', 'collection', 'note'}
def get_note_file_ids(entries: list[dict] | None) -> set[str]:
if not isinstance(entries, list):
return set()
return {
entry['id']
for entry in entries
if isinstance(entry, dict) and entry.get('type') == 'file' and isinstance(entry.get('id'), str) and entry['id']
}
async def has_access_to_note_files(
note: NoteModel, entries: list[dict] | None, user: UserModel, db: AsyncSession | None = None
) -> bool:
"""Check the editor's read access to newly attached files."""
if entries is not None and not isinstance(entries, list):
return False
# Existing attachments may belong to another editor; only additions need permission.
added_ids = get_note_file_ids(entries) - get_note_file_ids((note.data or {}).get('files'))
if not added_ids or user.role == 'admin':
return True
group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
for file_id in added_ids:
if not await has_access_to_file(file_id, 'read', user, db=db, user_group_ids=group_ids):
return False
return True
async def has_access_to_file(
file_id: str | None,
access_type: str,

View file

@ -32,3 +32,28 @@ async def has_folder_write_access(user_id: str, folder_id: str, db: AsyncSession
if not folder:
return False
return await has_folder_access(user_id, folder, 'write', db)
async def has_chat_move_access(
user_id: str, source_folder_id: str | None, target_folder_id: str | None, db: AsyncSession | None = None
) -> bool:
"""Only the owner may reorganize another user's chat within the same folder tree."""
async def get_owned_folder_root_id(folder_id):
seen_ids = set()
shared_root_id = None
while folder_id and folder_id not in seen_ids:
seen_ids.add(folder_id)
folder = await Folders.get_folder_by_id(folder_id, db=db)
if not folder or folder.user_id != user_id:
return None
if await AccessGrants.get_grants_by_resource('folder', folder.id, db=db):
shared_root_id = folder.id
if not folder.parent_id:
# A private parent must not let moves escape a shared subtree.
return shared_root_id or folder.id
folder_id = folder.parent_id
return None
source_root_id = await get_owned_folder_root_id(source_folder_id)
return source_root_id is not None and source_root_id == await get_owned_folder_root_id(target_folder_id)

View file

@ -24,7 +24,9 @@ def get_ask_user_tool_calls(tool_calls: list[dict]) -> tuple[list[dict], str | N
def normalize_ask_user_request(arguments: dict) -> dict:
questions = arguments.get('questions')
if not isinstance(questions, list) or not 1 <= len(questions) <= 3:
if not isinstance(questions, list):
raise ValueError('ask_user questions must be an array of question objects.')
if not 1 <= len(questions) <= 3:
raise ValueError('ask_user requires 1-3 questions.')
normalized_questions = []

View file

@ -27,6 +27,7 @@ from loguru import logger
from open_webui.env import AUDIT_INCLUDED_PATHS, AUDIT_LOG_LEVEL, ENABLE_AUDIT_GET_REQUESTS, MAX_BODY_LOG_SIZE
from open_webui.models.users import UserModel
from open_webui.utils.auth import get_current_user, get_http_authorization_cred
from open_webui.utils.response import normalize_usage
from starlette.requests import Request
if TYPE_CHECKING:
@ -44,6 +45,7 @@ class AuditLogEntry:
user_agent: Optional[str] = None
source_ip: Optional[str] = None
model_id: Optional[str] = None
usage: Optional[Dict[str, int]] = None
# `Request` audit level properties
request_object: Any = None
# `Request Response` level
@ -114,6 +116,31 @@ class AuditContext:
self.response_body.extend(chunk[: self.max_body_size - len(self.response_body)])
def log_model_usage(request: Request, user: UserModel, model_id: str, usage: Optional[Dict]):
"""Write a model call's usage separately from its originating HTTP request."""
request_id = getattr(request.state, 'audit_request_id', None)
if not request_id or not usage:
return
try:
usage = normalize_usage(usage)
AuditLogger(logger).write(
AuditLogEntry(
id=str(uuid.uuid4()),
user=user.model_dump(include={'id', 'name', 'email', 'role'}),
audit_level=request.state.audit_level,
verb=request.method,
request_uri=str(request.url),
source_ip=request.client.host if request.client else None,
user_agent=request.headers.get('user-agent'),
model_id=model_id,
usage={key: usage[key] for key in ('input_tokens', 'output_tokens', 'total_tokens')},
),
extra={'event': 'model_usage', 'request_id': request_id},
)
except Exception as e:
logger.error('Failed to log model usage: {}', e)
def redact_passwords(body: str) -> str:
if 'password' not in body.lower():
return body
@ -185,6 +212,8 @@ class AuditLoggingMiddleware:
if self._should_skip_auditing(request):
return await self.app(scope, receive, send)
request.state.audit_request_id = str(uuid.uuid4())
request.state.audit_level = self.audit_level.value
capture_body = not (request.url.path.startswith('/api/v1/auths') or request.url.path.startswith('/oauth/'))
async with self._audit_context(request) as context:
@ -305,7 +334,7 @@ class AuditLoggingMiddleware:
response_body = redact_passwords(response_body)
entry = AuditLogEntry(
id=str(uuid.uuid4()),
id=request.state.audit_request_id,
user=user,
audit_level=self.audit_level.value,
verb=request.method,

View file

@ -61,7 +61,7 @@ async def compact_messages_for_request(
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
system_prompt = system_prompt or (get_content_from_message(system_messages[0]) if system_messages else '')
token_threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], metadata)
if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3:
if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold):
return [*system_messages, *messages], previous_summary, False
boundary = _find_compaction_boundary(messages, config['retention_percentage'])
@ -145,6 +145,10 @@ async def compact_messages_for_request(
'action': 'context_compaction',
'description': 'Context compacted',
'done': True,
'checkpoint_message_id': (
recent_messages[0].get('id') if recent_messages[0].get('role') == 'user' else None
),
'context_summary': summary,
},
}
)
@ -374,6 +378,8 @@ def _find_compaction_boundary(messages: list[dict], retention_percentage: int =
][1:]
else:
boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:]
# The first later user turn stays a valid boundary even inside the retained share.
target = max(target, min(boundaries, default=0))
return next((idx for idx in reversed(boundaries) if idx <= target), 0)

View file

@ -19,6 +19,7 @@ from open_webui.env import (
FORWARD_USER_INFO_HEADER_USER_ROLE,
)
from open_webui.models.groups import Groups
from open_webui.utils.oauth import get_system_oauth_token
log = logging.getLogger(__name__)
@ -208,16 +209,7 @@ async def get_headers_and_cookies(
elif auth_type == 'session':
token = request.state.token.credentials
elif auth_type == 'system_oauth':
oauth_token = None
try:
if request.cookies.get('oauth_session_id', None):
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get('oauth_session_id', None),
)
except Exception as e:
log.error(f'Error getting OAuth token: {e}')
oauth_token = await get_system_oauth_token(request, user) if user else None
if oauth_token:
token = f'{oauth_token.get("access_token", "")}'

View file

@ -157,6 +157,7 @@ def file_format(record: 'Record'):
'source_ip': record['extra'].get('source_ip', ''),
'user_agent': record['extra'].get('user_agent', ''),
'model_id': record['extra'].get('model_id', ''),
'usage': record['extra'].get('usage'),
'request_object': record['extra'].get('request_object', b''),
'response_object': record['extra'].get('response_object', b''),
'extra': record['extra'].get('extra', {}),

View file

@ -1,68 +1,47 @@
import asyncio
import logging
import math
from contextlib import AsyncExitStack
from datetime import timedelta
from typing import Optional
from urllib.parse import urlsplit
log = logging.getLogger(__name__)
import anyio
import httpx
import httpx2
from jsonschema import Draft202012Validator, FormatChecker
from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import ElicitRequestFormParams, ElicitResult
from referencing import Registry
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
MCP_INITIALIZE_TIMEOUT,
)
from open_webui.utils.json_codec import JSONCodec
from referencing import Registry
log = logging.getLogger(__name__)
def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True):
"""Create an httpx AsyncClient for MCP transport.
def create_httpx_client(headers=None, auth=None):
"""Create the httpx2 AsyncClient that carries MCP traffic.
Falls back to AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER when the caller
(i.e. the MCP SDK) does not supply an explicit timeout.
Note: verify must be passed at construction time because httpx2
configures the SSL context during __init__. AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
may be True, False, or an ssl.SSLContext (when a custom CA bundle path is
configured); verify= accepts all three.
Note: verify must be passed at construction time because httpx
configures the SSL context during __init__. Setting client.verify = False
after construction does not affect the underlying transport's SSL context.
Redirects are not configured here: the MCP SDK follows same-origin
redirects itself and ignores the client's follow_redirects setting.
"""
kwargs = {
'follow_redirects': True,
'verify': verify,
}
if timeout is not None:
kwargs['timeout'] = timeout
elif AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None:
kwargs['timeout'] = float(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
if headers is not None:
kwargs['headers'] = headers
if auth is not None:
kwargs['auth'] = auth
return httpx.AsyncClient(**kwargs)
return httpx2.AsyncClient(
headers=headers,
auth=auth,
verify=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
# The SDK's transport defaults: reads stay open for long-lived SSE streams.
timeout=httpx2.Timeout(30, read=300),
)
def create_httpx_client(headers=None, timeout=None, auth=None):
# AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL may be True, False, or an
# ssl.SSLContext (when a custom CA bundle path is configured).
# httpx's verify= accepts bool | str | ssl.SSLContext, so all three work.
ssl_setting = AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
verify = ssl_setting if ssl_setting is not True else True
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=verify)
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False)
class OAuthTokenAuth(httpx.Auth):
class OAuthTokenAuth(httpx2.Auth):
"""Resolve current credentials per request and recover from concurrent token rotation."""
requires_request_body = True
@ -71,15 +50,15 @@ class OAuthTokenAuth(httpx.Auth):
self.get_headers = get_headers
async def async_auth_flow(self, request):
headers = httpx.Headers(await self.get_headers())
headers = httpx2.Headers(await self.get_headers())
authorization = headers.get('Authorization')
if not authorization:
raise httpx.RequestError('No OAuth access token available', request=request)
raise httpx2.RequestError('No OAuth access token available', request=request)
request.headers.update(headers)
response = yield request
if response.status_code == 401:
headers = httpx.Headers(await self.get_headers())
headers = httpx2.Headers(await self.get_headers())
if headers.get('Authorization') and headers['Authorization'] != authorization:
request.headers.update(headers)
yield request
@ -87,7 +66,7 @@ class OAuthTokenAuth(httpx.Auth):
class MCPClient:
def __init__(self, event_caller=None, server_name=''):
self.session: Optional[ClientSession] = None
self.client: Optional[Client] = None
self.exit_stack = None
self.event_caller = event_caller
self.server_name = server_name
@ -96,7 +75,7 @@ class MCPClient:
async def elicit(self, context, params):
try:
if isinstance(params, ElicitRequestFormParams):
Draft202012Validator.check_schema(params.requestedSchema)
Draft202012Validator.check_schema(params.requested_schema)
else:
url = urlsplit(params.url)
if url.scheme not in ('http', 'https') or not url.hostname or url.username or url.password:
@ -106,7 +85,9 @@ class MCPClient:
{
'type': 'request:elicitation',
'data': {
**params.model_dump(mode='json', include={'mode', 'message', 'requestedSchema', 'url'}),
**params.model_dump(
mode='json', by_alias=True, include={'mode', 'message', 'requested_schema', 'url'}
),
'server_name': self.server_name,
},
}
@ -119,51 +100,45 @@ class MCPClient:
content = response.get('content')
# Never fetch server-supplied schema references or coerce user answers.
Draft202012Validator(
params.requestedSchema, format_checker=FormatChecker(), registry=Registry()
params.requested_schema, format_checker=FormatChecker(), registry=Registry()
).validate(content)
return ElicitResult(action=response['action'], content=content)
except Exception as e:
log.warning('MCP elicitation failed: %s', type(e).__name__)
return ElicitResult(action='cancel')
async def connect(self, url: str, headers: Optional[dict] = None, auth: Optional[httpx.Auth] = None):
async def connect(self, url: str, headers: Optional[dict] = None, auth: Optional[httpx2.Auth] = None):
async with AsyncExitStack() as exit_stack:
try:
self._streams_context = streamablehttp_client(
url,
headers=headers,
auth=auth,
httpx_client_factory=create_httpx_client
if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
else create_insecure_httpx_client,
http_client = await exit_stack.enter_async_context(create_httpx_client(headers=headers, auth=auth))
# mode='auto' (the default) probes server/discover for the stateless
# 2026-07-28 protocol and falls back to the initialize handshake on
# legacy servers. The read timeout bounds that handshake and every
# later request that does not set its own; the SDK caps the probe itself.
self.client = await exit_stack.enter_async_context(
Client(
streamable_http_client(url, http_client=http_client),
read_timeout_seconds=MCP_INITIALIZE_TIMEOUT,
elicitation_callback=self.elicit if self.event_caller else None,
)
)
transport = await exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport
self._session_context = ClientSession(
read_stream, write_stream, elicitation_callback=self.elicit if self.event_caller else None
)
self.session = await exit_stack.enter_async_context(self._session_context)
with anyio.fail_after(MCP_INITIALIZE_TIMEOUT):
result = await self.session.initialize()
self.instructions = result.instructions
self.instructions = self.client.instructions
self.exit_stack = exit_stack.pop_all()
except Exception as e:
await self.disconnect()
raise e
async def list_tool_specs(self) -> Optional[dict]:
if not self.session:
if not self.client:
raise RuntimeError('MCP client is not connected.')
tools = []
cursor = None
while True:
result = await self.session.list_tools(cursor=cursor)
result = await self.client.list_tools(cursor=cursor)
tools.extend(result.tools)
cursor = result.nextCursor
cursor = result.next_cursor
if cursor is None:
break
@ -172,32 +147,33 @@ class MCPClient:
name = tool.name
description = tool.description
inputSchema = tool.inputSchema
input_schema = tool.input_schema
# TODO: handle outputSchema if needed
outputSchema = getattr(tool, 'outputSchema', None)
# TODO: handle output_schema if needed
tool_specs.append({'name': name, 'description': description, 'parameters': inputSchema})
tool_specs.append({'name': name, 'description': description, 'parameters': input_schema})
return tool_specs
async def call_tool(self, function_name: str, function_args: dict) -> list[dict]:
if not self.session:
if not self.client:
raise RuntimeError('MCP client is not connected.')
tool_call_timeout = None
# math.inf is the SDK's own "no timeout"; None would fall back to MCP_INITIALIZE_TIMEOUT.
tool_call_timeout = math.inf
if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None and AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER > 0:
tool_call_timeout = timedelta(seconds=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
tool_call_timeout = AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER
result = await self.session.call_tool(function_name, function_args, read_timeout_seconds=tool_call_timeout)
result = await self.client.call_tool(function_name, function_args, read_timeout_seconds=tool_call_timeout)
if not result:
raise Exception('No result returned from MCP tool call.')
result_dict = result.model_dump(mode='json')
result_dict = result.model_dump(mode='json', by_alias=True)
result_content = result_dict['content']
texts = [item['text'] for item in result_content if item['type'] == 'text']
if result.isError:
raise Exception(result_content)
if result.is_error:
raise Exception('\n'.join(texts) or 'MCP tool call returned an error.')
structured_content = result_dict.get('structuredContent')
if structured_content is None:
@ -205,7 +181,6 @@ class MCPClient:
# Compare serialized JSON: Python equality treats True and 1 as the same value.
structured_json = JSONCodec.dumps(structured_content, sort_keys=True)
texts = [item['text'] for item in result_content if item['type'] == 'text']
text_jsons = []
for text in texts:
try:
@ -219,6 +194,9 @@ class MCPClient:
# FastMCP wraps scalar/list results while keeping their original text blocks.
values = structured_content.get('result')
# The standalone fastmcp package sends the whole list as one text block.
if structured_content.keys() == {'result'} and text_jsons == [JSONCodec.dumps(values, sort_keys=True)]:
return result_content
values = values if isinstance(values, list) else [values]
if (
structured_content.keys() == {'result'}
@ -235,26 +213,26 @@ class MCPClient:
return result_content
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
if not self.session:
if not self.client:
raise RuntimeError('MCP client is not connected.')
result = await self.session.list_resources(cursor=cursor)
result = await self.client.list_resources(cursor=cursor)
if not result:
raise Exception('No result returned from MCP list_resources call.')
result_dict = result.model_dump()
result_dict = result.model_dump(by_alias=True)
resources = result_dict.get('resources', [])
return resources
async def read_resource(self, uri: str) -> Optional[dict]:
if not self.session:
if not self.client:
raise RuntimeError('MCP client is not connected.')
result = await self.session.read_resource(uri)
result = await self.client.read_resource(uri)
if not result:
raise Exception('No result returned from MCP read_resource call.')
result_dict = result.model_dump()
result_dict = result.model_dump(by_alias=True)
return result_dict
@ -270,7 +248,7 @@ class MCPClient:
# Prevent double-close from concurrent callers
self.exit_stack = None
self.session = None
self.client = None
try:
# IMPORTANT: Do NOT use asyncio.shield() or asyncio.wait_for()

View file

@ -86,8 +86,9 @@ from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control.files import get_owner_accessible_folder_files
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.ask_user import stage_ask_user_tool_calls
from open_webui.utils.audit import log_model_usage
from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.chat_id import is_saved_chat_id, is_temporary_chat_id
from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.context_compaction import compact_messages_for_request
from open_webui.utils.files import (
@ -1112,9 +1113,10 @@ async def chat_completion_tools_handler(
tool_type = tool.get('type', '')
direct_tool = tool.get('direct', False)
spec = tool.get('spec', {})
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
tool_function_params = {k: v for k, v in tool_function_params.items() if k in allowed_params}
if tool_type != 'mcp':
spec = tool.get('spec', {})
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
tool_function_params = {k: v for k, v in tool_function_params.items() if k in allowed_params}
if tool.get('direct', False):
tool_result = await event_caller(
@ -2109,6 +2111,13 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if metadata.get('tool_approval_resume') == 'tools' or pending_tool_calls(output):
pending_assistant_message = assistant_message
# Guided regeneration: show the model the reply it is asked to revise.
regeneration_message_id = metadata.get('regeneration_message_id')
if regeneration_message_id:
regenerated_message = await Chats.get_message_by_id_and_message_id(chat_id, regeneration_message_id)
if regenerated_message and (regenerated_message.get('content') or regenerated_message.get('output')):
db_messages.append({k: v for k, v in regenerated_message.items() if k in MESSAGE_REPLAY_KEYS})
system_message = get_system_message(form_data.get('messages', []))
form_data['messages'] = [system_message, *db_messages] if system_message else db_messages
@ -2137,7 +2146,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if regeneration_prompt:
form_data['messages'].append({'role': 'user', 'content': regeneration_prompt})
if is_saved_chat_id(chat_id) and user_message_id:
if (is_saved_chat_id(chat_id) or is_temporary_chat_id(chat_id)) and user_message_id:
system_message = get_system_message(form_data.get('messages', []))
chat_system_prompt = get_content_from_message(system_message) if system_message else ''
@ -2453,7 +2462,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Otherwise, save any tools that filter inlets added for merging later.
inlet_filter_tools = None if payload_tools is not None else form_data.get('tools', None)
# Selected and mentioned skills get bounded content; other skills remain available through view_skill.
# Mentions always load instructions; selected skills can optionally load through view_skill.
chat_context = metadata.get('chat_context') or {}
metadata['chat_context'] = chat_context
skill_versions = chat_context.setdefault('skill_versions', {})
@ -2462,6 +2471,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
activated_skill_ids = set(form_data.pop('skill_ids', None) or []) | mentioned_skill_ids
skill_ids = sorted(activated_skill_ids | set(model.get('info', {}).get('meta', {}).get('skillIds', [])))
if payload_tools is None and await Config.get('skills.lazy_loading.enable', False):
activated_skill_ids = mentioned_skill_ids
available_skills = []
terminal_skills = []
if is_note_chat:
@ -3088,8 +3099,10 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
return params, f'Error: Tool "{name}" not found.', None, None, False
tool_type = tool.get('type', '')
direct_tool = tool.get('direct', False)
allowed_params = tool.get('spec', {}).get('parameters', {}).get('properties', {}).keys()
params = {key: value for key, value in params.items() if key in allowed_params}
# MCP servers validate their own arguments
if tool_type != 'mcp':
allowed_params = tool.get('spec', {}).get('parameters', {}).get('properties', {}).keys()
params = {key: value for key, value in params.items() if key in allowed_params}
try:
if (
@ -3902,6 +3915,16 @@ async def non_streaming_chat_response_handler(response, ctx):
if response_data is None:
return response
usage = response_data.get('usage') or {}
timings = response_data.get('timings') or {}
if isinstance(usage, dict) and isinstance(timings, dict):
log_model_usage(
request,
user,
response_data.get('selected_model_id') or ctx['form_data']['model'],
{**usage, **timings},
)
chat_id = metadata.get('chat_id') or ''
save_to_chat = is_saved_chat_id(chat_id)
continuing = bool(metadata.get('assistant_message_id'))
@ -4135,7 +4158,6 @@ async def streaming_chat_response_handler(response, ctx):
# and pyodide code interpreter. Server-side tools work without it.
if event_emitter:
task_id = str(uuid4()) # Create a unique task ID.
model_id = form_data.get('model', '')
# Handle as a background task
async def response_handler(response, events):
@ -4623,8 +4645,9 @@ async def streaming_chat_response_handler(response, ctx):
},
)
async def stream_body_handler(response, form_data):
async def stream_body_handler(response, current_form_data):
nonlocal usage
nonlocal current_usage
nonlocal finish_reason
nonlocal output
nonlocal prior_output
@ -4632,7 +4655,8 @@ async def streaming_chat_response_handler(response, ctx):
nonlocal stream_size
response_tool_calls = []
response_usage = {}
current_usage = {}
form_data['model'] = current_form_data['model']
delta_count = 0
delta_chunk_size = max(
@ -4729,7 +4753,7 @@ async def streaming_chat_response_handler(response, ctx):
)
stream_size = 0
filter_extra_params = {'__body__': form_data, **extra_params} if filter_functions else None
filter_extra_params = {'__body__': current_form_data, **extra_params} if filter_functions else None
async for line in response.body_iterator:
line = line.decode('utf-8', 'replace') if isinstance(line, bytes) else line
@ -4785,13 +4809,13 @@ async def streaming_chat_response_handler(response, ctx):
await event_emitter(data.get('event', {}))
if 'selected_model_id' in data:
model_id = data['selected_model_id']
form_data['model'] = data['selected_model_id']
if save_to_chat:
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'selectedModelId': model_id,
'selectedModelId': form_data['model'],
},
touch=False,
)
@ -4857,7 +4881,7 @@ async def streaming_chat_response_handler(response, ctx):
# Normalize and capture usage for DB persistence
if response_metadata.get('usage'):
response_usage = response_metadata['usage']
current_usage = response_metadata['usage']
usage = merge_usage(usage, response_metadata['usage'])
response_metadata['usage'] = usage
@ -4900,7 +4924,7 @@ async def streaming_chat_response_handler(response, ctx):
raw_usage = data.get('usage', {}) or {}
raw_usage.update(data.get('timings', {})) # llama.cpp
if raw_usage:
response_usage = raw_usage
current_usage = raw_usage
usage = merge_usage(usage, raw_usage)
await event_emitter(
{
@ -5543,12 +5567,13 @@ async def streaming_chat_response_handler(response, ctx):
tool_calls.append(_split_tool_calls(responses_api_tool_calls))
await save_current_response_stream()
return normalize_usage(response_usage)
return normalize_usage(current_usage)
output_start = len(prior_output)
try:
response_usage = await stream_body_handler(response, form_data)
current_usage = await stream_body_handler(response, form_data)
finally:
log_model_usage(request, user, form_data['model'], current_usage)
if response.background:
await response.background()
@ -5895,7 +5920,6 @@ async def streaming_chat_response_handler(response, ctx):
try:
new_form_data = {
**form_data,
'model': model_id,
'stream': True,
'metadata': metadata,
}
@ -5929,8 +5953,8 @@ async def streaming_chat_response_handler(response, ctx):
image_urls.append(part.get('image_url', ''))
message['content'] = ''.join(text_parts)
if response_usage and tool_messages:
tool_messages[0]['usage'] = response_usage
if current_usage and tool_messages:
tool_messages[0]['usage'] = current_usage
# Refresh citation context without restoring compacted history.
user_prompt = get_last_user_message_item(form_data['messages'])
@ -5973,7 +5997,7 @@ async def streaming_chat_response_handler(response, ctx):
metadata.get('base_user_prompt') or user_message, [message]
)
messages, context_summary, compacted = await compact_messages_for_request(
request, user, messages, metadata, model_id, request.app.state.MODELS
request, user, messages, metadata, form_data['model'], request.app.state.MODELS
)
finally:
for message, content in user_messages:
@ -6062,7 +6086,10 @@ async def streaming_chat_response_handler(response, ctx):
prior_output.pop()
output = []
output_start = len(prior_output)
response_usage = await stream_body_handler(res, new_form_data)
try:
current_usage = await stream_body_handler(res, new_form_data)
finally:
log_model_usage(request, user, form_data['model'], current_usage)
elif getattr(res, 'status_code', 200) >= 400:
await emit_message_error(get_message_error_content(get_response_error_detail(res)))
break
@ -6227,7 +6254,6 @@ async def streaming_chat_response_handler(response, ctx):
try:
new_form_data = {
**form_data,
'model': model_id,
'stream': True,
'metadata': metadata,
'messages': [
@ -6261,7 +6287,10 @@ async def streaming_chat_response_handler(response, ctx):
)
if isinstance(res, StreamingResponse):
await stream_body_handler(res, new_form_data)
try:
await stream_body_handler(res, new_form_data)
finally:
log_model_usage(request, user, form_data['model'], current_usage)
elif getattr(res, 'status_code', 200) >= 400:
await emit_message_error(get_message_error_content(get_response_error_detail(res)))
break
@ -6386,6 +6415,8 @@ async def streaming_chat_response_handler(response, ctx):
def wrap_item(item):
return f'data: {item}\n\n'
audit_enabled = bool(getattr(request.state, 'audit_request_id', None))
current_usage = None
try:
message = {}
outlet_task = None
@ -6393,10 +6424,11 @@ async def streaming_chat_response_handler(response, ctx):
outlet_enabled = ENABLE_API_OUTLET_FILTERS and bool(filter_functions)
if ENABLE_API_OUTLET_FILTERS and not outlet_enabled:
try:
model_id = model.get('id') if isinstance(model, dict) else model
outlet_enabled = bool(
(isinstance(model, dict) and 'pipeline' in model)
or get_sorted_filters(model_id, request.app.state.MODELS)
or get_sorted_filters(
model.get('id') if isinstance(model, dict) else model, request.app.state.MODELS
)
)
except Exception:
outlet_enabled = True
@ -6415,7 +6447,7 @@ async def streaming_chat_response_handler(response, ctx):
yield wrap_item(JSONCodec.dumps(event))
async for data in original_generator:
if filter_functions:
if filter_functions or audit_enabled:
line = data.decode('utf-8', 'replace') if isinstance(data, bytes) else data
if isinstance(line, str) and line.startswith('data:'):
payload = line.removeprefix('data:').strip()
@ -6426,15 +6458,25 @@ async def streaming_chat_response_handler(response, ctx):
event = None
if isinstance(event, dict):
event, _ = await process_filter_functions(
request=request,
filter_context=filter_context,
filter_functions=filter_functions,
filter_type='stream',
form_data=event,
extra_params=extra_params,
)
data = wrap_item(JSONCodec.dumps(event)) if event else None
if audit_enabled:
form_data['model'] = event.get('selected_model_id') or form_data['model']
payload = (
event.get('response') if isinstance(event.get('response'), dict) else event
)
usage = payload.get('usage') or {}
timings = payload.get('timings') or {}
if isinstance(usage, dict) and isinstance(timings, dict) and (usage or timings):
current_usage = {**usage, **timings}
if filter_functions:
event, _ = await process_filter_functions(
request=request,
filter_context=filter_context,
filter_functions=filter_functions,
filter_type='stream',
form_data=event,
extra_params=extra_params,
)
data = wrap_item(JSONCodec.dumps(event)) if event else None
if data:
if outlet_enabled:
@ -6455,6 +6497,8 @@ async def streaming_chat_response_handler(response, ctx):
# Separate the error frame from any unfinished upstream event.
yield f'\n\ndata: {JSONCodec.dumps({"error": {"message": "Chat completion stream failed"}})}\n\n'
yield 'data: [DONE]\n\n'
finally:
log_model_usage(request, user, form_data['model'], current_usage)
return StreamingResponse(
stream_wrapper(response.body_iterator, events),

View file

@ -34,7 +34,7 @@ BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base'
def public_model_params(params):
controls = params.get('model_controls', {})
return (
public = (
{
'model_controls': {
key: {
@ -51,6 +51,9 @@ def public_model_params(params):
if controls
else {}
)
if 'compact_token_threshold' in params:
public['compact_token_threshold'] = params['compact_token_threshold']
return public
async def fetch_ollama_models(request: Request, user: UserModel = None):
@ -93,6 +96,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
'evaluation.arena.enable',
'evaluation.arena.models',
'models.default_metadata',
'models.default_params',
)
if refresh:
await openai.get_all_models.cache.clear()
@ -409,8 +413,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
action_items_by_id = {}
filter_items_by_id = {}
default_threshold = (config.get('models.default_params') or {}).get('compact_token_threshold')
for model in models:
if default_threshold is not None:
info = model.get('info') or {}
model['info'] = {
**info,
'params': {'compact_token_threshold': default_threshold, **(info.get('params') or {})},
}
action_ids = [
action_id
for action_id in set(model.pop('action_ids', [])) | global_action_ids

View file

@ -714,7 +714,11 @@ async def get_oauth_client_info_with_static_credentials(
oauth_server_metadata.token_endpoint_auth_methods_supported
and token_endpoint_auth_method not in oauth_server_metadata.token_endpoint_auth_methods_supported
):
token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0]
# List order is no preference; prefer a method that sends the secret
if 'client_secret_basic' in oauth_server_metadata.token_endpoint_auth_methods_supported:
token_endpoint_auth_method = 'client_secret_basic'
else:
token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0]
oauth_client_info = OAuthClientInformationFull(
client_id=oauth_client_id,

View file

@ -451,6 +451,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
# Prepend tool ID to function name
function_name = f'{tool_id}_{function_name}'
tool_dict['spec'] = {**tool_dict['spec'], 'name': function_name}
tools_dict[function_name] = tool_dict
else:
if tool_id.startswith('server:'):
@ -551,6 +552,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
# Prepend server ID to function name
function_name = f'{server_id}_{function_name}'
tool_dict['spec'] = {**tool_dict['spec'], 'name': function_name}
tools_dict[function_name] = tool_dict
else:
@ -1283,7 +1285,7 @@ def convert_openapi_to_tool_payload(openapi_spec):
tool['parameters']['properties'].update(resolved_schema['properties'])
if 'required' in resolved_schema:
tool['parameters']['required'] = list(
set(tool['parameters']['required'] + resolved_schema['required'])
dict.fromkeys(tool['parameters']['required'] + resolved_schema['required'])
)
elif resolved_schema.get('type') == 'array':
tool['parameters'] = resolved_schema # special case for array
@ -1867,7 +1869,7 @@ async def execute_tool_server(
final_url = final_url.replace(f'{{{key}}}', quote(str(value), safe=''))
if query_params:
final_url = f'{final_url}?{urlencode(query_params)}'
final_url = f'{final_url}?{urlencode(query_params, doseq=True)}'
request_body_content = operation.get('requestBody', {}).get('content')
if request_body_content and params:

View file

@ -4,14 +4,14 @@
numpy==2.4.6
typer==0.25.1
fastapi==0.136.3
uvicorn[standard]==0.51.0
uvicorn[standard]==0.54.0
pydantic==2.13.4
python-multipart==0.0.32
itsdangerous==2.2.0
python-socketio==5.16.2
orjson==3.11.9
cryptography==48.0.0
cryptography==48.0.1
pyotp==2.9.0
qrcode==8.2
bcrypt==5.0.0
@ -23,13 +23,14 @@ joserfc==1.7.4
requests==2.34.2
regex==2026.5.9
google-re2==1.1.20251105 # bounded compilation and linear-time knowledge searches
aiohttp==3.14.3 # do not update to 3.13.3 - broken
aiohttp==3.14.5 # do not update to 3.13.3 - broken
aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER
aiocache==0.12.3
aiofiles==25.1.0
starlette-compress==1.7.1
Brotli==1.2.0
Brotli==1.2.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1
httpx2==2.16.0
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
@ -50,7 +51,7 @@ asgiref==3.11.1
# AI libraries
tiktoken==0.13.0
mcp==1.27.2
mcp==2.3.0
openai==2.29.0
@ -69,7 +70,7 @@ lxml==6.1.1
validators==0.35.0
psutil==7.2.2
pillow==12.3.0
pillow==13.0.0
rank-bm25==0.2.2
black==26.5.1

View file

@ -1,12 +1,12 @@
fastapi==0.136.3
uvicorn[standard]==0.51.0
uvicorn[standard]==0.54.0
pydantic==2.13.4
python-multipart==0.0.32
itsdangerous==2.2.0
python-socketio==5.16.2
orjson==3.11.9
cryptography==48.0.0
cryptography==48.0.1
pyotp==2.9.0
qrcode==8.2
bcrypt==5.0.0
@ -18,14 +18,15 @@ joserfc==1.7.4
requests==2.34.2
regex==2026.5.9
google-re2==1.1.20251105 # bounded compilation and linear-time knowledge searches
aiohttp==3.14.3 # do not update to 3.13.3 - broken
aiohttp==3.14.5 # do not update to 3.13.3 - broken
aiodns==3.6.1 # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER
aiocache==0.12.3
aiofiles==25.1.0
starlette-compress==1.7.1
Brotli==1.2.0
brotlicffi==1.2.0.1
Brotli==1.2.1
brotlicffi==1.2.1.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
httpx2==2.16.0
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
@ -45,7 +46,7 @@ asgiref==3.11.1
# AI libraries
tiktoken==0.13.0
mcp==1.27.2
mcp==2.3.0
openai==2.29.0
anthropic==0.86.0
@ -67,12 +68,13 @@ einops==0.8.2
ftfy==6.3.1
chardet==7.4.3
pypdf==6.7.5
pypdf==6.21.2
docx2txt==0.9
python-docx==1.2.0
python-pptx==1.0.2
msoffcrypto-tool==6.0.0
unstructured==0.22.31
preshed==3.0.13 # keep pinned: transitive via unstructured -> spaCy; 3.0.14 breaks HTML extraction (web loader)
Markdown==3.10.2
beautifulsoup4==4.14.3
@ -87,7 +89,7 @@ psutil==7.2.2
sentencepiece==0.2.1
soundfile==0.13.1
pillow==12.3.0
pillow==13.0.0
opencv-python-headless==4.13.0.92
rapidocr==3.10.0
rank-bm25==0.2.2

View file

@ -60,7 +60,7 @@ fi
if [[ "${USE_CUDA_DOCKER,,}" == "true" ]]; then
echo "CUDA enabled — extending LD_LIBRARY_PATH for torch/cudnn libraries."
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}:/usr/local/lib/python3.11/site-packages/torch/lib:/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib"
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}:/usr/local/lib/python3.12/site-packages/torch/lib:/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib"
fi
# ── HuggingFace Space deployment ─────────────────────────────────────────────

BIN
demo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

View file

@ -60,9 +60,9 @@ Options:
Examples:
$(basename "$0") --drop
$(basename "$0") --enable-gpu[count=1]
$(basename "$0") --enable-gpu[count=all] --webui[port=8080]
$(basename "$0") --enable-gpu[count=1] --enable-api[port=12345] --data[folder=./data] --build
$(basename "$0") '--enable-gpu[count=1]'
$(basename "$0") '--enable-gpu[count=all]' '--webui[port=8080]'
$(basename "$0") '--enable-gpu[count=1]' '--enable-api[port=12345]' '--data[folder=./data]' --build
EOF
}

314
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.12.3",
"version": "0.12.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.12.3",
"version": "0.12.4",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@ -56,7 +56,7 @@
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"docx-preview": "0.4.0",
"dompurify": "^3.2.6",
"dompurify": "^3.4.17",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
@ -1241,9 +1241,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1367,9 +1367,9 @@
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3131,12 +3131,6 @@
"type-checked-collections": "^0.1.7"
}
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs": {
"version": "25.0.8",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz",
@ -3729,9 +3723,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.68.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.68.0.tgz",
"integrity": "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g==",
"version": "2.70.3",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz",
"integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@ -4105,29 +4099,29 @@
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"version": "0.5.20",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz",
"integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
"tailwindcss": ">=3.0.0 || >=4.0.0 || insiders"
}
},
"node_modules/@tiptap/core": {
"version": "3.20.2",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.2.tgz",
"integrity": "sha512-zKW4LqZt+aNdvz9o4R0/j+D+gfhwzuFItwh7wbqz8g8bWi0jaV95VybeVFVKeg/KGTc3sAa4mm+hGgvgrY+Gvg==",
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz",
"integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.20.2"
"@tiptap/pm": "3.32.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
@ -4636,29 +4630,24 @@
}
},
"node_modules/@tiptap/pm": {
"version": "3.20.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.2.tgz",
"integrity": "sha512-tEMZlLy/6ms41PQtyjmniZ3tTd8elavd8htjYzHLPSHtz11zaYV9YjnmnwHK8gynhcSES1orO9z1U4nT4ZLpqg==",
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz",
"integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
"prosemirror-changeset": "^2.4.4",
"prosemirror-commands": "^1.7.2",
"prosemirror-dropcursor": "^1.8.4",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.1",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.12",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.2",
"prosemirror-view": "^1.42.6"
},
"funding": {
"type": "github",
@ -5578,9 +5567,9 @@
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -6152,16 +6141,16 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/braces": {
@ -7814,9 +7803,9 @@
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"version": "5.9.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
"license": "MIT"
},
"node_modules/devlop": {
@ -7915,9 +7904,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@ -8348,9 +8337,9 @@
}
},
"node_modules/eslint-plugin-svelte/node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"version": "6.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz",
"integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -8399,9 +8388,9 @@
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -9227,9 +9216,9 @@
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -9802,9 +9791,9 @@
"license": "MIT"
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"devOptional": true,
"license": "MIT"
},
@ -10149,9 +10138,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@ -10697,9 +10686,9 @@
}
},
"node_modules/linkify-it": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
@ -10997,9 +10986,9 @@
"license": "BSD-3-Clause"
},
"node_modules/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
"version": "14.3.3",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
"integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
"funding": [
{
"type": "github",
@ -11013,8 +11002,8 @@
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
@ -11069,9 +11058,9 @@
"license": "MIT"
},
"node_modules/matcher-collection/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11435,6 +11424,24 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -12010,9 +12017,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"funding": [
{
"type": "opencollective",
@ -12029,9 +12036,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
@ -12145,24 +12152,6 @@
"node": ">=4"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -12278,9 +12267,9 @@
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
"integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
@ -12296,9 +12285,9 @@
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
@ -12307,9 +12296,9 @@
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
@ -12347,9 +12336,9 @@
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
@ -12402,9 +12391,9 @@
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"version": "1.25.12",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
@ -12454,45 +12443,30 @@
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz",
"integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.6",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
"version": "1.42.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz",
"integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-model": "^1.25.12",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/protobufjs": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"version": "7.6.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -12624,9 +12598,9 @@
"license": "MIT"
},
"node_modules/quick-temp/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -12906,9 +12880,9 @@
"license": "MIT"
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -13778,9 +13752,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@ -13813,9 +13787,9 @@
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@ -14306,9 +14280,9 @@
}
},
"node_modules/tar": {
"version": "7.5.19",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz",
"integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==",
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@ -14728,9 +14702,9 @@
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.30.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@ -16279,9 +16253,9 @@
"license": "MIT"
},
"node_modules/walk-sync/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.12.3",
"version": "0.12.4",
"private": true,
"scripts": {
"crypto:verify": "node scripts/encryption/artifact.mjs",
@ -10,7 +10,7 @@
"prebuild:watch": "npm run crypto:verify",
"dev": "npm run pyodide:fetch && vite dev --host",
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
"build": "npm run pyodide:fetch && vite build",
"build": "npm run pyodide:fetch && node --max-old-space-size=12288 node_modules/vite/bin/vite.js build",
"build:watch": "npm run pyodide:fetch && vite build --watch",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@ -21,7 +21,8 @@
"lint:backend": "pylint backend/",
"format": "prettier --plugin-search-dir --write \"**/*.{js,ts,svelte,css,md,html,json}\"",
"format:backend": "ruff format . --exclude .venv --exclude venv",
"i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\"",
"i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\" && npm run i18n:check",
"i18n:check": "node scripts/i18n/check.mjs --fix",
"cy:open": "cypress open",
"test:frontend": "vitest --passWithNoTests",
"pyodide:fetch": "node scripts/prepare-pyodide.js"
@ -106,7 +107,7 @@
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"docx-preview": "0.4.0",
"dompurify": "^3.2.6",
"dompurify": "^3.4.17",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",

View file

@ -7,14 +7,14 @@ authors = [
license = { file = "LICENSE" }
dependencies = [
"fastapi==0.136.3",
"uvicorn[standard]==0.51.0",
"uvicorn[standard]==0.54.0",
"pydantic==2.13.4",
"python-multipart==0.0.32",
"itsdangerous==2.2.0",
"python-socketio==5.16.2",
"orjson==3.11.9",
"cryptography==48.0.0",
"cryptography==48.0.1",
"pyotp==2.9.0",
"qrcode==8.2",
"bcrypt==5.0.0",
@ -24,14 +24,15 @@ dependencies = [
"joserfc==1.7.4",
"requests==2.34.2",
"aiohttp==3.14.3", # do not update to 3.13.3 - broken
"aiohttp==3.14.5", # do not update to 3.13.3 - broken
"aiodns==3.6.1", # keep pinned: 4.x pulls pycares 5 (c-ares 1.34.6) which breaks DNS on some hosts (#28013, #28215); opt-in via AIOHTTP_CLIENT_ASYNC_DNS_RESOLVER
"aiocache==0.12.3",
"aiofiles==25.1.0",
"starlette-compress==1.7.1",
"Brotli==1.2.0",
"brotlicffi==1.2.0.1",
"Brotli==1.2.1",
"brotlicffi==1.2.1.0",
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
"httpx2==2.16.0",
"starsessions[redis]==2.2.1",
"python-mimeparse==2.0.0",
@ -53,7 +54,7 @@ dependencies = [
"tiktoken==0.13.0",
"regex==2026.5.9",
"google-re2==1.1.20251105",
"mcp==1.27.2",
"mcp==2.3.0",
"openai==2.29.0",
"anthropic==0.86.0",
@ -76,7 +77,7 @@ dependencies = [
"ftfy==6.3.1",
"chardet==7.4.3",
"pypdf==6.7.5",
"pypdf==6.21.2",
"docx2txt==0.9",
"python-docx==1.2.0",
"python-pptx==1.0.2",
@ -95,7 +96,7 @@ dependencies = [
"soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.2",
"pillow==12.3.0",
"pillow==13.0.0",
"opencv-python-headless==4.13.0.92",
"rapidocr==3.10.0",
"rank-bm25==0.2.2",
@ -140,6 +141,7 @@ mariadb = [
]
unstructured = [
"unstructured==0.22.31",
"preshed==3.0.13", # keep pinned: transitive via unstructured -> spaCy; 3.0.14 breaks HTML extraction (web loader)
]
all = [
@ -158,6 +160,7 @@ all = [
"azure-search-documents==12.0.0",
"unstructured==0.22.31",
"preshed==3.0.13", # keep pinned: transitive via unstructured -> spaCy; 3.0.14 breaks HTML extraction (web loader)
]
[project.scripts]

View file

@ -1,8 +1,9 @@
// Normal builds only read committed files. No Rust or network required.
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
const root = new URL('../../', import.meta.url).pathname;
import { join, posix } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../../', import.meta.url));
const source = 'crypto/encryption';
const artifact = 'src/lib/encryption/vendor';
const hash = (data) => createHash('sha256').update(data).digest('hex');
@ -10,7 +11,7 @@ function files(dir) {
return readdirSync(join(root, dir), { withFileTypes: true })
.flatMap((e) => {
if (e.name === 'target' || e.name.startsWith('.')) return [];
const path = join(dir, e.name);
const path = posix.join(dir, e.name);
return e.isDirectory() ? files(path) : [path];
})
.sort();
@ -21,7 +22,9 @@ const inputs = [
'scripts/encryption/artifact.mjs'
].sort();
const sourceFingerprint = hash(
inputs.map((p) => `${p}\0${hash(readFileSync(join(root, p)))}`).join('\n')
inputs
.map((p) => `${p}\0${hash(readFileSync(join(root, p), 'utf8').replaceAll('\r\n', '\n'))}`)
.join('\n')
);
const manifestPath = join(root, artifact, 'manifest.json');
if (process.argv.includes('--write')) {
@ -31,7 +34,7 @@ if (process.argv.includes('--write')) {
const checksums = Object.fromEntries(
files(artifact)
.filter((p) => !p.endsWith('/manifest.json'))
.map((p) => [relative(artifact, p), hash(readFileSync(join(root, p)))])
.map((p) => [posix.relative(artifact, p), hash(readFileSync(join(root, p)))])
);
writeFileSync(
manifestPath,
@ -59,7 +62,7 @@ if (process.argv.includes('--write')) {
throw Error('Encryption source changed: rebuild and review the artifact.');
const actual = files(artifact)
.filter((p) => !p.endsWith('/manifest.json'))
.map((p) => relative(artifact, p))
.map((p) => posix.relative(artifact, p))
.sort();
if (JSON.stringify(actual) !== JSON.stringify(Object.keys(manifest.checksums).sort()))
throw Error('Unexpected crypto artifact files');

View file

@ -63,7 +63,7 @@ generate() {
# --- Python: resolve all transitive deps without installing ---
dim "Resolving Python transitive deps (uv pip compile)..."
uv pip compile "$ROOT_DIR/backend/requirements.txt" \
--python-version 3.11 \
--python-version 3.12 \
--quiet \
> "$WORK_DIR/requirements-resolved.txt" 2>/dev/null

126
scripts/i18n/check.mjs Normal file
View file

@ -0,0 +1,126 @@
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const pluralSuffix = /_(zero|one|two|few|many|other)$/;
/** @param {string} text */
function interpolation(text) {
const names = new Set();
const literal = text.replace(/\{\{([^{}]+)\}\}/g, (_, expression) => {
names.add(expression.replace(/^-/, '').split(',')[0].trim());
return '';
});
return { names, literal };
}
/**
* @param {string} key
* @param {unknown} value
* @param {string} source
*/
export function validateTranslation(key, value, source = key) {
if (typeof value !== 'string') return ['translation must be a string'];
if (!value.trim()) return [];
const expected = interpolation(source);
const actual = interpolation(value);
const problems = [];
for (const name of expected.names) {
// Plural forms can express the count in words (e.g. "one file").
if (name === 'count' && pluralSuffix.test(key)) continue;
if (!actual.names.has(name)) problems.push(`missing {{${name}}}`);
}
for (const name of actual.names) {
if (!expected.names.has(name)) problems.push(`unexpected {{${name}}}`);
}
// Literal JSON is allowed, but translations must not introduce extra braces.
for (const brace of ['{', '}']) {
if (actual.literal.split(brace).length > expected.literal.split(brace).length) {
problems.push('unexpected braces outside {{placeholders}}');
break;
}
}
return problems;
}
/** @param {string} key @param {unknown} value @param {string} source */
export function repairTranslation(key, value, source = key) {
if (typeof value !== 'string' || !validateTranslation(key, value, source).length) return value;
let repaired = value;
const expected = interpolation(source);
for (const name of expected.names) {
// Recover placeholders accidentally written with only one pair of braces.
if (!interpolation(repaired).names.has(name)) {
repaired = repaired.replaceAll(`{${name}}`, `{{${name}}}`);
}
}
const missing = [...expected.names].filter((name) => !interpolation(repaired).names.has(name));
if (
missing.length === 1 &&
!source.includes('[') &&
(repaired.match(/\[[^\[\]]+\]/g) ?? []).length === 1
) {
repaired = repaired.replace(/\[[^\[\]]+\]/, () => `{{${missing[0]}}}`);
}
// Unwrap escaped literal examples without changing their translated surroundings.
for (const [literal] of source.matchAll(/\{[^{}]+\}/g)) {
repaired = repaired.replaceAll("{{'{" + literal + "}'}}", literal);
}
for (const [url] of source.matchAll(/https?:\/\/[^\s)]+/g)) {
repaired = repaired.replaceAll(`{${url}}`, url);
}
return validateTranslation(key, repaired, source).length ? value : repaired;
}
/** @param {string} directory @param {boolean} fix */
export function checkCatalogs(directory, fix = false) {
/** @param {string} locale @returns {Record<string, string>} */
const readCatalog = (locale) =>
JSON.parse(readFileSync(join(directory, locale, 'translation.json'), 'utf8'));
const english = readCatalog('en-US');
const problems = [];
let fixed = 0;
for (const locale of readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()) {
const catalog = readCatalog(locale);
let changed = false;
for (const [key, value] of Object.entries(catalog)) {
const baseKey = key.replace(pluralSuffix, '');
const source = english[key] || english[baseKey] || english[`${baseKey}_other`] || key;
const repaired = fix ? repairTranslation(key, value, source) : value;
if (repaired !== value) {
catalog[key] = repaired;
changed = true;
fixed++;
}
const issues = validateTranslation(key, repaired, source);
if (issues.length) {
problems.push(`${locale}/translation.json: ${JSON.stringify(key)}: ${issues.join('; ')}`);
}
}
if (changed) {
writeFileSync(
join(directory, locale, 'translation.json'),
JSON.stringify(catalog, null, '\t') + '\n'
);
}
}
return { problems, fixed };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const fix = process.argv.includes('--fix');
const { problems, fixed } = checkCatalogs(resolve('src/lib/i18n/locales'), fix);
if (fixed) console.log(`i18n: repaired ${fixed} translation(s).`);
if (problems.length) {
console.error(problems.join('\n'));
console.error(`i18n: ${problems.length} invalid translation(s). Fix the entries above.`);
process.exitCode = 1;
} else {
console.log('i18n: translation placeholders verified.');
}
}

View file

@ -448,6 +448,13 @@ html.high-contrast :is(input, textarea, [contenteditable='true'], .ProseMirror):
outline-offset: -2px;
}
html [data-sonner-toaster][data-theme='light'] {
--success-text: hsl(140, 100%, 25%);
--info-text: hsl(210, 92%, 43%);
--warning-text: hsl(31, 92%, 35%);
--error-text: hsl(360, 100%, 43%);
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
@ -956,11 +963,11 @@ body {
}
}
html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) {
html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table, .cm-placeholder) {
color: var(--color-gray-700);
}
html.high-contrast.dark :is(.app-muted, .app-icon-muted, .tiptap table) {
html.high-contrast.dark :is(.app-muted, .app-icon-muted, .tiptap table, .cm-placeholder) {
color: var(--color-gray-300);
}

View file

@ -113,6 +113,7 @@
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
logo.alt = '';
document.addEventListener('DOMContentLoaded', function () {
const splash = document.getElementById('splash-screen');
@ -162,6 +163,7 @@
https://docs.openwebui.com/license. -->
<img
id="logo-her"
alt=""
style="width: auto; height: 13rem"
src="/static/splash.png"
loading="eager"

View file

@ -43,7 +43,8 @@ export const getUserAnalytics = async (
startDate: number | null = null,
endDate: number | null = null,
limit: number = 50,
groupId: string | null = null
groupId: string | null = null,
metric: 'messages' | 'tokens' = 'messages'
) => {
let error = null;
@ -52,6 +53,7 @@ export const getUserAnalytics = async (
if (endDate) searchParams.append('end_date', endDate.toString());
if (limit) searchParams.append('limit', limit.toString());
if (groupId) searchParams.append('group_id', groupId);
searchParams.append('metric', metric);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, {
method: 'GET',
@ -166,8 +168,10 @@ export const getDailyStats = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null,
granularity: 'hourly' | 'daily' = 'daily',
groupId: string | null = null
granularity: '10m' | 'hourly' | '3h' | '6h' | 'daily' = 'daily',
groupId: string | null = null,
metric: 'messages' | 'tokens' = 'messages',
compare = false
) => {
let error = null;
@ -175,6 +179,8 @@ export const getDailyStats = async (
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
searchParams.append('granularity', granularity);
searchParams.append('metric', metric);
if (compare) searchParams.append('compare', 'true');
if (groupId) searchParams.append('group_id', groupId);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, {

View file

@ -131,7 +131,7 @@ export const createCalendar = async (token: string, form: CalendarForm): Promise
export const updateCalendar = async (
token: string,
calendarId: string,
form: Partial<CalendarForm>
form: Partial<Omit<CalendarForm, 'access_grants'>>
): Promise<CalendarModel> => {
let error = null;
@ -142,7 +142,7 @@ export const updateCalendar = async (
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
body: JSON.stringify({ ...form, access_grants: undefined })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -456,3 +456,36 @@ export const searchCalendarEvents = async (
return res;
};
export const updateCalendarAccessGrants = async (
token: string,
id: string,
accessGrants: any[]
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${id}/access/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -333,7 +333,7 @@ export const removeMembersById = async (
export const updateChannelById = async (
token: string = '',
channel_id: string,
channel: ChannelForm
channel: Omit<ChannelForm, 'access_grants'>
) => {
let error = null;
@ -344,7 +344,7 @@ export const updateChannelById = async (
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...channel })
body: JSON.stringify({ ...channel, access_grants: undefined })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -1043,3 +1043,32 @@ export const removeAppFromChannel = async (
return res;
};
export const updateChannelAccessGrants = async (token: string, id: string, accessGrants: any[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${id}/access/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -1315,6 +1315,23 @@ export const updateChatById = async (
return res;
};
export const updateChatParams = async (
token: string,
id: string,
params: Record<string, unknown>
) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/params`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify(params)
});
if (!res.ok) throw getErrorDetail(await res.json());
return res.json();
};
export const compactChatById = async (token: string, id: string, model?: string | null) => {
let error = null;

View file

@ -1,5 +1,22 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import type { Banner } from '$lib/types';
import type { Visibility } from '$lib/utils/ui-visibility';
export const setInterfaceVisibility = async (
token: string,
visibility: Visibility
): Promise<Visibility> => {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/interface/visibility`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ visibility })
});
if (!res.ok) throw (await res.json()).detail;
return (await res.json()).visibility;
};
export const importConfig = async (token: string, config: object) => {
let error = null;

View file

@ -6,7 +6,8 @@ export const uploadFile = async (
file: File,
metadata?: object | null,
process?: boolean | null,
stream: boolean = true
stream: boolean = true,
onUploaded?: (uploadedFile: { id: string }) => void
) => {
const data = new FormData();
data.append('file', file);
@ -43,6 +44,10 @@ export const uploadFile = async (
throw error;
}
if (res) {
onUploaded?.(res);
}
if (res && stream) {
const status = await getFileProcessStatus(token, res.id);
@ -179,73 +184,56 @@ export const getFiles = async (token: string = '', content: boolean = false) =>
return res;
};
export const searchFiles = async (
const searchFilesByPath = async (
path: string,
token: string,
filename: string,
skip: number,
limit: number,
content: boolean
) => {
const searchParams = new URLSearchParams({
filename,
skip: String(skip),
limit: String(limit),
content: String(content)
});
const res = await fetch(`${WEBUI_API_BASE_URL}/files/${path}?${searchParams}`, {
headers: { Accept: 'application/json', authorization: `Bearer ${token}` }
});
if (!res.ok) {
const error = await res.json();
throw error.detail ?? error;
}
return res.json();
};
export const searchFiles = (
token: string,
filename: string = '*',
skip: number = 0,
limit: number = 50,
content: boolean = false
) => {
let error = null;
) => searchFilesByPath('search', token, filename, skip, limit, content);
const searchParams = new URLSearchParams();
searchParams.append('filename', filename);
searchParams.append('skip', String(skip));
searchParams.append('limit', String(limit));
searchParams.append('content', String(content));
export const searchUserFiles = (
token: string,
filename: string = '*',
skip: number = 0,
limit: number = 50,
content: boolean = false
) => searchFilesByPath('user/search', token, filename, skip, limit, content);
const res = await fetch(`${WEBUI_API_BASE_URL}/files/search?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return [];
});
if (error) {
throw error;
}
return res;
const getFileCountByPath = async (path: string, token: string) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/files/${path}`, {
headers: { Accept: 'application/json', authorization: `Bearer ${token}` }
});
if (!res.ok) throw await res.json();
return res.json();
};
export const getFileCount = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/files/count`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFileCount = (token: string = '') => getFileCountByPath('count', token);
export const getUserFileCount = (token: string = '') => getFileCountByPath('user/count', token);
export const getFileById = async (token: string, id: string) => {
let error = null;
@ -388,7 +376,7 @@ export const deleteFileById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
error = err.detail;
error = err.detail ?? err;
console.error(err);
return null;
});

View file

@ -288,7 +288,7 @@ export const updateExternalKnowledgeSource = async (token: string, id: string, p
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(payload)
body: JSON.stringify({ ...payload, access_grants: undefined })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -608,7 +608,6 @@ type KnowledgeUpdateForm = {
name?: string;
description?: string;
data?: object;
access_grants?: object[];
};
export const updateKnowledgeById = async (token: string, id: string, form: KnowledgeUpdateForm) => {
@ -624,8 +623,7 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl
body: JSON.stringify({
name: form?.name ? form.name : undefined,
description: form?.description ? form.description : undefined,
data: form?.data ? form.data : undefined,
access_grants: form.access_grants
data: form?.data ? form.data : undefined
})
})
.then(async (res) => {

View file

@ -326,15 +326,13 @@ export const toggleModelById = async (token: string, id: string) => {
export const updateModelById = async (token: string, id: string, model: object) => {
let error = null;
const { base_model_id, name, meta, params, access_grants, is_active, commit_message } =
model as any;
const { base_model_id, name, meta, params, is_active, commit_message } = model as any;
const payload = {
id,
base_model_id,
name,
meta,
params,
access_grants,
is_active,
commit_message
};

View file

@ -315,7 +315,11 @@ export const createNoteChatById = async (token: string, id: string) => {
return res;
};
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
export const updateNoteById = async (
token: string,
id: string,
note: Omit<NoteItem, 'access_grants'>
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, {
@ -326,7 +330,8 @@ export const updateNoteById = async (token: string, id: string, note: NoteItem)
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...note
...note,
access_grants: undefined
})
})
.then(async (res) => {

View file

@ -262,7 +262,10 @@ export const getPromptById = async (token: string, promptId: string) => {
return res;
};
export const updatePromptById = async (token: string, prompt: PromptItem) => {
export const updatePromptById = async (
token: string,
prompt: Omit<PromptItem, 'access_grants'>
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${prompt.id}/update`, {
@ -272,7 +275,7 @@ export const updatePromptById = async (token: string, prompt: PromptItem) => {
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(prompt)
body: JSON.stringify({ ...prompt, access_grants: undefined })
})
.then(async (res) => {
if (!res.ok) throw await res.json();

View file

@ -217,7 +217,8 @@ export const updateSkillById = async (token: string, id: string, skill: object)
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...skill
...skill,
access_grants: undefined
})
})
.then(async (res) => {

View file

@ -214,7 +214,8 @@ export const updateToolById = async (token: string, id: string, tool: object) =>
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...tool
...tool,
access_grants: undefined
})
})
.then(async (res) => {

View file

@ -198,7 +198,7 @@
if (direct) {
const res = await getToolServerData(
auth_type === 'bearer' ? key : localStorage.token,
auth_type === 'bearer' ? key : auth_type === 'session' ? localStorage.token : null,
path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
).catch((err) => {
toast.error($i18n.t('Connection failed'));

View file

@ -114,7 +114,7 @@
<!-- LICENSE covers this Open WebUI notification logo.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-6 rounded-full" />
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="" class="size-6 rounded-full" />
</div>
<div>

View file

@ -1,5 +1,6 @@
<script>
import { getContext } from 'svelte';
import { WEBUI_NAME } from '$lib/stores';
const i18n = getContext('i18n');
export let show = true;
@ -48,7 +49,7 @@
crossorigin="anonymous"
src="/static/favicon.png"
class="size-6 rounded-full"
alt="logo"
alt="{$WEBUI_NAME} logo"
/>
</div>

View file

@ -86,7 +86,9 @@
{@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1}
{@const isHourly = data[0]?.date?.includes(':')}
{@const dateFormat = isHourly
? 'h A'
? period === 'hour'
? 'h:mm A'
: 'M/D h A'
: period === 'year' || period === 'all'
? 'M/D/YY'
: 'M/D'}
@ -106,15 +108,17 @@
{#if hovered}
{@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)}
<div
class="pointer-events-none absolute top-1 text-[0.6875rem]"
style="left:{Math.min(Math.max((mouseX / w) * 100, 8), 92)}%"
class="pointer-events-none absolute top-1 w-56 max-w-full text-[0.6875rem]"
style="left:clamp(0px, calc({(mouseX / w) * 100}% - 7rem), max(0px, calc(100% - 14rem)))"
>
<div
class="min-w-[8.75rem] -translate-x-1/2 rounded border border-gray-100 bg-white px-2.5 py-1.5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
class="rounded border border-gray-100 bg-white px-2.5 py-1.5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="mb-1.5 text-[0.625rem] text-gray-400">
{#if hovered.date?.includes(':')}
{dayjs(hovered.date).locale($i18n.language).format('MMM D, h A')}
{dayjs(hovered.date)
.locale($i18n.language)
.format(period === 'hour' ? 'MMM D, h:mm A' : 'MMM D, h A')}
{:else}
{dayjs(hovered.date).locale($i18n.language).format('MMM D, YYYY')}
{/if}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,759 @@
<script context="module" lang="ts">
export type UsageBucket = {
date: string;
models: Record<string, number>;
start?: string;
end?: string;
previous?: { models: Record<string, number>; start: string; end: string };
};
export const comparisonChange = (current: number, previous: number) =>
previous === 0 ? (current === 0 ? 0 : null) : ((current - previous) / previous) * 100;
export const usageShare = (value: number, total: number) => {
if (total <= 0 || value <= 0) return '0%';
const share = (value / total) * 100;
return share < 0.1 ? '<0.1%' : `${share.toFixed(1)}%`;
};
export const chartSeries = (data: UsageBucket[], modelId: string | null = null) =>
data
.map((bucket) => ({
...bucket,
models: modelId ? { [modelId]: bucket.models[modelId] ?? 0 } : bucket.models,
// Calendar keys have no timezone. UTC is only a plotting coordinate, not a query timestamp.
time: Date.parse(
`${bucket.date.length === 10 ? `${bucket.date}T00:00` : bucket.date.replace(' ', 'T')}Z`
),
value: modelId
? (bucket.models[modelId] ?? 0)
: Object.values(bucket.models).reduce((total, value) => total + value, 0)
}))
.sort((a, b) => a.time - b.time);
export const chartCeiling = (max: number) => {
if (max <= 4) return 4;
const step = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / step) * step;
};
export const modelTotals = (data: UsageBucket[]) => {
const totals: Record<string, number> = Object.create(null);
for (const bucket of data) {
for (const [id, value] of Object.entries(bucket.models))
totals[id] = (totals[id] ?? 0) + value;
}
return totals;
};
// Identity-based colors remain stable when the range, metric, or ranking changes.
export const modelColor = (id: string) => {
let hash = 0;
for (const character of id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) | 0;
return `hsl(${((hash % 360) + 360) % 360} 65% 52%)`;
};
export const intervalMinutes = {
'10m': 10,
hourly: 60,
'3h': 180,
'6h': 360,
daily: 1440
} as const;
export type UsageInterval = keyof typeof intervalMinutes;
export const defaultInterval = (period: string): UsageInterval =>
period === '24h' ? '10m' : period === '7d' ? 'hourly' : period === '30d' ? '6h' : 'daily';
export const intervalBreakdown = (
models: Record<string, number>,
modelIds: string[],
modelId: string | null = null
) => (modelId === null ? modelIds : [modelId]).map((id) => ({ id, value: models[id] ?? 0 }));
</script>
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import tippy, { type Instance } from 'tippy.js';
import dayjs from 'dayjs';
export let data: UsageBucket[] = [];
export let compare = false;
export let granularity: UsageInterval = 'daily';
export let metricLabel = '';
export let modelNames: Record<string, string> = {};
export let selectedModel: string | null = null;
export let view: 'volume' | 'activity' = 'volume';
const i18n = getContext<any>('i18n');
let active: number | null = null;
let keyboardIndex = 0;
let plot: HTMLDivElement;
let chartWidth = 0;
const activityRowHeight = 28;
let activityViewport: HTMLDivElement;
let activityScrollTop = 0;
let activityHeight = 336;
let activityWidth = 0;
let legendExpanded = false;
$: legendColumns = Math.max(1, Math.floor((chartWidth + 16) / 256));
$: legendLimit = legendColumns * 2 - 1;
$: legendIds = legendExpanded ? ids : ids.slice(0, legendLimit);
let tooltip: Instance | undefined;
let tooltipContent: HTMLDivElement;
let tooltipId = '';
let anchor: HTMLElement | null = null;
let activeModel: string | null = null;
$: {
data;
selectedModel;
view;
compare;
tooltip?.hide();
active = null;
activeModel = null;
keyboardIndex = 0;
resetActivityScroll();
}
$: totals = modelTotals(data);
$: comparing = compare && view === 'volume';
$: previousData = comparing
? data.map((bucket) => ({ ...bucket, models: bucket.previous?.models ?? {} }))
: [];
$: previousTotals = modelTotals(previousData);
$: ids = [...new Set([...Object.keys(totals), ...Object.keys(previousTotals)])].sort(
(a, b) => (totals[b] ?? 0) - (totals[a] ?? 0) || a.localeCompare(b)
);
$: visibleIds = selectedModel ? [selectedModel] : ids;
$: series = chartSeries(data, selectedModel);
$: previousSeries = chartSeries(previousData, selectedModel);
$: ceiling = chartCeiling(
[...series, ...previousSeries].reduce((max, point) => Math.max(max, point.value), 0)
);
$: step = intervalMinutes[granularity] * 60000;
$: start = series[0]?.time ?? 0;
$: span = Math.max(step, (series[series.length - 1]?.time ?? start) - start + step);
$: barWidth = Math.min((100 * step) / span, 12);
$: hovered = active !== null ? series[active] : null;
$: previousHovered = active !== null ? previousSeries[active] : null;
$: change =
hovered && previousHovered ? comparisonChange(hovered.value, previousHovered.value) : null;
$: lineColor = selectedModel ? modelColor(selectedModel) : 'currentColor';
$: linePaths = [series, previousSeries].map((points) =>
points
.map(
(point) =>
`${((point.time - start + step / 2) / span) * 100},${100 - (point.value / ceiling) * 100}`
)
.join(' ')
);
$: tooltipRows = hovered ? intervalBreakdown(hovered.models, visibleIds, activeModel) : [];
$: hoveredModels = hovered ? data.find((bucket) => bucket.date === hovered.date)?.models : null;
$: labels = [
...new Set(
chartWidth >= 480
? [0, Math.floor((series.length - 1) / 2), series.length - 1]
: [0, series.length - 1]
)
].filter((i) => i >= 0 && i < series.length);
$: activityStart = Math.max(0, Math.floor(activityScrollTop / activityRowHeight) - 2);
$: activityEnd = Math.min(
visibleIds.length,
Math.ceil((activityScrollTop + activityHeight) / activityRowHeight) + 2
);
$: getActivityLanes = activityLaneWindow(series);
$: lanes = getActivityLanes(
view === 'activity' ? visibleIds.slice(activityStart, activityEnd) : []
);
$: stacks =
view === 'volume'
? series.map((point) => visibleIds.filter((id) => (point.models[id] ?? 0) > 0))
: [];
$: activityPositions = series.map((point) => (point.time - start) / span);
const name = (id: string) => modelNames[id] || id;
$: numberFormat = new Intl.NumberFormat($i18n.language, {
notation: 'compact',
maximumFractionDigits: 1
});
const number = (value: number) => numberFormat.format(value);
const dateLabel = (date: string) =>
dayjs(date)
.locale($i18n.language)
.format(granularity === 'daily' ? 'MMM D' : 'MMM D, HH:mm');
const position = (time: number) => ((time - start + step / 2) / span) * 100;
function resetActivityScroll() {
activityScrollTop = 0;
if (activityViewport) activityViewport.scrollTop = 0;
}
// Preserve overlapping rows so scrolling only prepares and draws newly visible models.
function activityLaneWindow(points: ReturnType<typeof chartSeries>) {
type Lane = { id: string; peak: number; values: number[] };
let cache = new Map<string, Lane>();
return (ids: string[]) => {
const next = new Map<string, Lane>();
const lanes = ids.map((id) => {
let lane = cache.get(id);
if (!lane) {
const values = points.map((point) => point.models[id] ?? 0);
lane = { id, peak: values.reduce((peak, value) => Math.max(peak, value), 0), values };
}
next.set(id, lane);
return lane;
});
cache = next;
return lanes;
};
}
async function navigate(event: KeyboardEvent, index: number) {
const buttons = plot.querySelectorAll<HTMLButtonElement>('button[data-point]');
const destinations: Record<string, number> = {
ArrowRight: index + 1,
ArrowLeft: index - 1,
Home: 0,
End: buttons.length - 1
};
if (view === 'activity') {
destinations.ArrowDown = index + series.length;
destinations.ArrowUp = index - series.length;
destinations.End = visibleIds.length * series.length - 1;
}
const next = destinations[event.key];
if (next === undefined) return;
event.preventDefault();
keyboardIndex = Math.max(
0,
Math.min((view === 'activity' ? visibleIds.length * series.length : buttons.length) - 1, next)
);
if (view === 'activity') {
const row = Math.floor(keyboardIndex / series.length);
const top = row * activityRowHeight;
activityScrollTop = Math.max(
top + activityRowHeight - activityHeight,
Math.min(activityScrollTop, top)
);
activityViewport.scrollTop = activityScrollTop;
await tick();
const button = plot.querySelector<HTMLButtonElement>(`button[data-row="${row}"]`);
button?.focus();
showTooltip({ currentTarget: button }, keyboardIndex % series.length, visibleIds[row]);
} else buttons[keyboardIndex]?.focus();
}
// One canvas and focus target per lane avoids a DOM element for every model/date pair.
function drawActivity(
canvas: HTMLCanvasElement,
options: {
lane: { id: string; peak: number; values: number[] };
positions: number[];
cellWidth: number;
width: number;
}
) {
function draw() {
const { lane, positions, cellWidth } = options;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const scale = window.devicePixelRatio || 1;
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const context = canvas.getContext('2d');
if (!context) return;
context.scale(scale, scale);
context.fillStyle = modelColor(lane.id);
lane.values.forEach((value, index) => {
if (value <= 0) return;
context.globalAlpha = 0.25 + (0.75 * value) / lane.peak;
context.fillRect(positions[index] * width, 0, cellWidth * width, height);
});
}
draw();
return {
update(next: typeof options) {
if (
next.lane === options.lane &&
next.positions === options.positions &&
next.cellWidth === options.cellWidth &&
next.width === options.width
)
return;
options = next;
draw();
},
destroy() {
canvas.width = 0;
canvas.height = 0;
}
};
}
function hoverActivity(event: MouseEvent, id: string) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const time = start + Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * span;
let low = 0;
let high = series.length - 1;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (series[mid].time <= time) low = mid;
else high = mid - 1;
}
if (time >= series[low].time + step && time < start + span) {
hideTooltip();
return;
}
if (active !== low || activeModel !== id) showTooltip(event, low, id);
}
onMount(() => {
tooltip = tippy(plot, {
content: tooltipContent,
trigger: 'manual',
theme: 'dark',
arrow: false,
placement: 'right-start',
offset: [0, 12],
popperOptions: {
modifiers: [{ name: 'flip', options: { fallbackPlacements: ['left-start', 'top'] } }]
},
duration: 0,
interactive: true,
maxWidth: 'min(20rem, calc(100vw - 1rem))',
appendTo: () => document.body,
aria: { content: null, expanded: false },
onHidden: () => {
active = null;
activeModel = null;
},
getReferenceClientRect: () => {
const rect = (anchor ?? plot).getBoundingClientRect();
const height = tooltip?.popper.offsetHeight || tooltipContent.offsetHeight;
return new DOMRect(
rect.x +
rect.width *
(view === 'activity' && active !== null ? position(series[active].time) / 100 : 0.5),
plot.getBoundingClientRect().top - height / 2,
0,
0
);
}
});
tooltipId = tooltip.popper.id;
return () => tooltip?.destroy();
});
async function showTooltip(
event: { currentTarget: EventTarget | null },
index: number,
modelId: string | null = null
) {
anchor = event.currentTarget as HTMLElement;
activeModel = modelId;
active = index;
await tick();
if (
active !== index ||
activeModel !== modelId ||
!anchor?.isConnected ||
tooltip?.state.isDestroyed
)
return;
tooltip?.clearDelayTimeouts();
tooltip?.show();
tooltip?.popperInstance?.update();
}
function leaveTooltip(event: MouseEvent) {
tooltip?.hideWithInteractivity(event);
}
function hideTooltip() {
tooltip?.hide();
active = null;
activeModel = null;
}
const intervalLabel = (date: string) => {
const start = dayjs(date).locale($i18n.language);
if (granularity === 'daily') return start.format('MMM D, YYYY');
const end = start.add(intervalMinutes[granularity], 'minute');
return `${start.format('MMM D, YYYY · HH:mm')}–${end.format(start.isSame(end, 'day') ? 'HH:mm' : 'MMM D, HH:mm')}`;
};
const comparisonInterval = (start: string, end: string) => {
const from = dayjs(start).locale($i18n.language);
const to = dayjs(end).locale($i18n.language);
return `${from.format('MMM D, YYYY · HH:mm')}–${to.format(from.isSame(to, 'day') ? 'HH:mm' : 'MMM D, HH:mm')}`;
};
function isolate(id: string | null) {
selectedModel = selectedModel === id ? null : id;
active = null;
keyboardIndex = 0;
}
</script>
<svelte:window
on:keydown|capture={(event) => {
if (event.key === 'Escape' && tooltip?.state.isVisible) {
event.stopPropagation();
hideTooltip();
}
}}
/>
<div
bind:clientWidth={chartWidth}
class="min-w-0 text-xs font-normal text-gray-500 dark:text-gray-400"
>
<div
class="h-5 mb-2 flex min-w-0 items-center justify-between gap-3 text-[0.6875rem] tabular-nums"
aria-live="polite"
aria-atomic="true"
>
<span
class="truncate"
title={hovered ? dateLabel(hovered.date) : selectedModel ? name(selectedModel) : undefined}
>{hovered
? dateLabel(hovered.date)
: selectedModel
? name(selectedModel)
: $i18n.t('All models')}</span
>
{#if comparing}
<span class="flex shrink-0 items-center gap-3 text-[0.625rem]">
<span class="flex items-center gap-1.5"
><svg width="16" height="8" aria-hidden="true" class="text-gray-900 dark:text-gray-200"
><path d="M0 4H16" stroke={lineColor} stroke-width="1.5" /></svg
>{$i18n.t('Current')}</span
>
<span class="flex items-center gap-1.5"
><svg
width="16"
height="8"
aria-hidden="true"
class="text-gray-900 dark:text-gray-200 opacity-50"
><path d="M0 4H16" stroke={lineColor} stroke-width="1.5" stroke-dasharray="4 3" /></svg
>{$i18n.t('Previous')}</span
>
</span>
{:else}<span class="shrink-0 whitespace-nowrap"
>{hovered
? `${hovered.value.toLocaleString($i18n.language)} ${metricLabel.toLocaleLowerCase($i18n.language)}`
: metricLabel}</span
>{/if}
</div>
<div
bind:this={plot}
class="relative"
role="group"
aria-label={`${metricLabel} — ${$i18n.t('Use arrow keys to explore the chart')}`}
>
{#if view === 'volume'}
<div class="relative h-40 pl-12">
{#each [ceiling, ceiling * 0.75, ceiling / 2, ceiling * 0.25, 0] as tick}
<div
class="absolute inset-x-0 flex items-center gap-2 pointer-events-none translate-y-1/2"
style:bottom={`${(tick / ceiling) * 100}%`}
>
<span class="w-10 shrink-0 text-right text-[0.6875rem] tabular-nums"
>{number(tick)}</span
>
<span class="h-px flex-1 bg-gray-100 dark:bg-gray-850/50"></span>
</div>
{/each}
<div class="relative h-full">
{#if comparing}
<svg
class="pointer-events-none absolute inset-0 h-full w-full overflow-visible text-gray-900 dark:text-gray-200"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline
points={linePaths[1]}
fill="none"
stroke={lineColor}
stroke-width="1.5"
stroke-dasharray="4 4"
opacity="0.5"
vector-effect="non-scaling-stroke"
/>
<polyline
points={linePaths[0]}
fill="none"
stroke={lineColor}
stroke-width="1.5"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</svg>
{/if}
{#each series as point, index (point.date)}
<button
type="button"
data-point
class="absolute bottom-0 top-0 flex justify-center items-end focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
style:left={`${position(point.time)}%`}
style:width={`${comparing ? (100 * step) / span : barWidth}%`}
style:transform="translateX(-50%)"
tabindex={keyboardIndex === index ? 0 : -1}
aria-describedby={active === index && activeModel === null ? tooltipId : undefined}
aria-label={comparing && point.previous && point.start && point.end
? `${$i18n.t('Current')}: ${comparisonInterval(point.start, point.end)}, ${point.value.toLocaleString($i18n.language)} ${metricLabel}. ${$i18n.t('Previous')}: ${comparisonInterval(point.previous.start, point.previous.end)}, ${previousSeries[index].value.toLocaleString($i18n.language)} ${metricLabel}`
: `${dateLabel(point.date)}: ${point.value.toLocaleString($i18n.language)} ${metricLabel}. ${stacks[index].map((id) => `${name(id)}: ${point.models[id].toLocaleString($i18n.language)}`).join(', ')}`}
on:mouseenter={(event) => showTooltip(event, index)}
on:mouseleave={leaveTooltip}
on:focus={(event) => {
keyboardIndex = index;
showTooltip(event, index);
}}
on:blur={hideTooltip}
on:click={(event) => showTooltip(event, index)}
on:keydown={(event) => navigate(event, index)}
>
{#if comparing}
{#if active === index || series.length === 1}
{#each [point.value, previousSeries[index].value] as value, period}
<span
class="absolute left-1/2 size-1.5 rounded-full bg-current text-gray-900 dark:text-gray-200"
style:bottom={`${(value / ceiling) * 100}%`}
style:transform="translate(-50%, 50%)"
style:background-color={lineColor}
style:opacity={period === 0 ? 1 : 0.5}
></span>
{/each}
{/if}
{:else}<span
class="w-3/4 max-w-12 h-full flex flex-col-reverse justify-start"
style:opacity={active === null || active === index ? 1 : 0.45}
>
{#each stacks[index] as id}
<span
class="block shrink-0 w-full"
style:height={`${(point.models[id] / ceiling) * 100}%`}
style:background-color={modelColor(id)}
></span>
{/each}
</span>{/if}
</button>
{/each}
</div>
</div>
{:else}
<div
bind:this={activityViewport}
bind:clientHeight={activityHeight}
bind:clientWidth={activityWidth}
class="overflow-y-auto overscroll-contain"
style:height={`${Math.min(visibleIds.length * activityRowHeight, 336)}px`}
style:max-height="50vh"
on:scroll={() => {
if (activityScrollTop !== activityViewport.scrollTop) {
activityScrollTop = activityViewport.scrollTop;
hideTooltip();
}
}}
>
<div
style:padding-top={`${activityStart * activityRowHeight}px`}
style:padding-bottom={`${(visibleIds.length - activityEnd) * activityRowHeight}px`}
>
{#each lanes as lane, offset (lane.id)}
{@const row = activityStart + offset}
{@const index =
Math.floor(keyboardIndex / series.length) === row ? keyboardIndex % series.length : 0}
<div
class="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 items-center"
style:height={`${activityRowHeight}px`}
>
<button
type="button"
class="text-left truncate py-1"
title={name(lane.id)}
aria-pressed={selectedModel === lane.id}
on:click={() => isolate(lane.id)}>{name(lane.id)}</button
>
<button
type="button"
data-point
data-row={row}
class="relative h-6 bg-gray-100/70 dark:bg-gray-850/40 focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
tabindex={Math.floor(keyboardIndex / series.length) === row ||
(row === activityStart &&
(Math.floor(keyboardIndex / series.length) < activityStart ||
Math.floor(keyboardIndex / series.length) >= activityEnd))
? 0
: -1}
aria-label={`${name(lane.id)}, ${dateLabel(series[index].date)}: ${lane.values[index].toLocaleString($i18n.language)} ${metricLabel}`}
aria-describedby={activeModel === lane.id ? tooltipId : undefined}
on:mousemove={(event) => hoverActivity(event, lane.id)}
on:mouseleave={leaveTooltip}
on:focus={(event) => {
keyboardIndex = row * series.length + index;
showTooltip(event, index, lane.id);
}}
on:blur={hideTooltip}
on:click={(event) => hoverActivity(event, lane.id)}
on:keydown={(event) => navigate(event, row * series.length + index)}
>
<canvas
class="absolute inset-0 w-full h-full pointer-events-none"
aria-hidden="true"
use:drawActivity={{
lane,
positions: activityPositions,
cellWidth: step / span,
width: activityWidth
}}
></canvas>
</button>
</div>
{/each}
</div>
</div>
{/if}
{#if hovered}
<div
class="pointer-events-none absolute inset-y-0 right-0 z-10"
style:left={view === 'volume' ? '3rem' : '7.5rem'}
style:right={view === 'activity' ? `${chartWidth - activityWidth}px` : '0'}
aria-hidden="true"
>
<div
class="absolute inset-y-0 w-px bg-gray-400/70 dark:bg-gray-300/70"
style:left={`${position(hovered.time)}%`}
style:transform="translateX(-50%)"
></div>
</div>
{/if}
</div>
<div
class="relative mt-2 h-4 text-[0.625rem] tabular-nums"
style:margin-left={view === 'volume' ? '3rem' : '7.5rem'}
style:margin-right={view === 'activity' ? `${chartWidth - activityWidth}px` : '0'}
>
{#each labels as index}
<span
class="absolute whitespace-nowrap"
style:left={`${position(series[index].time)}%`}
style:transform={series.length === 1
? 'translateX(-50%)'
: index === 0
? 'none'
: index === series.length - 1
? 'translateX(-100%)'
: 'translateX(-50%)'}>{dateLabel(series[index].date)}</span
>
{/each}
</div>
<div
class="mt-2 grid items-center gap-x-4 gap-y-1 text-[0.6875rem]"
style:grid-template-columns={`repeat(${legendColumns}, minmax(0, 1fr))`}
>
<button
type="button"
class="py-1 text-left"
class:text-gray-900={selectedModel === null}
class:dark:text-gray-200={selectedModel === null}
aria-pressed={selectedModel === null}
on:click={() => isolate(null)}>{$i18n.t('All models')}</button
>
{#each legendIds as id (id)}
<button
type="button"
class="flex min-w-0 max-w-full items-center gap-1.5 py-1 text-left hover:text-gray-900 dark:hover:text-gray-100"
style:opacity={selectedModel && selectedModel !== id ? 0.45 : 1}
aria-label={`${name(id)} — ${$i18n.t('Select to isolate model')}`}
aria-pressed={selectedModel === id}
on:click={() => isolate(id)}
>
<span class="size-2 shrink-0" style:background-color={modelColor(id)}></span>
<span class="max-w-40 truncate" title={name(id)}>{name(id)}</span>
<span
class="ml-auto w-14 shrink-0 text-right tabular-nums text-gray-400 dark:text-gray-500"
title={(hovered ? (hoveredModels?.[id] ?? 0) : (totals[id] ?? 0)).toLocaleString(
$i18n.language
)}>{number(hovered ? (hoveredModels?.[id] ?? 0) : (totals[id] ?? 0))}</span
>
</button>
{/each}
</div>
{#if ids.length > legendLimit}
<button
type="button"
class="mt-1 py-1 text-[0.6875rem] hover:text-gray-900 dark:hover:text-gray-100"
aria-expanded={legendExpanded}
on:click={() => (legendExpanded = !legendExpanded)}
>{legendExpanded ? $i18n.t('Show less') : $i18n.t('Show more')}</button
>
{/if}
<p class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-500">
{view === 'activity'
? $i18n.t('Intensity relative to each model’s peak. Blank means no recorded usage.')
: $i18n.t('Select a model to isolate it.')} · {$i18n.t('Server time')}
</p>
</div>
<div class="hidden">
<div
bind:this={tooltipContent}
class="w-64 max-w-full py-0.5 text-[0.625rem] leading-4 font-normal text-gray-100"
>
{#if hovered}
{#if comparing && hovered.previous && previousHovered && hovered.start && hovered.end}
<div class="truncate text-gray-400">
{selectedModel ? name(selectedModel) : $i18n.t('All models')} · {metricLabel}
</div>
<div class="mt-1 space-y-0.5">
{#each [{ label: $i18n.t('Current'), start: hovered.start, end: hovered.end, value: hovered.value }, { label: $i18n.t('Previous'), start: hovered.previous.start, end: hovered.previous.end, value: previousHovered.value }] as period, index}
<div class="flex h-4 items-center justify-between gap-3">
<span
class="flex min-w-0 items-center gap-1.5"
title={`${period.label}: ${period.start} – ${period.end}`}
>
<svg width="12" height="8" class="shrink-0" aria-hidden="true"
><path
d="M0 4H12"
stroke={selectedModel ? modelColor(selectedModel) : 'currentColor'}
stroke-width="1.5"
stroke-dasharray={index === 1 ? '3 2' : undefined}
opacity={index === 1 ? 0.5 : 1}
/></svg
>
<span class="truncate text-gray-400"
>{comparisonInterval(period.start, period.end)}</span
>
</span>
<span class="shrink-0 tabular-nums"
>{period.value.toLocaleString($i18n.language)}</span
>
</div>
{/each}
<div class="flex h-4 items-center justify-between gap-3 text-gray-400">
<span>{$i18n.t('Change')}</span><span class="tabular-nums"
>{change === null
? $i18n.t('No previous usage')
: `${change > 0 ? '+' : ''}${change.toLocaleString($i18n.language, { maximumFractionDigits: 1 })}%`}</span
>
</div>
</div>
{:else}
<div class="truncate text-gray-400" title={intervalLabel(hovered.date)}>
{intervalLabel(hovered.date)}
</div>
<div class="mt-1 max-h-48 overflow-y-auto scrollbar-hidden space-y-0.5">
{#each tooltipRows as row (row.id)}
<div
class="flex h-4 items-center justify-between gap-3"
class:text-gray-500={row.value === 0}
>
<span class="flex min-w-0 items-center gap-1.5"
><span class="size-1.5 shrink-0" style:background-color={modelColor(row.id)}
></span><span class="min-w-0 truncate" title={name(row.id)}>{name(row.id)}</span
></span
>
<span class="shrink-0 whitespace-nowrap tabular-nums"
>{row.value.toLocaleString($i18n.language)}
<span class="text-gray-400">({usageShare(row.value, hovered.value)})</span></span
>
</div>
{:else}<div class="text-gray-400">{$i18n.t('No activity in this period')}</div>{/each}
</div>
{/if}
{/if}
</div>
</div>

View file

@ -320,10 +320,18 @@
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th
scope="col"
class="px-2.5 py-2 font-normal cursor-pointer select-none w-3"
on:click={() => setSortKey('username')}
class="font-normal select-none w-3"
aria-sort={orderBy === 'username'
? direction === 'asc'
? 'ascending'
: 'descending'
: 'none'}
>
<div class="flex gap-1.5 items-center justify-end">
<button
type="button"
class="flex w-full cursor-pointer gap-1.5 items-center justify-end px-2.5 py-2 uppercase"
on:click={() => setSortKey('username')}
>
{$i18n.t('User')}
{#if orderBy === 'username'}
<span class="font-normal">
@ -338,15 +346,23 @@
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</button>
</th>
<th
scope="col"
class="px-2.5 py-2 font-normal cursor-pointer select-none"
on:click={() => setSortKey('model_id')}
class="font-normal select-none"
aria-sort={orderBy === 'model_id'
? direction === 'asc'
? 'ascending'
: 'descending'
: 'none'}
>
<div class="flex gap-1.5 items-center">
<button
type="button"
class="flex w-full cursor-pointer gap-1.5 items-center px-2.5 py-2 uppercase"
on:click={() => setSortKey('model_id')}
>
{$i18n.t('Models')}
{#if orderBy === 'model_id'}
<span class="font-normal">
@ -361,15 +377,23 @@
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</button>
</th>
<th
scope="col"
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')}
class="font-normal text-right select-none w-fit"
aria-sort={orderBy === 'rating'
? direction === 'asc'
? 'ascending'
: 'descending'
: 'none'}
>
<div class="flex gap-1.5 items-center justify-end">
<button
type="button"
class="flex w-full cursor-pointer gap-1.5 items-center justify-end px-2.5 py-2 uppercase"
on:click={() => setSortKey('rating')}
>
{$i18n.t('Result')}
{#if orderBy === 'rating'}
<span class="font-normal">
@ -384,15 +408,23 @@
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</button>
</th>
<th
scope="col"
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-0"
on:click={() => setSortKey('updated_at')}
class="font-normal text-right select-none w-0"
aria-sort={orderBy === 'updated_at'
? direction === 'asc'
? 'ascending'
: 'descending'
: 'none'}
>
<div class="flex gap-1.5 items-center justify-end">
<button
type="button"
class="flex w-full cursor-pointer gap-1.5 items-center justify-end px-2.5 py-2 uppercase"
on:click={() => setSortKey('updated_at')}
>
{$i18n.t('Updated At')}
{#if orderBy === 'updated_at'}
<span class="font-normal">
@ -407,7 +439,7 @@
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</button>
</th>
<th
@ -442,11 +474,13 @@
<div class="flex flex-col h-full">
{#if feedback.data?.sibling_model_ids}
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div
class="font-normal text-gray-600 dark:text-gray-400 flex-1 line-clamp-1"
<button
type="button"
class="cursor-pointer text-left font-normal text-gray-600 dark:text-gray-400 flex-1 line-clamp-1"
on:click={() => openFeedbackModal(feedback)}
>
{feedback.data?.model_id}
</div>
</button>
</Tooltip>
<Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
@ -466,11 +500,13 @@
</Tooltip>
{:else}
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div
class="text-sm font-normal text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1"
<button
type="button"
class="cursor-pointer text-left text-sm font-normal text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1"
on:click={() => openFeedbackModal(feedback)}
>
{feedback.data?.model_id}
</div>
</button>
</Tooltip>
{/if}
</div>
@ -505,6 +541,7 @@
>
<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"
aria-label={$i18n.t('More')}
>
<EllipsisHorizontal />
</button>

View file

@ -177,13 +177,21 @@
{#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: $i18n.t('Model'), class: '' }, { key: 'rating', label: $i18n.t('Rating'), class: 'text-right w-fit' }, { key: 'won', label: $i18n.t('Won'), class: 'text-right w-5' }, { key: 'lost', label: $i18n.t('Lost'), class: 'text-right w-5' }] as col}
<th
scope="col"
class="px-2.5 py-2 font-normal cursor-pointer select-none {col.class}"
on:click={() => toggleSort(col.key)}
class="font-normal select-none {col.class}"
aria-sort={orderBy === col.key
? direction === 'asc'
? 'ascending'
: 'descending'
: 'none'}
>
<div
class="flex gap-1.5 items-center {col.class.includes('right')
<button
type="button"
class="flex w-full cursor-pointer gap-1.5 items-center px-2.5 py-2 uppercase {col.class.includes(
'right'
)
? 'justify-end'
: ''}"
on:click={() => toggleSort(col.key)}
>
{$i18n.t(col.label)}
{#if orderBy === col.key}
@ -193,7 +201,7 @@
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</button>
</th>
{/each}
</tr>
@ -221,8 +229,10 @@
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-normal text-gray-800 dark:text-gray-200 line-clamp-1"
>{model.name}</span
<button
type="button"
class="cursor-pointer font-normal text-left text-gray-800 dark:text-gray-200 line-clamp-1"
on:click={() => openModal(model)}>{model.name}</button
>
</Tooltip>
</div>

View file

@ -25,6 +25,8 @@
} from '$lib/apis/functions';
import Tooltip from '../common/Tooltip.svelte';
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import ConfirmDialog from '../common/ConfirmDialog.svelte';
import { getModels } from '$lib/apis';
import FunctionMenu from './Functions/FunctionMenu.svelte';
@ -408,6 +410,7 @@
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
@ -498,19 +501,11 @@
{#each filteredItems as func (func.id)}
<div
class="group flex min-h-8 w-full cursor-pointer items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left"
role="button"
tabindex="0"
role="presentation"
on:click={(e) => {
if (shouldIgnoreRowClick(e.target)) return;
openFunction(func);
}}
on:keydown={(e) => {
if (e.currentTarget !== e.target) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFunction(func);
}
}}
>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
@ -523,11 +518,11 @@
</div>
<Tooltip content={func.id} className="min-w-0" placement="top-start">
<div
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
<a
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{resolveLocalizedResource(func, $i18n.language)}</a
>
{resolveLocalizedResource(func, $i18n.language)}
</div>
</Tooltip>
{#if func?.meta?.manifest?.version}
@ -573,17 +568,21 @@
<div
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[0.6875rem] leading-5 text-gray-500 dark:text-gray-500 md:block"
>
<Tooltip
content={func?.user?.email ?? $i18n.t('Deleted User')}
className="min-w-0"
placement="top-start"
>
<div class="truncate">
{capitalizeFirstLetter(
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
)}
</div>
</Tooltip>
<ProfilePreview user={func?.user ?? null} side="left">
<span class="flex max-w-44 items-center gap-1.75">
<ProfileImage
src={func?.user?.id
? `/api/v1/users/${func.user.id}/profile/image`
: '/user.png'}
className="size-4 shrink-0 rounded-full!"
/>
<span class="min-w-0 max-w-24 truncate">
{capitalizeFirstLetter(
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
)}
</span>
</span>
</ProfilePreview>
</div>
<div class="ml-2 flex shrink-0 flex-row items-center gap-1.5 self-center">
@ -697,9 +696,9 @@
</button>
</FunctionMenu>
<button
<div
class="flex h-6 items-center"
type="button"
role="presentation"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
@ -708,6 +707,7 @@
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={func.is_active}
ariaLabel={$i18n.t('Enabled')}
on:change={async () => {
toggleFunctionById(localStorage.token, func.id);
models.set(
@ -722,7 +722,7 @@
}}
/>
</Tooltip>
</button>
</div>
{/if}
</div>
</div>

View file

@ -639,6 +639,7 @@ class Pipe:
value={selected.snapshot.content}
lang="python"
readOnly
ariaLabel={$i18n.t('Code Editor')}
className="text-[0.6875rem]"
/>
{/if}
@ -648,6 +649,7 @@ class Pipe:
value={content}
readOnly={!!selected || selecting || promoting}
lang="python"
ariaLabel={$i18n.t('Code Editor')}
{boilerplate}
className="text-[0.6875rem]"
onChange={(e: string) => {

View file

@ -9,10 +9,14 @@
const labelId = `admin-setting-${uuidv4()}`;
</script>
<div class="flex items-center justify-between gap-4 {className}">
<div id={labelId} class="min-w-0 text-xs text-gray-600 dark:text-gray-400 {labelClassName}">
<div class="group/setting-row flex items-center justify-between gap-4 {className}">
<label
for={`${labelId}-control`}
id={labelId}
class="min-w-0 text-xs text-gray-600 group-has-[button[id$=-control]:enabled]/setting-row:cursor-pointer group-has-[button[id$=-control]:disabled]/setting-row:cursor-not-allowed transition-colors duration-150 group-hover/setting-row:text-gray-900 group-focus-within/setting-row:text-gray-900 dark:text-gray-400 dark:group-hover/setting-row:text-white dark:group-focus-within/setting-row:text-white {labelClassName}"
>
<slot name="label">{label}</slot>
</div>
</label>
<div class="min-w-0">
<slot {labelId} />

View file

@ -78,7 +78,9 @@
</div>
{:else}<div class="flex flex-col gap-1.5">
{#each apps as app (app.id)}
<div class="flex w-full items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<div
class="group/setting-row flex w-full items-center gap-3 text-xs text-gray-600 dark:text-gray-400"
>
<div
class="flex min-w-0 flex-1 items-center gap-1.5 {app.is_active
? ''
@ -91,9 +93,11 @@
className="size-4 shrink-0 rounded!"
/>
{:else}<Grid className="size-4 shrink-0" strokeWidth="1.5" />{/if}
<span class="truncate"
<label
for={`app-${app.id}-control`}
class="truncate text-gray-600 dark:text-gray-400 transition-colors duration-150 group-hover/setting-row:text-gray-900 group-focus-within/setting-row:text-gray-900 dark:group-hover/setting-row:text-white dark:group-focus-within/setting-row:text-white group-has-[button[id$=-control]:enabled]/setting-row:cursor-pointer group-has-[button[id$=-control]:disabled]/setting-row:cursor-not-allowed"
>{app.name}
<span class="text-gray-400 dark:text-gray-600">{app.slug}</span></span
<span class="text-gray-400 dark:text-gray-600">{app.slug}</span></label
>
</div>
{#if app.is_active}
@ -122,6 +126,7 @@
: $i18n.t('Uninstalled')}
>
{#key `${app.is_active}-${show}-${busy}`}<Switch
id={`app-${app.id}-control`}
state={app.is_active}
disabled={busy === app.id}
ariaLabel={`${$i18n.t('Enable')} ${app.name}`}

View file

@ -459,7 +459,7 @@
</AdminSettingField>
</AdminSettingSection>
<AdminSettingSection>
<AdminSettingRow label={$i18n.t('Scopes')}>
<AdminSettingRow let:labelId label={$i18n.t('Scopes')}>
<div class="flex items-center gap-3 text-xs">
<span class="text-gray-400 dark:text-gray-600"
>{$i18n.t('{{COUNT}} selected', {
@ -467,6 +467,7 @@
})}</span
>
<button
id={`${labelId}-control`}
type="button"
class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white hover:underline"
on:click={() => {

View file

@ -408,8 +408,13 @@
<AdminSettingRow
label={$i18n.t('settings.admin.audio.speechToTextEngine.label')}
description={$i18n.t('settings.admin.audio.speechToTextEngine.description')}
let:labelId
>
<SettingsSelect bind:value={STT_ENGINE} placeholder={$i18n.t('Select an engine')}>
<SettingsSelect
id={`${labelId}-control`}
bind:value={STT_ENGINE}
placeholder={$i18n.t('Select an engine')}
>
<option value="" disabled={$config?.features?.slim === true}
>{$i18n.t('Whisper (Local)')}</option
>
@ -456,8 +461,9 @@
<AdminSettingRow
label={$i18n.t('settings.admin.audio.requestFormat.label')}
description={$i18n.t('settings.admin.audio.requestFormat.description')}
let:labelId
>
<SettingsSelect bind:value={STT_OPENAI_API_REQUEST_FORMAT}>
<SettingsSelect id={`${labelId}-control`} bind:value={STT_OPENAI_API_REQUEST_FORMAT}>
<option value="multipart">{$i18n.t('Multipart Upload')}</option>
<option value="json">{$i18n.t('JSON Base64')}</option>
</SettingsSelect>
@ -633,8 +639,10 @@
<AdminSettingRow
label={$i18n.t('settings.admin.audio.textToSpeechEngine.label')}
description={$i18n.t('settings.admin.audio.textToSpeechEngine.description')}
let:labelId
>
<SettingsSelect
id={`${labelId}-control`}
bind:value={TTS_ENGINE}
placeholder={$i18n.t('Select a mode')}
on:change={async (e) => {
@ -745,8 +753,8 @@
{/if}
{#if TTS_ENGINE === ''}
<AdminSettingField label={$i18n.t('settings.admin.audio.ttsVoice.label')}>
<SettingsSelect bind:value={TTS_VOICE} className="w-full">
<AdminSettingField label={$i18n.t('settings.admin.audio.ttsVoice.label')} forId="tts-voice">
<SettingsSelect id="tts-voice" bind:value={TTS_VOICE} className="w-full">
<option value="" selected={TTS_VOICE !== ''}>{$i18n.t('Default')}</option>
{#each voices as voice}
<option

View file

@ -34,6 +34,7 @@
host: '',
port: null,
attribute_for_mail: 'mail',
default_email_domain: '',
attribute_for_username: 'uid',
app_dn: '',
app_dn_password: '',
@ -153,8 +154,10 @@
<AdminSettingRow
label={$i18n.t('settings.admin.authentication.defaultUserRole.label')}
description={$i18n.t('settings.admin.authentication.defaultUserRole.description')}
let:labelId
>
<SettingsSelect
id={`${labelId}-control`}
bind:value={adminConfig.DEFAULT_USER_ROLE}
placeholder={$i18n.t('Select a role')}
>
@ -167,8 +170,10 @@
<AdminSettingRow
label={$i18n.t('settings.admin.authentication.defaultGroup.label')}
description={$i18n.t('settings.admin.authentication.defaultGroup.description')}
let:labelId
>
<SettingsSelect
id={`${labelId}-control`}
bind:value={adminConfig.DEFAULT_GROUP_ID}
placeholder={$i18n.t('Select a group')}
>
@ -263,8 +268,9 @@
</AdminSettingSection>
<AdminSettingSection title={$i18n.t('Multi-factor authentication')}>
<AdminSettingRow label={$i18n.t('Require an authenticator for all users')}>
<AdminSettingRow let:labelId label={$i18n.t('Require an authenticator for all users')}>
<Switch
ariaLabelledbyId={labelId}
bind:state={adminConfig.ENABLE_MFA}
ariaLabel={$i18n.t('Require an authenticator for all users')}
/>
@ -273,14 +279,20 @@
{$i18n.t('Changes sign out all devices. Users enroll on their next sign-in.')}
</p>
{#if adminConfig.ENABLE_MFA}
<AdminSettingRow label={$i18n.t('Allow OAuth sign-in without an authenticator')}
<AdminSettingRow
let:labelId
label={$i18n.t('Allow OAuth sign-in without an authenticator')}
><Switch
ariaLabelledbyId={labelId}
bind:state={adminConfig.MFA_ALLOW_OAUTH_BYPASS}
ariaLabel={$i18n.t('Allow OAuth sign-in without an authenticator')}
/></AdminSettingRow
>
<AdminSettingRow label={$i18n.t('Allow trusted-header sign-in without an authenticator')}
<AdminSettingRow
let:labelId
label={$i18n.t('Allow trusted-header sign-in without an authenticator')}
><Switch
ariaLabelledbyId={labelId}
bind:state={adminConfig.MFA_ALLOW_TRUSTED_HEADER_BYPASS}
ariaLabel={$i18n.t('Allow trusted-header sign-in without an authenticator')}
/></AdminSettingRow
@ -469,6 +481,17 @@
</AdminSettingField>
</div>
<AdminSettingField
label={$i18n.t('settings.admin.authentication.defaultEmailDomain.label')}
description={$i18n.t('settings.admin.authentication.defaultEmailDomain.description')}
>
<input
class={inputClass}
placeholder="example.com"
bind:value={LDAP_SERVER.default_email_domain}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('settings.admin.authentication.searchBase.label')}
description={$i18n.t('settings.admin.authentication.searchBase.description')}

View file

@ -69,8 +69,10 @@
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks\u2014proceed with extreme caution.'
)
: $i18n.t('settings.admin.codeExecution.codeExecutionEngine.description')}
let:labelId
>
<SettingsSelect
id={`${labelId}-control`}
bind:value={config.CODE_EXECUTION_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
@ -106,8 +108,10 @@
description={$i18n.t(
'settings.admin.codeExecution.codeExecutionJupyterAuth.description'
)}
let:labelId
>
<SettingsSelect
id={`${labelId}-control`}
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
@ -183,8 +187,10 @@
'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks\u2014proceed with extreme caution.'
)
: $i18n.t('settings.admin.codeExecution.codeInterpreterEngine.description')}
let:labelId
>
<SettingsSelect
id={`${labelId}-control`}
bind:value={config.CODE_INTERPRETER_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
@ -220,8 +226,10 @@
description={$i18n.t(
'settings.admin.codeExecution.codeInterpreterJupyterAuth.description'
)}
let:labelId
>
<SettingsSelect
id={`${labelId}-control`}
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>

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