mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-27 06:12:21 +00:00
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
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:
commit
33c1978d5b
364 changed files with 26924 additions and 16988 deletions
2
.github/workflows/release-pypi.yml
vendored
2
.github/workflows/release-pypi.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
133
CHANGELOG.md
133
CHANGELOG.md
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
||||

|
||||

|
||||
|
||||
> [!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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "")}'
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {}),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
BIN
demo.png
Binary file not shown.
|
Before Width: | Height: | Size: 281 KiB |
|
|
@ -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
314
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
126
scripts/i18n/check.mjs
Normal 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.');
|
||||
}
|
||||
}
|
||||
11
src/app.css
11
src/app.css
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()}`, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
759
src/lib/components/admin/Analytics/UsageChart.svelte
Normal file
759
src/lib/components/admin/Analytics/UsageChart.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue