diff --git a/.env.example b/.env.example index 55bf5d386f..6f674e6e50 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ CORS_ALLOW_ORIGIN='*' # Set to false to keep memory tools enabled without adding memory context to the system context. ENABLE_MEMORY_SYSTEM_CONTEXT=true +# Set to false to disable workspace Tools and Functions. +ENABLE_PLUGINS=true + # For production you should set this to match the proxy configuration (127.0.0.1) FORWARDED_ALLOW_IPS='*' diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index b54f3631d9..f14afd6a58 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -75,6 +75,17 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Prepare CI Dockerfile + run: | + awk ' + /^FROM --platform=\$BUILDPLATFORM node:/ { + print + print "ENV NODE_OPTIONS=\"--max-old-space-size=12288\"" + next + } + { print } + ' Dockerfile > "${RUNNER_TEMP}/Dockerfile" + - name: Log in to the Container registry uses: docker/login-action@v3 with: @@ -115,6 +126,7 @@ jobs: id: build with: context: . + file: ${{ runner.temp }}/Dockerfile push: true platforms: ${{ matrix.platform.arch }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index 9b5f2a099f..a977c5bf4e 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -43,6 +43,8 @@ jobs: - name: Production build run: npm run build + env: + NODE_OPTIONS: --max-old-space-size=8192 # ── Vitest unit tests ──────────────────────────────────────────────────── unit-tests: diff --git a/.github/workflows/issue-label.yaml b/.github/workflows/issue-label.yaml new file mode 100644 index 0000000000..94f0d82ead --- /dev/null +++ b/.github/workflows/issue-label.yaml @@ -0,0 +1,43 @@ +name: Issue Labeler + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + label-bug-reports: + runs-on: ubuntu-latest + steps: + - name: Add "bug" label to unlabeled bug reports + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + + // Web-form submissions already carry the label from the issue template + if (issue.labels.some((label) => label.name === 'bug')) { + return; + } + + const title = issue.title ?? ''; + const body = issue.body ?? ''; + + // Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..." + const bugLikeTitle = /^\s*\[?(bug|issue|fix)\]?\s*[:/\-]/i.test(title); + + // API/CLI-created issues that reproduce the bug report form structure. + // Only headings distinctive to the bug form (both are required fields there) — + // generic headings like "Expected Behavior" also appear in freeform feature requests. + const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body); + + if (bugLikeTitle || bugFormBody) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['bug'] + }); + } diff --git a/README.md b/README.md index bc504ce641..1342c8d838 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,9 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it: -- ⚡ **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. +- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more. -- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes. - -- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more. +- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes. - 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync. diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index f89738b0db..721c890407 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1176,6 +1176,7 @@ WEB_SEARCH_TRUST_ENV = os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'tru OLLAMA_CLOUD_WEB_SEARCH_API_KEY = os.getenv('OLLAMA_CLOUD_API_KEY', '') SEARXNG_QUERY_URL = os.getenv('SEARXNG_QUERY_URL', '') +OPENSERP_BASE_URL = os.getenv('OPENSERP_BASE_URL', 'http://localhost:7000') SEARXNG_LANGUAGE = os.getenv('SEARXNG_LANGUAGE', 'all') @@ -1664,7 +1665,13 @@ if default_prompt_suggestions == []: DEFAULT_PROMPT_SUGGESTIONS = default_prompt_suggestions -MODEL_ORDER_LIST = [] +try: + model_order_list = json.loads(os.getenv('MODEL_ORDER_LIST', '[]')) +except Exception as e: + log.exception(f'Error loading MODEL_ORDER_LIST: {e}') + model_order_list = [] + +MODEL_ORDER_LIST = model_order_list try: default_model_metadata = json.loads(os.getenv('DEFAULT_MODEL_METADATA', '{}')) @@ -1806,6 +1813,9 @@ USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = ( USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = ( os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true' ) +USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS = ( + os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS', 'True').lower() == 'true' +) USER_PERMISSIONS_CHAT_CONTROLS = os.getenv('USER_PERMISSIONS_CHAT_CONTROLS', 'True').lower() == 'true' @@ -1840,6 +1850,10 @@ USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = ( os.getenv('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) +USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING = ( + os.getenv('USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING', 'False').lower() == 'true' +) + USER_PERMISSIONS_CHAT_EXPORT = os.getenv('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true' USER_PERMISSIONS_CHAT_IMPORT = os.getenv('USER_PERMISSIONS_CHAT_IMPORT', 'True').lower() == 'true' @@ -1926,10 +1940,12 @@ DEFAULT_USER_PERMISSIONS = { 'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, 'folders': USER_PERMISSIONS_FOLDERS_ALLOW_SHARING, 'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING, + 'open_chats': USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING, 'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING, }, 'access_grants': { 'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS, + 'allow_groups': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS, }, 'chat': { 'controls': USER_PERMISSIONS_CHAT_CONTROLS, @@ -1987,6 +2003,14 @@ ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true' ENABLE_AUTOMATIONS = os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true' +ENABLE_SUBAGENTS = os.getenv('ENABLE_SUBAGENTS', 'False').lower() == 'true' +SUBAGENTS_BACKGROUND_ENABLED = os.getenv('SUBAGENTS_BACKGROUND_ENABLED', 'False').lower() == 'true' +SUBAGENTS_MAX_CONCURRENT = int(os.getenv('SUBAGENTS_MAX_CONCURRENT', '20')) +SUBAGENTS_MAX_ASYNC = int(os.getenv('SUBAGENTS_MAX_ASYNC', '20')) +SUBAGENTS_MAX_ITERATIONS = int(os.getenv('SUBAGENTS_MAX_ITERATIONS', '30')) +SUBAGENTS_MAX_OUTPUT = int(os.getenv('SUBAGENTS_MAX_OUTPUT', '30000')) +SUBAGENTS_SYSTEM_PROMPT = os.getenv('SUBAGENTS_SYSTEM_PROMPT', '') + AUTOMATION_MAX_COUNT = os.getenv('AUTOMATION_MAX_COUNT', '') AUTOMATION_MIN_INTERVAL = os.getenv('AUTOMATION_MIN_INTERVAL', '') @@ -2128,29 +2152,35 @@ ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lowe CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000')) +_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP') +CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None + +CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min( + 50, max(10, int(os.getenv('CONTEXT_COMPACTION_RETENTION_PERCENTAGE', '40'))) +) + CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '') TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task: -Generate a concise, 3-5 word title with an emoji summarizing the chat history. +Generate a concise title summarizing the chat history. ### Guidelines: - The title should clearly represent the main theme or subject of the conversation. -- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting. +- Keep it short: 2-4 words is best. +- Do not use emojis, quotation marks, or special formatting. - Write the title in the chat's primary language; default to English if multilingual. -- Prioritize accuracy over excessive creativity; keep it clear and simple. +- Prioritize accuracy over creativity. - Your entire response must consist solely of the JSON object, without any introductory or concluding text. - The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text. - Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure. ### Output: JSON format: { "title": "your concise title here" } ### Examples: -- { "title": "📉 Stock Market Trends" }, -- { "title": "🍪 Perfect Chocolate Chip Recipe" }, -- { "title": "Evolution of Music Streaming" }, -- { "title": "Remote Work Productivity Tips" }, -- { "title": "Artificial Intelligence in Healthcare" }, -- { "title": "🎮 Video Game Development Insights" } +- { "title": "Stock Trends" }, +- { "title": "Chocolate Chip Cookies" }, +- { "title": "Music Streaming" }, +- { "title": "Remote Work" } ### Chat History: {{MESSAGES:END:2}} @@ -2400,6 +2430,11 @@ if JWT_EXPIRES_IN == '-1': # OAuth config #################################### +# Master switch for OAuth/OIDC sign-in. Defaults to enabled so existing +# deployments that already have a provider configured keep working; admins can +# turn it off to disable OAuth login without clearing their provider settings. +ENABLE_OAUTH = os.getenv('ENABLE_OAUTH', 'True').lower() == 'true' + ENABLE_OAUTH_SIGNUP = os.getenv('ENABLE_OAUTH_SIGNUP', 'False').lower() == 'true' OAUTH_AUTO_REDIRECT = os.getenv('OAUTH_AUTO_REDIRECT', 'False').lower() == 'true' @@ -2553,6 +2588,23 @@ if _oauth_authorize_params: log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') +def oauth_client_kwargs(scope: str, **kwargs): + client_kwargs = { + 'scope': scope, + **kwargs, + **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), + } + + if OAUTH_CODE_CHALLENGE_METHOD == 'S256': + client_kwargs['code_challenge_method'] = 'S256' + elif OAUTH_CODE_CHALLENGE_METHOD: + raise Exception( + 'Code challenge methods other than "%s" not supported. Given: "%s"' % ('S256', OAUTH_CODE_CHALLENGE_METHOD) + ) + + return client_kwargs + + def load_oauth_providers(): OAUTH_PROVIDERS.clear() if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET: @@ -2563,10 +2615,7 @@ def load_oauth_providers(): client_id=GOOGLE_CLIENT_ID, client_secret=GOOGLE_CLIENT_SECRET, server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', - client_kwargs={ - 'scope': GOOGLE_OAUTH_SCOPE, - **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), - }, + client_kwargs=oauth_client_kwargs(GOOGLE_OAUTH_SCOPE), redirect_uri=GOOGLE_REDIRECT_URI, **({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}), ) @@ -2584,10 +2633,7 @@ def load_oauth_providers(): client_id=MICROSOFT_CLIENT_ID, client_secret=MICROSOFT_CLIENT_SECRET, server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL}/{MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID}', - client_kwargs={ - 'scope': MICROSOFT_OAUTH_SCOPE, - **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), - }, + client_kwargs=oauth_client_kwargs(MICROSOFT_OAUTH_SCOPE), redirect_uri=MICROSOFT_REDIRECT_URI, ) return client @@ -2608,10 +2654,7 @@ def load_oauth_providers(): authorize_url='https://github.com/login/oauth/authorize', api_base_url='https://api.github.com', userinfo_endpoint='https://api.github.com/user', - client_kwargs={ - 'scope': GITHUB_CLIENT_SCOPE, - **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), - }, + client_kwargs=oauth_client_kwargs(GITHUB_CLIENT_SCOPE), redirect_uri=GITHUB_CLIENT_REDIRECT_URI, ) return client @@ -2624,30 +2667,19 @@ def load_oauth_providers(): if OAUTH_CLIENT_ID and (OAUTH_CLIENT_SECRET or OAUTH_CODE_CHALLENGE_METHOD) and OPENID_PROVIDER_URL: def oidc_oauth_register(oauth: OAuth): - client_kwargs = { - 'scope': OAUTH_SCOPES, - **( - {'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD} - if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD - else {} - ), - **({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}), - } - - if OAUTH_CODE_CHALLENGE_METHOD and OAUTH_CODE_CHALLENGE_METHOD == 'S256': - client_kwargs['code_challenge_method'] = 'S256' - elif OAUTH_CODE_CHALLENGE_METHOD: - raise Exception( - 'Code challenge methods other than "%s" not supported. Given: "%s"' - % ('S256', OAUTH_CODE_CHALLENGE_METHOD) - ) - client = oauth.register( name='oidc', client_id=OAUTH_CLIENT_ID, client_secret=OAUTH_CLIENT_SECRET, server_metadata_url=OPENID_PROVIDER_URL, - client_kwargs=client_kwargs, + client_kwargs=oauth_client_kwargs( + OAUTH_SCOPES, + **( + {'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD} + if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD + else {} + ), + ), redirect_uri=OPENID_REDIRECT_URI, ) return client @@ -2875,6 +2907,7 @@ DEFAULT_CONFIG = { 'web.search.trust_env': WEB_SEARCH_TRUST_ENV, 'web.search.ollama_cloud_api_key': OLLAMA_CLOUD_WEB_SEARCH_API_KEY, 'web.search.searxng_query_url': SEARXNG_QUERY_URL, + 'web.search.openserp_base_url': OPENSERP_BASE_URL, 'web.search.searxng_language': SEARXNG_LANGUAGE, 'web.search.yacy_query_url': YACY_QUERY_URL, 'web.search.yacy_username': YACY_USERNAME, @@ -3016,6 +3049,13 @@ DEFAULT_CONFIG = { 'channels.enable': ENABLE_CHANNELS, 'calendar.enable': ENABLE_CALENDAR, 'automations.enable': ENABLE_AUTOMATIONS, + 'subagents.enable': ENABLE_SUBAGENTS, + 'subagents.background_enabled': SUBAGENTS_BACKGROUND_ENABLED, + 'subagents.max_concurrent': SUBAGENTS_MAX_CONCURRENT, + 'subagents.max_async': SUBAGENTS_MAX_ASYNC, + 'subagents.max_iterations': SUBAGENTS_MAX_ITERATIONS, + 'subagents.max_output': SUBAGENTS_MAX_OUTPUT, + 'subagents.system_prompt': SUBAGENTS_SYSTEM_PROMPT, 'automations.max_count': AUTOMATION_MAX_COUNT, 'automations.min_interval': AUTOMATION_MIN_INTERVAL, 'automations.auth_token_expires_in': AUTOMATION_AUTH_TOKEN_EXPIRES_IN, @@ -3034,6 +3074,8 @@ DEFAULT_CONFIG = { 'task.model.external': TASK_MODEL_EXTERNAL, 'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION, 'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD, + 'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP, + 'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE, 'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE, 'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE, 'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE, @@ -3055,6 +3097,7 @@ DEFAULT_CONFIG = { 'auth.api_key.endpoint_restrictions': ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, 'auth.api_key.allowed_endpoints': API_KEYS_ALLOWED_ENDPOINTS, 'auth.jwt_expiry': JWT_EXPIRES_IN, + 'oauth.enable': ENABLE_OAUTH, 'oauth.enable_signup': ENABLE_OAUTH_SIGNUP, 'oauth.auto_redirect': OAUTH_AUTO_REDIRECT, 'oauth.refresh_token.include_scope': OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE, diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index cd97bda4a8..96a3d2e6e4 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -389,6 +389,12 @@ try: except ValueError: REDIS_SOCKET_CONNECT_TIMEOUT = None +REDIS_SOCKET_TIMEOUT = os.getenv('REDIS_SOCKET_TIMEOUT', '') +try: + REDIS_SOCKET_TIMEOUT = float(REDIS_SOCKET_TIMEOUT) +except ValueError: + REDIS_SOCKET_TIMEOUT = None + # Whether to enable TCP SO_KEEPALIVE on Redis client sockets. Opt-in: # defaults to off so behavior is unchanged for existing deployments. When # enabled, the kernel sends TCP keepalive probes on idle connections so @@ -565,6 +571,19 @@ try: except (ValueError, TypeError): AIOHTTP_CLIENT_TIMEOUT = 300 +# Optional between-chunks idle cap for streaming aiohttp requests. +AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = os.getenv('AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT', '') +if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT == '': + AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None +else: + try: + AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = int(AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT) + except (ValueError, TypeError): + AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None + +if AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT is not None and AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT <= 0: + AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT = None + # SSL verification for general outbound requests (OpenAI, OAuth, etc.). # Accepts "True", "False", or a path to a CA bundle file. @@ -594,6 +613,15 @@ try: except (ValueError, TypeError): AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10 +AIOHTTP_FILE_STREAM_CHUNK_SIZE = os.getenv('AIOHTTP_FILE_STREAM_CHUNK_SIZE', str(1024 * 1024)) +try: + AIOHTTP_FILE_STREAM_CHUNK_SIZE = int(AIOHTTP_FILE_STREAM_CHUNK_SIZE) +except Exception: + AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024 + +if AIOHTTP_FILE_STREAM_CHUNK_SIZE <= 0: + AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024 + # SSL verification for tool server connections specifically. # Accepts "True", "False", or a path to a CA bundle file. @@ -788,6 +816,13 @@ OAUTH_MAX_SESSIONS_PER_USER = int(os.getenv('OAUTH_MAX_SESSIONS_PER_USER', '10') # Token Exchange Configuration # Allows external apps to exchange OAuth tokens for OpenWebUI tokens ENABLE_OAUTH_TOKEN_EXCHANGE = os.getenv('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true' +_oauth_token_exchange_rate_limit = (os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT') or '').strip() +OAUTH_TOKEN_EXCHANGE_RATE_LIMIT = ( + int(_oauth_token_exchange_rate_limit) + if _oauth_token_exchange_rate_limit and _oauth_token_exchange_rate_limit.lower() != 'none' + else None +) +OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW = int(os.getenv('OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW', str(60 * 3))) # Back-Channel Logout Configuration # When enabled, exposes POST /oauth/backchannel-logout for IdP-initiated logout @@ -1038,6 +1073,8 @@ SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = ( # TOOLS/FUNCTIONS PIP OPTIONS #################################### +ENABLE_PLUGINS = os.getenv('ENABLE_PLUGINS', 'True').lower() == 'true' + ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = ( os.getenv('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS', 'True').lower() == 'true' ) @@ -1091,15 +1128,19 @@ except ValueError: MAX_BODY_LOG_SIZE = 2048 # Comma separated list for urls to exclude from audit -AUDIT_EXCLUDED_PATHS = os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',') -AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS] -AUDIT_EXCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_EXCLUDED_PATHS] +AUDIT_EXCLUDED_PATHS = [ + path + for path in ( + path.strip().lstrip('/') for path in os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',') + ) + if path +] # Comma separated list of urls to include in audit (whitelist mode) # When set, only these paths are audited and AUDIT_EXCLUDED_PATHS is ignored -AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',') -AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS] -AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path] +AUDIT_INCLUDED_PATHS = [ + path for path in (path.strip().lstrip('/') for path in os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')) if path +] # When enabled, GET requests are also audited (disabled by default to avoid log noise) ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true' diff --git a/backend/open_webui/events.py b/backend/open_webui/events.py index 71f9f15f1b..999a1c826c 100644 --- a/backend/open_webui/events.py +++ b/backend/open_webui/events.py @@ -8,7 +8,7 @@ import uuid from types import SimpleNamespace from typing import Any -from open_webui.env import VERSION +from open_webui.env import ENABLE_PLUGINS, VERSION from open_webui.models.config import Config from pydantic import BaseModel, ConfigDict, Field, model_validator from open_webui.retrieval.web.utils import validate_url @@ -161,6 +161,12 @@ class EventDefinitions(BaseModel): CHAT_CREATED: EventDefinition = EventDefinition( name='chat.created', description='A chat was created.', message='Chat created' ) + CHAT_FINISHED: EventDefinition = EventDefinition( + name='chat.finished', description='A chat response finished.', message='Chat finished' + ) + CHAT_FAILED: EventDefinition = EventDefinition( + name='chat.failed', description='A chat response failed.', message='Chat failed' + ) CHAT_IMPORTED: EventDefinition = EventDefinition( name='chat.imported', description='A chat was imported.', message='Chat imported' ) @@ -258,6 +264,11 @@ class EventDefinitions(BaseModel): description='A channel member active state was updated.', message='Channel member active updated', ) + CHANNEL_MESSAGE: EventDefinition = EventDefinition( + name='channel.message', + description='A channel message was posted.', + message='Channel message', + ) CHANNEL_WEBHOOK_CREATED: EventDefinition = EventDefinition( name='channel.webhook.created', description='A channel incoming webhook was created.', @@ -472,6 +483,16 @@ class EventDefinitions(BaseModel): FUNCTION_DISABLED: EventDefinition = EventDefinition( name='function.disabled', description='A function was disabled.', message='Function disabled' ) + FUNCTION_ENABLE_STARTED: EventDefinition = EventDefinition( + name='function.enable_started', + description='A function is about to be enabled.', + message='Function enable started', + ) + FUNCTION_DISABLE_STARTED: EventDefinition = EventDefinition( + name='function.disable_started', + description='A function is about to be disabled.', + message='Function disable started', + ) FUNCTION_VALVES_UPDATED: EventDefinition = EventDefinition( name='function.valves_updated', description='Function valves were updated.', message='Function valves updated' ) @@ -566,6 +587,11 @@ class EventDefinitions(BaseModel): description='A calendar event RSVP was updated.', message='Calendar Event rsvp updated', ) + CALENDAR_ALERT: EventDefinition = EventDefinition( + name='calendar.alert', + description='A calendar event alert was triggered.', + message='Calendar alert', + ) AUTOMATION_CREATED: EventDefinition = EventDefinition( name='automation.created', description='An automation was created.', message='Automation created' ) @@ -622,6 +648,12 @@ class EventDefinitions(BaseModel): TERMINAL_SESSION_CLOSED: EventDefinition = EventDefinition( name='terminal.session.closed', description='A terminal session was closed.', message='Terminal Session closed' ) + NOTIFICATION_TEST: EventDefinition = EventDefinition( + name='notification.test', description='A notification target test was sent.', message='Notification test' + ) + NOTIFICATION_MANUAL: EventDefinition = EventDefinition( + name='notification.manual', description='A manual notification was sent.', message='Notification sent' + ) EVENTS = EventDefinitions() @@ -629,6 +661,12 @@ EVENT_DEFINITIONS = tuple(getattr(EVENTS, field_name) for field_name in EventDef EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS} EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS) EVENT_CATALOG_SET = set(EVENT_CATALOG) +NOTIFICATION_EVENTS = ( + EVENTS.CHAT_FINISHED.name, + EVENTS.CHAT_FAILED.name, + EVENTS.CHANNEL_MESSAGE.name, + EVENTS.CALENDAR_ALERT.name, +) def get_event_catalog() -> list[dict[str, str]]: @@ -1024,7 +1062,27 @@ class WebhookEventSink: schedule_webhook_dispatch(app, event) -async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None: +def schedule_notification_dispatch(app: Any, event: Event) -> None: + try: + from open_webui.utils.notifications import dispatch_notification_event + + asyncio.create_task(dispatch_notification_event(app, event)) + except RuntimeError: + log.exception('Notification delivery could not be scheduled for %s', event.event) + + +class NotificationEventSink: + async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None: + if event.event in NOTIFICATION_EVENTS: + schedule_notification_dispatch(app, event) + + +async def dispatch_event_functions( + app: Any, event: Event, request: Any | None = None, extra_function_ids: list[str] | None = None +) -> None: + if not ENABLE_PLUGINS: + return + from open_webui.models.functions import Functions from open_webui.utils.plugin import get_function_module_from_cache @@ -1033,6 +1091,12 @@ async def dispatch_event_functions(app: Any, event: Event, request: Any | None = try: event_functions = await Functions.get_functions_by_type('event', active_only=True) + if extra_function_ids: + extra_functions = await Functions.get_functions_by_ids(extra_function_ids) + existing_ids = {function.id for function in event_functions} + event_functions.extend( + function for function in extra_functions if function.type == 'event' and function.id not in existing_ids + ) except Exception: log.exception('Event functions could not be loaded for %s', event.event) return @@ -1081,7 +1145,7 @@ class EventFunctionSink: schedule_event_function_dispatch(app, event, request) -EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()] +EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()] async def publish_event( @@ -1147,6 +1211,20 @@ async def publish_model_provider_request_failed( else 'upstream_error' ) + # Server-log only; the upstream error body is otherwise invisible to admins + # (event sinks require an event function or webhook to be configured). + log.log( + logging.ERROR if status >= 500 else logging.WARNING, + 'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s', + provider, + status, + error_type, + base_url, + requested_model or '-', + error_code or '-', + error_text[:MAX_STRING_LENGTH] or '-', + ) + data = { 'error_type': error_type, 'status': status, diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 4a82cf7f26..1900ee752c 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -20,7 +20,7 @@ from starlette.responses import Response, StreamingResponse from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.constants import ERROR_MESSAGES -from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL +from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL from open_webui.models.functions import Functions from open_webui.models.models import Models from open_webui.models.users import UserModel @@ -69,6 +69,9 @@ async def get_function_module_by_id(request: Request, pipe_id: str): async def get_function_models(request): + if not ENABLE_PLUGINS: + return [] + pipes = await Functions.get_functions_by_type('pipe', active_only=True) pipe_models = [] @@ -144,7 +147,10 @@ async def get_function_models(request): return pipe_models -async def generate_function_chat_completion(request, form_data, user, models: dict = {}): +async def generate_function_chat_completion(request, form_data, user, models: dict | None = None): + if models is None: + models = {} + async def execute_pipe(pipe, params): if inspect.iscoroutinefunction(pipe): return await pipe(**params) @@ -203,6 +209,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di return params + # Copy so the base-model substitution below doesn't leak into the caller's + # payload, which the tool-call continuation re-submits. Mirrors the routers. + form_data = {**form_data} + model_id = form_data.get('model') model_info = await Models.get_model_by_id(model_id) diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index 7c890e0ca2..acce09ed6d 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -370,6 +370,8 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL): if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: # Generous default — async coroutines + no session sharing = high connection demand. + # No pool_pre_ping: a local SQLite file cannot drop connections, and the + # ping costs a worker-thread hop plus a SELECT 1 on every checkout. _sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512 async_engine = create_async_engine( ASYNC_SQLALCHEMY_DATABASE_URL, @@ -377,7 +379,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: pool_size=_sqlite_pool_size, pool_timeout=DATABASE_POOL_TIMEOUT, pool_recycle=DATABASE_POOL_RECYCLE, - pool_pre_ping=True, ) @event.listens_for(async_engine.sync_engine, 'connect') diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 82eed2f4d6..6462a60625 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -83,6 +83,7 @@ from open_webui.env import ( ENABLE_COMPRESSION_MIDDLEWARE, ENABLE_CUSTOM_MODEL_FALLBACK, ENABLE_EASTER_EGGS, + ENABLE_PLUGINS, EXTERNAL_PWA_MANIFEST_URL, # OAuth Back-Channel Logout ENABLE_OAUTH_BACKCHANNEL_LOGOUT, @@ -153,6 +154,7 @@ from open_webui.routers import ( knowledge, memories, models, + notifications, notes, ollama, openai, @@ -218,6 +220,14 @@ from open_webui.utils.chat import ( from open_webui.utils.chat import ( generate_chat_completion as chat_completion_handler, ) +from open_webui.utils.chat_id import ( + get_temporary_chat_session_id, + is_saved_chat_id, + is_temporary_chat_id, +) +from open_webui.utils.chat_variables import ( + normalize_chat_variables, +) from open_webui.utils.embeddings import generate_embeddings from open_webui.utils.logger import start_logger from open_webui.utils.middleware import ( @@ -226,6 +236,7 @@ from open_webui.utils.middleware import ( process_chat_payload, process_chat_response, ) +from open_webui.utils.model_ids import strip_provider_model_prefix from open_webui.utils.models import ( check_model_access, get_all_base_models, @@ -247,7 +258,7 @@ from open_webui.utils.oauth import ( from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.redis import get_redis_client from open_webui.utils.security_headers import SecurityHeadersMiddleware -from open_webui.utils.session_pool import get_session +from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper from open_webui.utils.tools import set_terminal_servers, set_tool_servers if SAFE_MODE: @@ -258,6 +269,16 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +async def emit_chat_list_event(metadata: dict, chat_id: str): + if not is_saved_chat_id(chat_id): + return + + event_emitter = await get_event_emitter(metadata, update_db=False) + if event_emitter: + folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, metadata.get('user_id')) + await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id, 'folder_id': folder_id}}) + + class SPAStaticFiles(StaticFiles): async def get_response(self, path: str, scope): try: @@ -319,8 +340,9 @@ async def lifespan(app: FastAPI): await migrate_legacy_webhook_config() await publish_event(app, EVENTS.SYSTEM_STARTUP_STARTED, source='system') + license_task = None if LICENSE_KEY: - get_license_data(app, LICENSE_KEY) + license_task = asyncio.create_task(asyncio.to_thread(get_license_data, app, LICENSE_KEY)) # Create admin account from env vars if specified and no users exist if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD: @@ -408,6 +430,14 @@ async def lifespan(app: FastAPI): log.warning(f'Failed to initialize terminal servers at startup: {e}') # Mark application as ready to accept traffic from a startup perspective. + if license_task: + try: + await asyncio.wait_for(asyncio.shield(license_task), timeout=2) + except asyncio.TimeoutError: + log.warning('License data retrieval is still pending; continuing startup without it') + except Exception as e: + log.warning(f'License data retrieval failed during startup: {e}') + app.state.startup_complete = True await publish_event(app, EVENTS.SYSTEM_STARTUP_COMPLETED, source='system') @@ -697,6 +727,25 @@ app.state.speech_speaker_embeddings_dataset = None app.state.MODELS = MODELS # Add the middleware to the app +try: + audit_level = AuditLevel(AUDIT_LOG_LEVEL) +except ValueError as e: + logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}') + audit_level = AuditLevel.NONE + +# Added before CompressMiddleware so audit sits inside compression and +# captures response bodies before they are compressed (last added runs +# outermost). +if audit_level != AuditLevel.NONE: + app.add_middleware( + AuditLoggingMiddleware, + audit_level=audit_level, + excluded_paths=AUDIT_EXCLUDED_PATHS, + included_paths=AUDIT_INCLUDED_PATHS, + audit_get_requests=ENABLE_AUDIT_GET_REQUESTS, + max_body_size=MAX_BODY_LOG_SIZE, + ) + if ENABLE_COMPRESSION_MIDDLEWARE: app.add_middleware(CompressMiddleware) @@ -751,6 +800,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes']) app.include_router(models.router, prefix='/api/v1/models', tags=['models']) +app.include_router(notifications.router, prefix='/api/v1/notifications', tags=['notifications']) app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge']) app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts']) app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools']) @@ -774,21 +824,6 @@ if ENABLE_SCIM: app.include_router(scim.router, prefix='/api/v1/scim/v2', tags=['scim']) -try: - audit_level = AuditLevel(AUDIT_LOG_LEVEL) -except ValueError as e: - logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}') - audit_level = AuditLevel.NONE - -if audit_level != AuditLevel.NONE: - app.add_middleware( - AuditLoggingMiddleware, - audit_level=audit_level, - excluded_paths=AUDIT_EXCLUDED_PATHS, - included_paths=AUDIT_INCLUDED_PATHS, - audit_get_requests=ENABLE_AUDIT_GET_REQUESTS, - max_body_size=MAX_BODY_LOG_SIZE, - ) ################################## # # Chat Endpoints @@ -801,12 +836,20 @@ if audit_level != AuditLevel.NONE: async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)): all_models = await get_all_models(request, refresh=refresh, user=user) - models = [] - for model in all_models: - # Filter out filter pipelines - if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter': - continue + # Filter out filter pipelines + models = [ + model for model in all_models if not ('pipeline' in model and model['pipeline'].get('type', None) == 'filter') + ] + # Chat requests resolve models by ID from request.app.state.MODELS, where + # duplicate IDs collapse to the last model. Return the same effective list. + models = list({model['id']: model for model in models}.values()) + + # Access-filter first so the per-model payload work below only runs for + # models the caller can actually see. + models = await get_filtered_models(models, user) + + for model in models: # Remove profile image URL to reduce payload size if model.get('info', {}).get('meta', {}).get('profile_image_url'): model['info']['meta'].pop('profile_image_url', None) @@ -820,13 +863,6 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v except Exception as e: log.debug(f'Error processing model tags: {e}') model['tags'] = [] - pass - - models.append(model) - - # Chat requests resolve models by ID from request.app.state.MODELS, where - # duplicate IDs collapse to the last model. Return the same effective list. - models = list({model['id']: model for model in models}.values()) model_order_list = await Config.get('ui.model_order_list') if model_order_list: @@ -839,11 +875,10 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v ) ) - models = await get_filtered_models(models, user) - - log.debug( - f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}' - ) + if log.isEnabledFor(logging.DEBUG): + log.debug( + f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}' + ) return {'data': models} @@ -857,12 +892,6 @@ class ModelUnloadForm(BaseModel): model: str -def strip_provider_model_prefix(model_id: str, prefix_id: str | None) -> str: - if prefix_id and model_id.startswith(f'{prefix_id}.'): - return model_id[len(f'{prefix_id}.') :] - return model_id - - @app.post('/api/models/unload') async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)): """ @@ -1112,6 +1141,13 @@ async def chat_completion( message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}] user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None) + chat_id = form_data.get('chat_id') or '' + chat_variables = form_data.pop('chat_variables', None) + if chat_variables is None: + existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None + chat_variables = existing_chat.variables if existing_chat else {} + + chat_variables = normalize_chat_variables(chat_variables) # Drop tool_servers if caller lacks features.direct_tool_servers — # mirrors the storage-side strip in user/settings/update. @@ -1130,6 +1166,7 @@ async def chat_completion( metadata = { 'user_id': user.id, 'user_agent': request.headers.get('user-agent', '') or '', + 'internal': getattr(request.state, 'internal', False) is True, 'chat_id': form_data.pop('chat_id', None) or '', 'user_message': user_message, 'user_message_id': user_message.get('id') if user_message else None, @@ -1142,6 +1179,7 @@ async def chat_completion( 'files': form_data.get('files', None), 'features': form_data.get('features', {}), 'variables': form_data.get('variables', {}), + 'chat_variables': chat_variables, 'model': model, 'direct': model_item.get('direct', False), 'params': { @@ -1205,9 +1243,7 @@ async def chat_completion( detail=ERROR_MESSAGES.DEFAULT(), ) - if not chat_id.startswith('local:') and not chat_id.startswith( - 'channel:' - ): # temporary/channel chats are not stored + if is_saved_chat_id(chat_id): if is_new_chat: # Build the full history upfront with ALL assistant placeholders user_message = metadata.get('user_message') or {} @@ -1224,7 +1260,7 @@ async def chat_completion( target_model_id = entry['model_id'] assistant_message_id = entry['message_id'] if assistant_message_id: - history_messages[assistant_message_id] = { + assistant_message = { 'id': assistant_message_id, 'parentId': user_message_id, 'childrenIds': [], @@ -1234,6 +1270,11 @@ async def chat_completion( 'model': target_model_id, 'timestamp': int(time.time()), } + # Preserve the side-by-side column index so duplicate + # models don't collapse into one another on reload. + if entry.get('modelIdx') is not None: + assistant_message['modelIdx'] = entry['modelIdx'] + history_messages[assistant_message_id] = assistant_message await Chats.insert_new_chat( chat_id, @@ -1256,6 +1297,7 @@ async def chat_completion( 'tags': [], 'timestamp': int(time.time() * 1000), }, + variables=chat_variables, folder_id=metadata.get('folder_id'), ), ) @@ -1266,6 +1308,7 @@ async def chat_completion( subject_id=chat_id, data={'title': 'New Chat'}, ) + await emit_chat_list_event(metadata, chat_id) if user_message_id: await publish_event( request, @@ -1358,7 +1401,9 @@ async def chat_completion( updated['files'] = chat_files if selected_chat_models: updated['models'] = selected_chat_models - await Chats.update_chat_by_id(chat_id, updated) + await Chats.update_chat_by_id(chat_id, updated, touch=False) + + await Chats.update_chat_variables_by_id(chat_id, chat_variables) # Save user message to DB if user_message and user_message.get('id'): @@ -1367,6 +1412,7 @@ async def chat_completion( user_message['id'], user_message, ) + await emit_chat_list_event({**metadata, 'message_id': user_message['id']}, chat_id) await publish_event( request, EVENTS.MESSAGE_CREATED, @@ -1378,6 +1424,15 @@ async def chat_completion( 'content_preview': user_message.get('content', '')[:300], }, ) + if not getattr(request.state, 'internal', False) and not (user_message.get('meta') or {}).get( + 'internal' + ): + try: + from open_webui.utils.timers import cancel_timers_for_chat + + await cancel_timers_for_chat(chat_id, 'chat.user_message', user.id) + except Exception: + log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id) # Link grandparent → user message (childrenIds) grandparent_id = user_message.get('parentId') @@ -1432,19 +1487,24 @@ async def chat_completion( target_model_id = entry['model_id'] assistant_message_id = entry['message_id'] if assistant_message_id: + assistant_message = { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': target_model_id, + 'timestamp': int(time.time()), + } + # Preserve the side-by-side column index so duplicate + # models don't collapse into one another on reload. + if entry.get('modelIdx') is not None: + assistant_message['modelIdx'] = entry['modelIdx'] await Chats.upsert_message_to_chat_by_id_and_message_id( chat_id, assistant_message_id, - { - 'id': assistant_message_id, - 'parentId': user_message_id, - 'childrenIds': [], - 'role': 'assistant', - 'content': '', - 'done': False, - 'model': target_model_id, - 'timestamp': int(time.time()), - }, + assistant_message, ) await publish_event( request, @@ -1513,9 +1573,7 @@ async def chat_completion( if metadata.get('chat_id') and metadata.get('message_id'): # Update the chat message with the error try: - if not metadata.get('chat_id', '').startswith('local:') and not metadata.get( - 'chat_id', '' - ).startswith('channel:'): + if is_saved_chat_id(metadata.get('chat_id')): await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], @@ -1583,15 +1641,55 @@ async def chat_completion( event_emitter = await get_event_emitter(metadata, update_db=False) if event_emitter: try: - await asyncio.shield(event_emitter({'type': 'chat:active', 'data': {'active': False}})) + folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id( + chat_id, user.id + ) + await asyncio.shield( + event_emitter( + { + 'type': 'chat:active', + 'data': {'active': False, 'folder_id': folder_id}, + } + ) + ) except asyncio.CancelledError: pass except Exception: pass + try: + chat_id = metadata.get('chat_id') + if ( + chat_id + and getattr(request.state, 'internal', False) is not True + and not await has_active_tasks(request.app.state.redis, chat_id) + ): + from open_webui.utils.subagents import process_pending_internal_messages + + await process_pending_internal_messages( + request, + chat_id, + user.id, + { + 'model_id': metadata.get('model_id') or form_data.get('model'), + 'session_id': metadata.get('session_id'), + 'tool_ids': metadata.get('tool_ids') or [], + 'skill_ids': metadata.get('skill_ids') or [], + 'system_prompt': metadata.get('system_prompt'), + 'filter_ids': metadata.get('filter_ids') or [], + 'terminal_id': metadata.get('terminal_id'), + 'features': metadata.get('features') or {}, + 'variables': metadata.get('variables') or {}, + }, + ) + except Exception: + log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id')) + # Fan out: one task per model if metadata.get('session_id') and metadata.get('chat_id'): task_ids = [] + subagent_results = [] + is_internal = getattr(request.state, 'internal', False) is True chat_id = metadata['chat_id'] for idx, entry in enumerate(message_ids): @@ -1604,6 +1702,7 @@ async def chat_completion( per_model_metadata = { **metadata, 'message_id': assistant_message_id, + 'task_id': str(uuid4()), } # Per-model form_data: own model @@ -1618,28 +1717,39 @@ async def chat_completion( # Only the first model runs chat-level background tasks; # subsequent models only run follow-ups. + process = process_chat( + request, + model_form_data, + user, + per_model_metadata, + resolved_model, + tasks + if idx == 0 + else { + k: v for k, v in (tasks or {}).items() if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION) + } + or None, + ) + if is_internal: + subagent_results.append(await process) + continue + task_id, _ = await create_task( request.app.state.redis, - process_chat( - request, - model_form_data, - user, - per_model_metadata, - resolved_model, - tasks - if idx == 0 - else { - k: v - for k, v in (tasks or {}).items() - if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION) - } - or None, - ), + process, id=chat_id, + task_id=per_model_metadata['task_id'], ) - per_model_metadata['task_id'] = task_id task_ids.append(task_id) + if is_internal: + return { + 'status': True, + 'task_ids': [], + 'chat_id': chat_id, + 'results': subagent_results, + } + # Emit chat:active=true if task_ids: event_emitter = await get_event_emitter( @@ -1647,7 +1757,8 @@ async def chat_completion( update_db=False, ) if event_emitter: - await event_emitter({'type': 'chat:active', 'data': {'active': True}}) + folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, user.id) + await event_emitter({'type': 'chat:active', 'data': {'active': True, 'folder_id': folder_id}}) return { 'status': True, @@ -1679,10 +1790,80 @@ app.state.CHAT_COMPLETION_HANDLER = chat_completion from open_webui.utils.anthropic import ( convert_anthropic_to_openai_payload, convert_openai_to_anthropic_response, + is_anthropic_messages_passthrough, openai_stream_to_anthropic_stream, ) +@app.post('/api/message/count_tokens') +@app.post('/api/v1/messages/count_tokens') # Anthropic Messages token-count endpoint +async def count_message_tokens( + request: Request, + form_data: dict, + user=Depends(get_verified_user), +): + return {'input_tokens': await openai.count_anthropic_tokens(request, form_data, user)} + + +async def passthrough_anthropic_messages(request: Request, form_data: dict, user) -> Response | dict: + requested_model, payload, url, key, headers, cookies = await openai.get_anthropic_token_count_target( + request, form_data, user + ) + request_url = f'{url.rstrip("/")}/messages' + response = None + streaming = False + + try: + session = await get_session() + response = await session.request( + method='POST', + url=request_url, + data=json.dumps(payload), + headers=headers, + cookies=cookies, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=openai.AIOHTTP_CLIENT_TIMEOUT), + ) + + if 'text/event-stream' in response.headers.get('Content-Type', ''): + streaming = True + return StreamingResponse( + stream_wrapper(response), + status_code=response.status, + headers=openai._clean_proxy_headers(response.headers), + ) + + try: + response_data = await response.json() + except Exception: + response_data = await response.text() + + if response.status >= 400: + await openai.publish_model_provider_request_failed( + request, + actor=user, + provider='openai-compatible', + base_url=url, + api_key=key, + status=response.status, + requested_model=requested_model, + upstream_error=response_data, + ) + if isinstance(response_data, (dict, list)): + return JSONResponse(status_code=response.status, content=response_data) + return Response(status_code=response.status, content=response_data) + + return response_data + except HTTPException: + raise + except Exception: + log.exception('Failed to passthrough Anthropic Messages request for model %s', requested_model) + raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) + finally: + if not streaming: + await cleanup_response(response) + + @app.post('/api/message') @app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint async def generate_messages( @@ -1703,10 +1884,33 @@ async def generate_messages( Authentication: Supports both standard Authorization header and Anthropic's x-api-key header (via middleware translation). """ - # Convert Anthropic payload to OpenAI format requested_model = form_data.get('model', '') + input_tokens = None + try: + input_tokens = await openai.count_anthropic_tokens(request, form_data, user) + except Exception: + # Counting must not turn a compatible generation request into an outage. + log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True) - openai_payload = convert_anthropic_to_openai_payload(form_data) + model_id = requested_model + model_info = await Models.get_model_by_id(model_id) + if model_info and model_info.base_model_id: + model_id = model_info.base_model_id + + passthrough_params = [] + models = request.app.state.OPENAI_MODELS + if not models or model_id not in models: + await openai.get_all_models(request, user=user) + models = request.app.state.OPENAI_MODELS + model = models.get(model_id) + if model: + url, _, api_config = await openai.get_openai_connection(model['urlIdx']) + if is_anthropic_messages_passthrough(url, api_config): + return await passthrough_anthropic_messages(request, form_data, user) + passthrough_params = api_config.get('passthrough_params') or [] + + # Convert Anthropic payload to OpenAI format + openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params) # Route through the existing chat_completion handler response = await chat_completion(request, openai_payload, user) @@ -1715,7 +1919,9 @@ async def generate_messages( if isinstance(response, StreamingResponse): # Streaming response: wrap the generator to convert SSE format return StreamingResponse( - openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model), + openai_stream_to_anthropic_stream( + response.body_iterator, model=requested_model, input_tokens=input_tokens or 0 + ), media_type='text/event-stream', headers={ 'Cache-Control': 'no-cache', @@ -1723,16 +1929,37 @@ async def generate_messages( }, ) elif isinstance(response, dict): - return convert_openai_to_anthropic_response(response, model=requested_model) + return convert_openai_to_anthropic_response(response, model=requested_model, input_tokens=input_tokens) else: # Passthrough for error responses (JSONResponse, PlainTextResponse, etc.) return response +async def verify_chat_ownership(chat_id: str | None, user) -> None: + """Temporary chats are per-socket and unsaved, so they have no owner to check.""" + if not chat_id or is_temporary_chat_id(chat_id): + return + + # Channel messages need the membership and write-access gate that only /api/chat/completions has. + if chat_id.startswith('channel:'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Channel chats are not supported on this endpoint', + ) + + if user.role != 'admin' and not await Chats.is_chat_owner(chat_id, user.id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.DEFAULT(), + ) + + @app.post('/api/chat/completed') async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)): """Deprecated: outlet filters now run inline during chat completion. Kept for backward compatibility with external integrations.""" + await verify_chat_ownership(form_data.get('chat_id'), user) + try: model_item = form_data.pop('model_item', {}) @@ -1750,6 +1977,8 @@ async def chat_completed(request: Request, form_data: dict, user=Depends(get_ver @app.post('/api/chat/actions/{action_id}') async def chat_action(request: Request, action_id: str, form_data: dict, user=Depends(get_verified_user)): + await verify_chat_ownership(form_data.get('chat_id'), user) + try: model_item = form_data.pop('model_item', {}) @@ -1781,8 +2010,8 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)): @app.get('/api/tasks/chat/{chat_id:path}') async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): - if chat_id.startswith('local:') or chat_id.startswith('channel:'): - socket_id = chat_id[len('local:') :] + socket_id = get_temporary_chat_session_id(chat_id) + if socket_id: owner_id = get_user_id_from_session_pool(socket_id) if owner_id != user.id and user.role != 'admin': return {'task_ids': []} @@ -1799,8 +2028,8 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De @app.post('/api/tasks/chat/{chat_id:path}/stop') async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): - if chat_id.startswith('local:') or chat_id.startswith('channel:'): - socket_id = chat_id[len('local:') :] + socket_id = get_temporary_chat_session_id(chat_id) + if socket_id: owner_id = get_user_id_from_session_pool(socket_id) if owner_id != user.id and user.role != 'admin': raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1852,6 +2081,7 @@ async def get_app_config(request: Request): license_metadata = getattr(app.state, 'LICENSE_METADATA', None) user_count = await Users.get_num_users() if license_metadata else None config = await Config.get_many( + 'oauth.enable', 'oauth.auto_redirect', 'ldap.enable', 'ui.enable_signup', @@ -1865,6 +2095,7 @@ async def get_app_config(request: Request): 'calendar.enable', 'automations.enable', 'notes.enable', + 'chat.context_compaction.enable', 'web.search.enable', 'web.search.confirmation.enable', 'web.search.confirmation.content', @@ -1905,7 +2136,13 @@ async def get_app_config(request: Request): 'version': VERSION, 'default_locale': str(DEFAULT_LOCALE), 'oauth': { - 'providers': {name: config.get('name', name) for name, config in OAUTH_PROVIDERS.items()}, + # Hide providers (and thus the login buttons / auto-redirect) when OAuth + # is disabled, without clearing the admin's provider configuration. + 'providers': ( + {name: provider.get('name', name) for name, provider in OAUTH_PROVIDERS.items()} + if config.get('oauth.enable', True) + else {} + ), 'auto_redirect': config.get('oauth.auto_redirect'), }, 'features': { @@ -1927,12 +2164,14 @@ async def get_app_config(request: Request): 'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT, 'enable_easter_eggs': ENABLE_EASTER_EGGS, 'enable_direct_connections': config.get('direct.enable'), + 'enable_plugins': ENABLE_PLUGINS, 'enable_folders': config.get('folders.enable'), 'folder_max_file_count': config.get('folders.max_file_count'), 'enable_channels': config.get('channels.enable'), 'enable_calendar': config.get('calendar.enable'), 'enable_automations': config.get('automations.enable'), 'enable_notes': config.get('notes.enable'), + 'enable_context_compaction': config.get('chat.context_compaction.enable'), 'enable_web_search': config.get('web.search.enable'), 'enable_web_search_confirmation': config.get('web.search.confirmation.enable'), 'web_search_confirmation_content': config.get('web.search.confirmation.content'), diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 87a1e54608..2bcd45aea7 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -9,6 +9,8 @@ from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT from open_webui.internal.db import enable_iam_token_auth, extract_ssl_params_from_url, reattach_ssl_params_to_url from open_webui.models.auths import Auth from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401 +from open_webui.models.chat_messages import ChatMessage # noqa: F401 +from open_webui.models.chats import Chat # noqa: F401 from sqlalchemy import create_engine, engine_from_config, pool alembic_config = alembic.context.config diff --git a/backend/open_webui/migrations/versions/55f1302ac17c_add_memory_id_user_id_covering_index.py b/backend/open_webui/migrations/versions/55f1302ac17c_add_memory_id_user_id_covering_index.py new file mode 100644 index 0000000000..d1de5c05b8 --- /dev/null +++ b/backend/open_webui/migrations/versions/55f1302ac17c_add_memory_id_user_id_covering_index.py @@ -0,0 +1,36 @@ +"""Add memory (id, user_id) covering index + +Revision ID: 55f1302ac17c +Revises: b0018471bbbe +Create Date: 2026-07-24 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = '55f1302ac17c' +down_revision: Union[str, None] = 'b0018471bbbe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + indexes = {index['name'] for index in inspector.get_indexes('memory')} + + if 'ix_memory_id_user_id' not in indexes: + op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id']) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + indexes = {index['name'] for index in inspector.get_indexes('memory')} + + if 'ix_memory_id_user_id' in indexes: + op.drop_index('ix_memory_id_user_id', table_name='memory') diff --git a/backend/open_webui/migrations/versions/856c5b02fb54_add_chat_message_meta.py b/backend/open_webui/migrations/versions/856c5b02fb54_add_chat_message_meta.py new file mode 100644 index 0000000000..bfbf2751bb --- /dev/null +++ b/backend/open_webui/migrations/versions/856c5b02fb54_add_chat_message_meta.py @@ -0,0 +1,25 @@ +"""add chat message meta + +Revision ID: 856c5b02fb54 +Revises: 42e2978c7933 +Create Date: 2026-07-16 01:39:39.291935 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '856c5b02fb54' +down_revision: Union[str, None] = '42e2978c7933' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('chat_message', sa.Column('meta', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('chat_message', 'meta') diff --git a/backend/open_webui/migrations/versions/959eaac8f909_add_automation_folder_id.py b/backend/open_webui/migrations/versions/959eaac8f909_add_automation_folder_id.py new file mode 100644 index 0000000000..1298abd41d --- /dev/null +++ b/backend/open_webui/migrations/versions/959eaac8f909_add_automation_folder_id.py @@ -0,0 +1,54 @@ +"""add automation folder id + +Revision ID: 959eaac8f909 +Revises: 55f1302ac17c +Create Date: 2026-07-26 19:19:31.345756 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import context, op + +# revision identifiers, used by Alembic. +revision: str = '959eaac8f909' +down_revision: str | None = '55f1302ac17c' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + if context.is_offline_mode(): + op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True)) + op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id']) + return + + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = {col['name'] for col in inspector.get_columns('automation')} + indexes = {index['name'] for index in inspector.get_indexes('automation')} + + if 'folder_id' not in columns: + op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True)) + + if 'ix_automation_user_folder' not in indexes: + op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id']) + + +def downgrade() -> None: + if context.is_offline_mode(): + op.drop_index('ix_automation_user_folder', table_name='automation') + op.drop_column('automation', 'folder_id') + return + + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = {col['name'] for col in inspector.get_columns('automation')} + indexes = {index['name'] for index in inspector.get_indexes('automation')} + + if 'ix_automation_user_folder' in indexes: + op.drop_index('ix_automation_user_folder', table_name='automation') + + if 'folder_id' in columns: + op.drop_column('automation', 'folder_id') diff --git a/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py b/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py new file mode 100644 index 0000000000..a7840bc9d3 --- /dev/null +++ b/backend/open_webui/migrations/versions/9a1b2c3d4e5f_add_current_message_id_to_chat.py @@ -0,0 +1,219 @@ +"""add current_message_id to chat + +Revision ID: 9a1b2c3d4e5f +Revises: 856c5b02fb54 +Create Date: 2026-07-23 00:00:00.000000 + +""" + +import json +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '9a1b2c3d4e5f' +down_revision: Union[str, None] = '856c5b02fb54' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +BATCH_SIZE = 150 + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('chat')] + if 'current_message_id' not in columns: + op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True)) + + chat = sa.table( + 'chat', + sa.column('id', sa.String()), + sa.column('chat', sa.Text()), + sa.column('current_message_id', sa.Text()), + ) + chat_message = sa.table( + 'chat_message', + sa.column('id', sa.Text()), + sa.column('chat_id', sa.Text()), + sa.column('parent_id', sa.Text()), + sa.column('created_at', sa.BigInteger()), + ) + + has_chat_message = 'chat_message' in inspector.get_table_names() + result = conn.execute( + sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options( + yield_per=BATCH_SIZE, + stream_results=True, + ) + ) + + while True: + rows = result.fetchmany(BATCH_SIZE) + if not rows: + break + + batch_chat_ids: list[str] = [] + candidates_by_chat: dict[str, list[str]] = {} + current_by_chat: dict[str, str | None] = {} + json_messages_by_chat: dict[str, dict[str, dict]] = {} + + for row in rows: + values = row._mapping + chat_id = values['id'] + prefix = f'{chat_id}-' + batch_chat_ids.append(chat_id) + current_by_chat[chat_id] = values['current_message_id'] + + chat_data = {} + if isinstance(values['chat'], dict): + chat_data = values['chat'] + elif isinstance(values['chat'], str): + try: + parsed = json.loads(values['chat']) + chat_data = parsed if isinstance(parsed, dict) else {} + except (TypeError, ValueError, json.JSONDecodeError): + pass + + history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {} + candidates_by_chat[chat_id] = [] + for candidate in ( + values['current_message_id'], + history.get('currentId'), + chat_data.get('currentId'), + chat_data.get('branchPointMessageId'), + ): + if not isinstance(candidate, str) or not candidate: + continue + candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate + if candidate not in candidates_by_chat[chat_id]: + candidates_by_chat[chat_id].append(candidate) + + messages = history.get('messages') if isinstance(history.get('messages'), dict) else {} + if not messages and isinstance(chat_data.get('messages'), list): + messages = { + message['id']: message + for message in chat_data['messages'] + if isinstance(message, dict) and message.get('id') + } + if messages: + json_messages_by_chat[chat_id] = { + message_id: { + 'parent_id': message.get('parentId') if isinstance(message, dict) else None, + 'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0, + } + for message_id, message in messages.items() + } + + resolved: dict[str, str] = {} + + if has_chat_message: + candidate_ids = { + f'{chat_id}-{candidate}' + for chat_id, candidates in candidates_by_chat.items() + for candidate in candidates + } + if candidate_ids: + valid_by_chat: dict[str, set[str]] = {} + for row in conn.execute( + sa.select(chat_message.c.chat_id, chat_message.c.id).where( + chat_message.c.chat_id.in_(batch_chat_ids), + chat_message.c.id.in_(candidate_ids), + ) + ): + values = row._mapping + chat_id = values['chat_id'] + prefix = f'{chat_id}-' + message_id = values['id'] + if message_id and message_id.startswith(prefix): + message_id = message_id[len(prefix) :] + if message_id: + valid_by_chat.setdefault(chat_id, set()).add(message_id) + for chat_id, candidates in candidates_by_chat.items(): + valid_ids = valid_by_chat.get(chat_id, set()) + for candidate in candidates: + if candidate in valid_ids: + resolved[chat_id] = candidate + break + + unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved] + messages_by_chat: dict[str, dict[str, dict]] = {} + if unresolved_chat_ids: + for row in conn.execute( + sa.select( + chat_message.c.chat_id, + chat_message.c.id, + chat_message.c.parent_id, + chat_message.c.created_at, + ).where(chat_message.c.chat_id.in_(unresolved_chat_ids)) + ): + values = row._mapping + chat_id = values['chat_id'] + prefix = f'{chat_id}-' + message_id = values['id'] + if message_id and message_id.startswith(prefix): + message_id = message_id[len(prefix) :] + if not message_id: + continue + parent_id = values['parent_id'] + if parent_id and parent_id.startswith(prefix): + parent_id = parent_id[len(prefix) :] + messages_by_chat.setdefault(chat_id, {})[message_id] = { + 'parent_id': parent_id, + 'created_at': values['created_at'] or 0, + } + + for chat_id, messages in messages_by_chat.items(): + parent_ids = { + message['parent_id'] for message in messages.values() if message.get('parent_id') in messages + } + leaf_ids = [message_id for message_id in messages if message_id not in parent_ids] + resolved[chat_id] = max( + leaf_ids or list(messages), + key=lambda message_id: messages[message_id].get('created_at') or 0, + ) + + for chat_id in batch_chat_ids: + if chat_id in resolved: + continue + + messages = json_messages_by_chat.get(chat_id, {}) + valid_candidate = next( + (candidate for candidate in candidates_by_chat[chat_id] if candidate in messages), + None, + ) + if valid_candidate: + resolved[chat_id] = valid_candidate + elif messages: + parent_ids = { + message['parent_id'] for message in messages.values() if message.get('parent_id') in messages + } + leaf_ids = [message_id for message_id in messages if message_id not in parent_ids] + resolved[chat_id] = max( + leaf_ids or list(messages), + key=lambda message_id: messages[message_id].get('created_at') or 0, + ) + + updates = [ + {'chat_id': chat_id, 'current_message_id': message_id} + for chat_id, message_id in resolved.items() + if message_id and message_id != current_by_chat.get(chat_id) + ] + if updates: + conn.execute( + sa.update(chat) + .where(chat.c.id == sa.bindparam('update_chat_id')) + .values(current_message_id=sa.bindparam('update_current_message_id')), + [ + { + 'update_chat_id': row['chat_id'], + 'update_current_message_id': row['current_message_id'], + } + for row in updates + ], + ) + + +def downgrade() -> None: + op.drop_column('chat', 'current_message_id') diff --git a/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py b/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py new file mode 100644 index 0000000000..d8e94ca60b --- /dev/null +++ b/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py @@ -0,0 +1,32 @@ +"""add user variables + +Revision ID: b0018471bbbe +Revises: c49178636c78 +Create Date: 2026-07-24 01:21:46.457057 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'b0018471bbbe' +down_revision: Union[str, None] = 'c49178636c78' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('user')] + + if 'variables' not in columns: + op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('user', 'variables') diff --git a/backend/open_webui/migrations/versions/c49178636c78_add_chat_variables.py b/backend/open_webui/migrations/versions/c49178636c78_add_chat_variables.py new file mode 100644 index 0000000000..c4d4aaf355 --- /dev/null +++ b/backend/open_webui/migrations/versions/c49178636c78_add_chat_variables.py @@ -0,0 +1,32 @@ +"""add chat variables + +Revision ID: c49178636c78 +Revises: 9a1b2c3d4e5f +Create Date: 2026-07-23 23:33:45.497453 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'c49178636c78' +down_revision: Union[str, None] = '9a1b2c3d4e5f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('chat')] + + if 'variables' not in columns: + op.add_column('chat', sa.Column('variables', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('chat', 'variables') diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 1c86dc08e7..7cca23546c 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -11,6 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) +PRINCIPAL_TYPE_ANYONE = 'anyone' +PRINCIPAL_TYPE_GROUP = 'group' +PRINCIPAL_TYPE_USER = 'user' +WILDCARD_PRINCIPAL_ID = '*' + #################### # AccessGrant DB Schema @@ -23,7 +28,7 @@ class AccessGrant(Base): id = Column(Text, primary_key=True) resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file" resource_id = Column(Text, nullable=False) - principal_type = Column(Text, nullable=False) # "user" or "group" + principal_type = Column(Text, nullable=False) # "user", "group", or "anyone" principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public) permission = Column(Text, nullable=False) # "read" or "write" created_at = Column(BigInteger, nullable=False) @@ -163,12 +168,14 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]: principal_id = grant.get('principal_id') permission = grant.get('permission') - if principal_type not in ('user', 'group'): + if principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE): continue if permission not in ('read', 'write'): continue if not isinstance(principal_id, str) or not principal_id: continue + if principal_type == PRINCIPAL_TYPE_ANYONE and (principal_id != WILDCARD_PRINCIPAL_ID or permission != 'read'): + continue key = (principal_type, principal_id, permission) deduped[key] = { @@ -186,7 +193,11 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool: Returns True when a direct grant list includes wildcard public-read. """ for grant in normalize_access_grants(access_grants): - if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read': + if ( + grant['principal_type'] == PRINCIPAL_TYPE_USER + and grant['principal_id'] == WILDCARD_PRINCIPAL_ID + and grant['permission'] == 'read' + ): return True return False @@ -196,7 +207,25 @@ def has_public_write_access_grant(access_grants: Optional[list]) -> bool: Returns True when a direct grant list includes wildcard public-write. """ for grant in normalize_access_grants(access_grants): - if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write': + if ( + grant['principal_type'] == PRINCIPAL_TYPE_USER + and grant['principal_id'] == WILDCARD_PRINCIPAL_ID + and grant['permission'] == 'write' + ): + return True + return False + + +def has_anyone_read_access_grant(access_grants: Optional[list]) -> bool: + """ + Returns True when a direct grant list includes no-auth anyone-read. + """ + for grant in normalize_access_grants(access_grants): + if ( + grant['principal_type'] == PRINCIPAL_TYPE_ANYONE + and grant['principal_id'] == WILDCARD_PRINCIPAL_ID + and grant['permission'] == 'read' + ): return True return False @@ -206,7 +235,7 @@ def has_user_access_grant(access_grants: Optional[list]) -> bool: Returns True when a direct grant list includes any non-wildcard user grant. """ for grant in normalize_access_grants(access_grants): - if grant['principal_type'] == 'user' and grant['principal_id'] != '*': + if grant['principal_type'] == PRINCIPAL_TYPE_USER and grant['principal_id'] != WILDCARD_PRINCIPAL_ID: return True return False @@ -223,12 +252,27 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list: 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)) != '*' + == PRINCIPAL_TYPE_USER + and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) + != WILDCARD_PRINCIPAL_ID ) ] +def strip_anyone_access_grants(access_grants: Optional[list]) -> list: + """ + Remove no-auth anyone grants from the list. + """ + if not access_grants: + return [] + return [ + grant + for grant in access_grants + if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) + != PRINCIPAL_TYPE_ANYONE + ] + + def grants_to_access_control(grants: list) -> Optional[dict]: """ Convert a list of grant objects (AccessGrantModel or AccessGrantResponse) @@ -316,7 +360,6 @@ class AccessGrantsTable: ) db.add(grant) await db.commit() - await db.refresh(grant) return AccessGrantModel.model_validate(grant) async def revoke_access( @@ -494,6 +537,28 @@ class AccessGrantsTable: result_dict[g.resource_id].append(AccessGrantModel.model_validate(g)) return result_dict + async def has_anyone_access( + self, + resource_type: str, + resource_id: str, + permission: str = 'read', + db: Optional[AsyncSession] = None, + ) -> bool: + """Check for a no-auth anyone:* grant. Callers must opt in explicitly.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(AccessGrant) + .filter( + AccessGrant.resource_type == resource_type, + AccessGrant.resource_id == resource_id, + AccessGrant.principal_type == PRINCIPAL_TYPE_ANYONE, + AccessGrant.principal_id == WILDCARD_PRINCIPAL_ID, + AccessGrant.permission == permission, + ) + .limit(1) + ) + return result.scalars().first() is not None + async def has_access( self, user_id: str, diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 6538c1dbf9..d852b4ac40 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -133,9 +133,8 @@ class AuthsTable: oauth=oauth, db=session, ) - # persist both records and reload generated defaults + # persist both records await session.commit() - await session.refresh(credential) return created_user if credential and created_user else None async def authenticate_user( diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 4038a3bdbe..c0a7416cc6 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -21,6 +21,7 @@ class Automation(Base): id = Column(Text, primary_key=True) user_id = Column(Text, nullable=False) + folder_id = Column(Text, nullable=True) name = Column(Text, nullable=False) data = Column(JSON, nullable=False) # {prompt, model_id, rrule} meta = Column(JSON, nullable=True) @@ -31,7 +32,10 @@ class Automation(Base): created_at = Column(BigInteger, nullable=False) updated_at = Column(BigInteger, nullable=False) - __table_args__ = (Index('ix_automation_next_run', 'next_run_at'),) + __table_args__ = ( + Index('ix_automation_next_run', 'next_run_at'), + Index('ix_automation_user_folder', 'user_id', 'folder_id'), + ) class AutomationRun(Base): @@ -72,6 +76,7 @@ class AutomationModel(BaseModel): id: str user_id: str + folder_id: Optional[str] = None name: str data: dict meta: Optional[dict] = None @@ -96,6 +101,7 @@ class AutomationRunModel(BaseModel): class AutomationForm(BaseModel): name: str + folder_id: Optional[str] = None data: AutomationData meta: Optional[dict] = None is_active: Optional[bool] = True @@ -129,6 +135,7 @@ class AutomationTable: row = Automation( id=str(uuid4()), user_id=user_id, + folder_id=form.folder_id, name=form.name, data=form.data.model_dump(), meta=form.meta, @@ -139,7 +146,6 @@ class AutomationTable: ) db.add(row) await db.commit() - await db.refresh(row) return AutomationModel.model_validate(row) async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int: @@ -165,6 +171,7 @@ class AutomationTable: user_id: str, query: Optional[str] = None, status: Optional[str] = None, + folder_id: Optional[str] = None, skip: int = 0, limit: int = 30, db: Optional[AsyncSession] = None, @@ -172,6 +179,9 @@ class AutomationTable: async with get_async_db_context(db) as db: stmt = select(Automation).filter_by(user_id=user_id) + if folder_id is not None: + stmt = stmt.filter(Automation.folder_id == (folder_id or None)) + if query: search = f'%{query}%' # Search in name and prompt inside JSON data @@ -217,6 +227,7 @@ class AutomationTable: if not row: return None row.name = form.name + row.folder_id = form.folder_id row.data = form.data.model_dump() row.meta = form.meta if form.is_active is not None: @@ -224,9 +235,25 @@ class AutomationTable: row.next_run_at = next_run_at row.updated_at = int(time.time_ns()) await db.commit() - await db.refresh(row) return AutomationModel.model_validate(row) + async def clear_folder_ids( + self, + user_id: str, + folder_ids: list[str], + db: Optional[AsyncSession] = None, + ) -> int: + if not folder_ids: + return 0 + async with get_async_db_context(db) as db: + result = await db.execute( + update(Automation) + .where(Automation.user_id == user_id, Automation.folder_id.in_(folder_ids)) + .values(folder_id=None, updated_at=int(time.time_ns())) + ) + await db.commit() + return result.rowcount or 0 + async def toggle( self, id: str, @@ -241,7 +268,6 @@ class AutomationTable: row.next_run_at = next_run_at if row.is_active else None row.updated_at = int(time.time_ns()) await db.commit() - await db.refresh(row) return AutomationModel.model_validate(row) async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool: @@ -324,7 +350,6 @@ class AutomationRunTable: ) db.add(row) await db.commit() - await db.refresh(row) return AutomationRunModel.model_validate(row) async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]: diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index 2067ccfab5..4faad1a456 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -500,9 +500,12 @@ class CalendarEventTable: # Filter to requested calendars only accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids] - # Also get event IDs where user is an attendee + # Also get event IDs where the user is an attendee, excluding invites they declined attendee_event_ids_result = await db.execute( - select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id) + select(CalendarEventAttendee.event_id).filter( + CalendarEventAttendee.user_id == user_id, + CalendarEventAttendee.status != 'declined', + ) ) attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()] @@ -764,22 +767,32 @@ class CalendarEventAttendeeTable: async def set_attendees( self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None ) -> list[CalendarEventAttendeeModel]: - """Replace all attendees for an event. + """Replace all attendees for an event ({user_id, meta?} per dict). - Each dict in attendees: {user_id: str, status?: str, meta?: dict} + RSVP status is the attendee's alone to set (via update_rsvp): an existing + attendee keeps their status, a newly added one starts 'pending'. A + caller-supplied status is ignored so an organiser cannot set it for others. """ async with get_async_db_context(db) as db: + existing_status = { + row.user_id: row.status + for row in ( + await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) + ).scalars() + } + # Remove existing await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) now = int(time.time_ns()) models = [] for att in attendees: + user_id = att['user_id'] row = CalendarEventAttendee( id=str(uuid4()), event_id=event_id, - user_id=att['user_id'], - status=att.get('status', 'pending'), + user_id=user_id, + status=existing_status.get(user_id, 'pending'), meta=att.get('meta'), created_at=now, updated_at=now, diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 9d5f130355..5028cdf2c8 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -869,7 +869,6 @@ class ChannelTable: result = ChannelFile(**channel_file.model_dump()) db.add(result) await db.commit() - await db.refresh(result) if result: return ChannelFileModel.model_validate(result) else: diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 5579bdbe95..8215cb69db 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -1,7 +1,10 @@ import json import time import uuid +from collections import Counter +from datetime import datetime, timedelta from typing import Any, Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlalchemy import select, delete, func, cast, Integer, distinct from sqlalchemy.ext.asyncio import AsyncSession @@ -47,6 +50,17 @@ def _normalize_timestamp(timestamp: int) -> float: return timestamp +def _timezone(tz: Optional[str]) -> ZoneInfo: + try: + return ZoneInfo(tz or 'UTC') + except ZoneInfoNotFoundError: + return ZoneInfo('UTC') + + +def _date_key(timestamp: int, tz: ZoneInfo) -> str: + return datetime.fromtimestamp(_normalize_timestamp(timestamp), tz=tz).strftime('%Y-%m-%d') + + def get_usage(data: dict) -> Optional[dict]: """Extract and normalize usage from message data.""" usage = data.get('usage') or (data.get('info') or {}).get('usage') @@ -72,6 +86,40 @@ def _token_columns(dialect: str): ) +def _extract_tool_names(value: Any) -> list[str]: + names: list[str] = [] + + def add(name: Any): + if isinstance(name, str): + cleaned = name.strip() + if cleaned and len(cleaned) <= 128: + names.append(cleaned) + + def walk(item: Any): + if isinstance(item, list): + for child in item: + walk(child) + return + + if not isinstance(item, dict): + return + + item_type = str(item.get('type') or '') + looks_like_tool = 'tool' in item_type or item_type in {'function_call', 'function_call_output'} + if looks_like_tool: + add(item.get('name') or item.get('tool_name')) + function = item.get('function') + if isinstance(function, dict): + add(function.get('name')) + + for key in ('tool_calls', 'tools', 'output', 'meta'): + if key in item: + walk(item.get(key)) + + walk(value) + return names + + #################### # ChatMessage DB Schema #################### @@ -100,6 +148,7 @@ class ChatMessage(Base): files = Column(JSON, nullable=True) sources = Column(JSON, nullable=True) embeds = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) # Status done = Column(Boolean, default=True) @@ -142,6 +191,7 @@ class ChatMessageModel(BaseModel): files: Optional[list] = None sources: Optional[list] = None embeds: Optional[list] = None + meta: Optional[dict] = None done: bool = True status_history: Optional[list] = None error: Optional[dict | str] = None @@ -192,6 +242,8 @@ class ChatMessageTable: existing.sources = data.get('sources') if 'embeds' in data: existing.embeds = data.get('embeds') + if 'meta' in data: + existing.meta = data.get('meta') if 'done' in data: existing.done = data.get('done', True) if 'status_history' in data or 'statusHistory' in data: @@ -207,7 +259,6 @@ class ChatMessageTable: existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage) existing.updated_at = now await db.commit() - await db.refresh(existing) return ChatMessageModel.model_validate(existing) else: # Insert new @@ -225,6 +276,7 @@ class ChatMessageTable: files=data.get('files'), sources=data.get('sources'), embeds=data.get('embeds'), + meta=data.get('meta'), done=data.get('done', True), status_history=data.get('status_history') or data.get('statusHistory'), error=data.get('error'), @@ -235,7 +287,6 @@ class ChatMessageTable: ) db.add(message) await db.commit() - await db.refresh(message) return ChatMessageModel.model_validate(message) async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]: @@ -243,6 +294,21 @@ class ChatMessageTable: message = await db.get(ChatMessage, id) return ChatMessageModel.model_validate(message) if message else None + async def has_unfinished_assistant_by_chat_id( + self, + chat_id: str, + db: Optional[AsyncSession] = None, + ) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChatMessage.id) + .where(ChatMessage.chat_id == chat_id) + .where(ChatMessage.role == 'assistant') + .where(ChatMessage.done.is_(False)) + .limit(1) + ) + return result.scalar_one_or_none() is not None + async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]: async with get_async_db_context(db) as db: result = await db.execute( @@ -584,6 +650,233 @@ class ChatMessageTable: for row in result.all() } + async def get_user_usage_summary( + self, + user_id: str, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + include_active_days: bool = True, + timezone: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> dict: + async with get_async_db_context(db) as db: + bind = await db.connection() + dialect = bind.dialect.name + input_tokens, output_tokens = _token_columns(dialect) + + messages_stmt = select(ChatMessage.role, func.count(ChatMessage.id).label('count')).filter( + ChatMessage.user_id == user_id, + ) + token_stmt = select( + func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), + func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), + ).filter( + ChatMessage.user_id == user_id, + ChatMessage.role == 'assistant', + ChatMessage.usage.isnot(None), + ) + models_stmt = select(func.count(distinct(ChatMessage.model_id)).label('models_used')).filter( + ChatMessage.user_id == user_id, + ChatMessage.role == 'assistant', + ChatMessage.model_id.isnot(None), + ) + if start_date: + messages_stmt = messages_stmt.filter(ChatMessage.created_at >= start_date) + token_stmt = token_stmt.filter(ChatMessage.created_at >= start_date) + models_stmt = models_stmt.filter(ChatMessage.created_at >= start_date) + if end_date: + messages_stmt = messages_stmt.filter(ChatMessage.created_at <= end_date) + token_stmt = token_stmt.filter(ChatMessage.created_at <= end_date) + models_stmt = models_stmt.filter(ChatMessage.created_at <= end_date) + + messages_result = await db.execute(messages_stmt.group_by(ChatMessage.role)) + message_counts = {row.role: row.count for row in messages_result.all()} + + token_result = (await db.execute(token_stmt)).one() + models_used = (await db.execute(models_stmt)).scalar() or 0 + + active_days = set() + if include_active_days: + tz = _timezone(timezone) + day_stmt = select(ChatMessage.created_at).filter(ChatMessage.user_id == user_id) + if start_date: + day_stmt = day_stmt.filter(ChatMessage.created_at >= start_date) + if end_date: + day_stmt = day_stmt.filter(ChatMessage.created_at <= end_date) + day_result = await db.execute(day_stmt) + active_days = {_date_key(row.created_at, tz) for row in day_result.all()} + + input_total = int(token_result.input_tokens or 0) + output_total = int(token_result.output_tokens or 0) + + return { + 'messages': sum(message_counts.values()), + 'user_messages': message_counts.get('user', 0), + 'assistant_messages': message_counts.get('assistant', 0), + 'input_tokens': input_total, + 'output_tokens': output_total, + 'total_tokens': input_total + output_total, + 'models_used': int(models_used), + 'active_days': len(active_days), + } + + async def get_user_first_message_created_at( + self, + user_id: str, + db: Optional[AsyncSession] = None, + ) -> Optional[int]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(func.min(ChatMessage.created_at)).filter( + ChatMessage.user_id == user_id, + ChatMessage.created_at.isnot(None), + ) + ) + value = result.scalar() + return int(value) if value else None + + async def get_user_daily_usage( + self, + user_id: str, + start_date: int, + end_date: int, + timezone: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> list[dict]: + async with get_async_db_context(db) as db: + tz = _timezone(timezone) + bind = await db.connection() + dialect = bind.dialect.name + input_tokens, output_tokens = _token_columns(dialect) + + stmt = select( + ChatMessage.created_at, + ChatMessage.chat_id, + ChatMessage.role, + ChatMessage.model_id, + ChatMessage.usage, + input_tokens.label('input_tokens'), + output_tokens.label('output_tokens'), + ).filter( + ChatMessage.user_id == user_id, + ChatMessage.created_at >= start_date, + ChatMessage.created_at <= end_date, + ) + + result = await db.execute(stmt) + daily: dict[str, dict] = {} + for row in result.all(): + date = _date_key(row.created_at, tz) + entry = daily.setdefault( + date, + { + 'date': date, + 'messages': 0, + 'chat_ids': set(), + 'tokens': 0, + 'models': Counter(), + }, + ) + entry['messages'] += 1 + entry['chat_ids'].add(row.chat_id) + if row.role == 'assistant' and row.model_id: + entry['models'][row.model_id] += 1 + if row.usage: + entry['tokens'] += int(row.input_tokens or 0) + int(row.output_tokens or 0) + + current = datetime.fromtimestamp(_normalize_timestamp(start_date), tz=tz).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date), tz=tz).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + while current <= end_dt: + date = current.strftime('%Y-%m-%d') + daily.setdefault( + date, + {'date': date, 'messages': 0, 'chat_ids': set(), 'tokens': 0, 'models': Counter()}, + ) + current += timedelta(days=1) + + return [ + { + 'date': item['date'], + 'messages': item['messages'], + 'chats': len(item['chat_ids']), + 'tokens': item['tokens'], + 'models': dict(item['models']), + } + for item in sorted(daily.values(), key=lambda x: x['date']) + ] + + async def get_user_top_models( + self, + user_id: str, + start_date: int, + end_date: int, + limit: int = 5, + db: Optional[AsyncSession] = None, + ) -> list[dict]: + async with get_async_db_context(db) as db: + bind = await db.connection() + dialect = bind.dialect.name + input_tokens, output_tokens = _token_columns(dialect) + + stmt = ( + select( + ChatMessage.model_id, + func.count(ChatMessage.id).label('messages'), + func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), + func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), + ) + .filter( + ChatMessage.user_id == user_id, + ChatMessage.role == 'assistant', + ChatMessage.model_id.isnot(None), + ChatMessage.created_at >= start_date, + ChatMessage.created_at <= end_date, + ) + .group_by(ChatMessage.model_id) + .order_by(func.count(ChatMessage.id).desc()) + .limit(limit) + ) + result = await db.execute(stmt) + return [ + { + 'model_id': row.model_id, + 'messages': row.messages, + 'input_tokens': int(row.input_tokens or 0), + 'output_tokens': int(row.output_tokens or 0), + 'total_tokens': int(row.input_tokens or 0) + int(row.output_tokens or 0), + } + for row in result.all() + ] + + async def get_user_top_tools( + self, + user_id: str, + start_date: int, + end_date: int, + limit: int = 5, + db: Optional[AsyncSession] = None, + ) -> list[dict]: + async with get_async_db_context(db) as db: + stmt = select(ChatMessage.output, ChatMessage.meta).filter( + ChatMessage.user_id == user_id, + ChatMessage.created_at >= start_date, + ChatMessage.created_at <= end_date, + ) + result = await db.execute(stmt) + + counts: Counter[str] = Counter() + for output, meta in result.all(): + for name in _extract_tool_names(output): + counts[name] += 1 + for name in _extract_tool_names(meta): + counts[name] += 1 + + return [{'name': name, 'count': count} for name, count in counts.most_common(limit)] + async def get_message_count_by_user( self, start_date: Optional[int] = None, diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 52981d7e3f..aae292e0ad 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -13,8 +13,8 @@ from open_webui.models.automations import AutomationRun from open_webui.models.chat_messages import ChatMessage, ChatMessages from open_webui.models.folders import Folders from open_webui.models.tags import Tag, TagModel, Tags -from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db -from pydantic import BaseModel, ConfigDict +from open_webui.utils.misc import get_output_text, sanitize_data_for_db, sanitize_text_for_db +from pydantic import BaseModel, ConfigDict, field_validator from sqlalchemy import ( JSON, BigInteger, @@ -27,6 +27,7 @@ from sqlalchemy import ( UniqueConstraint, and_, delete, + exists, func, or_, select, @@ -35,10 +36,35 @@ from sqlalchemy import ( ) from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import flag_modified -from sqlalchemy.sql import exists +from sqlalchemy.sql import case, exists from sqlalchemy.sql.expression import bindparam log = logging.getLogger(__name__) +ACTIVE_CHAT_GAP_SECONDS = 30 * 60 + + +def chat_list_order(sort_by: str = 'updated_at', sort_dir: str = 'desc', user_id: str | None = None): + if sort_by != 'unread_updated_at': + sort_column = Chat.title if sort_by == 'title' else Chat.updated_at + order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc() + return order_clause, Chat.id + + unfinished_assistant = ( + select(ChatMessage.id) + .where(ChatMessage.chat_id == Chat.id) + .where(ChatMessage.role == 'assistant') + .where(ChatMessage.done.is_(False)) + .exists() + ) + conditions = [Chat.updated_at > func.coalesce(Chat.last_read_at, 0), ~unfinished_assistant] + if user_id is not None: + conditions.append(Chat.user_id == user_id) + + unread = case( + (and_(*conditions), 1), + else_=0, + ) + return unread.desc(), Chat.updated_at.desc(), Chat.id class Chat(Base): # database table mapping for chat entity @@ -57,10 +83,12 @@ class Chat(Base): # database table mapping for chat entity pinned = Column(Boolean, default=False, nullable=True) meta = Column(JSON, server_default='{}') + variables = Column(JSON, nullable=True) folder_id = Column(Text, nullable=True) tasks = Column(JSON, nullable=True) summary = Column(Text, nullable=True) + current_message_id = Column(Text, nullable=True) last_read_at = Column(BigInteger, nullable=True) @@ -74,6 +102,10 @@ class Chat(Base): # database table mapping for chat entity ) +def is_internal_chat(meta: dict | None) -> bool: + return bool(meta and meta.get('internal') is True) + + class ChatModel(BaseModel): model_config = ConfigDict(from_attributes=True) # allows ORM model binding id: str @@ -89,13 +121,20 @@ class ChatModel(BaseModel): pinned: bool | None = False meta: dict = {} + variables: dict = {} folder_id: str | None = None tasks: list | None = None summary: str | None = None + current_message_id: str | None = None last_read_at: int | None = None + @field_validator('variables', mode='before') + @classmethod + def normalize_variables(cls, value): + return value if isinstance(value, dict) else {} + class ChatFile(Base): __tablename__ = 'chat_file' @@ -134,12 +173,14 @@ class ChatFileModel(BaseModel): class ChatForm(BaseModel): chat: dict + variables: dict | None = None folder_id: str | None = None class ChatImportForm(ChatForm): meta: dict | None = {} pinned: bool | None = False + current_message_id: str | None = None created_at: int | None = None updated_at: int | None = None @@ -168,10 +209,18 @@ class ChatResponse(BaseModel): archived: bool pinned: bool | None = False meta: dict = {} + variables: dict = {} folder_id: str | None = None tasks: list | None = None summary: str | None = None + current_message_id: str | None = None + context_usage: dict | None = None + + @field_validator('variables', mode='before') + @classmethod + def normalize_variables(cls, value): + return value if isinstance(value, dict) else {} class ChatTitleIdResponse(BaseModel): @@ -181,6 +230,7 @@ class ChatTitleIdResponse(BaseModel): created_at: int last_read_at: int | None = None snippet: str | None = None + active: bool = False class SharedChatResponse(BaseModel): @@ -273,6 +323,21 @@ class ChatTable: """Recursively remove null bytes from strings in dict/list structures.""" return sanitize_data_for_db(obj) + def get_current_message_id(self, chat: dict | None) -> str | None: + chat = chat or {} + history = chat.get('history') if isinstance(chat.get('history'), dict) else {} + current_id = history.get('currentId') or chat.get('currentId') or chat.get('branchPointMessageId') + if current_id: + return current_id + + messages = chat.get('messages') + if isinstance(messages, list): + for message in reversed(messages): + if isinstance(message, dict) and message.get('id'): + return message['id'] + + return None + def _sanitize_chat_row(self, chat_item): """ Clean a Chat SQLAlchemy model's title + chat JSON, @@ -349,7 +414,13 @@ class ChatTable: return True async def insert_new_chat( - self, id: str, user_id: str, form_data: ChatForm, db: AsyncSession | None = None + self, + id: str, + user_id: str, + form_data: ChatForm, + db: AsyncSession | None = None, + *, + internal_meta: dict | None = None, ) -> ChatModel | None: async with get_async_db_context(db) as session: chat = ChatModel( @@ -361,6 +432,9 @@ class ChatTable: ), 'chat': self._clean_null_bytes(form_data.chat), 'folder_id': form_data.folder_id, + 'meta': internal_meta or {}, + 'variables': form_data.variables or {}, + 'current_message_id': self.get_current_message_id(form_data.chat), 'created_at': int(time.time()), 'updated_at': int(time.time()), 'last_read_at': int(time.time()), @@ -370,12 +444,17 @@ class ChatTable: chat_item = Chat(**chat.model_dump()) session.add(chat_item) await session.commit() - await session.refresh(chat_item) # Dual-write initial messages to chat_message table try: - history = form_data.chat.get('history', {}) - messages = history.get('messages', {}) + history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {} + messages = history.get('messages') if isinstance(history.get('messages'), dict) else {} + if not messages and isinstance(form_data.chat.get('messages'), list): + messages = { + message.get('id'): message + for message in form_data.chat['messages'] + if isinstance(message, dict) and message.get('id') + } for message_id, message in messages.items(): if isinstance(message, dict) and message.get('role'): await ChatMessages.upsert_message( @@ -389,6 +468,50 @@ class ChatTable: return ChatModel.model_validate(chat_item) if chat_item else None + async def get_internal_chat_ids_by_parent_id(self, parent_chat_id: str, user_id: str) -> list[str]: + async with get_async_db_context() as session: + result = await session.execute( + select(Chat.id).where( + Chat.user_id == user_id, + Chat.meta['internal'].as_boolean().is_(True), + Chat.meta['parent_chat_id'].as_string() == parent_chat_id, + ) + ) + return list(result.scalars().all()) + + async def get_internal_chat_by_note_id( + self, note_id: str, user_id: str, db: AsyncSession | None = None + ) -> ChatModel | None: + async with get_async_db_context(db) as session: + result = await session.execute( + select(Chat) + .where( + Chat.user_id == user_id, + Chat.meta['internal'].as_boolean().is_(True), + Chat.meta['type'].as_string() == 'note', + Chat.meta['note_id'].as_string() == note_id, + ) + .order_by(Chat.updated_at.desc(), Chat.created_at.desc()) + ) + chat = result.scalars().first() + return ChatModel.model_validate(chat) if chat else None + + async def get_internal_chats_by_note_id( + self, note_id: str, user_id: str, db: AsyncSession | None = None + ) -> list[ChatModel]: + async with get_async_db_context(db) as session: + result = await session.execute( + select(Chat) + .where( + Chat.user_id == user_id, + Chat.meta['internal'].as_boolean().is_(True), + Chat.meta['type'].as_string() == 'note', + Chat.meta['note_id'].as_string() == note_id, + ) + .order_by(Chat.updated_at.desc(), Chat.created_at.desc()) + ) + return [ChatModel.model_validate(chat) for chat in result.scalars().all()] + def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel: id = str(uuid.uuid4()) chat = ChatModel( @@ -398,8 +521,10 @@ class ChatTable: 'title': self._clean_null_bytes(form_data.chat['title'] if 'title' in form_data.chat else 'New Chat'), 'chat': self._clean_null_bytes(form_data.chat), 'meta': form_data.meta, + 'variables': form_data.variables or {}, 'pinned': form_data.pinned, 'folder_id': form_data.folder_id, + 'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat), 'created_at': (form_data.created_at if form_data.created_at else int(time.time())), 'updated_at': (form_data.updated_at if form_data.updated_at else int(time.time())), } @@ -439,8 +564,14 @@ class ChatTable: # Dual-write messages to chat_message table for form_data, chat_obj in zip(chat_import_forms, chats): - history = form_data.chat.get('history', {}) - messages = history.get('messages', {}) + history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {} + messages = history.get('messages') if isinstance(history.get('messages'), dict) else {} + if not messages and isinstance(form_data.chat.get('messages'), list): + messages = { + message.get('id'): message + for message in form_data.chat['messages'] + if isinstance(message, dict) and message.get('id') + } for message_id, message in messages.items(): if isinstance(message, dict) and message.get('role'): try: @@ -460,6 +591,8 @@ class ChatTable: id: str, chat: dict, db: AsyncSession | None = None, + *, + touch: bool = True, ) -> ChatModel | None: """Persist updated chat content, sanitizing null bytes.""" try: # load the chat record for in-place mutation @@ -470,8 +603,11 @@ class ChatTable: chat_item.chat = self._clean_null_bytes(chat) chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat' + if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')): + chat_item.current_message_id = self.get_current_message_id(chat) - chat_item.updated_at = int(time.time()) + if touch: + chat_item.updated_at = int(time.time()) await session.commit() @@ -479,17 +615,98 @@ class ChatTable: except Exception: return - async def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool: + async def update_chat_variables_by_id( + self, + id: str, + variables: dict | None, + db: AsyncSession | None = None, + *, + touch: bool = True, + ) -> ChatModel | None: + try: + async with get_async_db_context(db) as session: + chat_item = await session.get(Chat, id) + if chat_item is None: + return None + + chat_item.variables = variables if isinstance(variables, dict) else {} + if touch: + chat_item.updated_at = int(time.time()) + + await session.commit() + return ChatModel.model_validate(chat_item) + except Exception: + return None + + async def update_chat_last_read_at_by_id( + self, id: str, user_id: str, db: AsyncSession | None = None + ) -> tuple[int, bool] | None: try: async with get_async_db_context(db) as session: chat = await session.get(Chat, id) if chat and chat.user_id == user_id: - chat.last_read_at = int(time.time()) + last_read_at = int(time.time()) + was_unread = chat.last_read_at is None or chat.updated_at > chat.last_read_at + chat.last_read_at = last_read_at await session.commit() - return True - return False + return last_read_at, was_unread + return None except Exception: - return False + return None + + async def mark_chat_unread_by_id( + self, id: str, user_id: str, db: AsyncSession | None = None + ) -> ChatTitleIdResponse | None: + try: + async with get_async_db_context(db) as session: + chat = await session.get(Chat, id) + if chat and chat.user_id == user_id: + chat.last_read_at = 0 + await session.commit() + return ChatTitleIdResponse( + id=chat.id, + title=chat.title, + updated_at=chat.updated_at, + created_at=chat.created_at, + last_read_at=chat.last_read_at, + ) + return None + except Exception: + return None + + async def mark_chats_read_by_folder_ids( + self, user_id: str, folder_ids: list[str], db: AsyncSession | None = None + ) -> int: + if not folder_ids: + return 0 + + async with get_async_db_context(db) as session: + result = await session.execute( + update(Chat) + .where( + Chat.user_id == user_id, + Chat.folder_id.in_(folder_ids), + Chat.archived == False, + Chat.meta['internal'].as_boolean().is_not(True), + ) + .values(last_read_at=Chat.updated_at) + ) + await session.commit() + return result.rowcount or 0 + + async def mark_chats_read_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> int: + async with get_async_db_context(db) as session: + result = await session.execute( + update(Chat) + .where( + Chat.user_id == user_id, + Chat.archived == False, + Chat.meta['internal'].as_boolean().is_not(True), + ) + .values(last_read_at=Chat.updated_at) + ) + await session.commit() + return result.rowcount or 0 async def update_chat_title_by_id(self, id: str, title: str) -> ChatModel | None: try: @@ -501,7 +718,6 @@ class ChatTable: chat_item.title = clean_title chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title} await session.commit() - await session.refresh(chat_item) return ChatModel.model_validate(chat_item) except Exception: return None @@ -519,7 +735,6 @@ class ChatTable: # Single meta update chat.meta = {**chat.meta, 'tags': new_tag_ids} await session.commit() - await session.refresh(chat) # Batch-create any missing tag rows await Tags.ensure_tags_exist(new_tags, user.id, db=session) @@ -702,12 +917,17 @@ class ChatTable: return chat.chat.get('history', {}).get('messages', {}).get(message_id, {}) async def upsert_message_to_chat_by_id_and_message_id( - self, id: str, message_id: str, message: dict + self, id: str, message_id: str, message: dict, *, touch: bool = True ) -> ChatModel | None: chat = await self.get_chat_by_id(id) if chat is None: return None + if not message.get('content'): + output_text = get_output_text(message.get('output')) + if output_text: + message['content'] = output_text + # Sanitize message content for null characters before upserting if isinstance(message.get('content'), str): message['content'] = sanitize_text_for_db(message['content']) @@ -771,7 +991,7 @@ class ChatTable: except Exception as e: log.warning(f'Failed to write to chat_message table: {e}') - return await self.update_chat_by_id(id, chat) + return await self.update_chat_by_id(id, chat, touch=touch) async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None: chat_model = await self.get_chat_by_id(id) @@ -809,7 +1029,7 @@ class ChatTable: history['messages'][message_id]['statusHistory'] = status_history chat['history'] = history - return await self.update_chat_by_id(id, chat) + return await self.update_chat_by_id(id, chat, touch=False) async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]: async with get_async_db_context() as session: @@ -851,7 +1071,6 @@ class ChatTable: # Set share_id on the original chat chat.share_id = shared.id await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) # return the updated original # refresh helper @@ -898,7 +1117,6 @@ class ChatTable: chat = await session.get(Chat, id) chat.share_id = share_id await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -911,7 +1129,6 @@ class ChatTable: chat.updated_at = int(time.time()) chat.last_read_at = int(time.time()) await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -925,7 +1142,6 @@ class ChatTable: chat.updated_at = int(time.time()) chat.last_read_at = int(time.time()) await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -951,6 +1167,7 @@ class ChatTable: stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at).filter_by( user_id=user_id, archived=True ) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) if filter: query_key = filter.get('query') @@ -998,7 +1215,8 @@ class ChatTable: db: AsyncSession | None = None, ) -> int: async with get_async_db_context(db) as session: - result = await session.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True)) + stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True) + result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True))) return result.scalar() or 0 async def get_shared_chat_list_by_user_id( @@ -1027,6 +1245,7 @@ class ChatTable: stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by( user_id=user_id ) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) if not include_archived: stmt = stmt.filter_by(archived=False) @@ -1074,6 +1293,8 @@ class ChatTable: include_archived: bool = False, include_folders: bool = False, include_pinned: bool = False, + sort_by: str = 'updated_at', + sort_dir: str = 'desc', skip: int | None = None, limit: int | None = None, db: AsyncSession | None = None, @@ -1082,6 +1303,7 @@ class ChatTable: stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by( user_id=user_id ) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) if not include_folders: stmt = stmt.filter_by(folder_id=None) @@ -1092,7 +1314,7 @@ class ChatTable: if not include_archived: stmt = stmt.filter_by(archived=False) - stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) + stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir)) if skip: stmt = stmt.offset(skip) @@ -1123,9 +1345,9 @@ class ChatTable: db: AsyncSession | None = None, ) -> list[ChatModel]: async with get_async_db_context(db) as session: - result = await session.execute( - select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False).order_by(Chat.updated_at.desc()) - ) + stmt = select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) + result = await session.execute(stmt.order_by(Chat.updated_at.desc())) all_chats = result.scalars().all() return [ChatModel.model_validate(chat) for chat in all_chats] @@ -1170,6 +1392,7 @@ class ChatTable: select(Chat.id, Chat.user_id, Chat.title, Chat.updated_at, User.name.label('user_name')) .join(chat_ids, chat_ids.c.chat_id == Chat.id) .outerjoin(User, User.id == Chat.user_id) + .where(Chat.meta['internal'].as_boolean().is_not(True)) ) order_by = filter.get('order_by') if filter else None @@ -1226,7 +1449,6 @@ class ChatTable: flag_modified(chat_item, 'chat') if self._sanitize_chat_row(chat_item) or repaired_history: await session.commit() - await session.refresh(chat_item) return ChatModel.model_validate(chat_item) except Exception: @@ -1268,7 +1490,6 @@ class ChatTable: flag_modified(chat, 'chat') if self._sanitize_chat_row(chat) or repaired_history: await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: @@ -1299,12 +1520,74 @@ class ChatTable: except Exception: return None + async def count_unread_by_folder_ids( + self, + user_id: str, + folder_ids: list[str], + db: AsyncSession | None = None, + ) -> dict[str, int]: + if not folder_ids: + return {} + + unfinished_assistant = ( + select(ChatMessage.id) + .where(ChatMessage.chat_id == Chat.id) + .where(ChatMessage.role == 'assistant') + .where(ChatMessage.done.is_(False)) + .exists() + ) + + async with get_async_db_context(db) as session: + result = await session.execute( + select(Chat.folder_id, func.count(Chat.id)) + .where( + Chat.user_id == user_id, + Chat.folder_id.in_(folder_ids), + Chat.archived == False, + Chat.updated_at > func.coalesce(Chat.last_read_at, 0), + ~unfinished_assistant, + ) + .group_by(Chat.folder_id) + ) + return {folder_id: count for folder_id, count in result.all() if folder_id} + async def get_chats(self, skip: int = 0, limit: int = 50, db: AsyncSession | None = None) -> list[ChatModel]: async with get_async_db_context(db) as session: - result = await session.execute(select(Chat).order_by(Chat.updated_at.desc())) + stmt = select(Chat).where(Chat.meta['internal'].as_boolean().is_not(True)) + result = await session.execute(stmt.order_by(Chat.updated_at.desc())) all_chats = result.scalars().all() return [ChatModel.model_validate(chat) for chat in all_chats] + async def get_user_usage_chat_stats(self, user_id: str, db: AsyncSession | None = None) -> dict: + async with get_async_db_context(db) as session: + chat_filter = (Chat.user_id == user_id, Chat.meta['internal'].as_boolean().is_not(True)) + result = await session.execute(select(func.count(Chat.id).label('total_chats')).where(*chat_filter)) + total_chats = int(result.scalar() or 0) + + messages_stmt = ( + select(ChatMessage.chat_id, ChatMessage.created_at) + .join(Chat, Chat.id == ChatMessage.chat_id) + .where(*chat_filter, ChatMessage.created_at.isnot(None)) + .order_by(ChatMessage.chat_id, ChatMessage.created_at.asc()) + ) + messages_result = await session.execute(messages_stmt) + last_message_at_by_chat: dict[str, int] = {} + active_seconds_by_chat: dict[str, int] = {} + + for chat_id, created_at in messages_result.all(): + timestamp = int(created_at / 1000) if created_at > 10_000_000_000 else int(created_at) + last_message_at = last_message_at_by_chat.get(chat_id) + if last_message_at is not None: + delta = timestamp - last_message_at + if 0 < delta <= ACTIVE_CHAT_GAP_SECONDS: + active_seconds_by_chat[chat_id] = active_seconds_by_chat.get(chat_id, 0) + delta + last_message_at_by_chat[chat_id] = timestamp + + return { + 'total_chats': total_chats, + 'longest_chat_seconds': max(active_seconds_by_chat.values(), default=0), + } + # list user conversations async def get_chats_by_user_id( self, @@ -1316,6 +1599,7 @@ class ChatTable: ) -> ChatListResponse: async with get_async_db_context(db) as session: stmt = select(Chat).filter_by(user_id=user_id) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) if filter: if filter.get('updated_at'): @@ -1359,11 +1643,11 @@ class ChatTable: self, user_id: str, db: AsyncSession | None = None ) -> list[ChatTitleIdResponse]: async with get_async_db_context(db) as session: - result = await session.execute( - select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at) - .filter_by(user_id=user_id, pinned=True, archived=False) - .order_by(Chat.updated_at.desc()) + stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by( + user_id=user_id, pinned=True, archived=False ) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) + result = await session.execute(stmt.order_by(Chat.updated_at.desc())) all_chats = result.all() return [ ChatTitleIdResponse.model_validate( @@ -1380,9 +1664,9 @@ class ChatTable: async def get_archived_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[ChatModel]: async with get_async_db_context(db) as session: - result = await session.execute( - select(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc()) - ) + stmt = select(Chat).filter_by(user_id=user_id, archived=True) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) + result = await session.execute(stmt.order_by(Chat.updated_at.desc())) return [ChatModel.model_validate(chat) for chat in result.scalars().all()] # search user conversations @@ -1453,6 +1737,7 @@ class ChatTable: async with get_async_db_context(db) as session: stmt = select(Chat).filter(Chat.user_id == user_id) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) if is_archived is not None: stmt = stmt.filter(Chat.archived == is_archived) @@ -1528,9 +1813,23 @@ class ChatTable: postgres_content_sql = """ EXISTS ( SELECT 1 - FROM json_array_elements(Chat.chat->'messages') AS message - WHERE json_typeof(message->'content') = 'string' - AND LOWER(message->>'content') LIKE '%' || :content_key || '%' + FROM chat_message AS message + WHERE message.chat_id = Chat.id + AND message.user_id = Chat.user_id + AND json_typeof(message.content) = 'string' + AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%' + ) + OR EXISTS ( + SELECT 1 + FROM json_each(Chat.chat#>'{history,messages}') AS history_message + WHERE json_typeof(history_message.value->'content') = 'string' + AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%' + ) + OR EXISTS ( + SELECT 1 + FROM json_array_elements(Chat.chat->'messages') AS legacy_message + WHERE json_typeof(legacy_message->'content') = 'string' + AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%' ) """ @@ -1586,6 +1885,8 @@ class ChatTable: user_id: str, skip: int = 0, limit: int = 60, + sort_by: str = 'updated_at', + sort_dir: str = 'desc', db: AsyncSession | None = None, ) -> list[ChatTitleIdResponse]: async with get_async_db_context(db) as session: @@ -1594,8 +1895,9 @@ class ChatTable: .filter_by(folder_id=folder_id, user_id=user_id) .filter(or_(Chat.pinned == False, Chat.pinned == None)) .filter_by(archived=False) - .order_by(Chat.updated_at.desc(), Chat.id) + .where(Chat.meta['internal'].as_boolean().is_not(True)) ) + stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir)) if skip: stmt = stmt.offset(skip) @@ -1622,6 +1924,9 @@ class ChatTable: folder_id: str, skip: int = 0, limit: int = 60, + sort_by: str = 'updated_at', + sort_dir: str = 'desc', + unread_for_user_id: str | None = None, db: AsyncSession | None = None, ) -> list[dict]: """Get chats in a folder across ALL users. Returns dicts with user_id.""" @@ -1631,8 +1936,9 @@ class ChatTable: .filter_by(folder_id=folder_id) .filter(or_(Chat.pinned == False, Chat.pinned == None)) .filter_by(archived=False) - .order_by(Chat.updated_at.desc(), Chat.id) + .where(Chat.meta['internal'].as_boolean().is_not(True)) ) + stmt = stmt.order_by(*chat_list_order(sort_by, sort_dir, unread_for_user_id)) if skip: stmt = stmt.offset(skip) @@ -1653,6 +1959,22 @@ class ChatTable: for chat in all_chats ] + async def count_all_chats_by_folder_id( + self, + folder_id: str, + db: AsyncSession | None = None, + ) -> int: + async with get_async_db_context(db) as session: + stmt = ( + select(func.count(Chat.id)) + .filter_by(folder_id=folder_id) + .filter(or_(Chat.pinned == False, Chat.pinned == None)) + .filter_by(archived=False) + .where(Chat.meta['internal'].as_boolean().is_not(True)) + ) + result = await session.execute(stmt) + return result.scalar_one() + async def get_chats_by_folder_ids_and_user_id( self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None ) -> list[ChatModel]: @@ -1662,6 +1984,7 @@ class ChatTable: .filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id) .filter(or_(Chat.pinned == False, Chat.pinned == None)) .filter_by(archived=False) + .where(Chat.meta['internal'].as_boolean().is_not(True)) .order_by(Chat.updated_at.desc()) ) @@ -1679,8 +2002,12 @@ class ChatTable: chat.updated_at = int(time.time()) 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 + # chat into a folder would otherwise have no visible effect: the chat + # stays in the archived list and never appears in the folder. + chat.archived = False await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -1707,6 +2034,7 @@ class ChatTable: stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by( user_id=user_id ) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) tag_id = tag_name.replace(' ', '_').lower() bind = await session.connection() @@ -1759,7 +2087,6 @@ class ChatTable: 'tags': list(set(chat.meta.get('tags', []) + [tag_id])), } await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None @@ -1769,6 +2096,7 @@ class ChatTable: ) -> int: async with get_async_db_context(db) as session: stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False) + stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True)) tag_id = tag_name.replace(' ', '_').lower() bind = await session.connection() @@ -1816,7 +2144,8 @@ class ChatTable: self, folder_id: str, user_id: str, db: AsyncSession | None = None ) -> int: async with get_async_db_context(db) as session: - result = await session.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id)) + stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id) + result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True))) count = result.scalar() log.info(f"Count of chats for folder '{folder_id}': {count}") @@ -1829,9 +2158,8 @@ class ChatTable: return 0 async with get_async_db_context(db) as session: - result = await session.execute( - select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids)) - ) + stmt = select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids)) + result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True))) count = result.scalar() log.info(f"Count of chats for folders '{folder_ids}': {count}") @@ -2058,7 +2386,6 @@ class ChatTable: return None chat.tasks = tasks await session.commit() - await session.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py index 93a55f690d..6081214b4c 100644 --- a/backend/open_webui/models/config.py +++ b/backend/open_webui/models/config.py @@ -46,8 +46,10 @@ API_CONFIG_FIELDS = ( 'auth_type', 'headers', 'azure', + 'api_type', 'api_version', 'extra_params', + 'passthrough_params', ) @@ -247,6 +249,10 @@ class Config(Base): now = int(time.time()) new_count = 0 for key, value in defaults.items(): + # Skip keys the DB is not authoritative for (e.g. oauth.* while + # ENABLE_OAUTH_PERSISTENT_CONFIG is off), matching the read paths. + if not Config.persistent_enabled_for(key): + continue if key not in existing_keys: value = _json_value(value) db.add(Config(key=key, value=value, updated_at=now)) diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 0bf1a6a139..ca1fe39b38 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -165,7 +165,6 @@ class FeedbackTable: result = Feedback(**feedback.model_dump()) db.add(result) await db.commit() - await db.refresh(result) if result: return FeedbackModel.model_validate(result) else: diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 7f29fc5b7d..d1f61c7cc7 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -142,7 +142,6 @@ class FilesTable: result = File(**file.model_dump()) db.add(result) await db.commit() - await db.refresh(result) if result: return FileModel.model_validate(result) else: diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index a06a5c51f8..11d5dd5427 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -58,6 +58,7 @@ class FolderNameIdResponse(BaseModel): meta: Optional[FolderMetadataResponse] = None parent_id: Optional[str] = None is_expanded: bool = False + unread_count: int = 0 created_at: int updated_at: int diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 8f0f7e0d1b..573880d521 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -7,7 +7,7 @@ import time # local imports from open_webui.internal.db import Base, JSONField, get_async_db_context -from open_webui.models.users import UserResponse, Users +from open_webui.models.users import User, UserResponse, Users, UserSettings from open_webui.utils.valves import decrypt_valves, encrypt_valves from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update @@ -42,7 +42,7 @@ class FunctionMeta(BaseModel): class FunctionModel(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records name: str type: str content: str @@ -58,7 +58,7 @@ class FunctionModel(BaseModel): # --- form / schema definitions --- class FunctionWithValvesModel(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records name: str type: str content: str @@ -79,7 +79,7 @@ class FunctionWithValvesModel(BaseModel): class FunctionResponse(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records type: str name: str meta: FunctionMeta @@ -129,7 +129,6 @@ class FunctionsTable: result = Function(**function.model_dump()) db.add(result) await db.commit() - await db.refresh(result) if result: return FunctionModel.model_validate(result) else: @@ -275,6 +274,18 @@ class FunctionsTable: result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True)) return [FunctionModel.model_validate(function) for function in result.scalars().all()] + async def get_active_function_ids_by_type( + self, type: str, db: AsyncSession | None = None + ) -> list[tuple[str, bool]]: + """Return (id, is_global) for active functions without fetching plugin source.""" + async with get_async_db_context(db) as db: + result = await db.execute(select(Function.id, Function.is_global).filter_by(type=type, is_active=True)) + return [(id, bool(is_global)) for id, is_global in result.all()] + + async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]: + """Return (id, is_global) for active filters without fetching plugin source.""" + return await self.get_active_function_ids_by_type('filter', db=db) + async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]: async with get_async_db_context(db) as db: result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True)) @@ -283,8 +294,8 @@ class FunctionsTable: async def get_function_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None: async with get_async_db_context(db) as db: try: - function = await db.get(Function, id) - return decrypt_valves(function.valves if function else None) + result = await db.execute(select(Function.valves).filter_by(id=id)) + return decrypt_valves(result.scalar_one_or_none()) except Exception as e: log.exception(f'Error getting function valves by id {id}: {e}') return None @@ -300,8 +311,7 @@ class FunctionsTable: try: async with get_async_db_context(db) as db: result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids))) - functions = result.all() - return {f.id: decrypt_valves(f.valves) for f in functions} + return {id: decrypt_valves(valves) for id, valves in result.all()} except Exception as e: log.exception(f'Error batch-fetching function valves: {e}') return {} @@ -315,7 +325,6 @@ class FunctionsTable: function.valves = encrypt_valves(valves) function.updated_at = int(time.time()) await db.commit() - await db.refresh(function) return FunctionModel.model_validate(function) except Exception: return None @@ -335,7 +344,6 @@ class FunctionsTable: function.updated_at = int(time.time()) await db.commit() - await db.refresh(function) return FunctionModel.model_validate(function) else: return None @@ -347,8 +355,11 @@ class FunctionsTable: self, id: str, user_id: str, db: AsyncSession | None = None ) -> dict | None: try: - user = await Users.get_user_by_id(user_id, db=db) - user_settings = user.settings.model_dump() if user.settings else {} + async with get_async_db_context(db) as db: + result = await db.execute(select(User.settings).filter_by(id=user_id)) + settings = result.scalar_one_or_none() + + user_settings = UserSettings(**settings).model_dump() if settings else {} # Check if user has "functions" and "valves" settings if 'functions' not in user_settings: diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index f6650e2258..dceb3cbec0 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -36,6 +36,9 @@ from sqlalchemy.orm import defer log = logging.getLogger(__name__) +# Columns the knowledge base list may be ordered by; anything else falls back to the default. +KNOWLEDGE_SORTABLE_FIELDS = {'name', 'created_at', 'updated_at'} + #################### # Knowledge DB Schema # Let what was gathered here outlast the one who gathered it, @@ -149,6 +152,7 @@ class KnowledgeDirectoryForm(BaseModel): #################### class KnowledgeUserModel(KnowledgeModel): user: Optional[UserResponse] = None + file_count: int | None = None class KnowledgeResponse(KnowledgeModel): @@ -308,7 +312,17 @@ class KnowledgeTable: permission='read', ) - stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc()) + order_by = (filter or {}).get('order_by') + direction = (filter or {}).get('direction') + + if order_by in KNOWLEDGE_SORTABLE_FIELDS: + column = getattr(Knowledge, order_by) + if (direction or 'desc').lower() == 'asc': + stmt = stmt.order_by(column.asc(), Knowledge.id.asc()) + else: + stmt = stmt.order_by(column.desc(), Knowledge.id.asc()) + else: + stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc()) count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) total = count_result.scalar() @@ -322,6 +336,14 @@ class KnowledgeTable: knowledge_ids = [kb.id for kb, _ in items] grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) + file_counts = {} + if knowledge_ids: + file_count_result = await db.execute( + select(KnowledgeFile.knowledge_id, func.count(KnowledgeFile.id)) + .where(KnowledgeFile.knowledge_id.in_(knowledge_ids)) + .group_by(KnowledgeFile.knowledge_id) + ) + file_counts = dict(file_count_result.all()) knowledge_bases = [] for knowledge_base, user in items: @@ -336,6 +358,7 @@ class KnowledgeTable: ) ).model_dump(), 'user': (UserModel.model_validate(user).model_dump() if user else None), + 'file_count': file_counts.get(knowledge_base.id, 0), } ) ) @@ -469,20 +492,16 @@ class KnowledgeTable: user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} - result = [] - for knowledge_base in knowledge_bases: - if knowledge_base.user_id == user_id: - result.append(knowledge_base) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='knowledge', - resource_id=knowledge_base.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(knowledge_base) - return result + # One grants query for all non-owned knowledge bases instead of one each + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='knowledge', + resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id], + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids] async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]: try: @@ -665,9 +684,25 @@ class KnowledgeTable: async def get_file_metadatas_by_id( self, knowledge_id: str, db: Optional[AsyncSession] = None ) -> list[FileMetadataResponse]: + """Column-only listing: File.data holds each file's full extracted + text, which metadata views must never load.""" try: - files = await self.get_files_by_id(knowledge_id, db=db) - return [FileMetadataResponse(**file.model_dump()) for file in files] + async with get_async_db_context(db) as db: + result = await db.execute( + select(File.id, File.hash, File.meta, File.created_at, File.updated_at) + .join(KnowledgeFile, File.id == KnowledgeFile.file_id) + .filter(KnowledgeFile.knowledge_id == knowledge_id) + ) + return [ + FileMetadataResponse( + id=row.id, + hash=row.hash, + meta=row.meta, + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in result.all() + ] except Exception: return [] diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index ad32330f9f..e04f16b962 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -8,7 +8,7 @@ from typing import Literal from open_webui.internal.db import Base, get_async_db_context from pydantic import BaseModel, ConfigDict -from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, select +from sqlalchemy import JSON, BigInteger, Column, Index, String, Text, delete, select from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +16,7 @@ class Memory(Base): # user memory store """Stores user-created memory entries linked to a vector collection.""" __tablename__ = 'memory' + __table_args__ = (Index('ix_memory_id_user_id', 'id', 'user_id'),) id = Column(String, primary_key=True, unique=True) user_id = Column(String, index=True) @@ -70,7 +71,6 @@ class MemoriesTable: ) db.add(record) await db.commit() - await db.refresh(record) return MemoryModel.model_validate(record) if record else None async def update_memory_by_id_and_user_id( @@ -101,7 +101,6 @@ class MemoriesTable: memory.updated_at = int(time.time()) await db.commit() - await db.refresh(memory) return MemoryModel.model_validate(memory) except Exception: return None diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 9acd0c9b70..5f44d59405 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -3,7 +3,8 @@ from __future__ import annotations import json import logging import time -from typing import Optional +from copy import deepcopy +from typing import Any, Optional from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants @@ -22,6 +23,38 @@ log = logging.getLogger(__name__) _warned_profile_urls: set[str] = set() +def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any: + """Drop duplicated extracted text from ModelMeta.knowledge.""" + if not isinstance(knowledge, list): + return knowledge + + sanitized = [] + + for item in knowledge: + if not isinstance(item, dict): + sanitized.append(item) + continue + + next_item = item + data = item.get('data') + if isinstance(data, dict) and 'content' in data: + next_item = deepcopy(item) + next_item.get('data', {}).pop('content', None) + + file = next_item.get('file') + file_data = file.get('data') if isinstance(file, dict) else None + if isinstance(file_data, dict) and 'content' in file_data: + if next_item is item: + next_item = deepcopy(item) + file = next_item.get('file') + file_data = file.get('data') if isinstance(file, dict) else None + file_data.pop('content', None) + + sanitized.append(next_item) + + return sanitized + + # --- Models DB Schema --- @@ -37,6 +70,7 @@ class ModelMeta(BaseModel): profile_image_url: str | None = None description: str | None = Field(default=None, description='User-facing description of the model.') capabilities: dict | None = None + knowledge: list[Any] | None = None model_config = ConfigDict(extra='allow') @@ -56,6 +90,11 @@ class ModelMeta(BaseModel): ) return None + @field_validator('knowledge', mode='before') + @classmethod + def strip_knowledge_content(cls, v): + return strip_extracted_content_from_model_knowledge(v) + @model_validator(mode='before') @classmethod def normalize_tags(cls, data): @@ -152,6 +191,14 @@ class ModelsTable: access_grants: list[AccessGrantModel | None] = None, db: AsyncSession | None = None, ) -> ModelModel: + if isinstance(model.meta, dict): + knowledge = model.meta.get('knowledge') + stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge) + if stripped_knowledge != knowledge: + model.meta = {**model.meta, 'knowledge': stripped_knowledge} + if db is not None: + await db.commit() + model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'}) model_data['access_grants'] = ( access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db) @@ -173,7 +220,6 @@ class ModelsTable: ) db.add(result) await db.commit() - await db.refresh(result) await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db) if result: @@ -256,26 +302,26 @@ class ModelsTable: ] async def get_models_by_user_id( - self, user_id: str, permission: str = 'write', db: AsyncSession | None = None + self, + user_id: str, + permission: str = 'write', + db: AsyncSession | None = None, + user_group_ids: set[str] | None = None, ) -> list[ModelUserResponse]: models = await self.get_models(db=db) - user_groups = await Groups.get_groups_by_member_id(user_id, db=db) - user_group_ids = {group.id for group in user_groups} + if user_group_ids is None: + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)} - result = [] - for model in models: - if model.user_id == user_id: - result.append(model) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='model', - resource_id=model.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(model) - return result + # One grants query for all non-owned models instead of one per model + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='model', + resource_ids=[model.id for model in models if model.user_id != user_id], + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + return [model for model in models if model.user_id == user_id or model.id in accessible_ids] def _has_permission(self, db, query, filter: dict, permission: str = 'read'): return AccessGrants.has_permission_filter( @@ -483,7 +529,6 @@ class ModelsTable: model.is_active = not model.is_active model.updated_at = int(time.time()) await db.commit() - await db.refresh(model) return await self._to_model_model(model, db=db) except Exception: @@ -515,7 +560,6 @@ class ModelsTable: return None model_obj.updated_at = int(time.time()) await db.commit() - await db.refresh(model_obj) return await self._to_model_model(model_obj, db=db) except Exception as e: log.exception(f'Failed to update the model updated_at by id {id}: {e}') diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 1cddd2f8fa..55e19e7aaf 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -312,6 +312,9 @@ class NoteTable: if 'meta' in form_data: note.meta = {**note.meta, **form_data['meta']} + if not db.is_modified(note) and 'access_grants' not in form_data: + 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) diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index 0619bd574a..325f0f5f51 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -128,7 +128,6 @@ class OAuthSessionTable: db.add(result) await db.commit() - await db.refresh(result) if result: # Make a copy of the model data before closing session diff --git a/backend/open_webui/models/prompt_history.py b/backend/open_webui/models/prompt_history.py index bb27657032..947d33a133 100644 --- a/backend/open_webui/models/prompt_history.py +++ b/backend/open_webui/models/prompt_history.py @@ -70,7 +70,6 @@ class PromptHistoryTable: ) db.add(history) await db.commit() - await db.refresh(history) return PromptHistoryModel.model_validate(history) async def get_history_by_prompt_id( diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 23a5017acf..61ce0fc218 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -132,7 +132,6 @@ class PromptsTable: ) session.add(record) await session.commit() - await session.refresh(record) # populate generated defaults await AccessGrants.set_access_grants( 'prompt', @@ -169,7 +168,6 @@ class PromptsTable: if history_entry: record.version_id = history_entry.id await session.commit() - await session.refresh(record) # re-read version_id return await self._to_prompt_model(record, db=session) except Exception as e: @@ -637,7 +635,6 @@ class PromptsTable: prompt.is_active = not prompt.is_active prompt.updated_at = int(time.time()) await session.commit() - await session.refresh(prompt) return await self._to_prompt_model(prompt, db=session) return None except Exception: diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 5bc8b54efc..c3c007cdd9 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -137,7 +137,6 @@ class SkillsTable: ) db.add(result) await db.commit() - await db.refresh(result) await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db) if result: return await self._to_skill_model(result, db=db) @@ -259,7 +258,26 @@ class SkillsTable: permission='read', ) - stmt = stmt.order_by(Skill.updated_at.desc()) + order_by = filter.get('order_by') + direction = filter.get('direction') + + if order_by == 'name': + if direction == 'asc': + stmt = stmt.order_by(Skill.name.asc()) + else: + stmt = stmt.order_by(Skill.name.desc()) + elif order_by == 'created_at': + if direction == 'asc': + stmt = stmt.order_by(Skill.created_at.asc()) + else: + stmt = stmt.order_by(Skill.created_at.desc()) + elif order_by == 'updated_at': + if direction == 'asc': + stmt = stmt.order_by(Skill.updated_at.asc()) + else: + stmt = stmt.order_by(Skill.updated_at.desc()) + else: + stmt = stmt.order_by(Skill.updated_at.desc()) # Count BEFORE pagination count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) @@ -307,8 +325,8 @@ class SkillsTable: if access_grants is not None: await AccessGrants.set_access_grants('skill', id, access_grants, db=db) - skill = await db.get(Skill, id) - await db.refresh(skill) + # populate_existing: the Core update above bypasses any identity-map copy + skill = await db.get(Skill, id, populate_existing=True) return await self._to_skill_model(skill, db=db) except Exception: return None @@ -324,7 +342,6 @@ class SkillsTable: skill.is_active = not skill.is_active skill.updated_at = int(time.time()) await db.commit() - await db.refresh(skill) return await self._to_skill_model(skill, db=db) except Exception: diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 87f6bac7e3..319f0ec62d 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -63,7 +63,6 @@ class TagTable: record = Tag(id=tag_id, user_id=user_id, name=name) db.add(record) await db.commit() - await db.refresh(record) return TagModel.model_validate(record) if record else None except Exception as e: log.exception('Error inserting tag %r: %s', name, e) diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index a6468f1876..86568288e0 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -41,9 +41,10 @@ class ToolMeta(BaseModel): class ToolModel(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records name: str - content: str + # None when listed with defer_content=True (source skipped for listings) + content: str | None = None specs: list[dict] meta: ToolMeta access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -65,7 +66,7 @@ class ToolUserModel(ToolModel): class ToolResponse(BaseModel): id: str - user_id: str + user_id: str | None = None # may be null for legacy/malformed records name: str meta: ToolMeta access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -131,7 +132,6 @@ class ToolsTable: ) db.add(result) await db.commit() - await db.refresh(result) await AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db) if result: return await self._to_tool_model(result, db=db) @@ -171,11 +171,18 @@ class ToolsTable: async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]: async with get_async_db_context(db) as db: - stmt = select(Tool).order_by(Tool.updated_at.desc()) if defer_content: - stmt = stmt - result = await db.execute(stmt) - all_tools = result.scalars().all() + # Skip Tool.content (plugin source, potentially large) via a + # column select; Row attributes satisfy from_attributes. + result = await db.execute( + select( + Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at + ).order_by(Tool.updated_at.desc()) + ) + all_tools = result.all() + else: + result = await db.execute(select(Tool).order_by(Tool.updated_at.desc())) + all_tools = result.scalars().all() user_ids = list(set(tool.user_id for tool in all_tools)) tool_ids = [tool.id for tool in all_tools] @@ -214,20 +221,16 @@ class ToolsTable: user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} - result = [] - for tool in tools: - if tool.user_id == user_id: - result.append(tool) - elif await AccessGrants.has_access( - user_id=user_id, - resource_type='tool', - resource_id=tool.id, - permission=permission, - user_group_ids=user_group_ids, - db=db, - ): - result.append(tool) - return result + # One grants query for all non-owned tools instead of one per tool + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user_id, + resource_type='tool', + resource_ids=[tool.id for tool in tools if tool.user_id != user_id], + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids] async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None: try: @@ -301,8 +304,8 @@ class ToolsTable: if access_grants is not None: await AccessGrants.set_access_grants('tool', id, access_grants, db=db) - tool = await db.get(Tool, id) - await db.refresh(tool) + # populate_existing: the Core update above bypasses any identity-map copy + tool = await db.get(Tool, id, populate_existing=True) return await self._to_tool_model(tool, db=db) except Exception: return None diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index b0a9627a82..1eff4932d5 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -9,7 +9,7 @@ from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.utils.misc import throttle from open_webui.utils.validate import validate_profile_image_url -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy import ( JSON, BigInteger, @@ -69,6 +69,7 @@ class User(Base): # identity & profile # Metadata info = Column(JSON, nullable=True) + variables = Column(JSON, nullable=True) settings = Column(JSON, nullable=True) oauth = Column(JSON, nullable=True) scim = Column(JSON, nullable=True) @@ -105,6 +106,7 @@ class UserModel(BaseModel): status_expires_at: int | None = None info: dict | None = None + variables: dict = Field(default_factory=dict, exclude=True) settings: UserSettings | None = None oauth: dict | None = None @@ -126,6 +128,11 @@ class UserModel(BaseModel): self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id) return self + @field_validator('variables', mode='before') + @classmethod + def normalize_variables(cls, value): + return value if isinstance(value, dict) else {} + class UserStatusModel(UserModel): is_active: bool = False @@ -302,7 +309,6 @@ class UsersTable: result = User(**user.model_dump()) session.add(result) await session.commit() - await session.refresh(result) return user if result else None # database read methods @@ -566,13 +572,6 @@ class UsersTable: row = (await session.execute(stmt)).scalars().first() return UserModel.model_validate(row) if row else None - async def get_user_webhook_url_by_id(self, id: str, db: AsyncSession | None = None) -> str | None: - async with get_async_db_context(db) as session: - user = await session.get(User, id) - if user and user.settings: - return user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None) - return None - async def get_num_users_active_today(self, db: AsyncSession | None = None) -> int | None: async with get_async_db_context(db) as session: current_timestamp = int(time.time()) @@ -589,7 +588,6 @@ class UsersTable: return None user.role = role await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def update_user_status_by_id( @@ -602,7 +600,6 @@ class UsersTable: for key, value in form_data.model_dump(exclude_none=True).items(): setattr(user, key, value) await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def update_user_profile_image_url_by_id( @@ -622,7 +619,6 @@ class UsersTable: return None user.profile_image_url = profile_image_url await session.commit() - await session.refresh(user) return UserModel.model_validate(user) @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) @@ -643,7 +639,6 @@ class UsersTable: oauth[provider] = {'sub': sub} user.oauth = oauth await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def update_user_scim_by_id( @@ -662,7 +657,6 @@ class UsersTable: scim[provider] = {'external_id': external_id} user.scim = scim await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def update_user_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> UserModel | None: @@ -673,7 +667,6 @@ class UsersTable: for key, value in updated.items(): setattr(user, key, value) await session.commit() - await session.refresh(user) return UserModel.model_validate(user) # settings update helper @@ -688,7 +681,6 @@ class UsersTable: user_settings.update(updated) user.settings = user_settings await session.commit() - await session.refresh(user) return UserModel.model_validate(user) async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool: @@ -735,8 +727,8 @@ class UsersTable: async def get_valid_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[str]: async with get_async_db_context(db) as session: - result = await session.execute(select(User).where(User.id.in_(user_ids))) - return [u.id for u in result.scalars().all()] + result = await session.execute(select(User.id).where(User.id.in_(user_ids))) + return list(result.scalars().all()) async def get_super_admin_user(self, db: AsyncSession | None = None) -> UserModel | None: async with get_async_db_context(db) as session: @@ -762,11 +754,11 @@ class UsersTable: async def is_user_active(self, user_id: str, db: AsyncSession | None = None) -> bool: async with get_async_db_context(db) as session: - user = await session.get(User, user_id) - if user and user.last_active_at: + last_active_at = await session.scalar(select(User.last_active_at).where(User.id == user_id)) + if last_active_at: # Consider user active if last_active_at within the last 3 minutes three_minutes_ago = int(time.time()) - 180 - return user.last_active_at >= three_minutes_ago + return last_active_at >= three_minutes_ago return False diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py index 2dd70dbd4b..c199fabcef 100644 --- a/backend/open_webui/retrieval/loaders/external_document.py +++ b/backend/open_webui/retrieval/loaders/external_document.py @@ -6,7 +6,7 @@ from urllib.parse import quote import requests from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document -from open_webui.utils.headers import get_custom_headers, include_user_info_headers +from open_webui.utils.headers import include_user_info_headers, parse_custom_headers log = logging.getLogger(__name__) @@ -19,6 +19,7 @@ class ExternalDocumentLoader(BaseLoader): api_key: str, mime_type=None, user=None, + user_groups=None, headers=None, metadata=None, **kwargs, @@ -30,6 +31,7 @@ class ExternalDocumentLoader(BaseLoader): self.mime_type = mime_type self.user = user + self.user_groups = user_groups self.headers = headers self.metadata = metadata @@ -49,7 +51,7 @@ class ExternalDocumentLoader(BaseLoader): except Exception: pass - headers.update(get_custom_headers(self.headers, self.user, self.metadata)) + headers.update(parse_custom_headers(self.headers, self.user, self.metadata, user_groups=self.user_groups)) if self.user is not None: headers = include_user_info_headers(headers, self.user) diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index c5eeb32e91..3c8b1f11b2 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -27,7 +27,8 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader from open_webui.retrieval.loaders.external_document import ExternalDocumentLoader from open_webui.retrieval.loaders.mineru import MinerULoader from open_webui.retrieval.loaders.mistral import MistralLoader -from open_webui.retrieval.loaders.paddleocr_vl import PaddleOCRVLLoader +from open_webui.retrieval.loaders.paddleocr_vl import PADDLEOCR_VL_SUPPORTED_EXTENSIONS, PaddleOCRVLLoader +from open_webui.utils.headers import get_user_groups_for_custom_headers logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -246,6 +247,7 @@ class Loader: def __init__(self, engine: str = '', **kwargs): self.engine = engine self.user = kwargs.get('user', None) + self.user_groups = kwargs.get('user_groups', None) self.metadata = kwargs.get('metadata', {}) self.kwargs = kwargs @@ -264,6 +266,13 @@ class Loader: loop for the entire parse — minutes for large PDFs. This offloads the work to a worker thread so the loop stays responsive. """ + # Group lookup is async-only, so it must happen before `load` + # is offloaded to a thread without a running event loop. + if self.engine == 'external' and self.user_groups is None: + self.user_groups = await get_user_groups_for_custom_headers( + self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), self.user + ) + return await asyncio.to_thread(self.load, filename, file_content_type, file_path) def _is_text_file(self, file_ext: str, file_content_type: str) -> bool: @@ -303,13 +312,20 @@ class Loader: try: raw.decode('utf-8') return 'utf-8' - except UnicodeDecodeError: - pass + except UnicodeDecodeError as e: + first_non_utf8 = e.start # Use chardet as a hint, not as ground truth import chardet - detected = chardet.detect(raw) + # chardet is pure Python (~1.3s/MB), so sample around the first bad byte + window = 256 * 1024 + sample_start = max(0, first_non_utf8 - window // 2) + sample = raw[sample_start : sample_start + window] + detected = chardet.detect(sample) + # A stray byte can sit far from the real payload, leaving the sample with nothing to read + if len(sample.translate(None, delete=bytes(range(128)))) < 64 and len(sample) < len(raw): + detected = chardet.detect(raw) detected_enc = (detected.get('encoding') or '').lower().replace('-', '').replace('_', '') # Map chardet's detected encoding to the correct superset codec. @@ -422,6 +438,7 @@ class Loader: api_key=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY'), mime_type=file_content_type, user=self.user, + user_groups=self.user_groups, headers=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_HEADERS'), metadata={ **self.metadata, @@ -554,8 +571,14 @@ class Loader: api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'), file_path=file_path, use_base64=self.kwargs.get('MISTRAL_OCR_USE_BASE64', False), + user=self.user, ) - elif self.engine == 'paddleocr_vl' and self.kwargs.get('PADDLEOCR_VL_TOKEN') != '': + elif ( + self.engine == 'paddleocr_vl' + and self.kwargs.get('PADDLEOCR_VL_BASE_URL') + and self.kwargs.get('PADDLEOCR_VL_TOKEN') + and file_ext in PADDLEOCR_VL_SUPPORTED_EXTENSIONS + ): loader = PaddleOCRVLLoader( api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'), token=self.kwargs.get('PADDLEOCR_VL_TOKEN'), diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py index d9eb740d91..d5886bd981 100644 --- a/backend/open_webui/retrieval/loaders/mistral.py +++ b/backend/open_webui/retrieval/loaders/mistral.py @@ -5,12 +5,13 @@ import os import sys import time from contextlib import asynccontextmanager -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import aiohttp import requests from langchain_core.documents import Document -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, GLOBAL_LOG_LEVEL +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS, GLOBAL_LOG_LEVEL +from open_webui.utils.headers import include_user_info_headers logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -39,6 +40,7 @@ class MistralLoader: max_retries: int = 3, enable_debug_logging: bool = False, use_base64: bool = False, + user: Optional[Any] = None, ): """ Initializes the loader with enhanced features. @@ -50,6 +52,8 @@ class MistralLoader: max_retries: Maximum number of retry attempts. enable_debug_logging: Enable detailed debug logs. use_base64: Send the document as a data URL instead of uploading it first. + user: The requesting user, forwarded to Mistral via user-info headers + when ENABLE_FORWARD_USER_INFO_HEADERS is enabled. """ if not api_key: raise ValueError('API key cannot be empty.') @@ -63,6 +67,7 @@ class MistralLoader: self.max_retries = max_retries self.debug = enable_debug_logging self.use_base64 = use_base64 + self.user = user # PERFORMANCE OPTIMIZATION: Differentiated timeouts for different operations # This prevents long-running OCR operations from affecting quick operations @@ -82,6 +87,8 @@ class MistralLoader: 'Authorization': f'Bearer {self.api_key}', 'User-Agent': 'OpenWebUI-MistralLoader/2.0', # Helps API provider track usage } + if self.user is not None and ENABLE_FORWARD_USER_INFO_HEADERS: + self.headers = include_user_info_headers(self.headers, self.user) def _debug_log(self, message: str, *args) -> None: """ diff --git a/backend/open_webui/retrieval/loaders/paddleocr_vl.py b/backend/open_webui/retrieval/loaders/paddleocr_vl.py index 40c185eab6..50172ddb47 100644 --- a/backend/open_webui/retrieval/loaders/paddleocr_vl.py +++ b/backend/open_webui/retrieval/loaders/paddleocr_vl.py @@ -11,6 +11,9 @@ from open_webui.env import GLOBAL_LOG_LEVEL logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +PADDLEOCR_VL_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp'] +PADDLEOCR_VL_SUPPORTED_EXTENSIONS = ['pdf'] + PADDLEOCR_VL_IMAGE_EXTENSIONS + class PaddleOCRVLLoader: """Loader that uses PaddleOCR-vl API to extract text from PDF/images.""" @@ -46,8 +49,7 @@ class PaddleOCRVLLoader: # Detect fileType based on file extension ext = self.file_path.lower().split('.')[-1] - image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp'] - file_type = 1 if ext in image_extensions else 0 + file_type = 1 if ext in PADDLEOCR_VL_IMAGE_EXTENSIONS else 0 payload = { 'file': file_data, diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 2788d4dd0c..f816efb8fc 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -37,6 +37,7 @@ from open_webui.env import ( from open_webui.models.access_grants import AccessGrants from open_webui.models.chats import Chats from open_webui.models.files import Files +from open_webui.models.folders import Folders from open_webui.models.knowledge import Knowledges from open_webui.models.notes import Notes from open_webui.models.config import Config @@ -48,8 +49,9 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.main import GetResult, SearchResult from open_webui.retrieval.web.utils import get_web_loader from open_webui.utils.access_control.files import has_access_to_file +from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.headers import include_user_info_headers -from open_webui.utils.misc import get_message_list +from open_webui.utils.misc import get_content_from_message, get_message_list log = logging.getLogger(__name__) @@ -71,6 +73,20 @@ LOADER_CONFIG_KEYS = { 'web_loader_ssl_verification': 'web.loader.ssl_verification', 'web_loader_concurrent_requests': 'web.loader.concurrent_requests', 'web_search_trust_env': 'web.search.trust_env', + 'web_loader_engine': 'web.loader.engine', + 'web_loader_timeout': 'web.loader.timeout', + 'playwright_ws_url': 'web.loader.playwright_ws_url', + 'playwright_timeout': 'web.loader.playwright_timeout', + 'firecrawl_api_key': 'web.loader.firecrawl_api_key', + 'firecrawl_api_url': 'web.loader.firecrawl_api_url', + 'firecrawl_timeout': 'web.loader.firecrawl_timeout', + 'tavily_api_key': 'web.search.tavily_api_key', + 'tavily_extract_depth': 'web.search.tavily_extract_depth', + 'microsoft_web_iq_api_base_url': 'web.search.microsoft_web_iq_api_base_url', + 'microsoft_web_iq_api_key': 'web.search.microsoft_web_iq_api_key', + 'microsoft_web_iq_language': 'web.search.microsoft_web_iq_language', + 'external_web_loader_url': 'web.loader.external_web_loader_url', + 'external_web_loader_api_key': 'web.loader.external_web_loader_api_key', 'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine', 'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key', 'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url', @@ -126,6 +142,7 @@ def get_loader(request, url: str, config: dict): verify_ssl=config.get('web_loader_ssl_verification'), requests_per_second=config.get('web_loader_concurrent_requests'), trust_env=config.get('web_search_trust_env'), + loader_config=config, ) @@ -1323,6 +1340,23 @@ async def get_sources_from_items( bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval') extracted_collections = [] query_results = [] + folder_items = set() + expanded_folders = set() + + items = list(items) + for item in items: + if item.get('type') != 'folder' or not user: + continue + folder_id = item.get('id') + if not folder_id or folder_id in expanded_folders: + continue + expanded_folders.add(folder_id) + + folder = await Folders.get_folder_by_id(folder_id) + if folder and (user.role == 'admin' or await has_folder_access(user.id, folder, 'read', db=None)): + files = (folder.data or {}).get('files', []) + folder_items.update((entry.get('type'), entry.get('id')) for entry in files if isinstance(entry, dict)) + items.extend(files) for item in items: query_result = None @@ -1390,7 +1424,10 @@ async def get_sources_from_items( # Reconstruct the message list in order message_list = get_message_list(messages_map, message_id) message_history = '\n'.join( - [f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list] + [ + f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n' + for m in message_list + ] ) # User has access to the chat @@ -1429,6 +1466,7 @@ async def get_sources_from_items( user.role == 'admin' or file_object.user_id == user.id or await has_access_to_file(item.get('id'), 'read', user) + or ('file', item.get('id')) in folder_items ): query_result = { 'documents': [[file_object.data.get('content', '')]], @@ -1459,6 +1497,7 @@ async def get_sources_from_items( user.role == 'admin' or file_object.user_id == user.id or await has_access_to_file(file_id, 'read', user) + or ('file', file_id) in folder_items ): if item.get('legacy'): collection_names.append(f'{file_id}') @@ -1478,6 +1517,7 @@ async def get_sources_from_items( resource_id=knowledge_base.id, permission='read', ) + or ('collection', item.get('id')) in folder_items ): if (knowledge_base.meta or {}).get('source') == 'external': query_result = await retrieve_external_knowledge( @@ -1500,6 +1540,7 @@ async def get_sources_from_items( resource_id=knowledge_base.id, permission='read', ) + or ('collection', item.get('id')) in folder_items ): files = await Knowledges.get_files_by_id(knowledge_base.id) @@ -1570,7 +1611,7 @@ async def get_sources_from_items( continue # Filter out collections the user cannot read - if user: + if user and (item.get('type'), item.get('id')) not in folder_items: collection_names = await filter_accessible_collections(collection_names, user) if not collection_names: log.debug(f'access denied for all collections in item {item}') diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index b0331e3eea..fa2abe85d7 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -25,7 +25,7 @@ from open_webui.retrieval.vector.main import ( VectorItem, ) from open_webui.retrieval.vector.utils import process_metadata -from pymilvus import Collection, DataType, FieldSchema, connections +from pymilvus import DataType from pymilvus import MilvusClient as Client from pymilvus.exceptions import MilvusException @@ -202,8 +202,6 @@ class MilvusClient(VectorDBBase): return self._result_to_search_result(result) def query(self, collection_name: str, filter: dict, limit: int = -1): - connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB) - collection_name = collection_name.replace('-', '_') if not self.has_collection(collection_name): log.warning(f'Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}') @@ -218,16 +216,16 @@ class MilvusClient(VectorDBBase): filter_string = ' && '.join(filter_expressions) - collection = Collection(f'{self.collection_prefix}_{collection_name}') - collection.load() + self.client.load_collection(collection_name=f'{self.collection_prefix}_{collection_name}') try: log.info( f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}" ) - iterator = collection.query_iterator( - expr=filter_string, + iterator = self.client.query_iterator( + collection_name=f'{self.collection_prefix}_{collection_name}', + filter=filter_string, output_fields=[ 'id', 'data', diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index 6549c58c62..599cb8712c 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py @@ -23,14 +23,8 @@ from open_webui.retrieval.vector.main import ( VectorDBBase, VectorItem, ) -from pymilvus import ( - Collection, - CollectionSchema, - DataType, - FieldSchema, - connections, - utility, -) +from pymilvus import DataType +from pymilvus import MilvusClient as Client from pymilvus.exceptions import MilvusException log = logging.getLogger(__name__) @@ -70,12 +64,7 @@ class MilvusClient(VectorDBBase): def __init__(self): # Milvus collection names can only contain numbers, letters, and underscores. self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace('-', '_') - connections.connect( - alias='default', - uri=MILVUS_URI, - token=MILVUS_TOKEN, - db_name=MILVUS_DB, - ) + self.client = Client(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB) # Main collection types for multi-tenancy self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories' @@ -116,53 +105,66 @@ class MilvusClient(VectorDBBase): return self.KNOWLEDGE_COLLECTION, resource_id def _create_shared_collection(self, mt_collection_name: str, dimension: int): - fields = [ - FieldSchema( - name='id', - dtype=DataType.VARCHAR, - is_primary=True, - auto_id=False, - max_length=36, - ), - FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=dimension), - FieldSchema(name='text', dtype=DataType.VARCHAR, max_length=65535), - FieldSchema(name='metadata', dtype=DataType.JSON), - FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255), - ] - schema = CollectionSchema(fields, 'Shared collection for multi-tenancy') - collection = Collection(mt_collection_name, schema) + schema = self.client.create_schema(auto_id=False, description='Shared collection for multi-tenancy') + schema.add_field(field_name='id', datatype=DataType.VARCHAR, is_primary=True, max_length=36) + schema.add_field(field_name='vector', datatype=DataType.FLOAT_VECTOR, dim=dimension) + schema.add_field(field_name='text', datatype=DataType.VARCHAR, max_length=MILVUS_TEXT_MAX_LENGTH) + schema.add_field(field_name='metadata', datatype=DataType.JSON) + schema.add_field(field_name=RESOURCE_ID_FIELD, datatype=DataType.VARCHAR, max_length=255) - index_params = { - 'metric_type': MILVUS_METRIC_TYPE, - 'index_type': MILVUS_INDEX_TYPE, - 'params': {}, - } + index_build_params = {} if MILVUS_INDEX_TYPE == 'HNSW': - index_params['params'] = { + index_build_params = { 'M': MILVUS_HNSW_M, 'efConstruction': MILVUS_HNSW_EFCONSTRUCTION, } elif MILVUS_INDEX_TYPE == 'IVF_FLAT': - index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST} + index_build_params = {'nlist': MILVUS_IVF_FLAT_NLIST} - collection.create_index('vector', index_params) - collection.create_index(RESOURCE_ID_FIELD) + vector_index = self.client.prepare_index_params( + field_name='vector', + index_type=MILVUS_INDEX_TYPE, + metric_type=MILVUS_METRIC_TYPE, + params=index_build_params, + ) + + self.client.create_collection(collection_name=mt_collection_name, schema=schema) + self.client.create_index(collection_name=mt_collection_name, index_params=vector_index) + try: + # A Milvus server auto-selects the scalar index type from a parameterless call. + self.client.create_index( + collection_name=mt_collection_name, + index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD), + ) + except MilvusException: + try: + self.client.create_index( + collection_name=mt_collection_name, + index_params=self.client.prepare_index_params(field_name=RESOURCE_ID_FIELD, index_type='INVERTED'), + ) + except MilvusException as e: + # The index only accelerates resource_id filters; never fail + # collection creation over it. + log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}') log.info(f'Created shared collection: {mt_collection_name}') - return collection def _ensure_collection(self, mt_collection_name: str, dimension: int): - if not utility.has_collection(mt_collection_name): + if not self.client.has_collection(mt_collection_name): self._create_shared_collection(mt_collection_name, dimension) def has_collection(self, collection_name: str) -> bool: mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) _validate_resource_id(resource_id) - if not utility.has_collection(mt_collection): + if not self.client.has_collection(mt_collection): return False - collection = Collection(mt_collection) - collection.load() - res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1) + self.client.load_collection(mt_collection) + res = self.client.query( + collection_name=mt_collection, + filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'", + output_fields=['id'], + limit=1, + ) return len(res) > 0 def upsert(self, collection_name: str, items: List[VectorItem]): @@ -172,7 +174,6 @@ class MilvusClient(VectorDBBase): _validate_resource_id(resource_id) dimension = len(items[0]['vector']) self._ensure_collection(mt_collection, dimension) - collection = Collection(mt_collection) entities = [] for item in items: @@ -195,7 +196,7 @@ class MilvusClient(VectorDBBase): ) try: - collection.insert(entities) + self.client.insert(collection_name=mt_collection, data=entities) except MilvusException as e: log.error( f'Milvus insert failed (collection={mt_collection}, ' @@ -215,19 +216,18 @@ class MilvusClient(VectorDBBase): mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) _validate_resource_id(resource_id) - if not utility.has_collection(mt_collection): + if not self.client.has_collection(mt_collection): return None - collection = Collection(mt_collection) - collection.load() + self.client.load_collection(mt_collection) - search_params = {'metric_type': MILVUS_METRIC_TYPE, 'params': {}} - results = collection.search( + results = self.client.search( + collection_name=mt_collection, data=vectors, anns_field='vector', - param=search_params, + search_params={'metric_type': MILVUS_METRIC_TYPE, 'params': {}}, limit=limit, - expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", + filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'", output_fields=['id', 'text', 'metadata'], ) @@ -235,10 +235,11 @@ class MilvusClient(VectorDBBase): for hits in results: batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], [] for hit in hits: - batch_ids.append(hit.entity.get('id')) - batch_docs.append(hit.entity.get('text')) - batch_metadatas.append(hit.entity.get('metadata')) - batch_dists.append(hit.distance) + entity = hit.get('entity', {}) + batch_ids.append(entity.get('id')) + batch_docs.append(entity.get('text')) + batch_metadatas.append(entity.get('metadata')) + batch_dists.append(hit.get('distance')) ids.append(batch_ids) documents.append(batch_docs) metadatas.append(batch_metadatas) @@ -254,11 +255,9 @@ class MilvusClient(VectorDBBase): ): mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) _validate_resource_id(resource_id) - if not utility.has_collection(mt_collection): + if not self.client.has_collection(mt_collection): return - collection = Collection(mt_collection) - expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"] if ids: # Milvus expects a string list for 'in' operator @@ -270,30 +269,28 @@ class MilvusClient(VectorDBBase): _validate_metadata_key(key) expr.append(f"metadata['{key}'] == '{_escape_milvus_string(str(value))}'") - collection.delete(' and '.join(expr)) + self.client.delete(collection_name=mt_collection, filter=' and '.join(expr)) def reset(self): for collection_name in self.shared_collections: - if utility.has_collection(collection_name): - utility.drop_collection(collection_name) + if self.client.has_collection(collection_name): + self.client.drop_collection(collection_name) def delete_collection(self, collection_name: str): mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) _validate_resource_id(resource_id) - if not utility.has_collection(mt_collection): + if not self.client.has_collection(mt_collection): return - collection = Collection(mt_collection) - collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'") + self.client.delete(collection_name=mt_collection, filter=f"{RESOURCE_ID_FIELD} == '{resource_id}'") def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]: mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) _validate_resource_id(resource_id) - if not utility.has_collection(mt_collection): + if not self.client.has_collection(mt_collection): return None - collection = Collection(mt_collection) - collection.load() + self.client.load_collection(mt_collection) expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"] if filter: @@ -308,8 +305,9 @@ class MilvusClient(VectorDBBase): else: raise TypeError(f'Unsupported Milvus filter value type for key {key!r}: {type(value).__name__}') - iterator = collection.query_iterator( - expr=' and '.join(expr), + iterator = self.client.query_iterator( + collection_name=mt_collection, + filter=' and '.join(expr), output_fields=['id', 'text', 'metadata'], limit=limit if limit else -1, ) diff --git a/backend/open_webui/retrieval/web/main.py b/backend/open_webui/retrieval/web/main.py index 23cbf08aeb..d8127807cf 100644 --- a/backend/open_webui/retrieval/web/main.py +++ b/backend/open_webui/retrieval/web/main.py @@ -1,10 +1,11 @@ from __future__ import annotations +import ipaddress from urllib.parse import urlparse import validators from open_webui.retrieval.web.utils import resolve_hostname -from open_webui.utils.misc import is_host_allowed +from open_webui.utils.misc import get_allow_block_lists, is_host_allowed from pydantic import BaseModel @@ -12,6 +13,16 @@ def get_filtered_results(results, filter_list): if not filter_list: return results + allow_list, block_list = get_allow_block_lists(filter_list) + resolve_ips = False + for entry in allow_list + block_list: + try: + ipaddress.ip_address(entry) + except ValueError: + continue + resolve_ips = True + break + filtered_results = [] for result in results: @@ -25,12 +36,13 @@ def get_filtered_results(results, filter_list): hostnames = [domain] - try: - ipv4_addresses, ipv6_addresses = resolve_hostname(domain) - hostnames.extend(ipv4_addresses) - hostnames.extend(ipv6_addresses) - except Exception: - pass + if resolve_ips: + try: + ipv4_addresses, ipv6_addresses = resolve_hostname(domain) + hostnames.extend(ipv4_addresses) + hostnames.extend(ipv6_addresses) + except Exception: + pass if is_host_allowed(hostnames, filter_list): filtered_results.append(result) diff --git a/backend/open_webui/retrieval/web/openserp.py b/backend/open_webui/retrieval/web/openserp.py new file mode 100644 index 0000000000..a9276cb1a0 --- /dev/null +++ b/backend/open_webui/retrieval/web/openserp.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import logging + +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.utils.session_pool import get_session + +log = logging.getLogger(__name__) + + +async def search_openserp( + base_url: str, + query: str, + count: int, + filter_list: list[str | None] | None = None, +) -> list[SearchResult]: + """Query an OpenSERP instance and return normalised results. + + OpenSERP aggregates results from 6 engines (google, bing, yandex, + baidu, duckduckgo, ecosia) at once via ``/mega/search``. + + No API key is required -- only a reachable OpenSERP base URL. + """ + url = f'{base_url.rstrip("/")}/mega/search' + params = {'text': query, 'limit': count} + + log.debug('searching OpenSERP at %s', url) + + session = await get_session() + async with session.get(url, params=params) as response: + response.raise_for_status() + payload = await response.json() + + results = payload.get('results', []) + if filter_list: + results = get_filtered_results(results, filter_list) + + return [ + SearchResult( + link=item.get('url', ''), + title=item.get('title'), + snippet=item.get('snippet'), + ) + for item in results[:count] + ] diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 0cb10eb9a6..5d143e9d5c 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -3,9 +3,10 @@ import ipaddress import logging import socket import ssl +import time import urllib.parse import urllib.request -from datetime import datetime, time, timedelta +from datetime import datetime, timedelta from typing import ( Any, AsyncIterator, @@ -74,6 +75,34 @@ def resolve_hostname(hostname): return ipv4_addresses, ipv6_addresses +def _is_global_addr(ip: str) -> bool: + addr = ipaddress.ip_address(ip) + if not addr.is_global: + return False + if not isinstance(addr, ipaddress.IPv6Address): + return True + + embedded = [] + if addr.ipv4_mapped: + embedded.append(addr.ipv4_mapped) + if addr.sixtofour: + embedded.append(addr.sixtofour) + if addr.teredo: + embedded.extend(addr.teredo) + + b = addr.packed + if b[:12] == b"\x00" * 12: + embedded.append(ipaddress.IPv4Address(b[12:])) + elif b[:12] == b"\x00\x64\xff\x9b" + b"\x00" * 8: + embedded.append(ipaddress.IPv4Address(b[12:])) + elif b[:6] == b"\x00\x64\xff\x9b\x00\x01": + if b[8] != 0: + return False + embedded.append(ipaddress.IPv4Address(bytes((b[6], b[7], b[9], b[10])))) + + return all(ip.is_global for ip in embedded) + + def validate_url(url: Union[str, Sequence[str]]): if isinstance(url, str): if isinstance(validators.url(url), validators.ValidationError): @@ -110,8 +139,7 @@ def validate_url(url: Union[str, Sequence[str]]): # Check if any of the resolved addresses are private # DNS rebinding is mitigated at the connection layer; see _SSRFSafeResolver / _SSRFSafeAdapter for ip in ipv4_addresses + ipv6_addresses: - addr = ipaddress.ip_address(ip) - if not addr.is_global: + if not _is_global_addr(ip): raise ValueError(ERROR_MESSAGES.INVALID_URL) return True elif isinstance(url, Sequence): @@ -146,7 +174,7 @@ def _ssrf_safe_new_conn(self): raise OSError(f'getaddrinfo for {host!r} returned empty list') if not ENABLE_LOCAL_WEB_FETCH: for _, _, _, _, sa in infos: - if not ipaddress.ip_address(sa[0]).is_global: + if not _is_global_addr(sa[0]): raise ValueError(ERROR_MESSAGES.INVALID_URL) err = None for fam, typ, proto, _, sa in infos: @@ -158,6 +186,11 @@ def _ssrf_safe_new_conn(self): if getattr(self, 'source_address', None): sock.bind(self.source_address) for opt in getattr(self, 'socket_options', None) or (): + if len(opt) == 4 and isinstance(opt[3], str): + # urllib3-future per-protocol form: (level, optname, value, "tcp"/"udp") + if opt[3].lower() == 'tcp': + sock.setsockopt(*opt[:3]) + continue sock.setsockopt(*opt) sock.connect(sa) return sock @@ -202,7 +235,7 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver): results = await super().resolve(host, port, family) if not ENABLE_LOCAL_WEB_FETCH: for entry in results: - if not ipaddress.ip_address(entry['host']).is_global: + if not _is_global_addr(entry['host']): raise ValueError(ERROR_MESSAGES.INVALID_URL) return results @@ -600,57 +633,63 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing def _intercept_navigation_sync(self, route, request=None): req = request or route.request - if req.resource_type != 'document': - route.continue_() - return - try: validate_url(req.url) + resp = route.fetch(max_redirects=0) + + if 300 <= resp.status < 400: + for _ in range(20): + if not AIOHTTP_CLIENT_ALLOW_REDIRECTS: + route.abort() + return + + location = resp.headers.get('location') + if not location: + break + + url = urllib.parse.urljoin(resp.url, location) + validate_url(url) + resp = route.fetch(url=url, max_redirects=0) + if not 300 <= resp.status < 400: + break + else: + route.abort() + return except Exception: route.abort() return - if AIOHTTP_CLIENT_ALLOW_REDIRECTS: - resp = route.fetch() - else: - try: - resp = route.fetch(max_redirects=0) - except TypeError: - route.abort() - return - - if 300 <= resp.status < 400: - route.abort() - return - route.fulfill(response=resp) async def _intercept_navigation(self, route, request=None): req = request or route.request - if req.resource_type != 'document': - await route.continue_() - return - try: await run_in_threadpool(validate_url, req.url) + resp = await route.fetch(max_redirects=0) + + if 300 <= resp.status < 400: + for _ in range(20): + if not AIOHTTP_CLIENT_ALLOW_REDIRECTS: + await route.abort() + return + + location = resp.headers.get('location') + if not location: + break + + url = urllib.parse.urljoin(resp.url, location) + await run_in_threadpool(validate_url, url) + resp = await route.fetch(url=url, max_redirects=0) + if not 300 <= resp.status < 400: + break + else: + await route.abort() + return except Exception: await route.abort() return - if AIOHTTP_CLIENT_ALLOW_REDIRECTS: - resp = await route.fetch() - else: - try: - resp = await route.fetch(max_redirects=0) - except TypeError: - await route.abort() - return - - if 300 <= resp.status < 400: - await route.abort() - return - await route.fulfill(response=resp) def lazy_load(self) -> Iterator[Document]: @@ -664,24 +703,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing else: browser = p.chromium.launch(headless=self.headless, proxy=self.proxy) - for url in self.urls: - try: - self._safe_process_url_sync(url) - page = browser.new_page() - page.route('**/*', self._intercept_navigation_sync) - response = page.goto(url, timeout=self.playwright_timeout) - if response is None: - raise ValueError(f'page.goto() returned None for url {url}') + with browser: + for url in self.urls: + try: + self._safe_process_url_sync(url) + with browser.new_page(service_workers='block') as page: + page.route('**/*', self._intercept_navigation_sync) + page.route_web_socket('**/*', lambda ws_route: ws_route.close()) + response = page.goto(url, timeout=self.playwright_timeout) + if response is None: + raise ValueError(f'page.goto() returned None for url {url}') - text = self.evaluator.evaluate(page, browser, response) - metadata = {'source': url} - yield Document(page_content=text, metadata=metadata) - except Exception as e: - if self.continue_on_failure: - log.exception(f'Error loading {url}: {e}') - continue - raise e - browser.close() + text = self.evaluator.evaluate(page, browser, response) + metadata = {'source': url} + yield Document(page_content=text, metadata=metadata) + except Exception as e: + if self.continue_on_failure: + log.exception(f'Error loading {url}: {e}') + continue + raise e async def alazy_load(self) -> AsyncIterator[Document]: """Safely load URLs asynchronously with support for remote browser.""" @@ -694,24 +734,25 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing else: browser = await p.chromium.launch(headless=self.headless, proxy=self.proxy) - for url in self.urls: - try: - await self._safe_process_url(url) - page = await browser.new_page() - await page.route('**/*', self._intercept_navigation) - response = await page.goto(url, timeout=self.playwright_timeout) - if response is None: - raise ValueError(f'page.goto() returned None for url {url}') + async with browser: + for url in self.urls: + try: + await self._safe_process_url(url) + async with await browser.new_page(service_workers='block') as page: + await page.route('**/*', self._intercept_navigation) + await page.route_web_socket('**/*', lambda ws_route: ws_route.close()) + response = await page.goto(url, timeout=self.playwright_timeout) + if response is None: + raise ValueError(f'page.goto() returned None for url {url}') - text = await self.evaluator.evaluate_async(page, browser, response) - metadata = {'source': url} - yield Document(page_content=text, metadata=metadata) - except Exception as e: - if self.continue_on_failure: - log.exception(f'Error loading {url}: {e}') - continue - raise e - await browser.close() + text = await self.evaluator.evaluate_async(page, browser, response) + metadata = {'source': url} + yield Document(page_content=text, metadata=metadata) + except Exception as e: + if self.continue_on_failure: + log.exception(f'Error loading {url}: {e}') + continue + raise e class SafeWebBaseLoader(WebBaseLoader): @@ -723,6 +764,8 @@ class SafeWebBaseLoader(WebBaseLoader): trust_env (bool, optional): set to True if using proxy to make web requests, for example using http(s)_proxy environment variables. Defaults to False. """ + # lxml parses scraped pages far faster than the html.parser default + kwargs.setdefault('default_parser', 'lxml') super().__init__(*args, **kwargs) self.trust_env = trust_env @@ -785,20 +828,13 @@ class SafeWebBaseLoader(WebBaseLoader): final_results = [] for i, result in enumerate(results): url = urls[i] - if parser is None: - if url.endswith('.xml'): - parser = 'xml' - else: - parser = self.default_parser - self._check_parser(parser) - final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs)) + url_parser = parser + if url_parser is None: + url_parser = 'xml' if url.endswith('.xml') else self.default_parser + self._check_parser(url_parser) + final_results.append(BeautifulSoup(result, url_parser, **self.bs_kwargs)) return final_results - async def ascrape_all(self, urls: List[str], parser: Union[str, None] = None) -> List[Any]: - """Async fetch all urls, then return soups for all results.""" - results = await self.fetch_all(urls) - return self._unpack_fetch_results(results, urls, parser=parser) - def lazy_load(self) -> Iterator[Document]: """Lazy load text from the url(s) in web_path with error handling.""" for path in self.web_paths: @@ -814,19 +850,20 @@ class SafeWebBaseLoader(WebBaseLoader): # Log the error and continue with the next URL log.exception(f'Error loading {path}: {e}') + def _document_from_html(self, html: str, url: str) -> Document: + """Build one Document.""" + soup = self._unpack_fetch_results([html], [url])[0] + return Document( + page_content=soup.get_text(**self.bs_get_text_kwargs), + metadata=extract_metadata(soup, url), + ) + async def alazy_load(self) -> AsyncIterator[Document]: """Async lazy load text from the url(s) in web_path.""" - results = await self.ascrape_all(self.web_paths) - for path, soup in zip(self.web_paths, results): - text = soup.get_text(**self.bs_get_text_kwargs) - metadata = {'source': path} - if title := soup.find('title'): - metadata['title'] = title.get_text() - if description := soup.find('meta', attrs={'name': 'description'}): - metadata['description'] = description.get('content', 'No description found.') - if html := soup.find('html'): - metadata['language'] = html.get('lang', 'No language found.') - yield Document(page_content=text, metadata=metadata) + results = await self.fetch_all(self.web_paths) + for path, html in zip(self.web_paths, results): + # parsing a large page costs hundreds of ms, keep it off the event loop + yield await asyncio.to_thread(self._document_from_html, html, path) async def aload(self) -> list[Document]: """Load data into Document objects.""" @@ -838,6 +875,7 @@ def get_web_loader( verify_ssl: bool = True, requests_per_second: int = 2, trust_env: bool = False, + loader_config: Optional[dict] = None, ): # Check if the URLs are valid safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls) @@ -846,6 +884,16 @@ def get_web_loader( log.warning(f'All provided URLs were blocked or invalid: {urls}') raise ValueError(ERROR_MESSAGES.INVALID_URL) + loader_config = loader_config or {} + + def cfg(key, env_value): + # Admin-saved DB value wins; env constant covers keys never saved. + value = loader_config.get(key) + return env_value if value is None else value + + engine = cfg('web_loader_engine', WEB_LOADER_ENGINE) + web_loader_timeout = cfg('web_loader_timeout', WEB_LOADER_TIMEOUT) + web_loader_args = { 'web_paths': safe_urls, 'verify_ssl': verify_ssl, @@ -854,13 +902,15 @@ def get_web_loader( 'trust_env': trust_env, } - if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web': + WebLoaderClass = None + + if engine == '' or engine == 'safe_web': WebLoaderClass = SafeWebBaseLoader request_kwargs = {} - if WEB_LOADER_TIMEOUT: + if web_loader_timeout: try: - timeout_value = float(WEB_LOADER_TIMEOUT) + timeout_value = float(web_loader_timeout) except ValueError: timeout_value = None @@ -870,42 +920,44 @@ def get_web_loader( if request_kwargs: web_loader_args['requests_kwargs'] = request_kwargs - if WEB_LOADER_ENGINE == 'playwright': + if engine == 'playwright': WebLoaderClass = SafePlaywrightURLLoader - web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT - if PLAYWRIGHT_WS_URL: - web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL + web_loader_args['playwright_timeout'] = cfg('playwright_timeout', PLAYWRIGHT_TIMEOUT) + playwright_ws_url = cfg('playwright_ws_url', PLAYWRIGHT_WS_URL) + if playwright_ws_url: + web_loader_args['playwright_ws_url'] = playwright_ws_url - if WEB_LOADER_ENGINE == 'firecrawl': + if engine == 'firecrawl': WebLoaderClass = SafeFireCrawlLoader - web_loader_args['api_key'] = FIRECRAWL_API_KEY - web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL - if FIRECRAWL_TIMEOUT: + web_loader_args['api_key'] = cfg('firecrawl_api_key', FIRECRAWL_API_KEY) + web_loader_args['api_url'] = cfg('firecrawl_api_url', FIRECRAWL_API_BASE_URL) + firecrawl_timeout = cfg('firecrawl_timeout', FIRECRAWL_TIMEOUT) + if firecrawl_timeout: try: - web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT) + web_loader_args['timeout'] = int(firecrawl_timeout) except ValueError: pass - if WEB_LOADER_ENGINE == 'tavily': + if engine == 'tavily': WebLoaderClass = SafeTavilyLoader - web_loader_args['api_key'] = TAVILY_API_KEY - web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH + web_loader_args['api_key'] = cfg('tavily_api_key', TAVILY_API_KEY) + web_loader_args['extract_depth'] = cfg('tavily_extract_depth', TAVILY_EXTRACT_DEPTH) - if WEB_LOADER_ENGINE == 'microsoft_web_iq': + if engine == 'microsoft_web_iq': WebLoaderClass = SafeMicrosoftWebIQLoader - web_loader_args['api_base_url'] = MICROSOFT_WEB_IQ_API_BASE_URL - web_loader_args['api_key'] = MICROSOFT_WEB_IQ_API_KEY - web_loader_args['language'] = MICROSOFT_WEB_IQ_LANGUAGE - if WEB_LOADER_TIMEOUT: + web_loader_args['api_base_url'] = cfg('microsoft_web_iq_api_base_url', MICROSOFT_WEB_IQ_API_BASE_URL) + web_loader_args['api_key'] = cfg('microsoft_web_iq_api_key', MICROSOFT_WEB_IQ_API_KEY) + web_loader_args['language'] = cfg('microsoft_web_iq_language', MICROSOFT_WEB_IQ_LANGUAGE) + if web_loader_timeout: try: - web_loader_args['timeout'] = int(WEB_LOADER_TIMEOUT) + web_loader_args['timeout'] = int(web_loader_timeout) except ValueError: pass - if WEB_LOADER_ENGINE == 'external': + if engine == 'external': WebLoaderClass = ExternalWebLoader - web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL - web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY + web_loader_args['external_url'] = cfg('external_web_loader_url', EXTERNAL_WEB_LOADER_URL) + web_loader_args['external_api_key'] = cfg('external_web_loader_api_key', EXTERNAL_WEB_LOADER_API_KEY) if WebLoaderClass: web_loader = WebLoaderClass(**web_loader_args) @@ -919,6 +971,6 @@ def get_web_loader( return web_loader else: raise ValueError( - f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. ' + f'Invalid WEB_LOADER_ENGINE: {engine}. ' "Please set it to 'safe_web', 'playwright', 'firecrawl', 'tavily', 'external', or 'microsoft_web_iq'." ) diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 47dec5fa4c..17bb32f35c 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -49,6 +49,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, + AIOHTTP_FILE_STREAM_CHUNK_SIZE, BYPASS_PYDUB_PREPROCESSING, DEVICE_TYPE, ENABLE_FORWARD_USER_INFO_HEADERS, @@ -678,15 +679,19 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir, for key, value in payload.items(): form_data.add_field(key, str(value)) - with open(file_path, 'rb') as audio_file: - form_data.add_field('file', audio_file, filename=filename) + async def audio_chunks(): + async with aiofiles.open(file_path, 'rb') as audio_file: + while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + yield chunk - r = await session.post( - url=f'{api_base_url}/audio/transcriptions', - headers=headers, - data=form_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) + form_data.add_field('file', audio_chunks(), filename=filename) + + r = await session.post( + url=f'{api_base_url}/audio/transcriptions', + headers=headers, + data=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) if r.status == 200: break @@ -823,13 +828,18 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id): base_url or f'https://{region}.api.cognitive.microsoft.com' ) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15' - form_data = aiohttp.FormData() - form_data.add_field('definition', definition) - form_data.add_field('audio', open(file_path, 'rb'), filename=filename) - r = None try: session = await get_session() + form_data = aiohttp.FormData() + form_data.add_field('definition', definition) + + async def audio_chunks(): + async with aiofiles.open(file_path, 'rb') as audio_file: + while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + yield chunk + + form_data.add_field('audio', audio_chunks(), filename=filename) r = await session.post( url=endpoint, data=form_data, @@ -1002,7 +1012,12 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, if language: form_data.add_field('language', language) - form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type) + async def audio_chunks(): + async with aiofiles.open(file_path, 'rb') as audio_file: + while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + yield chunk + + form_data.add_field('file', audio_chunks(), filename=filename, content_type=mime_type) r = await session.post( url=f'{api_base_url}/audio/transcriptions', @@ -1076,25 +1091,23 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict] detail=ERROR_MESSAGES.DEFAULT(e, 'Error processing audio file'), ) - results = [] try: tasks = [transcription_handler(request, chunk_path, metadata, user) for chunk_path in chunk_paths] - for coro in asyncio.as_completed(tasks): - try: - results.append(await coro) - except HTTPException: - raise - except Exception as transcribe_exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f'Error transcribing chunk: {transcribe_exc}', - ) + # gather keeps results in chunk order, unlike as_completed + results = await asyncio.gather(*tasks) + except HTTPException: + raise + except Exception as transcribe_exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f'Error transcribing chunk: {transcribe_exc}', + ) finally: # Clean up only the temporary chunks, never the original file for chunk_path in chunk_paths: if chunk_path != file_path and os.path.isfile(chunk_path): try: - os.remove(chunk_path) + await asyncio.to_thread(os.remove, chunk_path) except Exception: pass @@ -1208,12 +1221,8 @@ async def transcription( if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)): raise ValueError('Invalid file path detected') - def _write_upload(): - with open(file_path, 'wb') as f: - f.write(contents) - - # Audio uploads can be large; write to disk off the event loop. - await asyncio.to_thread(_write_upload) + async with aiofiles.open(file_path, 'wb') as f: + await f.write(contents) try: metadata = None diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 660b9e7d96..be936128cb 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import JSONResponse, Response from ldap3 import NONE, Connection, Server, Tls from ldap3.utils.conv import escape_filter_chars +from ldap3.utils.dn import parse_dn from open_webui.config import ( ENABLE_PASSWORD_AUTH, OAUTH_PROVIDERS, @@ -24,6 +25,8 @@ from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, ENABLE_INITIAL_ADMIN_SIGNUP, ENABLE_OAUTH_TOKEN_EXCHANGE, + OAUTH_TOKEN_EXCHANGE_RATE_LIMIT, + OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW, WEBUI_AUTH, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, @@ -83,6 +86,17 @@ log = logging.getLogger(__name__) # Forgive us our failed attempts, as we forgive those # who exceed their allotted rate against this gate. signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3) +# Best-effort throttle only: there is no caller identity before the provider answers, +# and deployments may derive request.client from proxy headers. +token_exchange_rate_limiter = ( + RateLimiter( + redis_client=get_redis_client(), + limit=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT, + window=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW, + ) + if OAUTH_TOKEN_EXCHANGE_RATE_LIMIT is not None + else None +) ADMIN_CONFIG_KEYS = { 'SHOW_ADMIN_DETAILS': 'auth.admin.show', @@ -128,6 +142,9 @@ LDAP_SERVER_CONFIG_KEYS = { 'certificate_path': 'ldap.server.ca_cert_file', 'validate_cert': 'ldap.server.validate_cert', 'ciphers': 'ldap.server.ciphers', + 'enable_group_management': 'ldap.group.enable_management', + 'enable_group_creation': 'ldap.group.enable_creation', + 'attribute_for_groups': 'ldap.server.attribute_for_groups', } @@ -398,6 +415,52 @@ async def update_password( raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) +def _unescape_ldap_dn_value(value: str) -> str: + """Resolve RFC 4514 escapes in a DN value, e.g. ``CN=Sales\\, EMEA`` -> ``Sales, EMEA``. + + Consecutive ``\\XX`` hex escapes encode UTF-8 bytes and are decoded together. + """ + hexdigits = '0123456789abcdefABCDEF' + result = [] + pos = 0 + length = len(value) + while pos < length: + char = value[pos] + if char == '\\' and pos + 1 < length: + if pos + 2 < length and value[pos + 1] in hexdigits and value[pos + 2] in hexdigits: + byte_values = bytearray() + while ( + pos + 2 < length + and value[pos] == '\\' + and value[pos + 1] in hexdigits + and value[pos + 2] in hexdigits + ): + byte_values.append(int(value[pos + 1 : pos + 3], 16)) + pos += 3 + result.append(byte_values.decode('utf-8', errors='replace')) + else: + # Backslash escaping a literal special char, e.g. "\," or "\+". + result.append(value[pos + 1]) + pos += 2 + else: + result.append(char) + pos += 1 + return ''.join(result) + + +def extract_group_cn_from_dn(group_dn: str) -> str | None: + """Return the first CN component of an LDAP group DN, or None. + + Uses ``parse_dn`` so escaped separators inside a value (e.g. a group whose + name contains a comma) are handled correctly instead of naively splitting + on ``,``. + """ + for attr_type, attr_value, _ in parse_dn(group_dn): + if attr_type.upper() == 'CN': + return _unescape_ldap_dn_value(attr_value) + return None + + ############################ # LDAP Authentication ############################ @@ -545,17 +608,10 @@ async def ldap_auth( log.info(f'Processing group DN #{group_idx + 1}: {group_dn}') try: - group_cn = None - - for item in group_dn.split(','): - item = item.strip() - if item.upper().startswith('CN='): - group_cn = item[3:] - break + group_cn = extract_group_cn_from_dn(group_dn) if group_cn: user_groups.append(group_cn) - else: log.warning(f'Could not extract CN from group DN: {group_dn}') except Exception as e: @@ -627,9 +683,9 @@ async def ldap_auth( if user: if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups: - if ENABLE_LDAP_GROUP_CREATION: - await Groups.create_groups_by_group_names(user.id, user_groups, db=db) try: + if ENABLE_LDAP_GROUP_CREATION: + await Groups.create_groups_by_group_names(user.id, user_groups, db=db) await Groups.sync_groups_by_group_names(user.id, user_groups, db=db) log.info(f'Successfully synced groups for user {user.id}: {user_groups}') except Exception as e: @@ -1188,6 +1244,9 @@ class LdapServerConfig(BaseModel): certificate_path: str | None = None validate_cert: bool = True ciphers: str | None = 'ALL' + enable_group_management: bool = False + enable_group_creation: bool = False + attribute_for_groups: str = 'memberOf' @router.get('/admin/config/ldap/server', response_model=LdapServerConfig) @@ -1209,6 +1268,11 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user if not value: raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key)) + # The group attribute is what group management reads from the directory + # entry; an empty value would make group sync silently do nothing. + if form_data.enable_group_management and not (form_data.attribute_for_groups or '').strip(): + raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY('attribute_for_groups')) + updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS) updates['ldap.server.app_dn'] = form_data.app_dn or '' updates['ldap.server.app_password'] = form_data.app_dn_password or '' @@ -1240,6 +1304,7 @@ class OAuthConfigForm(BaseModel): """All OAuth/OIDC settings exposed to the admin panel.""" # General OAuth + ENABLE_OAUTH: bool | None = None ENABLE_OAUTH_SIGNUP: bool | None = None OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None OAUTH_AUTO_REDIRECT: bool | None = None @@ -1295,6 +1360,7 @@ OAUTH_COMMA_LIST_FIELDS = { OAUTH_CONFIG_KEYS = { + 'ENABLE_OAUTH': 'oauth.enable', 'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup', 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email', 'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect', @@ -1467,6 +1533,14 @@ async def token_exchange( detail='Token exchange is disabled', ) + if token_exchange_rate_limiter and token_exchange_rate_limiter.is_limited( + request.client.host if request.client else 'unknown' + ): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED, + ) + provider = provider.lower() # Check if provider is configured diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index f7d027bad9..e01c5c255b 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -16,6 +16,7 @@ from open_webui.models.automations import ( Automations, ) from open_webui.models.config import Config +from open_webui.models.folders import Folders from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.automations import ( @@ -56,16 +57,11 @@ async def check_automations_permission(request, user): def check_automation_access(automation, user): - if not automation: + if not automation or user.id != automation.user_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - if user.role != 'admin' and user.id != automation.user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.UNAUTHORIZED, - ) async def check_automation_limits(request, user, rrule_str: str, db, is_create: bool = False): @@ -97,6 +93,17 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create: ) +async def check_automation_folder_access(folder_id: Optional[str], user, db: AsyncSession): + if folder_id is None: + return + folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id, db=db) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse: """Full enrichment for single-item views (includes next_runs computation).""" last_run = await AutomationRuns.get_latest(automation.id, db=db) @@ -117,6 +124,7 @@ async def get_automation_items( request: Request, query: Optional[str] = None, status: Optional[str] = None, + folder_id: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), @@ -130,6 +138,7 @@ async def get_automation_items( user_id=user.id, query=query, status=status, + folder_id=folder_id, skip=skip, limit=limit, db=db, @@ -164,6 +173,7 @@ async def create_new_automation( db: AsyncSession = Depends(get_async_session), ): await check_automations_permission(request, user) + await check_automation_folder_access(form_data.folder_id, user, db) try: validate_rrule(form_data.data.rrule, tz=user.timezone) except ValueError as e: @@ -182,7 +192,7 @@ async def create_new_automation( EVENTS.AUTOMATION_CREATED, actor=user, subject_id=automation.id, - data={'name': automation.name, 'is_active': automation.is_active}, + data={'name': automation.name, 'is_active': automation.is_active, 'folder_id': automation.folder_id}, ) return response @@ -221,6 +231,7 @@ async def update_automation_by_id( await check_automations_permission(request, user) automation = await Automations.get_by_id(id, db=db) check_automation_access(automation, user) + await check_automation_folder_access(form_data.folder_id, user, db) try: validate_rrule(form_data.data.rrule, tz=user.timezone) @@ -240,7 +251,7 @@ async def update_automation_by_id( EVENTS.AUTOMATION_UPDATED, actor=user, subject_id=updated.id, - data={'name': updated.name, 'is_active': updated.is_active}, + data={'name': updated.name, 'is_active': updated.is_active, 'folder_id': updated.folder_id}, ) return response diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 459a238ba1..64413fa74e 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -51,7 +51,6 @@ from open_webui.utils.models import ( get_all_models, get_filtered_models, ) -from open_webui.utils.webhook import post_webhook from pydantic import BaseModel, field_validator from sqlalchemy.ext.asyncio import AsyncSession @@ -915,7 +914,6 @@ async def get_pinned_channel_messages( async def send_notification(request, channel, message, active_user_ids, db=None): - name = request.app.state.WEBUI_NAME webui_url = await Config.get('webui.url') enable_user_webhooks = await Config.get('ui.enable_user_webhooks') @@ -923,23 +921,29 @@ async def send_notification(request, channel, message, active_user_ids, db=None) # Batch fetch channel members in 1 query (fixes N+1) member_ids = {m.user_id for m in await Channels.get_members_by_channel_id(channel.id, db=db)} + url = f'{webui_url}/channels/{channel.id}' for u in users: if (u.id not in active_user_ids) and u.id in member_ids: if enable_user_webhooks and u.settings: - webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None) - if webhook_url: - await post_webhook( - name, - webhook_url, - f'#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}', - { - 'action': 'channel', - 'message': message.content, - 'title': channel.name, - 'url': f'{webui_url}/channels/{channel.id}', - }, - ) + await publish_event( + request, + EVENTS.CHANNEL_MESSAGE, + subject_id=channel.id, + subject_type='channel', + data={ + 'user_id': u.id, + 'channel_id': channel.id, + 'message_id': message.id, + 'sender_id': message.user_id, + 'content': message.content, + 'message': f'#{channel.name} - {url}\n\n{message.content}', + 'content_preview': message.content[:300], + 'title': channel.name, + 'url': url, + }, + message=channel.name, + ) return True @@ -1496,12 +1500,13 @@ async def update_message_by_id( if user.role != 'admin' and message.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if ( - user.role != 'admin' - and message.user_id != user.id - and not await channel_has_access(user.id, channel, permission='write', strict=False, db=db) + if user.role != 'admin' and not await channel_has_access( + user.id, channel, permission='write', strict=False, db=db ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Write access is not authorship — block cross-member edits. + if user.role != 'admin' and message.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: await Messages.update_message_by_id(message_id, form_data, db=db) @@ -1721,18 +1726,17 @@ async def delete_message_by_id( if user.role != 'admin' and message.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if ( - user.role != 'admin' - and message.user_id != user.id - and not await channel_has_access( - user.id, - channel, - permission='write', - strict=False, - db=db, - ) + if user.role != 'admin' and not await channel_has_access( + user.id, + channel, + permission='write', + strict=False, + db=db, ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Write access is not authorship — block cross-member deletes. + if user.role != 'admin' and message.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: await Messages.delete_message_by_id(message_id, db=db) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 98621ff80e..35dbf70abd 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -6,14 +6,16 @@ import logging from typing import Optional from uuid import uuid4 -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config +from open_webui.models.chat_messages import ChatMessages from open_webui.models.chats import ( AggregateChatStats, ChatBody, @@ -26,6 +28,7 @@ from open_webui.models.chats import ( ChatStatsExport, ChatTitleIdResponse, ChatUsageStatsListResponse, + is_internal_chat, MessageStats, ) from open_webui.models.folders import Folders @@ -35,8 +38,9 @@ from open_webui.socket.main import get_event_emitter from open_webui.tasks import 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_access -from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.context_compaction import compact_chat_branch +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 from open_webui.utils.misc import get_message_list from open_webui.utils.models import get_all_models from pydantic import BaseModel @@ -51,13 +55,89 @@ SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:') CHAT_CONFIG_KEYS = { 'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable', 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold', + 'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap', + 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage', 'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template', } +async def get_optional_verified_user( + request: Request, + response: Response, + background_tasks: BackgroundTasks, + auth_token: HTTPAuthorizationCredentials | None = Depends(bearer_security), +): + try: + user = await get_current_user(request, response, background_tasks, auth_token) + except HTTPException: + return None + + if user.role not in {'user', 'admin'}: + return None + return user + + +async def is_open_shared_chat(shared, db: AsyncSession) -> bool: + return await AccessGrants.has_anyone_access( + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + + +async def can_read_shared_chat(user, shared, db: AsyncSession) -> bool: + if user.role == 'pending': + return False + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + return True + if shared.user_id == user.id: + return True + return await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + + +async def add_active_state_to_chat_list( + request: Request, chat_list: list[ChatTitleIdResponse] +) -> list[ChatTitleIdResponse]: + for chat in chat_list: + chat.active = False + if not await has_active_tasks(request.app.state.redis, chat.id): + continue + + chat.active = await ChatMessages.has_unfinished_assistant_by_chat_id(chat.id) + + return chat_list + + +async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]: + user_folders = await Folders.get_folders_by_user_id(user_id, db=db) + parent_by_id = {folder.id: folder.parent_id for folder in user_folders} + unread_counts = dict.fromkeys(parent_by_id.keys(), 0) + direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db) + + for unread_folder_id, unread_count in direct_unread_counts.items(): + current_id = unread_folder_id + seen = set() + while current_id and current_id not in seen: + seen.add(current_id) + if current_id in unread_counts: + unread_counts[current_id] += unread_count + current_id = parent_by_id.get(current_id) + + return unread_counts + + class ChatConfigForm(BaseModel): ENABLE_CONTEXT_COMPACTION: bool CONTEXT_COMPACTION_TOKEN_THRESHOLD: int + CONTEXT_COMPACTION_TOKEN_CAP: int | None = None + CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40 CONTEXT_COMPACTION_PROMPT_TEMPLATE: str @@ -104,7 +184,12 @@ def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) -> async def get_chat_config_values() -> dict: values = await Config.get_many(*CHAT_CONFIG_KEYS.values()) - return {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values} + config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values} + if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None: + config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000) + if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None: + config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40 + return config def chat_config_updates(data: dict) -> dict: @@ -131,10 +216,13 @@ async def require_chat_import_permission(request: Request, user, db: AsyncSessio @router.get('/', response_model=list[ChatTitleIdResponse]) @router.get('/list', response_model=list[ChatTitleIdResponse]) async def get_session_user_chat_list( + request: Request, user=Depends(get_verified_user), page: int | None = None, include_pinned: bool | None = False, include_folders: bool | None = False, + sort_by: str = 'updated_at', + sort_dir: str = 'desc', db: AsyncSession = Depends(get_async_session), ): try: @@ -142,26 +230,42 @@ async def get_session_user_chat_list( limit = 60 skip = (page - 1) * limit - return await Chats.get_chat_title_id_list_by_user_id( + chats = await Chats.get_chat_title_id_list_by_user_id( user.id, include_folders=include_folders, include_pinned=include_pinned, + sort_by=sort_by, + sort_dir=sort_dir, skip=skip, limit=limit, db=db, ) else: - return await Chats.get_chat_title_id_list_by_user_id( + chats = await Chats.get_chat_title_id_list_by_user_id( user.id, include_folders=include_folders, include_pinned=include_pinned, + sort_by=sort_by, + sort_dir=sort_dir, db=db, ) + return await add_active_state_to_chat_list(request, chats) except Exception as e: log.exception(e) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) +@router.post('/read') +async def mark_chats_read_by_user_id( + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + return { + 'updated_count': await Chats.mark_chats_read_by_user_id(user.id, db=db), + 'folder_unread_counts': await get_folder_unread_counts(user.id, db=db), + } + + ############################ # GetChatUsageStats # EXPERIMENTAL: may be removed in future releases @@ -599,6 +703,7 @@ async def delete_all_user_chats( @router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse]) async def get_user_chat_list_by_user_id( + request: Request, user_id: str, page: int | None = None, query: str | None = None, @@ -623,9 +728,10 @@ async def get_user_chat_list_by_user_id( if direction: filter['direction'] = direction - return await Chats.get_chat_list_by_user_id( + chats = await Chats.get_chat_list_by_user_id( user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db ) + return await add_active_state_to_chat_list(request, chats) ############################ @@ -664,7 +770,7 @@ async def create_new_chat( subject_id=chat.id, data={'title': chat.title, 'folder_id': chat.folder_id}, ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) except Exception as e: log.exception(e) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) @@ -712,11 +818,15 @@ async def get_chat_config(user=Depends(get_admin_user)): @router.post('/config', response_model=ChatConfigForm) async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)): threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD)) + token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold)) + retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE))) await Config.upsert( chat_config_updates( { **form_data.model_dump(), 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold, + 'CONTEXT_COMPACTION_TOKEN_CAP': token_cap, + 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage, } ) ) @@ -730,6 +840,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user @router.get('/search', response_model=list[ChatTitleIdResponse]) async def search_user_chats( + request: Request, text: str, page: int | None = None, user=Depends(get_verified_user), @@ -744,7 +855,17 @@ async def search_user_chats( search_text = chat_search_content_text(text) chat_list = [] for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db): - chat_list.append(ChatTitleIdResponse(**chat.model_dump(), snippet=chat_search_snippet(chat.chat, search_text))) + # Explicit fields: model_dump() would deep-copy the entire chat blob per row + chat_list.append( + ChatTitleIdResponse( + id=chat.id, + title=chat.title, + updated_at=chat.updated_at, + created_at=chat.created_at, + last_read_at=chat.last_read_at, + snippet=chat_search_snippet(chat.chat, search_text), + ) + ) # Delete tag if no chat is found words = text.strip().split(' ') @@ -755,7 +876,7 @@ async def search_user_chats( log.debug(f'deleting tag: {tag_id}') await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db) - return chat_list + return await add_active_state_to_chat_list(request, chat_list) ############################ @@ -773,15 +894,18 @@ async def get_chats_by_folder_id( folder_ids.extend([folder.id for folder in children_folders]) return [ - ChatResponse(**chat.model_dump()) + ChatResponse.model_validate(chat, from_attributes=True) for chat in await Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id, db=db) ] -@router.get('/folder/{folder_id}/list') +@router.get('/folder/{folder_id}/list', response_model=list[ChatTitleIdResponse]) async def get_chat_list_by_folder_id( + request: Request, folder_id: str, page: int | None = 1, + sort_by: str = 'unread_updated_at', + sort_dir: str = 'desc', user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): @@ -789,11 +913,16 @@ async def get_chat_list_by_folder_id( limit = 10 skip = (page - 1) * limit - chats = await Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db) - return [ - {'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at} - for chat in chats - ] + chats = await Chats.get_chats_by_folder_id_and_user_id( + folder_id, + user.id, + skip=skip, + limit=limit, + sort_by=sort_by, + sort_dir=sort_dir, + db=db, + ) + return await add_active_state_to_chat_list(request, chats) except Exception as e: log.exception(e) @@ -806,8 +935,11 @@ async def get_chat_list_by_folder_id( @router.get('/pinned', response_model=list[ChatTitleIdResponse]) -async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): - return await Chats.get_pinned_chats_by_user_id(user.id, db=db) +async def get_user_pinned_chats( + request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + chats = await Chats.get_pinned_chats_by_user_id(user.id, db=db) + return await add_active_state_to_chat_list(request, chats) ############################ @@ -838,7 +970,7 @@ async def generate_chat_export_ndjson(user_id: str): for chat in result.items: try: - yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n' + yield ChatResponse.model_validate(chat, from_attributes=True).model_dump_json() + '\n' except Exception as e: log.exception(f'Error serializing chat {chat.id}: {e}') @@ -863,7 +995,10 @@ async def get_user_chats(user=Depends(get_verified_user)): @router.get('/all/archived', response_model=list[ChatResponse]) async def get_user_archived_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): - return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)] + return [ + ChatResponse.model_validate(chat, from_attributes=True) + for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db) + ] ############################ @@ -890,7 +1025,7 @@ async def get_all_user_tags(user=Depends(get_verified_user), db: AsyncSession = async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): if not ENABLE_ADMIN_EXPORT: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) - return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_chats(db=db)] + return [ChatResponse.model_validate(chat, from_attributes=True) for chat in await Chats.get_chats(db=db)] ############################ @@ -900,6 +1035,7 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSessio @router.get('/archived', response_model=list[ChatTitleIdResponse]) async def get_archived_session_user_chat_list( + request: Request, page: int | None = None, query: str | None = None, order_by: str | None = None, @@ -921,13 +1057,14 @@ async def get_archived_session_user_chat_list( if direction: filter['direction'] = direction - return await Chats.get_archived_chat_list_by_user_id( + chats = await Chats.get_archived_chat_list_by_user_id( user.id, filter=filter, skip=skip, limit=limit, db=db, ) + return await add_active_state_to_chat_list(request, chats) ############################ @@ -1045,38 +1182,30 @@ async def get_shared_session_user_chat_list( @router.get('/share/{share_id}', response_model=ChatResponse | None) async def get_shared_chat_by_id( - share_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) + share_id: str, user=Depends(get_optional_verified_user), db: AsyncSession = Depends(get_async_session) ): - if user.role == 'pending': - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + shared = await SharedChats.get_by_id(share_id, db=db) + if shared: + if await is_open_shared_chat(shared, db=db) or ( + user is not None and await can_read_shared_chat(user, shared, db=db) + ): + chat = await Chats.get_chat_by_share_id(share_id, db=db) + if chat: + return ChatResponse.model_validate(chat, from_attributes=True) - chat = await Chats.get_chat_by_share_id(share_id, db=db) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED if user else ERROR_MESSAGES.INVALID_TOKEN, + ) # Fallback: admins can also access any chat directly by chat ID - if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = None + if user is not None and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(share_id, db=db) + if chat: + return ChatResponse.model_validate(chat, from_attributes=True) - if not chat: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - - # Look up the original chat_id to check access grants (admins bypass) - if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS: - shared = await SharedChats.get_by_id(share_id, db=db) - if shared and shared.user_id != user.id: - has_grant = await AccessGrants.has_access( - user_id=user.id, - resource_type='shared_chat', - resource_id=shared.chat_id, - permission='read', - db=db, - ) - if not has_grant: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - - return ChatResponse(**chat.model_dump()) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -1095,6 +1224,7 @@ class TagFilterForm(TagForm): @router.post('/tags', response_model=list[ChatTitleIdResponse]) async def get_user_chat_list_by_tag_name( + request: Request, form_data: TagFilterForm, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), @@ -1105,7 +1235,7 @@ async def get_user_chat_list_by_tag_name( if len(chats) == 0: await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) - return chats + return await add_active_state_to_chat_list(request, chats) ############################ @@ -1136,7 +1266,8 @@ async def compact_chat_by_id( history = (chat.chat or {}).get('history') or {} messages_map = await Chats.get_messages_map_by_chat_id(id) - message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId')) + current_message_id = chat.current_message_id or history.get('currentId') + message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id) model_id = (form_data.model if form_data else None) or next( (message.get('model') for message in reversed(message_list) if message.get('model')), None, @@ -1149,6 +1280,7 @@ async def compact_chat_by_id( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.') result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS) + result['context_usage'] = await get_chat_context_usage(chat, model_id) if result.get('compacted'): await publish_event( request, @@ -1169,31 +1301,36 @@ async def compact_chat_by_id( async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) - if not chat: - # Check if user has access via access grants (shared_chat grants) - if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: - chat = await Chats.get_chat_by_id(id, db=db) - else: - has_grant = await AccessGrants.has_access( - user_id=user.id, - resource_type='shared_chat', - resource_id=id, - permission='read', - db=db, - ) - if has_grant: - chat = await Chats.get_chat_by_id(id, db=db) + if not chat and user.role == 'admin': + candidate = await Chats.get_chat_by_id(id, db=db) + if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)): + chat = candidate - # Check folder-based access (shared folders) - if not chat: - candidate = await Chats.get_chat_by_id(id, db=db) - if candidate and candidate.folder_id: - folder = await Folders.get_folder_by_id(candidate.folder_id, db=db) - if folder and await has_folder_access(user.id, folder, 'read', db): - chat = candidate + # Access explicitly granted to this user applies to admins too, so an admin + # does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off. + if not chat: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=id, + permission='read', + db=db, + ) + if has_grant: + chat = await Chats.get_chat_by_id(id, db=db) + + # Check folder-based access (shared folders) + if not chat: + candidate = await Chats.get_chat_by_id(id, db=db) + if candidate and candidate.folder_id: + folder = await Folders.get_folder_by_id(candidate.folder_id, db=db) + if folder and await has_folder_access(user.id, folder, 'read', db): + chat = candidate if chat: - return ChatResponse(**chat.model_dump()) + data = ChatResponse.model_validate(chat, from_attributes=True).model_dump() + data['context_usage'] = await get_chat_context_usage(chat) + return data raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1220,7 +1357,18 @@ async def update_chat_by_id( form_data.chat.get('history'), ) - chat = await Chats.update_chat_by_id(id, updated_chat, db=db) + touch = 'history' in form_data.chat or 'messages' in form_data.chat + chat = await Chats.update_chat_by_id(id, updated_chat, db=db, touch=touch) + if form_data.variables is not None: + chat = ( + await Chats.update_chat_variables_by_id( + id, + form_data.variables, + db=db, + touch=False, + ) + or chat + ) # Reconcile chat_message rows without inferring deletes from missing IDs. # Message deletion has its own endpoint below. @@ -1235,7 +1383,7 @@ async def update_chat_by_id( subject_id=id, data={'title': chat.title}, ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1309,7 +1457,7 @@ async def update_chat_message_by_id( subject_id=message_id, data={'chat_id': id, 'content_preview': form_data.content[:300]}, ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) @router.delete('/{id}/messages/{message_id}', response_model=ChatResponse | None) @@ -1348,7 +1496,7 @@ async def delete_chat_message_by_id( subject_id=message_id, data={'chat_id': id}, ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) ############################ @@ -1419,55 +1567,48 @@ async def delete_chat_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - # Cancel any in-flight LLM tasks (streaming, title/tags generation) - # before deleting the chat to prevent orphaned requests. - await stop_item_tasks(request.app.state.redis, id) - + # Authorize before any side effect: cancelling a chat's in-flight tasks must + # not be reachable for a chat the caller may not delete. if user.role == 'admin': chat = await Chats.get_chat_by_id(id, db=db) - if not chat: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) - - result = await Chats.delete_chat_by_id(id, db=db) - - if result: - await publish_event( - request, - EVENTS.CHAT_DELETED, - actor=user, - subject_id=id, - data={'owner_id': chat.user_id}, - ) - return result else: if not await has_permission(user.id, 'chat.delete', await Config.get('user.permissions')): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) - if not chat: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Cancel any in-flight LLM tasks (streaming, title/tags generation) before + # deleting the chat to prevent orphaned requests. + await stop_item_tasks(request.app.state.redis, id) + await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) + + # Cascade to internal child chats spawned from this one. + for child_id in await Chats.get_internal_chat_ids_by_parent_id(id, chat.user_id): + await stop_item_tasks(request.app.state.redis, child_id) + await Chats.delete_chat_by_id_and_user_id(child_id, chat.user_id) + + if user.role == 'admin': + result = await Chats.delete_chat_by_id(id, db=db) + else: result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db) - if result: - await publish_event( - request, - EVENTS.CHAT_DELETED, - actor=user, - subject_id=id, - data={'owner_id': user.id}, - ) - return result + + if result: + await publish_event( + request, + EVENTS.CHAT_DELETED, + actor=user, + subject_id=id, + data={'owner_id': chat.user_id}, + ) + return result ############################ @@ -1519,6 +1660,101 @@ class CloneForm(BaseModel): title: str | None = None +class ForkForm(BaseModel): + message_id: str | None = None + + +@router.post('/{id}/fork', response_model=ChatResponse | None) +async def fork_chat_by_id( + request: Request, + id: str, + form_data: ForkForm | None = None, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await require_chat_import_permission(request, user, db) + + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) + + if await has_active_tasks(request.app.state.redis, id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Wait for the current response to finish before forking.', + ) + + history = (chat.chat or {}).get('history') or {} + messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {} + if any( + message.get('role') == 'assistant' and message.get('done') is False + for message in messages_map.values() + if isinstance(message, dict) + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Wait for the current response to finish before forking.', + ) + + source_message_id = ( + (form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId') + ) + if not source_message_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork') + + try: + fork_history, fork_messages = build_fork_history(messages_map, source_message_id) + except ValueError as exc: + detail = str(exc) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST, + detail=detail, + ) from exc + + updated_chat = {**(chat.chat or {})} + updated_chat.pop('currentId', None) + updated_chat.update( + { + 'originalChatId': chat.id, + 'branchPointMessageId': source_message_id, + 'title': f'{chat.title} (fork)', + 'history': fork_history, + 'messages': fork_messages, + } + ) + meta = { + **(chat.meta or {}), + 'forked_from': chat.id, + 'forked_from_message_id': source_message_id, + } + + fork = await Chats.insert_new_chat( + str(uuid4()), + user.id, + ChatForm(chat=updated_chat, folder_id=chat.folder_id), + db=db, + internal_meta=meta, + ) + + if fork and chat.variables: + fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork + + if not fork: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT()) + + if chat.pinned: + fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork + + await publish_event( + request, + EVENTS.CHAT_CLONED, + actor=user, + subject_id=fork.id, + data={'original_chat_id': id, 'forked_from_message_id': source_message_id}, + ) + return ChatResponse.model_validate(fork, from_attributes=True) + + @router.post('/{id}/clone', response_model=ChatResponse | None) async def clone_chat_by_id( request: Request, @@ -1545,6 +1781,7 @@ async def clone_chat_by_id( **{ 'chat': updated_chat, 'meta': chat.meta, + 'variables': chat.variables or {}, 'pinned': chat.pinned, 'folder_id': chat.folder_id, } @@ -1562,7 +1799,7 @@ async def clone_chat_by_id( subject_id=chat.id, data={'original_chat_id': id}, ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -1601,7 +1838,7 @@ async def clone_shared_chat_by_id( # Enforce access grants (owner and admins bypass) shared = await SharedChats.get_by_id(id, db=db) if shared and user.role != 'admin' and shared.user_id != user.id: - has_grant = await AccessGrants.has_access( + has_grant = await is_open_shared_chat(shared, db=db) or await AccessGrants.has_access( user_id=user.id, resource_type='shared_chat', resource_id=shared.chat_id, @@ -1628,6 +1865,7 @@ async def clone_shared_chat_by_id( **{ 'chat': updated_chat, 'meta': chat.meta, + 'variables': chat.variables or {}, 'pinned': chat.pinned, 'folder_id': chat.folder_id, } @@ -1638,7 +1876,7 @@ async def clone_shared_chat_by_id( if chats: chat = chats[0] - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -1679,7 +1917,7 @@ async def archive_chat_by_id( subject_id=id, subject_type='chat', ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) @@ -1713,7 +1951,7 @@ async def share_chat_by_id( subject_id=id, data={'share_id': chat.share_id, 'updated': True}, ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) # Create a new share shared = await SharedChats.create(id, user.id, db=db) @@ -1731,7 +1969,7 @@ async def share_chat_by_id( subject_id=id, data={'share_id': shared.id}, ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) # --- Delete Shared Chat --- @@ -1795,11 +2033,13 @@ async def update_shared_chat_access_by_id( user.role, form_data.access_grants, 'sharing.public_chats', + 'sharing.open_chats', + db=db, ) await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) ############################ @@ -1844,6 +2084,28 @@ class ChatFolderIdForm(BaseModel): folder_id: str | None = None +@router.post('/{id}/unread') +async def mark_chat_unread_by_id( + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.mark_chat_unread_by_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + folder_id = await Chats.get_chat_folder_id(id, user.id, db=db) + return { + 'chat_id': id, + 'last_read_at': chat.last_read_at, + 'folder_id': folder_id, + 'folder_unread_counts': await get_folder_unread_counts(user.id, db=db), + } + + @router.post('/{id}/folder', response_model=ChatResponse | None) async def update_chat_folder_id_by_id( request: Request, @@ -1874,7 +2136,7 @@ async def update_chat_folder_id_by_id( subject_id=id, data={'folder_id': form_data.folder_id}, ) - return ChatResponse(**chat.model_dump()) + return ChatResponse.model_validate(chat, from_attributes=True) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 533c1bd27f..c9618dfa88 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -67,6 +67,15 @@ MODELS_CONFIG_KEYS = { 'DEFAULT_MODEL_METADATA': 'models.default_metadata', 'DEFAULT_MODEL_PARAMS': 'models.default_params', } +SUBAGENTS_CONFIG_KEYS = { + 'ENABLE_SUBAGENTS': 'subagents.enable', + 'SUBAGENTS_BACKGROUND_ENABLED': 'subagents.background_enabled', + 'SUBAGENTS_MAX_CONCURRENT': 'subagents.max_concurrent', + 'SUBAGENTS_MAX_ASYNC': 'subagents.max_async', + 'SUBAGENTS_MAX_ITERATIONS': 'subagents.max_iterations', + 'SUBAGENTS_MAX_OUTPUT': 'subagents.max_output', + 'SUBAGENTS_SYSTEM_PROMPT': 'subagents.system_prompt', +} async def get_config_values(key_map: dict[str, str]) -> dict: @@ -298,10 +307,8 @@ class TerminalServerConnection(BaseModel): config: dict | None = None - # Orchestrator policy fields - server_type: str | None = None # "orchestrator", "terminal" + server_type: str | None = None policy_id: str | None = None - policy: dict | None = None # cached policy data model_config = ConfigDict(extra='allow') @@ -321,7 +328,9 @@ async def set_terminal_servers_config( form_data: TerminalServersConfigForm, user=Depends(get_admin_user), ): - connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS] + connections = [ + connection.model_dump(exclude={'policy', 'lifecycle'}) for connection in form_data.TERMINAL_SERVER_CONNECTIONS + ] await Config.upsert({'terminal_server.connections': connections}) await set_terminal_servers(request) @@ -391,7 +400,7 @@ class TerminalServerPolicyForm(BaseModel): key: str | None = '' auth_type: str | None = 'bearer' policy_id: str - policy_data: dict + policy_data: dict | None = None class TerminalServerLifecycleForm(BaseModel): @@ -399,7 +408,7 @@ class TerminalServerLifecycleForm(BaseModel): key: str | None = '' auth_type: str | None = 'bearer' policy_id: str - lifecycle_data: dict + lifecycle_data: dict | None = None class TerminalServerRefreshForm(BaseModel): @@ -416,9 +425,7 @@ class TerminalServerRefreshForm(BaseModel): async def put_terminal_server_policy( request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user) ): - """ - Proxy a policy PUT to an orchestrator terminal server. - """ + """Proxy a policy read or update to an orchestrator terminal server.""" base_url = (form_data.url or '').rstrip('/') if not base_url: raise HTTPException(status_code=400, detail='Terminal server URL is required') @@ -433,8 +440,12 @@ async def put_terminal_server_policy( timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) as session: policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}' - async with session.put( - policy_url, headers=headers, json=form_data.policy_data, ssl=AIOHTTP_CLIENT_SESSION_SSL + async with session.request( + 'GET' if form_data.policy_data is None else 'PUT', + policy_url, + headers=headers, + json=form_data.policy_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as resp: if resp.ok: return await resp.json() @@ -443,17 +454,15 @@ async def put_terminal_server_policy( except HTTPException: raise except Exception as e: - log.debug(f'Failed to save policy to terminal server: {e}') - raise HTTPException(status_code=400, detail='Failed to save policy to terminal server') + log.debug(f'Failed to access policy on terminal server: {e}') + raise HTTPException(status_code=400, detail='Failed to access policy on terminal server') @router.post('/terminal_servers/lifecycle') async def put_terminal_server_lifecycle( request: Request, form_data: TerminalServerLifecycleForm, user=Depends(get_admin_user) ): - """ - Proxy a policy lifecycle PUT to an orchestrator terminal server. - """ + """Proxy a lifecycle read or update to an orchestrator terminal server.""" base_url = (form_data.url or '').rstrip('/') if not base_url: raise HTTPException(status_code=400, detail='Terminal server URL is required') @@ -468,7 +477,8 @@ async def put_terminal_server_lifecycle( timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) as session: lifecycle_url = f'{base_url}/api/v1/policies/{form_data.policy_id}/lifecycle' - async with session.put( + async with session.request( + 'GET' if form_data.lifecycle_data is None else 'PUT', lifecycle_url, headers=headers, json=form_data.lifecycle_data, @@ -481,8 +491,8 @@ async def put_terminal_server_lifecycle( except HTTPException: raise except Exception as e: - log.debug(f'Failed to save lifecycle to terminal server: {e}') - raise HTTPException(status_code=400, detail='Failed to save lifecycle to terminal server') + log.debug(f'Failed to access lifecycle on terminal server: {e}') + raise HTTPException(status_code=400, detail='Failed to access lifecycle on terminal server') @router.post('/terminal_servers/refresh') @@ -604,7 +614,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn if form_data.headers and isinstance(form_data.headers, dict): if headers is None: headers = {} - custom_headers = get_custom_headers(form_data.headers, user) + custom_headers = await get_custom_headers(form_data.headers, user) headers.update(custom_headers) await client.connect(form_data.url, headers=headers) @@ -649,7 +659,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn if form_data.headers and isinstance(form_data.headers, dict): if headers is None: headers = {} - custom_headers = get_custom_headers(form_data.headers, user) + custom_headers = await get_custom_headers(form_data.headers, user) headers.update(custom_headers) url = get_tool_server_url(form_data.url, form_data.path) @@ -754,6 +764,40 @@ async def set_models_config(request: Request, form_data: ModelsConfigForm, user= return values +class SubagentsConfigForm(BaseModel): + ENABLE_SUBAGENTS: bool + SUBAGENTS_BACKGROUND_ENABLED: bool + SUBAGENTS_MAX_CONCURRENT: int + SUBAGENTS_MAX_ASYNC: int + SUBAGENTS_MAX_ITERATIONS: int + SUBAGENTS_MAX_OUTPUT: int + SUBAGENTS_SYSTEM_PROMPT: str + + +@router.get('/subagents', response_model=SubagentsConfigForm) +async def get_subagents_config(user=Depends(get_admin_user)): + return await get_config_values(SUBAGENTS_CONFIG_KEYS) + + +@router.post('/subagents', response_model=SubagentsConfigForm) +async def set_subagents_config( + request: Request, + form_data: SubagentsConfigForm, + user=Depends(get_admin_user), +): + await Config.upsert(config_updates(form_data.model_dump(), SUBAGENTS_CONFIG_KEYS)) + values = await get_config_values(SUBAGENTS_CONFIG_KEYS) + await publish_event( + request, + EVENTS.CONFIG_UPDATED, + actor=user, + subject_id='subagents', + subject_type='config', + data={'enabled': values.get('ENABLE_SUBAGENTS')}, + ) + return values + + class PromptSuggestion(BaseModel): title: list[str] content: str diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index fd18843435..8a6bf71f4a 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -424,7 +424,7 @@ async def create_feedback( EVENTS.FEEDBACK_CREATED, actor=user, subject_id=feedback.id, - data={'rating': getattr(feedback, 'rating', None)}, + data={'rating': (feedback.data or {}).get('rating')}, ) return feedback @@ -463,7 +463,7 @@ async def update_feedback_by_id( EVENTS.FEEDBACK_UPDATED, actor=user, subject_id=feedback.id, - data={'rating': getattr(feedback, 'rating', None)}, + data={'rating': (feedback.data or {}).get('rating')}, ) return feedback diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 33e7dc1220..aaebedc26c 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -201,21 +201,28 @@ async def process_uploaded_file( f'{knowledge_id}: user {user.id} lacks write access' ) else: - await Knowledges.add_file_to_knowledge_by_id( - knowledge_id=knowledge_id, - file_id=file_item.id, - user_id=user.id, - directory_id=file_metadata.get('directory_id'), - ) + # 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( request, ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id), user=user, db=db_session, ) + knowledge_file = await Knowledges.add_file_to_knowledge_by_id( + knowledge_id=knowledge_id, + file_id=file_item.id, + user_id=user.id, + directory_id=file_metadata.get('directory_id'), + db=db_session, + ) + if not knowledge_file: + raise Exception(f'Failed to link file {file_item.id} to knowledge {knowledge_id}') log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}') except Exception as e: log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}') + raise except Exception as e: log.error(f'Error processing file: {file_item.id}') diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index f048bbbf75..621eee2d88 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -6,12 +6,13 @@ import uuid from pathlib import Path from typing import Optional -from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status from fastapi.responses import FileResponse, StreamingResponse 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.chat_messages import ChatMessages from open_webui.models.config import Config from open_webui.models.chats import Chats from open_webui.models.folders import ( @@ -22,6 +23,7 @@ from open_webui.models.folders import ( 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.users import Users from open_webui.utils.access_control import has_permission @@ -30,6 +32,7 @@ from open_webui.utils.access_control import ( ) from open_webui.utils.access_control.files import 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 @@ -42,6 +45,24 @@ router = APIRouter() from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access +async def get_folder_unread_counts(user_id: str, db: AsyncSession | None = None) -> dict[str, int]: + folders = await Folders.get_folders_by_user_id(user_id, db=db) + parent_by_id = {folder.id: folder.parent_id for folder in folders} + unread_counts = dict.fromkeys(parent_by_id.keys(), 0) + direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys()), db=db) + + for unread_folder_id, unread_count in direct_unread_counts.items(): + current_id = unread_folder_id + seen = set() + while current_id and current_id not in seen: + seen.add(current_id) + if current_id in unread_counts: + unread_counts[current_id] += unread_count + current_id = parent_by_id.get(current_id) + + return unread_counts + + async def check_folders_permission(request: Request, user, db=None): """Verify the folders feature is enabled and the user has permission.""" config = await Config.get_many('folders.enable', 'user.permissions') @@ -76,11 +97,12 @@ async def get_folders( await check_folders_permission(request, user, db=db) folders = await Folders.get_folders_by_user_id(user.id, db=db) + folder_ids = {folder.id for folder in folders} # Verify folder data integrity folder_list = [] for folder in folders: - if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db): + if folder.parent_id and folder.parent_id not in folder_ids: folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db) if folder.data and 'files' in folder.data: @@ -91,9 +113,14 @@ async def get_folders( folder.id, user.id, FolderUpdateForm(data=folder.data), db=db ) - folder_list.append(FolderNameIdResponse(**folder.model_dump())) + folder_list.append(folder) - return folder_list + unread_counts = await get_folder_unread_counts(user.id, db=db) + + return [ + FolderNameIdResponse(**folder.model_dump(), unread_count=unread_counts.get(folder.id, 0)) + for folder in folder_list + ] ############################ @@ -390,6 +417,11 @@ async def update_folder_is_expanded_by_id( ): await check_folders_permission(request, user, db=db) folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) + if not folder: + folder = await Folders.get_folder_by_id(id, db=db) + if folder and (user.role == 'admin' or await _has_folder_access(user.id, folder, 'read', db)): + return folder + if folder: try: folder = await Folders.update_folder_is_expanded_by_id_and_user_id( @@ -477,6 +509,9 @@ async def update_folder_access_by_id( async def get_shared_folder_chats( request: Request, id: str, + page: int | None = Query(None, ge=1), + sort_by: str = Query('unread_updated_at'), + sort_dir: str = Query('desc'), user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): @@ -500,7 +535,18 @@ async def get_shared_folder_chats( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chats = await Chats.get_all_chats_by_folder_id(id, db=db) + limit = 10 + skip = (page - 1) * limit if page is not None else 0 + chats = await Chats.get_all_chats_by_folder_id( + id, + skip=skip, + limit=limit if page is not None else 60, + sort_by=sort_by, + sort_dir=sort_dir, + unread_for_user_id=user.id, + db=db, + ) + total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats) # Resolve owner names for display (avatar URLs are constructed client-side) owner_cache: dict[str, str] = {} @@ -510,11 +556,57 @@ async def get_shared_folder_chats( u = await Users.get_user_by_id(uid, db=db) owner_cache[uid] = u.name if u else 'Unknown' chat['owner_name'] = owner_cache[uid] + chat['active'] = False + if chat['user_id'] != user.id: + chat['last_read_at'] = chat['updated_at'] + if await has_active_tasks(request.app.state.redis, chat['id']): + chat['active'] = await ChatMessages.has_unfinished_assistant_by_chat_id(chat['id'], db=db) - return { + response = { 'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats], 'folder_permission': 'write' if has_write else 'read', } + if page is not None: + response.update({'total': total, 'has_more': skip + limit < total}) + return response + + +@router.post('/{id}/read') +async def mark_folder_chats_read_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_folders_permission(request, user, db=db) + folder = await Folders.get_folder_by_id(id, db=db) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + is_owner = user.id == folder.user_id + is_admin = user.role == 'admin' + if not (is_owner or is_admin or await _has_folder_access(user.id, folder, 'read', db)): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + folder_ids = ( + await Folders.get_folder_ids_by_id_and_user_id_in_subtree(id, folder.user_id, db=db) + if is_owner or is_admin + else [id] + ) + updated_count = await Chats.mark_chats_read_by_folder_ids(user.id, folder_ids, db=db) + + return { + 'folder_id': id, + 'folder_ids': folder_ids, + 'updated_count': updated_count, + 'folder_unread_counts': await get_folder_unread_counts(user.id, db=db), + } ############################ @@ -585,6 +677,8 @@ async def delete_folder_by_id( # Clean up access grants for this folder await AccessGrants.revoke_all_access('folder', folder_id, db=db) + await Automations.clear_folder_ids(folder_owner_id, folder_ids, db=db) + await publish_event( request, EVENTS.FOLDER_DELETED, diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 8917bb37f0..3c1cf58cd6 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -10,8 +10,8 @@ import aiohttp from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT -from open_webui.events import EVENTS, publish_event +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS +from open_webui.events import EVENTS, build_event, dispatch_event_functions, publish_event, schedule_webhook_dispatch from open_webui.internal.db import get_async_session from open_webui.models.functions import ( FunctionForm, @@ -46,11 +46,17 @@ router = APIRouter() @router.get('/', response_model=list[FunctionResponse]) async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + if not ENABLE_PLUGINS: + return [] + return await Functions.get_functions(db=db) @router.get('/list', response_model=list[FunctionUserResponse]) async def get_function_list(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + if not ENABLE_PLUGINS: + return [] + return await Functions.get_function_list(db=db) @@ -65,6 +71,9 @@ async def get_functions( user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session), ): + if not ENABLE_PLUGINS: + return [] + return await Functions.get_functions(include_valves=include_valves, db=db) @@ -284,6 +293,22 @@ async def toggle_function_by_id( ): function = await Functions.get_function_by_id(id, db=db) if function: + lifecycle_event = build_event( + request, + EVENTS.FUNCTION_DISABLE_STARTED if function.is_active else EVENTS.FUNCTION_ENABLE_STARTED, + actor=user, + subject_id=function.id, + subject_type='function', + data={'type': function.type, 'name': function.name}, + ) + await dispatch_event_functions( + request.app, + lifecycle_event, + request=request, + extra_function_ids=[function.id] if not function.is_active else None, + ) + schedule_webhook_dispatch(request.app, lifecycle_event) + function = await Functions.update_function_by_id(id, {'is_active': not function.is_active}, db=db) if function: diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 78247c0b7e..6820e96ab6 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -13,6 +13,7 @@ from types import SimpleNamespace from typing import Optional from urllib.parse import quote, urlparse +import aiofiles import aiohttp from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from fastapi.responses import FileResponse @@ -785,7 +786,11 @@ async def image_generations( images.append({'url': url}) return images elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '': - if form_data.model: + # Automatic1111 holds one checkpoint instance-wide, so set_image_model + # persists the global default and switches the shared backend. Only an + # admin may do that; a non-admin generates on the currently configured + # checkpoint. The model field is not a per-user selection on this backend. + if form_data.model and user.role == 'admin': await set_image_model(request, form_data.model) data = { @@ -936,10 +941,10 @@ async def image_edits( if isinstance(file_response, FileResponse): file_path = file_response.path - with open(file_path, 'rb') as f: - file_bytes = f.read() - image_data = base64.b64encode(file_bytes).decode('utf-8') - mime_type, _ = mimetypes.guess_type(file_path) + async with aiofiles.open(file_path, 'rb') as f: + file_bytes = await f.read() + image_data = base64.b64encode(file_bytes).decode('utf-8') + mime_type, _ = mimetypes.guess_type(file_path) return f'data:{mime_type};base64,{image_data}' return data diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index b7a5772c1d..db3ba0b86d 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -21,6 +21,7 @@ from open_webui.models.config import Config from open_webui.models.files import FileMetadataResponse, FileModel, FileModelResponse, Files from open_webui.models.groups import Groups from open_webui.models.knowledge import ( + KNOWLEDGE_SORTABLE_FIELDS, KnowledgeDirectoryForm, KnowledgeDirectoryModel, KnowledgeFileListResponse, @@ -181,6 +182,8 @@ async def search_knowledge_bases( view_option: str | None = None, source: str | None = None, page: int | None = 1, + order_by: str | None = None, + direction: str | None = None, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): @@ -195,6 +198,10 @@ async def search_knowledge_bases( filter['view_option'] = view_option if source in {'local', 'external'}: filter['source'] = source + if order_by in KNOWLEDGE_SORTABLE_FIELDS: + filter['order_by'] = order_by + if direction in {'asc', 'desc'}: + filter['direction'] = direction groups = await Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 1c6369fb04..b2ed9e9531 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -40,6 +40,7 @@ from open_webui.models.models import ( from open_webui.utils.access_control import filter_allowed_access_grants, has_permission from open_webui.utils.access_control.files import has_access_to_file from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.chat_variables import get_chat_variables_schema from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -48,6 +49,14 @@ log = logging.getLogger(__name__) router = APIRouter() +def add_chat_variables_schema(model_dict: dict) -> dict: + system = (model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None + schema = get_chat_variables_schema(system) + if schema: + model_dict.setdefault('meta', {})['chat_variables_schema'] = schema + return model_dict + + def _safe_static_redirect_path(url: str) -> str | None: """ If url is a same-origin static asset path, return a normalized path safe for @@ -119,7 +128,11 @@ async def _verify_knowledge_file_access( PAGE_ITEM_COUNT = 30 -@router.get('/list', response_model=ModelAccessListResponse) # do NOT use "/" as path, conflicts with main.py +@router.get( + '/list', + response_model=ModelAccessListResponse, + response_model_exclude={'items': {'__all__': {'meta': {'profile_image_url'}}}}, +) # do NOT use "/" as path, conflicts with main.py async def get_models( query: str | None = None, view_option: str | None = None, @@ -173,7 +186,7 @@ async def get_models( # Strip profile_image_url from meta — images are served via /model/profile/image. items = [] for model in result.items: - data = model.model_dump() + data = add_chat_variables_schema(model.model_dump()) if data.get('meta'): data['meta'].pop('profile_image_url', None) items.append( @@ -415,7 +428,10 @@ async def import_models( continue # Update existing model - model_data['meta'] = model_data.get('meta', {}) + model_data['meta'] = { + **existing_model.meta.model_dump(), + **(model_data.get('meta') or {}), + } model_data['params'] = model_data.get('params', {}) updated_model = ModelForm(**{**existing_model.model_dump(), **model_data}) @@ -520,6 +536,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes db=db, ): model_dict = model.model_dump() + model_dict = add_chat_variables_schema(model_dict) # Strip params (system prompt and other admin-curated config) # for read-only callers — matches the params strip already # enforced on /api/models in utils/models.py. Owners, admins @@ -721,6 +738,9 @@ async def update_model_by_id( if 'base_model_id' not in form_data.model_fields_set: form_data.base_model_id = model.base_model_id + if 'profile_image_url' not in form_data.meta.model_fields_set: + form_data.meta.profile_image_url = model.meta.profile_image_url + form_data.access_grants = await filter_allowed_access_grants( await Config.get('user.permissions'), user.id, diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 477558e423..ceb47708f5 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -1,6 +1,7 @@ import json import logging from typing import Optional +from uuid import uuid4 from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status from open_webui.config import ( @@ -12,6 +13,7 @@ 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 +from open_webui.models.chats import ChatForm, ChatResponse, Chats from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.notes import ( @@ -303,6 +305,229 @@ async def get_note_by_id( ) +@router.get('/{id}/chat', response_model=ChatResponse) +async def get_note_chat_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + log.info('[note-chat] get-or-create requested note_id=%s user_id=%s', id, user.id) + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', await Config.get('user.permissions'), db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = await Notes.get_note_by_id(id, db=db) + if not note: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and ( + user.id != note.user_id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + db=db, + ) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + + chat = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db) + if chat: + log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id) + payload = {**(chat.chat or {})} + params = {**(payload.get('params') or {})} + changed = False + + if params.pop('note_id', None) is not None: + changed = True + + system = ( + f'CONTEXT:\nCurrent note id: {note.id}\n' + 'This chat is attached to the current note.\n' + 'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n' + 'Do not say an edit is done unless replace_note_content succeeds.' + ) + if params.get('system') != system: + params['system'] = system + changed = True + + if payload.pop('system', None) is not None: + changed = True + + payload['params'] = params + if changed: + updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) + if updated_chat: + return updated_chat + + return chat + + chat_id = str(uuid4()) + chat = await Chats.insert_new_chat( + chat_id, + user.id, + ChatForm( + chat={ + 'id': chat_id, + 'title': 'Chat', + 'models': [''], + 'params': { + 'system': ( + f'CONTEXT:\nCurrent note id: {note.id}\n' + 'This chat is attached to the current note.\n' + 'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n' + 'Do not say an edit is done unless replace_note_content succeeds.' + ) + }, + 'history': {'messages': {}, 'currentId': None}, + 'messages': [], + 'tags': [], + } + ), + db=db, + internal_meta={'internal': True, 'type': 'note', 'note_id': note.id}, + ) + if not chat: + log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) + + log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id) + return chat + + +@router.get('/{id}/chats', response_model=list[ChatResponse]) +async def get_note_chats_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', await Config.get('user.permissions'), db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = await Notes.get_note_by_id(id, db=db) + if not note: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and ( + user.id != note.user_id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + db=db, + ) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + + chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db) + normalized_chats = [] + for chat in chats: + payload = {**(chat.chat or {})} + params = {**(payload.get('params') or {})} + changed = False + + if params.pop('note_id', None) is not None: + changed = True + + system = ( + f'CONTEXT:\nCurrent note id: {note.id}\n' + 'This chat is attached to the current note.\n' + 'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n' + 'Do not say an edit is done unless replace_note_content succeeds.' + ) + if params.get('system') != system: + params['system'] = system + changed = True + + if payload.pop('system', None) is not None: + changed = True + + payload['params'] = params + if changed: + chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) or chat + + normalized_chats.append(chat) + + return normalized_chats + + +@router.post('/{id}/chat', response_model=ChatResponse) +async def create_note_chat_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', await Config.get('user.permissions'), db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = await Notes.get_note_by_id(id, db=db) + if not note: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and ( + user.id != note.user_id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + db=db, + ) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + + chat_id = str(uuid4()) + chat = await Chats.insert_new_chat( + chat_id, + user.id, + ChatForm( + chat={ + 'id': chat_id, + 'title': 'Chat', + 'models': [''], + 'params': { + 'system': ( + f'CONTEXT:\nCurrent note id: {note.id}\n' + 'This chat is attached to the current note.\n' + 'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n' + 'Do not say an edit is done unless replace_note_content succeeds.' + ) + }, + 'history': {'messages': {}, 'currentId': None}, + 'messages': [], + 'tags': [], + } + ), + db=db, + internal_meta={'internal': True, 'type': 'note', 'note_id': note.id}, + ) + if not chat: + log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) + + log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id) + return chat + + ############################ # UpdateNoteById ############################ @@ -354,9 +579,15 @@ async def update_note_by_id( pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) note.is_pinned = note.id in pinned_note_ids + event_data = note.model_dump() + if form_data.data is not None: + event_data['data'] = { + key: note.data.get(key) for key in form_data.data.keys() if note.data is not None and key in note.data + } + await sio.emit( - 'note-events', - note.model_dump(), + 'events:note', + event_data, to=f'note:{note.id}', ) diff --git a/backend/open_webui/routers/notifications.py b/backend/open_webui/routers/notifications.py new file mode 100644 index 0000000000..c3ae3ff3b7 --- /dev/null +++ b/backend/open_webui/routers/notifications.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel + +from open_webui.constants import ERROR_MESSAGES +from open_webui.models.config import Config +from open_webui.utils.access_control import has_permission +from open_webui.utils.auth import get_verified_user +from open_webui.utils.notifications import ( + create_target, + delete_target, + get_notification_event_catalog, + list_targets, + set_default_target, + test_target, + update_target, +) + +router = APIRouter() + + +class NotificationTargetForm(BaseModel): + id: str | None = None + type: str | None = None + enabled: bool | None = None + events: list[str] | None = None + delivery: str | None = None + config: dict[str, Any] | None = None + + +async def _check_notifications_access(user) -> None: + if not await Config.get('ui.enable_user_webhooks'): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and not await has_permission( + user.id, 'features.webhooks', await Config.get('user.permissions') + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) + + +@router.get('/events') +async def get_notification_events(user=Depends(get_verified_user)): + await _check_notifications_access(user) + return {'events': get_notification_event_catalog()} + + +@router.get('/targets') +async def get_notification_targets(user=Depends(get_verified_user)): + await _check_notifications_access(user) + return await list_targets(user.id) + + +@router.post('/targets') +async def create_notification_target(form_data: NotificationTargetForm, user=Depends(get_verified_user)): + await _check_notifications_access(user) + try: + return await create_target(user.id, form_data.model_dump(exclude_unset=True)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.put('/targets/{target_id}') +async def update_notification_target( + target_id: str, form_data: NotificationTargetForm, user=Depends(get_verified_user) +): + await _check_notifications_access(user) + try: + return await update_target(user.id, target_id, form_data.model_dump(exclude_unset=True)) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.delete('/targets/{target_id}') +async def delete_notification_target(target_id: str, user=Depends(get_verified_user)): + await _check_notifications_access(user) + if not await delete_target(user.id, target_id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + return {'ok': True} + + +@router.put('/targets/{target_id}/default') +async def set_default_notification_target(target_id: str, user=Depends(get_verified_user)): + await _check_notifications_access(user) + try: + return await set_default_target(user.id, target_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.post('/targets/{target_id}/test') +async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)): + await _check_notifications_access(user) + try: + app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI') + return await test_target(user.id, target_id, app_name) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 956e310088..b02205f709 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -11,6 +11,7 @@ from datetime import datetime from typing import Optional, Union from urllib.parse import urlparse +import aiofiles import aiohttp from aiocache import cached from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile @@ -25,6 +26,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, + AIOHTTP_FILE_STREAM_CHUNK_SIZE, BYPASS_MODEL_ACCESS_CONTROL, ENABLE_FORWARD_USER_INFO_HEADERS, FORWARD_SESSION_INFO_HEADER_CHAT_ID, @@ -39,13 +41,14 @@ from open_webui.models.users import UserModel from open_webui.utils.access_control import check_model_access from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.headers import get_custom_headers, include_user_info_headers +from open_webui.utils.model_ids import strip_provider_model_prefix from open_webui.utils.misc import calculate_sha256 from open_webui.utils.payload import ( apply_model_params_to_body_ollama, apply_model_params_to_body_openai, apply_system_prompt_to_body, ) -from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper +from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper log = logging.getLogger(__name__) @@ -97,6 +100,8 @@ async def send_request( key: str | None = None, user: UserModel = None, stream: bool = False, + # passthrough must stay False for /api/chat: middleware parses it per line + passthrough: bool = False, content_type: str | None = None, metadata: dict | None = None, api_config: dict | None = None, @@ -119,7 +124,7 @@ async def send_request( # Custom per-connection headers last so admin-set headers take precedence. if api_config and api_config.get('headers'): - headers.update(get_custom_headers(api_config['headers'], user, metadata, request=request)) + headers.update(await get_custom_headers(api_config['headers'], user, metadata, request=request)) r = await session.request( method, @@ -127,7 +132,7 @@ async def send_request( data=payload, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + timeout=get_client_timeout(stream=stream), ) if not r.ok: @@ -168,7 +173,7 @@ async def send_request( streaming = True return StreamingResponse( - stream_wrapper(r), + stream_wrapper(r, passthrough=passthrough), status_code=r.status, headers=response_headers, ) @@ -313,6 +318,16 @@ async def update_config( 'ollama.api_configs': api_configs, } ) + + await get_all_models.cache.clear() + request.app.state.BASE_MODELS = [] + request.app.state.OLLAMA_MODELS = {} + models = getattr(request.app.state, 'MODELS', None) + if hasattr(models, 'clear'): + models.clear() + else: + request.app.state.MODELS = {} + await publish_event( request, EVENTS.MODEL_PROVIDER_CONFIG_UPDATED, @@ -355,6 +370,12 @@ def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict: return api_configs.get(str(idx), api_configs.get(url, {})) +async def get_ollama_connection_config() -> tuple[list, dict]: + """Base URLs and per-connection API configs in one batched SELECT.""" + config = await Config.get_many('ollama.base_urls', 'ollama.api_configs') + return config.get('ollama.base_urls', []), config.get('ollama.api_configs', {}) + + @cached( ttl=MODELS_CACHE_TTL, # key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a @@ -517,6 +538,7 @@ async def get_ollama_loaded_models( @router.get('/api/version/{url_idx}') async def get_ollama_versions( request: Request, + user=Depends(get_verified_user), url_idx: int | None = None, ): """Return the lowest Ollama version across all configured backends.""" @@ -638,6 +660,7 @@ async def pull_model( key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=True, + passthrough=True, ) @@ -677,6 +700,7 @@ async def push_model( key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=True, + passthrough=True, ) @@ -709,6 +733,7 @@ async def create_model( key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), user=user, stream=True, + passthrough=True, ) @@ -871,16 +896,13 @@ async def embed( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) url_idx = random.choice(models[model]['urls']) - url = (await Config.get('ollama.base_urls', []))[url_idx] - api_config = (await Config.get('ollama.api_configs', {})).get( - str(url_idx), - (await Config.get('ollama.api_configs', {})).get(url, {}), - ) - key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) + base_urls, api_configs = await get_ollama_connection_config() + url = base_urls[url_idx] + api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) + key = get_api_key(url_idx, url, api_configs) prefix_id = api_config.get('prefix_id') - if prefix_id: - form_data.model = form_data.model.replace(f'{prefix_id}.', '') + form_data.model = strip_provider_model_prefix(form_data.model, prefix_id) return await send_request( f'{url}/api/embed', @@ -925,16 +947,13 @@ async def embeddings( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) url_idx = random.choice(models[model]['urls']) - url = (await Config.get('ollama.base_urls', []))[url_idx] - api_config = (await Config.get('ollama.api_configs', {})).get( - str(url_idx), - (await Config.get('ollama.api_configs', {})).get(url, {}), - ) - key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))) + base_urls, api_configs = await get_ollama_connection_config() + url = base_urls[url_idx] + api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) + key = get_api_key(url_idx, url, api_configs) prefix_id = api_config.get('prefix_id') - if prefix_id: - form_data.model = form_data.model.replace(f'{prefix_id}.', '') + form_data.model = strip_provider_model_prefix(form_data.model, prefix_id) return await send_request( f'{url}/api/embeddings', @@ -984,22 +1003,20 @@ async def generate_completion( raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model)) url_idx = random.choice(models[model]['urls']) - url = (await Config.get('ollama.base_urls', []))[url_idx] - api_config = (await Config.get('ollama.api_configs', {})).get( - str(url_idx), - (await Config.get('ollama.api_configs', {})).get(url, {}), - ) + base_urls, api_configs = await get_ollama_connection_config() + url = base_urls[url_idx] + api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) prefix_id = api_config.get('prefix_id') - if prefix_id: - form_data.model = form_data.model.replace(f'{prefix_id}.', '') + form_data.model = strip_provider_model_prefix(form_data.model, prefix_id) return await send_request( f'{url}/api/generate', payload=form_data.model_dump_json(exclude_none=True).encode(), - key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + key=get_api_key(url_idx, url, api_configs), user=user, stream=True, + passthrough=True, ) @@ -1051,6 +1068,10 @@ async def get_ollama_url(request: Request, model: str, url_idx: int | None = Non await validate_ollama_backend_idx(request, model, url_idx, user) if url_idx is None: models = request.app.state.OLLAMA_MODELS + if not models or model not in models: + await get_all_models.cache.clear() + await get_all_models(request, user=user) + models = request.app.state.OLLAMA_MODELS if model not in models: raise HTTPException( status_code=400, @@ -1119,16 +1140,16 @@ async def generate_chat_completion( await check_model_access(user, None, bypass_filter) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) + api_configs = await Config.get('ollama.api_configs', {}) + api_config = resolve_api_config(api_configs, url_idx, url) prefix_id = api_config.get('prefix_id') - if prefix_id: - payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) return await send_request( f'{url}/api/chat', payload=json.dumps(payload), - key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + key=get_api_key(url_idx, url, api_configs), user=user, stream=form_data.stream, content_type='application/x-ndjson', @@ -1170,6 +1191,14 @@ class OpenAICompletionForm(BaseModel): model_config = ConfigDict(extra='allow') +class OpenAIEmbeddingsForm(BaseModel): + """Payload for the OpenAI-compatible /v1/embeddings proxy.""" + + model: str + input: object + model_config = ConfigDict(extra='allow') + + @router.post('/v1/completions') @router.post('/v1/completions/{url_idx}') async def generate_openai_completion( @@ -1207,18 +1236,68 @@ async def generate_openai_completion( await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) + api_configs = await Config.get('ollama.api_configs', {}) + api_config = resolve_api_config(api_configs, url_idx, url) prefix_id = api_config.get('prefix_id') - if prefix_id: - payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) return await send_request( f'{url}/v1/completions', payload=json.dumps(payload), - key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + key=get_api_key(url_idx, url, api_configs), user=user, stream=payload.get('stream', False), + passthrough=True, + metadata=metadata, + api_config=api_config, + request=request, + ) + + +@router.post('/v1/embeddings') +@router.post('/v1/embeddings/{url_idx}') +async def generate_openai_embeddings( + request: Request, + form_data: dict, + url_idx: int | None = None, + user=Depends(get_verified_user), # noqa: B008 +): + """Forward an embeddings request via the OpenAI-compatible proxy.""" + if not await Config.get('ollama.enable'): + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) + + metadata = form_data.pop('metadata', None) + + try: + form_data = OpenAIEmbeddingsForm(**form_data) + except Exception as exc: + log.exception(exc) + raise HTTPException(status_code=400, detail=str(exc)) + + payload = {**form_data.model_dump(exclude_none=True)} + payload.pop('metadata', None) + + model_id = form_data.model + model_info = await Models.get_model_by_id(model_id) + if model_info is not None: + if model_info.base_model_id: + payload['model'] = model_info.base_model_id + await check_model_access(user, model_info) + else: + await check_model_access(user, None) + + url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) + api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) + + prefix_id = api_config.get('prefix_id') + payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) + + return await send_request( + f'{url}/v1/embeddings', + payload=json.dumps(payload), + key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + user=user, metadata=metadata, api_config=api_config, request=request, @@ -1266,18 +1345,19 @@ async def generate_openai_chat_completion( await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url) + api_configs = await Config.get('ollama.api_configs', {}) + api_config = resolve_api_config(api_configs, url_idx, url) prefix_id = api_config.get('prefix_id') - if prefix_id: - payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) return await send_request( f'{url}/v1/chat/completions', payload=json.dumps(payload), - key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + key=get_api_key(url_idx, url, api_configs), user=user, stream=payload.get('stream', False), + passthrough=True, metadata=metadata, api_config=api_config, request=request, @@ -1317,21 +1397,19 @@ async def generate_anthropic_messages( await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = (await Config.get('ollama.api_configs', {})).get( - str(url_idx), - (await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support - ) + api_configs = await Config.get('ollama.api_configs', {}) + api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support prefix_id = api_config.get('prefix_id', None) - if prefix_id: - payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) return await send_request( f'{url}/v1/messages', payload=json.dumps(payload), - key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + key=get_api_key(url_idx, url, api_configs), user=user, stream=payload.get('stream', False), + passthrough=True, content_type='text/event-stream' if payload.get('stream', False) else None, api_config=api_config, request=request, @@ -1377,21 +1455,19 @@ async def generate_responses( await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user) - api_config = (await Config.get('ollama.api_configs', {})).get( - str(url_idx), - (await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support - ) + api_configs = await Config.get('ollama.api_configs', {}) + api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support prefix_id = api_config.get('prefix_id', None) - if prefix_id: - payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) return await send_request( f'{url}/v1/responses', payload=json.dumps(payload), - key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))), + key=get_api_key(url_idx, url, api_configs), user=user, stream=payload.get('stream', False), + passthrough=True, content_type='text/event-stream' if payload.get('stream', False) else None, api_config=api_config, request=request, @@ -1462,7 +1538,7 @@ async def download_file_stream( file_url: str, file_path: str, file_name: str, - chunk_size: int = 1024 * 1024, + chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE, ): """Stream a model file download from *file_url*, then push the blob to Ollama.""" current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 @@ -1477,37 +1553,38 @@ async def download_file_stream( ) as response: total_size = int(response.headers.get('content-length', 0)) + current_size - with open(file_path, 'ab+') as f: + async with aiofiles.open(file_path, 'ab') as f: async for data in response.content.iter_chunked(chunk_size): current_size += len(data) - f.write(data) + await f.write(data) - done = current_size == total_size - progress = round((current_size / total_size) * 100, 2) + progress_total = total_size or current_size + progress = round((current_size / progress_total) * 100, 2) yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n' - if done: - f.close() - hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) + done = True + hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size) - def _read_blob(): - with open(file_path, 'rb') as blob_f: - return blob_f.read() + blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}' + blob_size = await asyncio.to_thread(os.path.getsize, file_path) - blob_data = await asyncio.to_thread(_read_blob) + async def blob_chunks(): + async with aiofiles.open(file_path, 'rb') as blob_file: + while chunk := await blob_file.read(chunk_size): + yield chunk - blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}' - async with session.post( - blob_url, - data=blob_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - timeout=aiohttp.ClientTimeout(total=30), - ) as blob_resp: - if blob_resp.ok: - os.remove(file_path) - yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n' - else: - raise RuntimeError('Ollama: Could not create blob, Please try again.') + async with session.post( + blob_url, + data=blob_chunks(), + headers={'Content-Length': str(blob_size)}, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=30), + ) as blob_resp: + if blob_resp.ok: + await asyncio.to_thread(os.remove, file_path) + yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n' + else: + raise RuntimeError('Ollama: Could not create blob, Please try again.') @router.post('/models/download') @@ -1554,17 +1631,11 @@ async def upload_model( os.makedirs(UPLOAD_DIR, exist_ok=True) # Stage 1: persist the uploaded file to disk - chunk_size = 1024 * 1024 * 2 # 2 MiB + chunk_size = AIOHTTP_FILE_STREAM_CHUNK_SIZE - def _persist_upload(): - with open(file_path, 'wb') as out_f: - while True: - chunk = file.file.read(chunk_size) - if not chunk: - break - out_f.write(chunk) - - await asyncio.to_thread(_persist_upload) + async with aiofiles.open(file_path, 'wb') as out_f: + while chunk := await file.read(chunk_size): + await out_f.write(chunk) async def file_process_stream(): nonlocal ollama_url @@ -1576,25 +1647,26 @@ async def upload_model( log.info(f'Model Hash: {file_hash}') try: - with open(file_path, 'rb') as f: - bytes_read = 0 - while chunk := f.read(chunk_size): + bytes_read = 0 + async with aiofiles.open(file_path, 'rb') as f: + while chunk := await f.read(chunk_size): bytes_read += len(chunk) progress = round(bytes_read / total_size * 100, 2) - yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n' - - # Stage 3: push blob to Ollama - def _read_blob(): - with open(file_path, 'rb') as f: - return f.read() - - blob_data = await asyncio.to_thread(_read_blob) + event = json.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read}) + yield f'data: {event}\n\n' session = await get_session() blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}' + + async def blob_chunks(): + async with aiofiles.open(file_path, 'rb') as blob_file: + while chunk := await blob_file.read(chunk_size): + yield chunk + async with session.post( blob_url, - data=blob_data, + data=blob_chunks(), + headers={'Content-Length': str(total_size)}, ssl=AIOHTTP_CLIENT_SESSION_SSL, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) as resp: @@ -1602,7 +1674,7 @@ async def upload_model( raise Exception('Ollama: Could not create blob, Please try again.') log.info('Uploaded to /api/blobs') - os.remove(file_path) + await asyncio.to_thread(os.remove, file_path) # Stage 4: create the model model, _ext = os.path.splitext(filename) @@ -1623,7 +1695,10 @@ async def upload_model( ) as create_resp: if create_resp.ok: log.info('API SUCCESS!') - yield f'data: {json.dumps({"done": True, "blob": f"sha256:{file_hash}", "name": filename, "model_created": model})}\n\n' + event = json.dumps( + {'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model} + ) + yield f'data: {event}\n\n' else: resp_text = await create_resp.text() raise Exception(f'Failed to create model in Ollama. {resp_text}') diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 66e1932318..73a5f7ecc3 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -8,6 +8,7 @@ import re from typing import Optional from urllib.parse import quote, urlparse +import aiofiles import aiohttp from aiocache import cached from azure.identity import DefaultAzureCredential, get_bearer_token_provider @@ -43,6 +44,7 @@ from open_webui.utils.access_control import check_model_access, has_connection_a from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.headers import get_custom_headers, include_user_info_headers +from open_webui.utils.model_ids import strip_provider_model_prefix from open_webui.utils.misc import ( convert_logit_bias_input_to_json, stream_chunks_handler, @@ -53,6 +55,7 @@ from open_webui.utils.payload import ( ) from open_webui.utils.session_pool import ( cleanup_response, + get_client_timeout, get_session, stream_wrapper, ) @@ -209,7 +212,7 @@ async def get_headers_and_cookies( headers['Authorization'] = f'Bearer {token}' if config.get('headers') and isinstance(config.get('headers'), dict): - custom_headers = get_custom_headers(config.get('headers'), user, metadata, request=request) + custom_headers = await get_custom_headers(config.get('headers'), user, metadata, request=request) headers.update(custom_headers) return headers, cookies @@ -301,6 +304,90 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]: return url, key, api_config +async def get_anthropic_token_count_target(request: Request, form_data: dict, user: UserModel): + """Resolve the upstream LiteLLM connection for an Anthropic token-count request.""" + requested_model = form_data.get('model') + if not requested_model: + raise HTTPException(status_code=400, detail='model is required') + + payload = {**form_data} + model_id = requested_model + model_info = await Models.get_model_by_id(model_id) + await check_model_access(user, model_info, BYPASS_MODEL_ACCESS_CONTROL) + + if model_info and model_info.base_model_id: + model_id = model_info.base_model_id + payload['model'] = model_id + + models = request.app.state.OPENAI_MODELS + if not models or model_id not in models: + await get_all_models(request, user=user) + models = request.app.state.OPENAI_MODELS + + model = models.get(model_id) + if not model or 'urlIdx' not in model: + raise HTTPException(status_code=404, detail=ERROR_MESSAGES.MODEL_NOT_FOUND()) + + url, key, api_config = await get_openai_connection(model['urlIdx']) + prefix_id = api_config.get('prefix_id') + payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) + + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) + return requested_model, payload, url, key, headers, cookies + + +async def count_anthropic_tokens(request: Request, form_data: dict, user: UserModel) -> int: + """Forward an Anthropic token-count request through an OpenAI-compatible connection.""" + requested_model, payload, url, key, headers, cookies = await get_anthropic_token_count_target( + request, form_data, user + ) + request_url = f'{url.rstrip("/")}/messages/count_tokens' + response = None + + try: + session = await get_session() + response = await session.request( + method='POST', + url=request_url, + data=json.dumps(payload), + headers=headers, + cookies=cookies, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) + + try: + response_data = await response.json() + except Exception: + response_data = await response.text() + + if response.status >= 400: + await publish_model_provider_request_failed( + request, + actor=user, + provider='openai-compatible', + base_url=url, + api_key=key, + status=response.status, + requested_model=requested_model, + upstream_error=response_data, + ) + raise HTTPException(status_code=response.status, detail=response_data) + + input_tokens = response_data.get('input_tokens') if isinstance(response_data, dict) else None + if isinstance(input_tokens, bool) or not isinstance(input_tokens, int) or input_tokens < 0: + raise HTTPException(status_code=502, detail='Invalid token-count response from upstream provider') + + return input_tokens + except HTTPException: + raise + except Exception: + log.exception('Failed to count Anthropic tokens for model %s', requested_model) + raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) + finally: + await cleanup_response(response) + + @router.get('/config') async def get_config(request: Request, user=Depends(get_admin_user)): return await get_openai_config() @@ -333,6 +420,16 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe 'openai.api_configs': api_configs, } ) + + await get_all_models.cache.clear() + request.app.state.BASE_MODELS = [] + request.app.state.OPENAI_MODELS = {} + models = getattr(request.app.state, 'MODELS', None) + if hasattr(models, 'clear'): + models.clear() + else: + request.app.state.MODELS = {} + await publish_event( request, EVENTS.MODEL_PROVIDER_CONFIG_UPDATED, @@ -396,13 +493,12 @@ async def speech(request: Request, user=Depends(get_verified_user)): r.raise_for_status() - # Save the streaming content to a file - with open(file_path, 'wb') as f: + async with aiofiles.open(file_path, 'wb') as f: async for chunk in r.content.iter_chunked(8192): - f.write(chunk) + await f.write(chunk) - with open(file_body_path, 'w') as f: - json.dump(json.loads(body.decode('utf-8')), f) + async with aiofiles.open(file_body_path, 'w') as f: + await f.write(json.dumps(json.loads(body.decode('utf-8')))) # Return the saved file return FileResponse(file_path) @@ -551,6 +647,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: enable_openai_api, api_base_urls, _, api_configs = await get_openai_runtime_config() if not enable_openai_api: + request.app.state.OPENAI_MODELS = {} return {'data': []} responses = await get_all_models_responses(request, user=user) @@ -1107,6 +1204,9 @@ async def generate_chat_completion( form_data: dict, user=Depends(get_verified_user), ): + if not await Config.get('openai.enable'): + raise HTTPException(status_code=503, detail='OpenAI API is disabled') + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. # This prevents holding a connection during the entire LLM call (30-60+ seconds), @@ -1169,8 +1269,7 @@ async def generate_chat_completion( url, key, api_config = await get_openai_connection(idx) prefix_id = api_config.get('prefix_id', None) - if prefix_id: - payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + payload['model'] = strip_provider_model_prefix(payload['model'], prefix_id) # Add user info to the payload if the model is a pipeline if 'pipeline' in model and model.get('pipeline'): @@ -1246,6 +1345,7 @@ async def generate_chat_completion( part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text') ) + is_streaming_request = bool(payload.get('stream', False)) payload = json.dumps(payload) r = None @@ -1262,7 +1362,7 @@ async def generate_chat_completion( headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + timeout=get_client_timeout(stream=is_streaming_request), ) # Check if response is SSE @@ -1420,7 +1520,7 @@ async def embeddings(request: Request, form_data: dict, user): if 'text/event-stream' in r.headers.get('Content-Type', ''): streaming = True return StreamingResponse( - stream_wrapper(r), + stream_wrapper(r, passthrough=True), status_code=r.status, headers=_clean_proxy_headers(r.headers), ) @@ -1489,6 +1589,7 @@ async def responses( Routes to the correct upstream backend based on the model field. """ payload = form_data.model_dump(exclude_none=True) + is_streaming_request = bool(payload.get('stream', False)) idx = 0 model_id = form_data.model @@ -1539,14 +1640,14 @@ async def responses( headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + timeout=get_client_timeout(stream=is_streaming_request), ) # Check if response is SSE if 'text/event-stream' in r.headers.get('Content-Type', ''): streaming = True return StreamingResponse( - stream_wrapper(r), + stream_wrapper(r, passthrough=True), status_code=r.status, headers=_clean_proxy_headers(r.headers), ) @@ -1609,6 +1710,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): payload = json.loads(body) except (json.JSONDecodeError, ValueError): payload = None + is_streaming_request = bool(payload.get('stream', False)) if isinstance(payload, dict) else False idx = 0 model_id = payload.get('model') if isinstance(payload, dict) else None @@ -1660,14 +1762,14 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + timeout=get_client_timeout(stream=is_streaming_request), ) # Check if response is SSE if 'text/event-stream' in r.headers.get('Content-Type', ''): streaming = True return StreamingResponse( - stream_wrapper(r), + stream_wrapper(r, passthrough=True), status_code=r.status, headers=_clean_proxy_headers(r.headers), ) diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 604e45375d..bffc7dd012 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -1,9 +1,9 @@ import asyncio import logging import os -import shutil from typing import Optional +import aiofiles import aiohttp from fastapi import ( APIRouter, @@ -18,7 +18,7 @@ from fastapi import ( ) from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_FILE_STREAM_CHUNK_SIZE from open_webui.events import EVENTS, publish_event from open_webui.models.config import Config from open_webui.routers.openai import get_all_models_responses @@ -237,35 +237,37 @@ async def upload_pipeline( response = None try: - # Save the uploaded file off the event loop (uploads can be large). - def _save_upload(): - with open(file_path, 'wb') as buffer: - shutil.copyfileobj(file.file, buffer) - - await asyncio.to_thread(_save_upload) + async with aiofiles.open(file_path, 'wb') as buffer: + while chunk := await file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + await buffer.write(chunk) url, key = await get_openai_connection(urlIdx) headers = {'Authorization': f'Bearer {key}'} async with aiohttp.ClientSession(trust_env=True) as session: - with open(file_path, 'rb') as f: - form_data = aiohttp.FormData() - form_data.add_field( - 'file', - f, - filename=filename, - content_type='application/octet-stream', - ) + form_data = aiohttp.FormData() - async with session.post( - f'{url}/pipelines/upload', - headers=headers, - data=form_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as response: - response.raise_for_status() - data = await response.json() + async def pipeline_chunks(): + async with aiofiles.open(file_path, 'rb') as pipeline_file: + while chunk := await pipeline_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE): + yield chunk + + form_data.add_field( + 'file', + pipeline_chunks(), + filename=filename, + content_type='application/octet-stream', + ) + + async with session.post( + f'{url}/pipelines/upload', + headers=headers, + data=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + data = await response.json() await publish_event( request, @@ -297,7 +299,7 @@ async def upload_pipeline( finally: # Ensure the file is deleted after the upload is completed or on failure if os.path.exists(file_path): - os.remove(file_path) + await asyncio.to_thread(os.remove, file_path) class AddPipelineForm(BaseModel): diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 3f62af4af4..9d2342496f 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -101,6 +101,7 @@ from open_webui.retrieval.web.ollama import search_ollama_cloud from open_webui.retrieval.web.perplexity import search_perplexity from open_webui.retrieval.web.perplexity_search import search_perplexity_search from open_webui.retrieval.web.searchapi import search_searchapi +from open_webui.retrieval.web.openserp import search_openserp from open_webui.retrieval.web.searxng import search_searxng from open_webui.retrieval.web.serpapi import search_serpapi from open_webui.retrieval.web.serper import search_serper @@ -127,6 +128,8 @@ from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) +TIKTOKEN_DISALLOWED_SPECIAL = () + ########################################## # # Utility functions @@ -363,6 +366,7 @@ RETRIEVAL_CONFIG_KEYS = { 'SEARCHAPI_ENGINE': 'web.search.searchapi_engine', 'SEARXNG_LANGUAGE': 'web.search.searxng_language', 'SEARXNG_QUERY_URL': 'web.search.searxng_query_url', + 'OPENSERP_BASE_URL': 'web.search.openserp_base_url', 'SERPAPI_API_KEY': 'web.search.serpapi_api_key', 'SERPAPI_ENGINE': 'web.search.serpapi_engine', 'SERPER_API_KEY': 'web.search.serper_api_key', @@ -701,6 +705,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, 'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL, 'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE, + 'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL, 'YACY_QUERY_URL': config.YACY_QUERY_URL, 'YACY_USERNAME': config.YACY_USERNAME, 'YACY_PASSWORD': config.YACY_PASSWORD, @@ -779,6 +784,7 @@ class WebConfig(BaseModel): OLLAMA_CLOUD_WEB_SEARCH_API_KEY: str | None = None SEARXNG_QUERY_URL: str | None = None SEARXNG_LANGUAGE: str | None = None + OPENSERP_BASE_URL: str | None = None YACY_QUERY_URL: str | None = None YACY_USERNAME: str | None = None YACY_PASSWORD: str | None = None @@ -1247,6 +1253,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE + config.OPENSERP_BASE_URL = form_data.web.OPENSERP_BASE_URL config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL config.YACY_USERNAME = form_data.web.YACY_USERNAME config.YACY_PASSWORD = form_data.web.YACY_PASSWORD @@ -1398,6 +1405,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, 'SEARXNG_QUERY_URL': config.SEARXNG_QUERY_URL, 'SEARXNG_LANGUAGE': config.SEARXNG_LANGUAGE, + 'OPENSERP_BASE_URL': config.OPENSERP_BASE_URL, 'YACY_QUERY_URL': config.YACY_QUERY_URL, 'YACY_USERNAME': config.YACY_USERNAME, 'YACY_PASSWORD': config.YACY_PASSWORD, @@ -1582,7 +1590,7 @@ def get_splitter_length_function( ) -> Callable[[str], int]: if config.TEXT_SPLITTER == 'token': encoding = tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME)) - return lambda text: len(encoding.encode(text, disallowed_special=())) + return lambda text: len(encoding.encode(text, disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL)) if config.TEXT_SPLITTER == 'token_transformers': tokenizer = get_transformers_tokenizer(request, config) @@ -1689,6 +1697,7 @@ def save_docs_to_vector_db( chunk_size=config.CHUNK_SIZE, chunk_overlap=config.CHUNK_OVERLAP, add_start_index=True, + disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL, ) docs = text_splitter.split_documents(docs) elif config.TEXT_SPLITTER == 'token_transformers': @@ -2209,6 +2218,16 @@ async def search_web(request: Request, engine: str, query: str, user=None) -> li ) else: raise Exception('No SEARXNG_QUERY_URL found in environment variables') + elif engine == 'openserp': + if config.OPENSERP_BASE_URL: + return await search_openserp( + config.OPENSERP_BASE_URL, + query, + config.WEB_SEARCH_RESULT_COUNT, + config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + else: + raise Exception('No OPENSERP_BASE_URL found in environment variables') elif engine == 'yacy': if config.YACY_QUERY_URL: return await asyncio.to_thread( @@ -2609,6 +2628,7 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen verify_ssl=config.ENABLE_WEB_LOADER_SSL_VERIFICATION, requests_per_second=config.WEB_LOADER_CONCURRENT_REQUESTS, trust_env=config.WEB_SEARCH_TRUST_ENV, + loader_config=await get_loader_config(), ) docs = await loader.aload() @@ -2910,21 +2930,24 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo folder = f'{UPLOAD_DIR}' try: # Check if the directory exists - if os.path.exists(folder): + if await asyncio.to_thread(os.path.exists, folder): # Iterate over all the files and directories in the specified directory - for filename in os.listdir(folder): + for filename in await asyncio.to_thread(os.listdir, folder): file_path = os.path.join(folder, filename) try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) # Remove the file or link - elif os.path.isdir(file_path): - shutil.rmtree(file_path) # Remove the directory + if await asyncio.to_thread(os.path.isfile, file_path) or await asyncio.to_thread( + os.path.islink, file_path + ): + await asyncio.to_thread(os.unlink, file_path) # Remove the file or link + elif await asyncio.to_thread(os.path.isdir, file_path): + await asyncio.to_thread(shutil.rmtree, file_path) # Remove the directory except Exception as e: log.exception(f'Failed to delete {file_path}. Reason: {e}') else: log.warning(f'The directory {folder} does not exist') except Exception as e: log.exception(f'Failed to process the directory {folder}. Reason: {e}') + await publish_event( request, EVENTS.RETRIEVAL_UPLOADS_RESET, diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 9af2d81573..4fefa2cb7a 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -72,6 +72,8 @@ async def get_skills( async def get_skill_list( query: Optional[str] = None, view_option: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), @@ -86,6 +88,10 @@ async def get_skill_list( filter['query'] = query if view_option: filter['view_option'] = view_option + if order_by: + filter['order_by'] = order_by + if direction: + filter['direction'] = direction if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL): groups = await Groups.get_groups_by_member_id(user.id, db=db) diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 7cc7449b4b..4e377e2b78 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -75,19 +75,6 @@ def config_updates(data: dict, key_map: dict[str, str]) -> dict: ################################## -class ActiveChatsForm(BaseModel): - chat_ids: list[str] - - -@router.post('/active/chats') -async def check_active_chats(request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)): - """Check which chat IDs have active tasks.""" - from open_webui.tasks import get_active_chat_ids - - active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids) - return {'active_chat_ids': active} - - @router.get('/config') async def get_task_config(request: Request, user=Depends(get_verified_user)): return await get_config_values(TASK_CONFIG_KEYS) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index ce2efa7096..191fb96a1f 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -17,9 +17,9 @@ from open_webui.events import EVENTS, publish_event from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL from open_webui.models.config import Config from open_webui.models.groups import Groups -from open_webui.models.users import Users from open_webui.utils.access_control import has_connection_access from open_webui.utils.auth import get_verified_user +from open_webui.utils.terminals import get_terminal_server_url from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token from starlette.background import BackgroundTask @@ -49,6 +49,10 @@ def _sanitize_proxy_path(path: str) -> str | None: # Fail closed: still encoded after the cap means the upstream would decode further into traversal. if unquote(decoded) != decoded: return None + # posixpath splits on '/' only, so 'a/..\..\b' survives normpath as one component. + # Upstreams that treat '\' as a separator would resolve it, so reject outright. + if '\\' in decoded: + return None had_trailing_slash = decoded.endswith('/') normalized = posixpath.normpath(decoded) # Remove any leading slashes that would reset the base @@ -100,7 +104,7 @@ async def proxy_terminal( if not await has_connection_access(user, connection, user_group_ids): return JSONResponse({'error': 'Access denied'}, status_code=403) - base_url = (connection.get('url') or '').rstrip('/') + base_url = get_terminal_server_url(connection) if not base_url: return JSONResponse({'error': 'Terminal server URL not configured'}, status_code=503) @@ -110,11 +114,6 @@ async def proxy_terminal( target_url = f'{base_url}/{safe_path}' - # Route through orchestrator policy endpoint if policy_id is set - policy_id = connection.get('policy_id') - if policy_id: - target_url = f'{base_url}/p/{policy_id}/{safe_path}' - if request.query_params: target_url += f'?{request.query_params}' @@ -212,7 +211,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): import asyncio import json - from open_webui.utils.auth import decode_token, is_valid_token + from open_webui.utils.auth import get_verified_user_by_token # First-message authentication try: @@ -221,14 +220,9 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): if payload.get('type') != 'auth': await ws.close(code=4001, reason='Expected auth message') return None - token = payload.get('token', '') - data = decode_token(token) - if data is None or 'id' not in data or not await is_valid_token(data, getattr(ws.app.state, 'redis', None)): - await ws.close(code=4001, reason='Invalid token') - return None - user = await Users.get_user_by_id(data['id']) + user = await get_verified_user_by_token(payload.get('token', ''), getattr(ws.app.state, 'redis', None)) if user is None: - await ws.close(code=4001, reason='User not found') + await ws.close(code=4001, reason='Invalid token') return None except (asyncio.TimeoutError, json.JSONDecodeError): await ws.close(code=4001, reason='Auth timeout or invalid payload') @@ -272,7 +266,7 @@ async def ws_terminal( return user, connection = result - base_url = (connection.get('url') or '').rstrip('/') + base_url = get_terminal_server_url(connection) if not base_url: await ws.close(code=4003, reason='Terminal server URL not configured') return @@ -280,8 +274,6 @@ async def ws_terminal( # Build upstream WebSocket URL (no token in URL) ws_base = base_url.replace('https://', 'wss://').replace('http://', 'ws://') - # Route through orchestrator policy endpoint if policy_id is set - policy_id = connection.get('policy_id') upstream_params = {} # For orchestrator-backed servers, pass user_id upstream_params['user_id'] = user.id @@ -292,10 +284,7 @@ async def ws_terminal( # decode depth) and inject an attacker-chosen user_id ahead of the one appended below. safe_session_id = urllib.parse.quote(session_id, safe='') - if policy_id: - upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{safe_session_id}' - else: - upstream_url = f'{ws_base}/api/terminals/{safe_session_id}' + upstream_url = f'{ws_base}/api/terminals/{safe_session_id}' if upstream_params: upstream_url += f'?{urllib.parse.urlencode(upstream_params)}' diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index c830753b5a..b8ef51a280 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -10,7 +10,7 @@ import aiohttp from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, CACHE_DIR from open_webui.constants import ERROR_MESSAGES -from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS 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 @@ -72,20 +72,23 @@ async def get_tools( tools = [] # Local Tools - tools_cache = get_tools_cache(request) - for tool in await Tools.get_tools(defer_content=True, db=db): - tool_module = tools_cache.get(tool.id) - has_user_valves = ( - hasattr(tool_module, 'UserValves') if tool_module else (tool.meta.has_user_valves if tool.meta else False) - ) - tools.append( - ToolUserResponse( - **{ - **tool.model_dump(), - 'has_user_valves': has_user_valves, - } + if ENABLE_PLUGINS: + tools_cache = get_tools_cache(request) + for tool in await Tools.get_tools(defer_content=True, db=db): + tool_module = tools_cache.get(tool.id) + has_user_valves = ( + hasattr(tool_module, 'UserValves') + if tool_module + else (tool.meta.has_user_valves if tool.meta else False) + ) + tools.append( + ToolUserResponse( + **{ + **tool.model_dump(), + 'has_user_valves': has_user_valves, + } + ) ) - ) # OpenAPI Tool Servers server_access_grants = {} @@ -199,6 +202,9 @@ async def get_tools( @router.get('/list', response_model=list[ToolAccessResponse]) async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + if not ENABLE_PLUGINS: + return [] + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: tools = await Tools.get_tools(defer_content=True, db=db) else: diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 378b682357..54559c748e 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -4,9 +4,11 @@ import base64 import io import logging import time +from collections import Counter +from datetime import datetime, timedelta from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import FileResponse, Response, StreamingResponse from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event @@ -14,6 +16,8 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_AL from open_webui.internal.db import get_async_session from open_webui.models.auths import Auths from open_webui.models.config import Config +from open_webui.models.chat_messages import ChatMessages +from open_webui.models.chats import Chats from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import ( @@ -40,6 +44,7 @@ from open_webui.utils.auth import ( get_verified_user, validate_password, ) +from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -196,12 +201,14 @@ class SharingPermissions(BaseModel): public_skills: bool = False notes: bool = False public_notes: bool = True + folders: bool = False public_chats: bool = False public_calendars: bool = False class AccessGrantsPermissions(BaseModel): allow_users: bool = True + allow_groups: bool = True class ChatPermissions(BaseModel): @@ -259,6 +266,129 @@ class UserPermissions(BaseModel): settings: SettingsPermissions +class UserUsageTotals(BaseModel): + lifetime_tokens: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + peak_daily_tokens: int = 0 + longest_chat_seconds: int = 0 + current_streak: int = 0 + longest_streak: int = 0 + total_chats: int = 0 + active_days: int = 0 + models_used: int = 0 + messages: int = 0 + user_messages: int = 0 + assistant_messages: int = 0 + + +class UserUsageHeatmapEntry(BaseModel): + date: str + messages: int = 0 + chats: int = 0 + tokens: int = 0 + models: dict[str, int] = Field(default_factory=dict) + + +class UserUsageModelEntry(BaseModel): + model_id: str + messages: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + + +class UserUsageToolEntry(BaseModel): + name: str + count: int + + +class UserUsageInsights(BaseModel): + most_used_model: Optional[str] = None + average_tokens_per_chat: float = 0 + average_messages_per_active_day: float = 0 + user_message_share: float = 0 + assistant_message_share: float = 0 + + +class UserUsagePeriod(BaseModel): + start_date: int + end_date: int + days: int + + +class UserUsageResponse(BaseModel): + totals: UserUsageTotals + heatmap: list[UserUsageHeatmapEntry] + weekly_heatmap: list[UserUsageHeatmapEntry] + cumulative_heatmap: list[UserUsageHeatmapEntry] + insights: UserUsageInsights + top_models: list[UserUsageModelEntry] + top_tools: list[UserUsageToolEntry] = [] + period: UserUsagePeriod + + +def _week_start(date: datetime) -> datetime: + return date - timedelta(days=date.weekday()) + + +def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]: + weeks: dict[str, dict] = {} + for day in heatmap: + week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d') + entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()}) + entry['messages'] += day.get('messages', 0) + entry['chats'] += day.get('chats', 0) + entry['tokens'] += day.get('tokens', 0) + entry['models'].update(day.get('models', {})) + + return [ + { + **weeks[key], + 'models': dict(weeks[key]['models']), + } + for key in sorted(weeks) + ] + + +def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]: + totals = {'messages': 0, 'chats': 0, 'tokens': 0} + models: Counter[str] = Counter() + cumulative = [] + for day in heatmap: + totals['messages'] += day.get('messages', 0) + totals['chats'] += day.get('chats', 0) + totals['tokens'] += day.get('tokens', 0) + models.update(day.get('models', {})) + cumulative.append( + { + 'date': day['date'], + **totals, + 'models': dict(models), + } + ) + return cumulative + + +def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]: + longest = 0 + current_run = 0 + for day in heatmap: + if day.get('messages', 0) > 0: + current_run += 1 + longest = max(longest, current_run) + else: + current_run = 0 + + current = 0 + for day in reversed(heatmap): + if day.get('messages', 0) <= 0: + break + current += 1 + + return {'current': current, 'longest': longest} + + @router.get('/default/permissions', response_model=UserPermissions) async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)): user_permissions = await Config.get('user.permissions') @@ -348,6 +478,23 @@ async def update_user_settings_by_session_user( # If the user is not an admin and does not have permission to use tool servers, remove the key updated_user_settings['ui'].pop('toolServers', None) + ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None + if ( + user.role != 'admin' + and ( + 'notifications' in updated_user_settings + or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications) + ) + and not await has_permission( + user.id, + 'features.webhooks', + await Config.get('user.permissions'), + ) + ): + updated_user_settings.pop('notifications', None) + if isinstance(ui_notifications, dict): + ui_notifications.pop('webhook_url', None) + user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db) if user: await publish_event( @@ -428,6 +575,52 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy return user.info +class UserVariablesForm(BaseModel): + variables: dict = Field(default_factory=dict) + + +class UserVariablesResponse(BaseModel): + variables: dict[str, str] = Field(default_factory=dict) + + +############################ +# GetUserVariablesBySessionUser +############################ + + +@router.get('/user/variables', response_model=UserVariablesResponse) +async def get_user_variables_by_session_user(user=Depends(get_verified_user)): + return UserVariablesResponse(variables=normalize_user_variables(user.variables)) + + +############################ +# UpdateUserVariablesBySessionUser +############################ + + +@router.post('/user/variables/update', response_model=UserVariablesResponse) +async def update_user_variables_by_session_user( + form_data: UserVariablesForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + try: + variables = validate_user_variables(form_data.variables) + except ChatVariablesError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db) + if not updated: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + return UserVariablesResponse(variables=variables) + + ############################ # UpdateUserInfoBySessionUser ############################ @@ -455,6 +648,86 @@ async def update_user_info_by_session_user( # PATCH-style merge return updated.info +############################ +# GetUserUsageBySessionUser +############################ + + +@router.get('/usage', response_model=UserUsageResponse) +async def get_user_usage_by_session_user( + days: Optional[int] = Query(None, ge=7, le=732), + start_date: Optional[int] = Query(None), + end_date: Optional[int] = Query(None), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + now = int(time.time()) + period_end = end_date or now + if start_date is not None: + period_start = start_date + elif days is not None: + period_start = period_end - ((days - 1) * 86400) + else: + period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400)) + + if period_start > period_end: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='start_date must be before end_date', + ) + + period_days = max(1, int((period_end - period_start) / 86400) + 1) + + lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db) + period_summary = await ChatMessages.get_user_usage_summary( + user.id, period_start, period_end, timezone=user.timezone, db=db + ) + chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db) + heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db) + top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db) + top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db) + + streaks = _calculate_streaks(heatmap) + total_messages = period_summary.get('messages', 0) + total_chats = chat_stats.get('total_chats', 0) + active_days = period_summary.get('active_days', 0) + assistant_messages = period_summary.get('assistant_messages', 0) + user_messages = period_summary.get('user_messages', 0) + + return UserUsageResponse( + totals=UserUsageTotals( + lifetime_tokens=lifetime_summary.get('total_tokens', 0), + input_tokens=lifetime_summary.get('input_tokens', 0), + output_tokens=lifetime_summary.get('output_tokens', 0), + peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0), + longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0), + current_streak=streaks['current'], + longest_streak=streaks['longest'], + total_chats=total_chats, + active_days=active_days, + models_used=lifetime_summary.get('models_used', 0), + messages=total_messages, + user_messages=user_messages, + assistant_messages=assistant_messages, + ), + heatmap=heatmap, + weekly_heatmap=_build_weekly_heatmap(heatmap), + cumulative_heatmap=_build_cumulative_heatmap(heatmap), + insights=UserUsageInsights( + most_used_model=top_models[0]['model_id'] if top_models else None, + average_tokens_per_chat=( + round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0 + ), + average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0, + user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0, + assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0, + ), + top_models=top_models, + top_tools=top_tools, + period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days), + ) + + ############################ # GetUserById ############################ diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 1cdd064b3a..f209e999f0 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -5,7 +5,6 @@ import logging import random import sys import time -from typing import Dict import pycrdt as Y import socketio @@ -16,7 +15,6 @@ from open_webui.env import ( ENABLE_WEBSOCKET_SUPPORT, GLOBAL_LOG_LEVEL, REDIS_KEY_PREFIX, - VERSION, WEBSOCKET_EVENT_CALLER_TIMEOUT, WEBSOCKET_MANAGER, WEBSOCKET_REDIS_CLUSTER, @@ -33,18 +31,19 @@ from open_webui.env import ( from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels from open_webui.models.chats import Chats +from open_webui.models.folders import Folders from open_webui.models.notes import Notes, NoteUpdateForm from open_webui.models.users import UserNameResponse, Users from open_webui.socket.utils import RedisDict, RedisLock, YdocManager from open_webui.tasks import create_task, stop_item_tasks from open_webui.utils.access_control import has_permission -from open_webui.utils.auth import decode_token, is_valid_token +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.redis import ( build_sentinel_url, get_redis_connection, get_sentinels_from_env, ) -from redis import asyncio as aioredis logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -57,6 +56,12 @@ REDIS = None # Configure CORS for Socket.IO SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN + +def get_room_sid_map(manager, namespace: str, room: str): + """Return this process's Socket.IO sid map for a room, without copying it.""" + return manager.rooms.get(namespace, {}).get(room) + + if WEBSOCKET_MANAGER == 'redis': sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or '' ws_redis_url = ( @@ -166,25 +171,31 @@ YDOC_MANAGER = YdocManager( async def periodic_session_pool_cleanup(): """Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance).""" - if not session_aquire_func(): - log.debug('Session cleanup lock held by another node. Skipping.') - return + retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT) + while True: + if not session_aquire_func(): + log.debug('Session cleanup lock held by another node. Retrying.') + await asyncio.sleep(retry_delay) + continue - try: - while True: - if not session_renew_func(): - log.error('Unable to renew session cleanup lock. Exiting.') - return + try: + while True: + if not session_renew_func(): + log.warning('Unable to renew session cleanup lock. Retrying cleanup ownership.') + break - now = int(time.time()) - for sid in list(SESSION_POOL.keys()): - entry = SESSION_POOL.get(sid) - if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT: - log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})') - del SESSION_POOL[sid] - await asyncio.sleep(SESSION_POOL_TIMEOUT) - finally: - session_release_func() + now = int(time.time()) + for sid in list(SESSION_POOL.keys()): + entry = SESSION_POOL.get(sid) + if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT: + log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})') + try: + del SESSION_POOL[sid] + except KeyError: + pass + await asyncio.sleep(SESSION_POOL_TIMEOUT) + finally: + session_release_func() async def periodic_usage_pool_cleanup(): @@ -205,17 +216,19 @@ async def periodic_usage_pool_cleanup(): try: while True: if not renew_func(): - log.error(f'Unable to renew cleanup lock. Exiting usage pool cleanup.') + log.error('Unable to renew cleanup lock. Exiting usage pool cleanup.') raise Exception('Unable to renew usage pool cleanup lock.') now = int(time.time()) - send_usage = False for model_id, connections in list(USAGE_POOL.items()): # Creating a list of sids to remove if they have timed out expired_sids = [ sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION ] + if connections and not expired_sids: + continue + for sid in expired_sids: del connections[sid] @@ -224,8 +237,6 @@ async def periodic_usage_pool_cleanup(): del USAGE_POOL[model_id] else: USAGE_POOL[model_id] = connections - - send_usage = True await asyncio.sleep(TIMEOUT_DURATION) finally: release_func() @@ -252,24 +263,21 @@ def get_user_id_from_session_pool(sid): def get_session_ids_from_room(room): """Get all session IDs from a specific room.""" - active_session_ids = sio.manager.get_participants( - namespace='/', - room=room, - ) - return [session_id[0] for session_id in active_session_ids] + members = get_room_sid_map(sio.manager, '/', room) + return list(members) if members else [] def get_user_ids_from_room(room): active_session_ids = get_session_ids_from_room(room) + # Single pool lookup per session (each .get is a Redis round trip + # when the session pool is Redis-backed). active_user_ids = list( - set( - [ - SESSION_POOL.get(session_id)['id'] - for session_id in active_session_ids - if SESSION_POOL.get(session_id) is not None - ] - ) + { + entry['id'] + for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids) + if entry is not None + } ) return active_user_ids @@ -333,7 +341,7 @@ async def usage(sid, data): # Store the new usage data and task USAGE_POOL[model_id] = { - **(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}), + **(USAGE_POOL.get(model_id) or {}), sid: {'updated_at': current_time}, } @@ -345,13 +353,10 @@ async def connect(sid, environ, auth): scope = (environ or {}).get('asgi.scope') or {} fastapi_app = scope.get('app') redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS - data = decode_token(auth['token']) - - if data is not None and 'id' in data and await is_valid_token(data, redis): - user = await Users.get_user_by_id(data['id']) + user = await get_verified_user_by_token(auth['token'], redis) if user: - SESSION_POOL[sid] = { + socket_user = { **user.model_dump( exclude=[ 'profile_image_url', @@ -363,6 +368,8 @@ async def connect(sid, environ, auth): ), 'last_seen_at': int(time.time()), } + SESSION_POOL[sid] = socket_user + await sio.save_session(sid, {'user': socket_user}) await sio.enter_room(sid, f'user:{user.id}') @@ -376,15 +383,11 @@ async def user_join(sid, data): scope = environ.get('asgi.scope') or {} fastapi_app = scope.get('app') redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS - token_data = decode_token(auth['token']) - if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis): - return - - user = await Users.get_user_by_id(token_data['id']) + user = await get_verified_user_by_token(auth['token'], redis) if not user: return - SESSION_POOL[sid] = { + socket_user = { **user.model_dump( exclude=[ 'profile_image_url', @@ -397,6 +400,8 @@ async def user_join(sid, data): 'last_seen_at': int(time.time()), } + SESSION_POOL[sid] = socket_user + await sio.save_session(sid, {'user': socket_user}) await sio.enter_room(sid, f'user:{user.id}') # Join all the channels only if user has channels permission @@ -427,11 +432,7 @@ async def join_channel(sid, data): scope = environ.get('asgi.scope') or {} fastapi_app = scope.get('app') redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS - data = decode_token(auth['token']) - if data is None or 'id' not in data or not await is_valid_token(data, redis): - return - - user = await Users.get_user_by_id(data['id']) + user = await get_verified_user_by_token(auth['token'], redis) if not user: return @@ -453,11 +454,7 @@ async def join_note(sid, data): scope = environ.get('asgi.scope') or {} fastapi_app = scope.get('app') redis = getattr(getattr(fastapi_app, 'state', None), 'redis', None) or REDIS - token_data = decode_token(auth['token']) - if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis): - return - - user = await Users.get_user_by_id(token_data['id']) + user = await get_verified_user_by_token(auth['token'], redis) if not user: return @@ -486,13 +483,7 @@ async def join_note(sid, data): @sio.on('events:channel') async def channel_events(sid, data): room = f'channel:{data["channel_id"]}' - participants = sio.manager.get_participants( - namespace='/', - room=room, - ) - - sids = [sid for sid, _ in participants] - if sid not in sids: + if sid not in (get_room_sid_map(sio.manager, '/', room) or {}): return event_data = data['data'] @@ -518,9 +509,35 @@ async def channel_events(sid, data): await Channels.update_member_last_read_at(data['channel_id'], user['id']) +async def get_folder_unread_counts(user_id: str) -> dict[str, int]: + folder_list = await Folders.get_folders_by_user_id(user_id) + parent_by_id = {folder.id: folder.parent_id for folder in folder_list} + unread_counts = dict.fromkeys(parent_by_id.keys(), 0) + + direct_unread_counts = await Chats.count_unread_by_folder_ids(user_id, list(parent_by_id.keys())) + for unread_folder_id, unread_count in direct_unread_counts.items(): + current_id = unread_folder_id + seen = set() + while current_id and current_id not in seen: + seen.add(current_id) + if current_id in unread_counts: + unread_counts[current_id] += unread_count + current_id = parent_by_id.get(current_id) + + return unread_counts + + @sio.on('events:chat') async def chat_events(sid, data): - user = SESSION_POOL.get(sid) + try: + session = await sio.get_session(sid) + user = session.get('user') + except KeyError: + user = None + + if not user: + user = SESSION_POOL.get(sid) + if not user: return @@ -528,7 +545,34 @@ async def chat_events(sid, data): event_type = event_data.get('type') if event_type == 'last_read_at': - await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id']) + read_update = await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id']) + if not read_update: + return + last_read_at, was_unread = read_update + response_data = { + 'chat_id': data['chat_id'], + 'last_read_at': last_read_at, + } + if was_unread: + response_data['folder_unread_counts'] = await get_folder_unread_counts(user['id']) + + await sio.emit( + 'events', + { + 'chat_id': data['chat_id'], + 'data': { + 'type': 'chat:list', + 'data': response_data, + }, + }, + room=f'user:{user["id"]}', + ) + try: + from open_webui.utils.timers import cancel_timers_for_chat + + await cancel_timers_for_chat(data['chat_id'], 'chat.read', user['id']) + except Exception: + log.exception('Failed to cancel chat.read timers for chat %s', data.get('chat_id')) def normalize_document_id(document_id: str) -> str: @@ -829,7 +873,6 @@ async def yjs_awareness_update(sid, data): @sio.event async def disconnect(sid, reason=None): if sid in SESSION_POOL: - user = SESSION_POOL[sid] del SESSION_POOL[sid] # Clean up USAGE_POOL entries for this session @@ -925,18 +968,27 @@ async def get_event_emitter(request_info, update_db=True): user_id = request_info['user_id'] chat_id = request_info['chat_id'] message_id = request_info['message_id'] + internal = request_info.get('internal') is True + save_to_chat = update_db and message_id and is_saved_chat_id(chat_id) - await sio.emit( - 'events', - { - 'chat_id': chat_id, - 'message_id': message_id, - 'data': event_data, - }, - room=f'user:{user_id}', - ) + if internal and event_data.get('type') == 'notification': + return - if update_db and message_id and not (request_info.get('chat_id') or '').startswith('local:'): + room = f'user:{user_id}' + # Local rooms are authoritative; Redis may have listeners on another instance. + if WEBSOCKET_MANAGER == 'redis' or room in sio.manager.rooms.get('/', {}): + await sio.emit( + 'events', + { + 'chat_id': chat_id, + 'message_id': message_id, + **({'internal': True} if internal else {}), + 'data': event_data, + }, + room=room, + ) + + if save_to_chat: event_type = event_data.get('type') if event_type == 'status': @@ -992,6 +1044,7 @@ async def get_event_emitter(request_info, update_db=True): { 'embeds': embeds, }, + touch=False, ) elif event_type == 'files': @@ -1009,6 +1062,7 @@ async def get_event_emitter(request_info, update_db=True): { 'files': files, }, + touch=False, ) elif event_type in ('source', 'citation'): @@ -1028,6 +1082,7 @@ async def get_event_emitter(request_info, update_db=True): { 'sources': sources, }, + touch=False, ) if 'user_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info: @@ -1059,6 +1114,11 @@ async def get_event_call(request_info): ) except TimeoutError: log.warning(f'Event caller timed out for session {session_id}') + if SESSION_POOL.get(session_id) == session: + try: + del SESSION_POOL[session_id] + except KeyError: + pass return {'error': 'Event call timed out. The browser tab may be inactive or closed.'} if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info: diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index b337b08f40..5e72753f42 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -7,8 +7,8 @@ import json import uuid import pycrdt as Y -from open_webui.utils.redis import get_redis_connection from open_webui.env import REDIS_KEY_PREFIX +from open_webui.utils.redis import get_redis_connection YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents' @@ -16,6 +16,19 @@ YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents' class RedisLock: """Distributed lock backed by a Redis SET with NX/EX semantics.""" + _RENEW_SCRIPT = """ + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('expire', KEYS[1], ARGV[2]) + end + return 0 + """ + _RELEASE_SCRIPT = """ + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + end + return 0 + """ + def __init__( self, redis_url, @@ -41,13 +54,10 @@ class RedisLock: return self.lock_obtained def renew_lock(self): - # xx=True will only set this key if it _has_ already been set - return self.redis.set(self.lock_name, self.lock_id, xx=True, ex=self.timeout_secs) + return bool(self.redis.eval(self._RENEW_SCRIPT, 1, self.lock_name, self.lock_id, self.timeout_secs)) def release_lock(self): - lock_value = self.redis.get(self.lock_name) - if lock_value and lock_value == self.lock_id: - self.redis.delete(self.lock_name) + self.redis.eval(self._RELEASE_SCRIPT, 1, self.lock_name, self.lock_id) class RedisDict: @@ -102,12 +112,18 @@ class RedisDict: # Serialize values once — reused for both the fingerprint and the write. serialized = {k: json.dumps(v) for k, v in mapping.items()} + digest = hashlib.sha256() + for key in sorted(serialized): + digest.update(key.encode()) + digest.update(b'\0') + digest.update(serialized[key].encode()) + digest.update(b'\0') + signature = digest.hexdigest() # Skip the write when the prepared mapping is identical to the last one # this process wrote. The check is per-instance (not distributed), but # still eliminates the majority of redundant writes because each pod # typically produces the same model list on consecutive refreshes. - signature = hashlib.sha256(json.dumps(serialized, sort_keys=True).encode()).hexdigest() if signature == self._last_signature: return diff --git a/backend/open_webui/static/favicon-dark.png b/backend/open_webui/static/favicon-dark.png deleted file mode 100644 index 08627a23f7..0000000000 Binary files a/backend/open_webui/static/favicon-dark.png and /dev/null differ diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index 6475e5a239..e5ff754297 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -2,10 +2,8 @@ import asyncio import json import logging -from typing import Dict, List, Optional from uuid import uuid4 -from fastapi import Request from redis.asyncio import Redis from open_webui.env import REDIS_KEY_PREFIX @@ -13,7 +11,7 @@ from open_webui.env import REDIS_KEY_PREFIX log = logging.getLogger(__name__) # A dictionary to keep track of active tasks -tasks: Dict[str, asyncio.Task] = {} +tasks: dict[str, asyncio.Task] = {} item_tasks = {} @@ -46,7 +44,7 @@ async def redis_task_command_listener(app): ### ------------------------------ -async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]): +async def redis_save_task(redis: Redis, task_id: str, item_id: str | None): pipe = redis.pipeline() pipe.hset(REDIS_TASKS_KEY, task_id, item_id or '') if item_id: @@ -54,7 +52,7 @@ async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]): await pipe.execute() -async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]): +async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None): pipe = redis.pipeline() pipe.hdel(REDIS_TASKS_KEY, task_id) if item_id: @@ -67,11 +65,11 @@ async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]) await pipe.execute() -async def redis_list_tasks(redis: Redis) -> List[str]: +async def redis_list_tasks(redis: Redis) -> list[str]: return list(await redis.hkeys(REDIS_TASKS_KEY)) -async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]: +async def redis_list_item_tasks(redis: Redis, item_id: str) -> list[str]: return list(await redis.smembers(f'{REDIS_ITEM_TASKS_KEY}:{item_id}')) @@ -101,11 +99,11 @@ async def cleanup_task(redis, task_id: str, id=None): item_tasks.pop(id, None) -async def create_task(redis, coroutine, id=None): +async def create_task(redis, coroutine, id=None, task_id=None): """ Create a new asyncio task and add it to the global task dictionary. """ - task_id = str(uuid4()) # Generate a unique ID for the task + task_id = task_id or str(uuid4()) # Generate a unique ID for the task task = asyncio.create_task(coroutine) # Create the task # Add a done callback for cleanup @@ -199,12 +197,3 @@ async def has_active_tasks(redis, chat_id: str) -> bool: """Check if a chat has any active tasks.""" task_ids = await list_task_ids_by_item_id(redis, chat_id) return len(task_ids) > 0 - - -async def get_active_chat_ids(redis, chat_ids: List[str]) -> List[str]: - """Filter a list of chat_ids to only those with active tasks.""" - active = [] - for chat_id in chat_ids: - if await has_active_tasks(redis, chat_id): - active.append(chat_id) - return active diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index f6631691d3..208f3e7dbe 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -12,9 +12,9 @@ import asyncio import json import logging import time -from typing import Optional +from typing import Literal, Optional -from fastapi import Request +from fastapi import HTTPException, Request from open_webui.models.channels import Channel, ChannelMember, Channels from open_webui.models.chats import Chats @@ -49,6 +49,11 @@ from open_webui.routers.memories import ( add_memory as _add_memory, ) from open_webui.routers.retrieval import search_web as _search_web +from open_webui.tasks import stop_item_tasks +from open_webui.events import EVENTS, publish_event +from open_webui.socket.main import sio +from open_webui.utils.chat_id import is_saved_chat_id +from open_webui.utils.notifications import notify_target from open_webui.utils.sanitize import sanitize_code log = logging.getLogger(__name__) @@ -56,6 +61,33 @@ log = logging.getLogger(__name__) MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000 +async def _has_write_access_to_note(note, user_id: str) -> bool: + if note.user_id == user_id: + return True + + from open_webui.models.access_grants import AccessGrants + + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] + return await AccessGrants.has_access( + user_id=user_id, + resource_type='note', + resource_id=note.id, + permission='write', + user_group_ids=set(user_group_ids), + ) + + +async def _emit_note_updated(request: Request, user: dict, note) -> None: + await sio.emit('events:note', note.model_dump(), to=f'note:{note.id}') + await publish_event( + request, + EVENTS.NOTE_UPDATED, + actor=user, + subject_id=note.id, + data={'title': note.title}, + ) + + async def _has_read_access_to_file( file, user_id: str, @@ -81,6 +113,33 @@ async def _has_read_access_to_file( # ============================================================================= +async def notify( + message: str, + target: str = '', + title: str = '', + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Send a notification to the user's configured notification target. + + :param message: Notification body. + :param target: Optional target id or name. Empty uses the default target. + :param title: Optional notification title. + """ + user_id = (__user__ or {}).get('id') + if not user_id: + return 'Notification failed: user not found.' + + app_name = getattr(getattr(__request__, 'app', None), 'state', None) + app_name = getattr(app_name, 'WEBUI_NAME', 'Open WebUI') + try: + result = await notify_target(user_id, message, target=target, title=title, app_name=app_name) + return f'Notification sent to {result.get("target_id")}.' + except Exception as e: + return f'Notification failed: {e}' + + async def get_current_timestamp( __request__: Request = None, __user__: dict = None, @@ -320,7 +379,7 @@ async def generate_image( image_files = [{'type': 'image', 'url': img['url']} for img in images] # Persist files to DB if chat context is available - if __chat_id__ and __message_id__ and images: + if is_saved_chat_id(__chat_id__) and __message_id__ and images: db_files = await Chats.add_message_files_by_id_and_message_id( __chat_id__, __message_id__, @@ -388,7 +447,7 @@ async def edit_image( image_files = [{'type': 'image', 'url': img['url']} for img in images] # Persist files to DB if chat context is available - if __chat_id__ and __message_id__ and images: + if is_saved_chat_id(__chat_id__) and __message_id__ and images: db_files = await Chats.add_message_files_by_id_and_message_id( __chat_id__, __message_id__, @@ -1048,12 +1107,16 @@ async def view_note( from open_webui.models.access_grants import AccessGrants - if note.user_id != user_id and not await AccessGrants.has_access( - user_id=user_id, - resource_type='note', - resource_id=note.id, - permission='read', - user_group_ids=set(user_group_ids), + if ( + __user__.get('role') != 'admin' + and note.user_id != user_id + and not await AccessGrants.has_access( + user_id=user_id, + resource_type='note', + resource_id=note.id, + permission='read', + user_group_ids=set(user_group_ids), + ) ): return json.dumps({'error': 'Access denied'}) @@ -1128,16 +1191,20 @@ async def write_note( async def replace_note_content( note_id: str, - content: str, + content: Optional[str] = None, + operations: Optional[list[dict]] = None, title: Optional[str] = None, __request__: Request = None, __user__: dict = None, ) -> str: """ - Update the markdown content, and optionally the title, of an existing note. + Update an existing note by replacing the whole markdown content or applying range operations. :param note_id: The ID of the note to update - :param content: The new markdown content for the note + :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": "..."} :param title: Optional new title for the note :return: JSON with success status and updated note info """ @@ -1153,25 +1220,113 @@ async def replace_note_content( note = await Notes.get_note_by_id(note_id) if not note: - return json.dumps({'error': 'Note not found'}) + return json.dumps({'error': 'Note not found', 'code': 'not_found'}) - # Check write permission user_id = __user__.get('id') - user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] + if __user__.get('role') != 'admin' and not await _has_write_access_to_note(note, user_id): + return json.dumps({'error': 'Write access denied', 'code': 'write_access_denied'}) - from open_webui.models.access_grants import AccessGrants + current_content = ((note.data or {}).get('content') or {}).get('md') or '' + applied_operation_count = 0 + if operations is not None: + if not isinstance(operations, list) or len(operations) == 0: + return json.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'}) - if note.user_id != user_id and not await AccessGrants.has_access( - user_id=user_id, - resource_type='note', - resource_id=note.id, - permission='write', - user_group_ids=set(user_group_ids), - ): - return json.dumps({'error': 'Write access denied'}) + range_operations = [] + for idx, operation in enumerate(operations): + if not isinstance(operation, dict): + return json.dumps( + {'error': 'each operation must be an object', 'code': 'invalid_operation', 'index': idx} + ) - # Build update form - update_data = {'data': {'content': {'md': content}}} + action = operation.get('action') + replacement = operation.get('content') + + if action == 'replace': + if len(operations) != 1: + return json.dumps( + { + 'error': 'replace operation must be the only operation', + 'code': 'invalid_operations', + 'index': idx, + } + ) + if not isinstance(replacement, str): + return json.dumps( + { + 'error': 'replace operation content must be a string', + 'code': 'invalid_content', + 'index': idx, + } + ) + content = replacement + applied_operation_count = 1 + break + + if action != 'replace_range': + return json.dumps( + {'error': 'unknown operation action', 'code': 'invalid_action', 'index': idx, 'action': action} + ) + + start = operation.get('start') + end = operation.get('end') + expected = operation.get('expected') + if not isinstance(start, int) or not isinstance(end, int): + return json.dumps( + {'error': 'operation start and end must be integers', 'code': 'invalid_range', 'index': idx} + ) + if not isinstance(replacement, str): + return json.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 json.dumps( + {'error': 'operation range is out of bounds', 'code': 'range_out_of_bounds', 'index': idx} + ) + if expected is not None and current_content[start:end] != expected: + return json.dumps( + { + 'error': 'operation expected text does not match current content', + 'code': 'expected_mismatch', + 'index': idx, + } + ) + + range_operations.append({'start': start, 'end': end, 'content': replacement}) + + range_operations.sort(key=lambda operation: operation['start']) + previous_end = 0 + for idx, operation in enumerate(range_operations): + if operation['start'] < previous_end: + return json.dumps( + {'error': 'operation ranges must not overlap', 'code': 'overlapping_operations', 'index': idx} + ) + previous_end = operation['end'] + + if range_operations: + content = current_content + for operation in reversed(range_operations): + content = content[: operation['start']] + operation['content'] + content[operation['end'] :] + applied_operation_count = len(range_operations) + elif content is None: + return json.dumps({'error': 'content or operations is required', 'code': 'content_required'}) + + try: + await stop_item_tasks(__request__.app.state.redis, f'note:{note_id}') + except Exception: + pass + + update_data = { + 'data': { + **(note.data or {}), + 'content': { + **((note.data or {}).get('content') or {}), + 'json': None, + 'html': '', + 'md': content, + }, + } + } if title: update_data['title'] = title @@ -1179,7 +1334,9 @@ async def replace_note_content( updated_note = await Notes.update_note_by_id(note_id, form) if not updated_note: - return json.dumps({'error': 'Failed to update note'}) + return json.dumps({'error': 'Failed to update note', 'code': 'update_failed'}) + + await _emit_note_updated(__request__, __user__, updated_note) return json.dumps( { @@ -1187,12 +1344,13 @@ async def replace_note_content( 'id': updated_note.id, 'title': updated_note.title, 'updated_at': updated_note.updated_at, + 'applied_operation_count': applied_operation_count, }, ensure_ascii=False, ) except Exception as e: log.exception(f'replace_note_content error: {e}') - return json.dumps({'error': str(e)}) + return json.dumps({'error': str(e), 'code': 'unexpected_error'}) # ============================================================================= @@ -1348,6 +1506,85 @@ async def view_chat( return json.dumps({'error': str(e)}) +# ============================================================================= +# SUB-AGENT TOOL +# ============================================================================= + + +async def delegate_task( + task: str, + context: str = '', + background: bool = False, + __request__: Request = None, + __user__: dict = None, + __metadata__: dict = None, + __chat_id__: str = None, + __message_id__: str = None, +) -> str: + """ + Delegate focused work to a parallel sub-agent using the current model and tools. + + :param task: The specific task for the sub-agent to complete + :param context: Relevant context, decisions, or file paths for the task + :param background: Return immediately and continue this chat when the sub-agent finishes + :return: Foreground result text, or a JSON dispatch handle for background work + """ + if __request__ is None: + return 'Error: request context not available.' + if getattr(__request__.state, 'internal', False) is True: + return 'Error: sub-agents cannot delegate recursively.' + + from open_webui.utils.subagents import delegate + + return await delegate( + task, + context, + background, + request=__request__, + user_data=__user__ or {}, + metadata=__metadata__ or {}, + parent_chat_id=__chat_id__ or '', + parent_message_id=__message_id__, + ) + + +async def timer( + prompt: str, + at: str, + cancel_on: list[Literal['chat.read', 'chat.user_message']] | None = None, + __request__: Request = None, + __user__: dict = None, + __metadata__: dict = None, + __chat_id__: str = None, + __message_id__: str = None, +) -> str: + """ + Set a one-shot timer for this chat. + + :param prompt: The prompt to send back into this chat when the timer fires + :param at: Relative time like 10s, 5m, 1h, 2d, or a timezone-aware RFC 3339 timestamp + :param cancel_on: Optional events that cancel the timer before it fires + :return: JSON status with the scheduled time, or an error string + """ + if __request__ is None: + return 'Error: request context not available.' + if getattr(__request__.state, 'internal', False) is True: + return 'Error: timers cannot be set from internal chats.' + + from open_webui.utils.timers import create_timer + + return await create_timer( + prompt=prompt, + at=at, + cancel_on=cancel_on, + request=__request__, + user_data=__user__ or {}, + metadata=__metadata__ or {}, + parent_chat_id=__chat_id__ or '', + parent_message_id=__message_id__, + ) + + # ============================================================================= # CHANNELS TOOLS # ============================================================================= @@ -1925,6 +2162,309 @@ DEFAULT_VIEW_FILE_MAX_CHARS = 10_000 MAX_GREP_RESULTS = 50 +async def _get_accessible_chat_files( + files: Optional[list[dict]], + user: dict, + file_id: Optional[str] = None, +) -> list[tuple[dict, object]]: + from open_webui.models.files import Files + + user_id = user.get('id') + user_role = user.get('role', 'user') + accessible = [] + seen = set() + + for item in files or []: + if not isinstance(item, dict) or item.get('type', 'file') != 'file': + continue + fid = item.get('id') or item.get('url') or '' + if ( + not isinstance(fid, str) + or not fid + or fid in seen + or fid.startswith(('http://', 'https://', 'data:')) + or (file_id and fid != file_id) + ): + continue + normalized = {**item, 'id': fid, 'type': 'file'} + if 'name' not in normalized and item.get('filename'): + normalized['name'] = item.get('filename') + seen.add(fid) + + file = await Files.get_file_by_id(fid) + if file and await _has_read_access_to_file(file, user_id, user_role): + accessible.append((normalized, file)) + + return accessible + + +def _grep_file_models( + files_to_search: list, + pattern: str, + case_insensitive: bool = False, + count_only: bool = False, +) -> str: + from open_webui.tools.knowledge_fs import build_matcher + + matches, err = build_matcher(pattern, case_insensitive) + if err: + return json.dumps({'error': err}) + + results = [] + total_matches = 0 + counts = [] + + for file in files_to_search: + content = '' + if file.data: + content = file.data.get('content', '') + if not content: + continue + + lines = content.split('\n') + file_matches = 0 + + for i, line in enumerate(lines, 1): + if matches(line): + file_matches += 1 + total_matches += 1 + if not count_only and len(results) < MAX_GREP_RESULTS: + results.append(f'{file.id} {file.filename}:{i}: {line}') + + if file_matches > 0 and count_only: + counts.append(f'{file.id} {file.filename}: {file_matches}') + + if count_only: + if not counts: + return f'No matches for "{pattern}"' + return '\n'.join(counts) + f'\n[{total_matches} total matches]' + + if not results: + return f'No matches for "{pattern}"' + + output = '\n'.join(results) + if total_matches > MAX_GREP_RESULTS: + output += f'\n[{MAX_GREP_RESULTS} of {total_matches} matches shown — use file_id to narrow]' + return output + + +async def list_chat_files( + __request__: Request = None, + __user__: dict = None, + __files__: list[dict] = None, +) -> str: + """ + List files attached to the current chat. + + :return: JSON with attached chat files containing id, filename, content type, size, and updated time when available + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + files = [] + for item, file in await _get_accessible_chat_files(__files__, __user__): + file_info = { + 'id': file.id, + 'filename': file.filename, + 'name': item.get('name') or file.filename, + 'type': item.get('type', 'file'), + 'updated_at': file.updated_at, + } + content_type = item.get('content_type') or (file.meta or {}).get('content_type') + size = item.get('size') or (file.meta or {}).get('size') + if content_type: + file_info['content_type'] = content_type + if size: + file_info['size'] = size + files.append(file_info) + + return json.dumps(files, ensure_ascii=False) + except Exception as e: + log.exception(f'list_chat_files error: {e}') + return json.dumps({'error': str(e)}) + + +async def grep_chat_files( + pattern: str, + file_id: Optional[str] = None, + case_insensitive: bool = False, + count_only: bool = False, + __request__: Request = None, + __user__: dict = None, + __files__: list[dict] = None, +) -> str: + """ + Search exact text across files attached to the current chat. + Pass file_id from the attached_files block to search one file. + + :param pattern: The text pattern to search for + :param file_id: Optional attached file ID to search within a single file + :param case_insensitive: If true, ignore case when matching + :param count_only: If true, return only match counts per file + :return: Matching lines with file IDs, filenames, and line numbers + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + if not pattern or not pattern.strip(): + return json.dumps({'error': 'Pattern is required'}) + + if isinstance(file_id, str) and file_id.lower() in ('none', 'null', ''): + file_id = None + + try: + attached_ids = set() + for item in __files__ or []: + if not isinstance(item, dict) or item.get('type', 'file') != 'file': + continue + fid = item.get('id') or item.get('url') + if isinstance(fid, str) and fid and not fid.startswith(('http://', 'https://', 'data:')): + attached_ids.add(fid) + + if not attached_ids: + return json.dumps({'error': 'No files are attached to this chat'}) + if file_id and file_id not in attached_ids: + return json.dumps({'error': 'File not found'}) + + files_to_search = [file for _, file in await _get_accessible_chat_files(__files__, __user__, file_id)] + if not files_to_search: + return json.dumps({'error': 'No accessible files found'}) + + return _grep_file_models(files_to_search, pattern, case_insensitive, count_only) + except Exception as e: + log.exception(f'grep_chat_files error: {e}') + return json.dumps({'error': str(e)}) + + +async def query_chat_files( + query: str, + file_id: Optional[str] = None, + count: Optional[int] = None, + __request__: Request = None, + __user__: dict = None, + __files__: list[dict] = None, +) -> str: + """ + Search files attached to the current chat using semantic/vector search. + Pass file_id from the attached_files block to search one file, or omit it to search all attached chat files. + + :param query: The search query to find semantically relevant content + :param file_id: Optional attached file ID to search within a single file + :param count: Maximum number of results to return, capped by the server RAG top k + :return: JSON with relevant chunks containing content, source filename, and relevance score + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + if isinstance(file_id, str) and file_id.lower() in ('none', 'null', ''): + file_id = None + if isinstance(count, str): + if count.lower() in ('none', 'null', ''): + count = None + else: + try: + count = int(count) + except ValueError: + count = None + + try: + from open_webui.retrieval.utils import get_sources_from_items + + attached_ids = set() + for item in __files__ or []: + if not isinstance(item, dict) or item.get('type', 'file') != 'file': + continue + fid = item.get('id') or item.get('url') + if isinstance(fid, str) and fid and not fid.startswith(('http://', 'https://', 'data:')): + attached_ids.add(fid) + + if not attached_ids: + return json.dumps({'error': 'No files are attached to this chat'}) + if file_id and file_id not in attached_ids: + return json.dumps({'error': 'File not found'}) + + accessible = await _get_accessible_chat_files(__files__, __user__, file_id) + if not accessible: + return json.dumps({'error': 'No accessible files found'}) + + file_items = [{**item} for item, _ in accessible] + rag_config = await Config.get_many( + 'rag.top_k', + 'rag.top_k_reranker', + 'rag.relevance_threshold', + 'rag.hybrid_bm25_weight', + 'rag.enable_hybrid_search', + 'rag.full_context', + ) + top_k = rag_config.get('rag.top_k') or 5 + count = top_k if count is None else max(1, min(count, top_k)) + full_context = all(item.get('context') == 'full' for item in file_items) or rag_config.get('rag.full_context') + + embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None) + if not embedding_function and not full_context: + return json.dumps({'error': 'Embedding function not configured'}) + + user_model = UserModel.model_construct( + id=__user__.get('id'), + role=__user__.get('role', 'user'), + ) + sources = await get_sources_from_items( + request=__request__, + items=file_items, + queries=[query], + embedding_function=( + lambda queries, prefix: embedding_function(queries, prefix=prefix, user=user_model) + if embedding_function + else None + ), + k=count, + reranking_function=( + (lambda q, docs: __request__.app.state.RERANKING_FUNCTION(q, docs, user=user_model)) + if getattr(__request__.app.state, 'RERANKING_FUNCTION', None) + else None + ), + k_reranker=rag_config.get('rag.top_k_reranker'), + r=rag_config.get('rag.relevance_threshold'), + hybrid_bm25_weight=rag_config.get('rag.hybrid_bm25_weight'), + hybrid_search=rag_config.get('rag.enable_hybrid_search'), + full_context=full_context, + user=user_model, + ) + + chunks = [] + for source in sources or []: + documents = source.get('document') or [] + metadatas = source.get('metadata') or [] + distances = source.get('distances') or [] + source_info = source.get('source') or {} + + for idx, doc in enumerate(documents): + metadata = metadatas[idx] if idx < len(metadatas) and isinstance(metadatas[idx], dict) else {} + chunk = { + 'content': doc, + 'source': metadata.get('source', metadata.get('name', source_info.get('name', 'Unknown'))), + 'file_id': metadata.get('file_id', source_info.get('id', '')), + } + if idx < len(distances): + chunk['distance'] = distances[idx] + chunks.append(chunk) + + return json.dumps(chunks[:count], ensure_ascii=False) + except Exception as e: + log.exception(f'query_chat_files error: {e}') + return json.dumps({'error': str(e)}) + + async def grep_knowledge_files( pattern: str, file_id: Optional[str] = None, @@ -1958,16 +2498,11 @@ async def grep_knowledge_files( try: from open_webui.models.files import Files from open_webui.models.knowledge import Knowledges - from open_webui.tools.knowledge_fs import build_matcher user_id = __user__.get('id') user_role = __user__.get('role', 'user') user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] - _matches, err = build_matcher(pattern, case_insensitive) - if err: - return json.dumps({'error': err}) - # Collect files to search files_to_search = [] @@ -2043,43 +2578,7 @@ async def grep_knowledge_files( if not files_to_search: return json.dumps({'error': 'No accessible files found'}) - # Search - results = [] - total_matches = 0 - counts = [] - - for file in files_to_search: - content = '' - if file.data: - content = file.data.get('content', '') - if not content: - continue - - lines = content.split('\n') - file_matches = 0 - - for i, line in enumerate(lines, 1): - if _matches(line): - file_matches += 1 - total_matches += 1 - if not count_only and len(results) < MAX_GREP_RESULTS: - results.append(f'{file.id} {file.filename}:{i}: {line}') - - if file_matches > 0 and count_only: - counts.append(f'{file.id} {file.filename}: {file_matches}') - - if count_only: - if not counts: - return f'No matches for "{pattern}"' - return '\n'.join(counts) + f'\n[{total_matches} total matches]' - - if not results: - return f'No matches for "{pattern}"' - - output = '\n'.join(results) - if total_matches > MAX_GREP_RESULTS: - output += f'\n[{MAX_GREP_RESULTS} of {total_matches} matches shown — use file_id to narrow]' - return output + return _grep_file_models(files_to_search, pattern, case_insensitive, count_only) except Exception as e: log.exception(f'grep_knowledge_files error: {e}') @@ -2928,8 +3427,8 @@ async def create_tasks( :param tasks: List of task items. Each item: content (string, required), status (pending|in_progress|completed|cancelled, default pending), id (optional, auto-generated). :return: JSON with the full task list and summary counts """ - if __chat_id__ is None: - return json.dumps({'error': 'Chat context not available'}) + if not is_saved_chat_id(__chat_id__): + return json.dumps({'error': 'Saved chat context not available'}) try: all_tasks = [] @@ -2980,8 +3479,8 @@ async def update_task( :param status: New status: completed, in_progress, pending, or cancelled (default: completed) :return: JSON with the updated task list and summary counts """ - if __chat_id__ is None: - return json.dumps({'error': 'Chat context not available'}) + if not is_saved_chat_id(__chat_id__): + return json.dumps({'error': 'Saved chat context not available'}) try: status = status.strip().lower() @@ -3019,10 +3518,22 @@ async def update_task( # ============================================================================= +async def _validate_owned_automation_folder(user_id: str, folder_id: Optional[str]) -> Optional[str]: + if not folder_id: + return None + from open_webui.models.folders import Folders + + folder = await Folders.get_folder_by_id_and_user_id(folder_id, user_id) + if not folder: + raise ValueError('Folder not found') + return folder.id + + async def create_automation( name: str, prompt: str, rrule: str, + folder_id: Optional[str] = None, __request__: Request = None, __user__: dict = None, __metadata__: dict = None, @@ -3045,6 +3556,7 @@ async def create_automation( :param name: A short descriptive name for the automation :param prompt: The prompt/instructions to execute on each run :param rrule: An iCalendar RRULE string defining the schedule + :param folder_id: Optional owner-owned folder ID for generated chats :return: JSON with the created automation details including id, next scheduled runs """ if __request__ is None: @@ -3056,6 +3568,7 @@ async def create_automation( try: from open_webui.models.automations import AutomationData, AutomationForm, Automations from open_webui.models.users import Users + from open_webui.routers.automations import check_automation_limits from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule user_id = __user__.get('id') @@ -3071,15 +3584,26 @@ async def create_automation( if not model_id: return json.dumps({'error': 'Could not detect current model'}) + try: + folder_id = await _validate_owned_automation_folder(user_id, folder_id) + except ValueError as e: + return json.dumps({'error': str(e)}) + # Validate the RRULE try: validate_rrule(rrule, tz=user.timezone) except ValueError as e: return json.dumps({'error': f'Invalid schedule: {e}'}) + try: + await check_automation_limits(__request__, user, rrule, None, is_create=True) + except HTTPException as e: + return json.dumps({'error': e.detail}) + tz = user.timezone form = AutomationForm( name=name, + folder_id=folder_id, data=AutomationData( prompt=prompt, model_id=model_id, @@ -3095,6 +3619,7 @@ async def create_automation( 'status': 'success', 'id': automation.id, 'name': automation.name, + 'folder_id': automation.folder_id, 'model_id': model_id, 'is_active': automation.is_active, 'next_runs': next_n_runs_ns(rrule, tz=tz), @@ -3112,6 +3637,7 @@ async def update_automation( prompt: Optional[str] = None, rrule: Optional[str] = None, model_id: Optional[str] = None, + folder_id: Optional[str] = None, __request__: Request = None, __user__: dict = None, ) -> str: @@ -3123,6 +3649,7 @@ async def update_automation( :param prompt: New prompt/instructions (optional) :param rrule: New iCalendar RRULE schedule string (optional). See create_automation for format examples. :param model_id: New model ID to use (optional) + :param folder_id: New owner-owned folder ID (optional); pass an empty string to clear :return: JSON with the updated automation details """ if __request__ is None: @@ -3134,10 +3661,13 @@ async def update_automation( try: from open_webui.models.automations import AutomationData, AutomationForm, Automations from open_webui.models.users import Users + from open_webui.routers.automations import check_automation_limits from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule user_id = __user__.get('id') user = await Users.get_user_by_id(user_id) + if not user: + return json.dumps({'error': 'User not found'}) automation = await Automations.get_by_id(automation_id) if not automation: @@ -3150,17 +3680,30 @@ async def update_automation( new_prompt = prompt if prompt is not None else automation.data.get('prompt', '') new_model_id = model_id if model_id is not None else automation.data.get('model_id', '') new_rrule = rrule if rrule is not None else automation.data.get('rrule', '') + if folder_id is None: + new_folder_id = automation.folder_id + else: + try: + new_folder_id = await _validate_owned_automation_folder(user_id, folder_id) + except ValueError as e: + return json.dumps({'error': str(e)}) # Validate RRULE if changed if rrule is not None: try: - validate_rrule(new_rrule, tz=user.timezone if user else None) + validate_rrule(new_rrule, tz=user.timezone) except ValueError as e: return json.dumps({'error': f'Invalid schedule: {e}'}) - tz = user.timezone if user else None + try: + await check_automation_limits(__request__, user, new_rrule, None) + except HTTPException as e: + return json.dumps({'error': e.detail}) + + tz = user.timezone form = AutomationForm( name=new_name, + folder_id=new_folder_id, data=AutomationData( prompt=new_prompt, model_id=new_model_id, @@ -3176,6 +3719,7 @@ async def update_automation( 'status': 'success', 'id': updated.id, 'name': updated.name, + 'folder_id': updated.folder_id, 'model_id': new_model_id, 'is_active': updated.is_active, 'next_runs': next_n_runs_ns(new_rrule, tz=tz), @@ -3189,6 +3733,7 @@ async def update_automation( async def list_automations( status: Optional[str] = None, + folder_id: Optional[str] = None, count: int = 10, __request__: Request = None, __user__: dict = None, @@ -3197,6 +3742,7 @@ async def list_automations( List the user's scheduled automations. :param status: Filter by status: "active", "paused", or omit for all + :param folder_id: Optional owner-owned folder ID filter; pass an empty string to clear the folder filter :param count: Maximum number of automations to return (default: 10) :return: JSON list of automations with id, name, prompt snippet, schedule, status, and next runs """ @@ -3213,10 +3759,16 @@ async def list_automations( user_id = __user__.get('id') user = await Users.get_user_by_id(user_id) + if folder_id: + try: + folder_id = await _validate_owned_automation_folder(user_id, folder_id) + except ValueError as e: + return json.dumps({'error': str(e)}) result = await Automations.search_automations( user_id=user_id, status=status, + folder_id=folder_id, skip=0, limit=count, ) @@ -3231,6 +3783,7 @@ async def list_automations( { 'id': item.id, 'name': item.name, + 'folder_id': item.folder_id, 'prompt_snippet': snippet, 'model_id': item.data.get('model_id', ''), 'rrule': rrule, diff --git a/backend/open_webui/tools/knowledge_fs.py b/backend/open_webui/tools/knowledge_fs.py index 0ec1cc8983..704ea45b1f 100644 --- a/backend/open_webui/tools/knowledge_fs.py +++ b/backend/open_webui/tools/knowledge_fs.py @@ -33,9 +33,9 @@ MAX_GREP_MATCHES = 50 def is_regex_pattern(pattern: str) -> bool: - """Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...]).""" + """Detect if a pattern looks like regex (|, .*, .+, \d, \w, \s, [...]).""" return ( - '\|' in pattern + '|' in pattern or '.*' in pattern or '.+' in pattern or '.?' in pattern @@ -686,12 +686,16 @@ async def _kb_grep( # Grep on piped input if piped_input is not None: - lines = piped_input.split('\\n') + lines = piped_input.split('\n') matched = [] for i, line in enumerate(lines, 1): if _matches(line): matched.append(f'{i}: {line}') - return '\\n'.join(matched) if matched else f'No matches for "{pattern}"' + if count_only: + return str(len(matched)) + if filenames_only: + return '(standard input)' if matched else f'No matches for "{pattern}"' + return '\n'.join(matched) if matched else f'No matches for "{pattern}"' # Single file grep if file_ref and not dir_scope: @@ -702,7 +706,7 @@ async def _kb_grep( elif 'error' in resolved: return resolved['error'] else: - lines = resolved['content'].split('\\n') + lines = resolved['content'].split('\n') matched = [] for i, line in enumerate(lines, 1): if _matches(line): @@ -715,7 +719,7 @@ async def _kb_grep( if not matched: return f'No matches for "{pattern}" in {resolved["filename"]}' - return '\\n'.join(matched) + return '\n'.join(matched) # Cross-file grep (optionally scoped to directory) accessible = await _get_accessible_files(user, model_knowledge) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index a98f95f97e..f7e6806bc9 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -3,9 +3,11 @@ from typing import Any 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, + strip_anyone_access_grants, strip_user_access_grants, ) from open_webui.models.groups import Groups @@ -215,13 +217,31 @@ async def filter_allowed_access_grants( user_role: str, access_grants: list, public_permission_key: str, + anyone_permission_key: str | None = None, 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. """ - if user_role == 'admin' or not access_grants: + 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 = strip_anyone_access_grants(access_grants) + + if user_role == 'admin': return access_grants # Check if user can share publicly @@ -253,6 +273,22 @@ async def filter_allowed_access_grants( ): access_grants = strip_user_access_grants(access_grants) + if any( + (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group' + 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' + ] + return access_grants @@ -260,6 +296,7 @@ async def has_base_model_access( user_id: str, model_info, *, + user_role: str | None = None, user_group_ids: set[str] | None = None, db=None, ) -> bool: @@ -267,9 +304,11 @@ async def has_base_model_access( Walk the ``base_model_id`` chain and verify the caller has read access at every hop. - Returns ``True`` when access is granted (or the chain ends at a raw - provider model that has no per-model ACL). Returns ``False`` the - moment a registered base model denies access. + A base model without a ``model`` table row is admin-only, matching how + unregistered models are treated for direct use (``get_filtered_models`` + hides them from non-admins and ``check_model_access`` rejects them), so + a shared preset cannot be used to reach a base model the caller could + not use directly. Returns ``False`` the moment any hop denies access. """ from open_webui.models.access_grants import AccessGrants from open_webui.models.models import Models @@ -280,7 +319,7 @@ async def has_base_model_access( seen.add(base_model_id) base_model_info = await Models.get_model_by_id(base_model_id, db=db) if base_model_info is None: - break # Raw provider model — no per-model ACL + return user_role == 'admin' if not ( user_id == base_model_info.user_id or await AccessGrants.has_access( @@ -339,7 +378,7 @@ async def check_model_access( raise HTTPException(status_code=403, detail='Model not found') # Enforce access on chained base models - if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids): + if not await has_base_model_access(user.id, model_info, user_role=user.role, user_group_ids=user_group_ids): raise HTTPException(status_code=403, detail='Model not found') else: if user.role != 'admin': diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index ddb6acb066..f015e71c70 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -18,6 +18,7 @@ async def has_access_to_file( access_type: str, user: UserModel, db: AsyncSession | None = None, + user_group_ids: set[str] | None = None, ) -> bool: """ Check if a user has the specified access to a file through any of: @@ -43,7 +44,8 @@ async def has_access_to_file( # the object's OWNER owns that file; otherwise a read-only file laundered into an object # the user controls would gain write/delete on it (CWE-863). Read access is unaffected. knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db) - user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + if user_group_ids is None: + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} for knowledge_base in knowledge_bases: if ( knowledge_base.user_id == user.id @@ -60,12 +62,25 @@ async def has_access_to_file( knowledge_base_id = file.meta.get('collection_name') if file.meta else None if knowledge_base_id: - knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db) - for knowledge_base in knowledge_bases: - if knowledge_base.id == knowledge_base_id and ( - access_type == 'read' or knowledge_base.user_id == file.user_id - ): - return True + # Fetch the one referenced knowledge base instead of listing every + # knowledge base the user can access just to scan for this id. + knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id, db=db) + if ( + knowledge_base + and (access_type == 'read' or knowledge_base.user_id == file.user_id) + and ( + knowledge_base.user_id == user.id + or await AccessGrants.has_access( + user_id=user.id, + resource_type='knowledge', + resource_id=knowledge_base.id, + permission=access_type, + user_group_ids=user_group_ids, + db=db, + ) + ) + ): + return True # Check if the file is associated with any channels the user has access to channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db) @@ -88,7 +103,9 @@ async def has_access_to_file( # Check if the file is directly attached to a shared workspace model (per the ownership # note above, model write is conferred only for files the model owner owns). - for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db): + for model in await Models.get_models_by_user_id( + user.id, permission=access_type, db=db, user_group_ids=user_group_ids + ): knowledge_items = getattr(model.meta, 'knowledge', None) or [] for item in knowledge_items: if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: @@ -105,14 +122,18 @@ async def get_accessible_folder_files( ) -> list[dict]: """Filter folder.data['files'] entries to those the caller can read. - Each entry is expected to have 'type' ('file' or 'collection') and 'id'. - Admins bypass all checks. Unknown types are kept as-is. + Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. File, collection and + note ids are each access-checked against the caller; admins bypass all checks and + genuinely unknown types are kept as-is. """ if not entries: return [] if user.role == 'admin': return list(entries) + # One group-membership fetch for the whole folder listing + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + accessible: list[dict] = [] for entry in entries: if not isinstance(entry, dict): @@ -123,11 +144,27 @@ async def get_accessible_folder_files( accessible.append(entry) continue if entry_type == 'file': - if await has_access_to_file(entry_id, 'read', user, db=db): + if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids): accessible.append(entry) elif entry_type == 'collection': if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db): accessible.append(entry) + elif entry_type == 'note': + # Owner has no self-grant (notes are private by default), so check ownership too. + from open_webui.models.notes import Notes + + note = await Notes.get_note_by_id(entry_id, db=db) + if note and ( + note.user_id == user.id + or await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=entry_id, + permission='read', + db=db, + ) + ): + accessible.append(entry) else: accessible.append(entry) return accessible diff --git a/backend/open_webui/utils/actions.py b/backend/open_webui/utils/actions.py index eb5a84d0d4..1249dd60cb 100644 --- a/backend/open_webui/utils/actions.py +++ b/backend/open_webui/utils/actions.py @@ -4,12 +4,12 @@ import sys from typing import Any from fastapi import Request -from open_webui.env import GLOBAL_LOG_LEVEL +from open_webui.env import ENABLE_PLUGINS, GLOBAL_LOG_LEVEL from open_webui.models.functions import Functions from open_webui.models.users import UserModel from open_webui.socket.main import get_event_call, get_event_emitter from open_webui.utils.middleware import process_tool_result -from open_webui.utils.models import get_all_models +from open_webui.utils.models import check_model_access, get_all_models from open_webui.utils.plugin import get_function_module_from_cache logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -17,6 +17,9 @@ log = logging.getLogger(__name__) async def chat_action(request: Request, action_id: str, form_data: dict, user: Any): + if not ENABLE_PLUGINS: + raise Exception('Plugins are disabled by ENABLE_PLUGINS=false') + if '.' in action_id: action_id, sub_action_id = action_id.split('.') else: @@ -43,6 +46,23 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A raise Exception('Model not found') model = models[model_id] + # Availability gate — keep this route consistent with the actions a model + # actually surfaces to the client. Executing admin-authored Function code is + # intended; this only stops a disabled, unassigned, or access-restricted + # action from being reached by calling the route with a raw action_id. + if action.type != 'action' or not action.is_active: + raise Exception(f'Action not available: {action_id}') + + # Direct connections carry a client-supplied model the caller already owns, + # so scope the model-bound checks to server-resolved models. + if not getattr(request.state, 'direct', False) and user.role != 'admin': + await check_model_access(user, model) + # model['actions'] entries are '' or '.'; + # the function id is always the prefix. + surfaced_action_ids = {item.get('id', '').split('.', 1)[0] for item in model.get('actions', [])} + if action_id not in surfaced_action_ids: + raise Exception(f'Action not available: {action_id}') + __event_emitter__ = await get_event_emitter( { 'chat_id': data['chat_id'], diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 9e8bc54cf0..3c8d5ce9cd 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers log = logging.getLogger(__name__) +ANTHROPIC_CONVERTED_REQUEST_PARAMS = { + 'model', + 'messages', + 'system', + 'max_tokens', + 'temperature', + 'top_p', + 'top_k', + 'stop_sequences', + 'stream', + 'metadata', + 'service_tier', + 'tools', + 'tool_choice', + 'reasoning_effort', +} + def is_anthropic_url(url: str) -> bool: """Check if the URL is an Anthropic API endpoint.""" @@ -109,7 +126,16 @@ def _finalize_openai_content(blocks: list) -> str | list: return blocks -def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: +def is_anthropic_messages_passthrough(url: str, api_config: dict | None = None) -> bool: + api_config = api_config or {} + provider = str(api_config.get('provider', '')).lower() + + return is_anthropic_url(url or '') or provider == 'litellm' + + +def convert_anthropic_to_openai_payload( + anthropic_payload: dict, passthrough_params: list[str] | str | None = None +) -> dict: """ Convert an Anthropic Messages API request to OpenAI Chat Completions format. @@ -173,6 +199,8 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: }, ) ) + elif block_type in ('thinking', 'redacted_thinking'): + openai_content.append(_copy_cache_control(block, dict(block))) elif block_type == 'image': source = block.get('source', {}) if source.get('type') == 'base64': @@ -350,6 +378,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: if 'max_tokens' in anthropic_payload: openai_payload['max_tokens'] = anthropic_payload['max_tokens'] + captured_passthrough_params = { + param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS + } + if isinstance(passthrough_params, str): + passthrough_params = passthrough_params.split(',') + elif not isinstance(passthrough_params, (list, tuple, set)): + passthrough_params = [] + passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()} + if '*' in passthrough_param_names: + openai_payload.update(captured_passthrough_params) + else: + for param in passthrough_param_names: + if param in captured_passthrough_params: + openai_payload[param] = captured_passthrough_params[param] + + output_config = anthropic_payload.get('output_config') + if isinstance(output_config, dict): + if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload: + openai_payload['reasoning_effort'] = output_config['effort'] + + format_config = output_config.get('format') + if isinstance(format_config, dict): + format_type = format_config.get('type') + if format_type == 'json_schema': + json_schema = { + 'name': format_config.get('name', 'response_schema'), + 'schema': format_config.get('schema', {}), + } + if 'description' in format_config: + json_schema['description'] = format_config['description'] + if 'strict' in format_config: + json_schema['strict'] = format_config['strict'] + openai_payload['response_format'] = { + 'type': 'json_schema', + 'json_schema': json_schema, + } + elif format_type == 'json_object': + openai_payload['response_format'] = {'type': format_type} + + if 'reasoning_effort' in anthropic_payload: + openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort'] + # Common parameters for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'): if param in anthropic_payload: @@ -395,7 +465,9 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: return openai_payload -def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') -> dict: +def convert_openai_to_anthropic_response( + openai_response: dict, model: str = '', input_tokens: int | None = None +) -> dict: """ Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format. """ @@ -419,6 +491,37 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') # Build content blocks content = [] + message_thinking = message.get('thinking') + thinking_blocks = message.get('thinking_blocks') or [] + if not thinking_blocks and isinstance(message_thinking, dict): + thinking_blocks = message_thinking.get('blocks') or [] + + has_thinking = False + for block in thinking_blocks: + if not isinstance(block, dict): + continue + + if block.get('type') == 'redacted_thinking': + content.append({k: v for k, v in block.items() if k in {'type', 'data'}}) + has_thinking = True + continue + + thinking = block.get('thinking') or block.get('content') or block.get('text') + if not thinking: + continue + + thinking_block = {'type': 'thinking', 'thinking': thinking} + if block.get('signature'): + thinking_block['signature'] = block['signature'] + content.append(thinking_block) + has_thinking = True + + reasoning_content = message.get('reasoning_content') or message.get('reasoning') + if not reasoning_content and isinstance(message_thinking, str): + reasoning_content = message_thinking + if reasoning_content and not has_thinking: + content.append({'type': 'thinking', 'thinking': reasoning_content}) + message_content = message.get('content') if message_content: content.append({'type': 'text', 'text': message_content}) @@ -443,8 +546,12 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') # Usage openai_usage = openai_response.get('usage', {}) usage = { - 'input_tokens': openai_usage.get('prompt_tokens', 0), - 'output_tokens': openai_usage.get('completion_tokens', 0), + 'input_tokens': ( + input_tokens + if input_tokens is not None + else openai_usage.get('input_tokens', openai_usage.get('prompt_tokens', 0)) + ), + 'output_tokens': openai_usage.get('output_tokens', openai_usage.get('completion_tokens', 0)), } if 'cache_creation_input_tokens' in openai_usage: usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens'] @@ -463,7 +570,7 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') } -async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ''): +async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = '', input_tokens: int = 0): """ Convert an OpenAI SSE streaming response to Anthropic Messages SSE format. @@ -480,13 +587,13 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str import uuid as _uuid message_id = f'msg_{_uuid.uuid4().hex[:24]}' - input_tokens = 0 output_tokens = 0 stop_reason = 'end_turn' # Track content blocks with a running index. # Each text block or tool_use block gets its own index. current_block_index = 0 + thinking_block_open = False text_block_open = False # Accumulated state for each tool call, keyed by tool call id. @@ -510,7 +617,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str 'model': model, 'stop_reason': None, 'stop_sequence': None, - 'usage': {'input_tokens': 0, 'output_tokens': 0}, + 'usage': {'input_tokens': input_tokens, 'output_tokens': 0}, }, } yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode() @@ -541,8 +648,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str if not choices: # Check for usage in the final chunk if data.get('usage'): - input_tokens = data['usage'].get('prompt_tokens', input_tokens) - output_tokens = data['usage'].get('completion_tokens', output_tokens) + input_tokens = data['usage'].get( + 'input_tokens', data['usage'].get('prompt_tokens', input_tokens) + ) + output_tokens = data['usage'].get( + 'output_tokens', data['usage'].get('completion_tokens', output_tokens) + ) continue delta = choices[0].get('delta', {}) @@ -551,14 +662,57 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str # Update usage if present if data.get('usage'): - input_tokens = data['usage'].get('prompt_tokens', input_tokens) - output_tokens = data['usage'].get('completion_tokens', output_tokens) + input_tokens = data['usage'].get('input_tokens', data['usage'].get('prompt_tokens', input_tokens)) + output_tokens = data['usage'].get( + 'output_tokens', data['usage'].get('completion_tokens', output_tokens) + ) + + reasoning_content = ( + delta.get('reasoning_content') + or delta.get('reasoning') + or delta.get('thinking') + or message.get('reasoning_content') + or message.get('reasoning') + ) + if not reasoning_content: + thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or [] + for block in thinking_blocks: + if isinstance(block, dict): + reasoning_content = block.get('thinking') or block.get('content') or block.get('text') + if reasoning_content: + break + + if reasoning_content and not text_block_open and not has_tool_calls: + if not thinking_block_open: + block_start = { + 'type': 'content_block_start', + 'index': current_block_index, + 'content_block': {'type': 'thinking', 'thinking': ''}, + } + yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode() + thinking_block_open = True + + block_delta = { + 'type': 'content_block_delta', + 'index': current_block_index, + 'delta': {'type': 'thinking_delta', 'thinking': reasoning_content}, + } + yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode() # --- Handle text content --- # Anthropic expects text blocks before tool blocks, so skip # text deltas once any tool call has started. content = delta.get('content') if content and not has_tool_calls: + if thinking_block_open: + block_stop = { + 'type': 'content_block_stop', + 'index': current_block_index, + } + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + thinking_block_open = False + current_block_index += 1 + if not text_block_open: block_start = { 'type': 'content_block_start', @@ -584,6 +738,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str if tool_calls: # Close text block if one is open (text comes before tools) + if thinking_block_open: + block_stop = { + 'type': 'content_block_stop', + 'index': current_block_index, + } + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + thinking_block_open = False + current_block_index += 1 + if text_block_open: block_stop = { 'type': 'content_block_stop', @@ -695,6 +858,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str except Exception as e: log.error(f'Error in Anthropic stream conversion: {e}') + # Close any open thinking block + if thinking_block_open: + block_stop = {'type': 'content_block_stop', 'index': current_block_index} + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() + current_block_index += 1 + # Flush any tools that buffered arguments but never emitted a block for tool in tracked_tool_calls.values(): if not tool['started'] and tool['name']: diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py index 1ed539fefa..d28abc90f8 100644 --- a/backend/open_webui/utils/asgi_middleware.py +++ b/backend/open_webui/utils/asgi_middleware.py @@ -39,7 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse from fastapi.security import HTTPAuthorizationCredentials from open_webui.env import CUSTOM_API_KEY_HEADER from open_webui.internal.db import ScopedSession -from open_webui.models.config import Config from open_webui.utils.auth import get_http_authorization_cred from starlette.datastructures import MutableHeaders from starlette.requests import Request @@ -101,14 +100,20 @@ class CommitSessionMiddleware: # Downstream did not complete successfully. Roll back any # pending sync writes, release the connection, and let the # exception propagate. - try: - ScopedSession.rollback() - except Exception: - log.exception('CommitSessionMiddleware: rollback failed after downstream error') - finally: - ScopedSession.remove() + if ScopedSession.registry.has(): + try: + ScopedSession.rollback() + except Exception: + log.exception('CommitSessionMiddleware: rollback failed after downstream error') + finally: + ScopedSession.remove() raise + # Nothing in this request touched the sync session: committing would + # only instantiate one to run an empty transaction. + if not ScopedSession.registry.has(): + return + # Downstream completed. Commit pending sync work. try: ScopedSession.commit() @@ -138,9 +143,7 @@ class AuthTokenMiddleware: the middleware checks that instead and avoids the 401 short-circuit. Routes that depend on `get_verified_user` etc. read this state. - Also exposes `request.state.enable_api_keys` (snapshotted at request - entry from runtime config) and stamps an `X-Process-Time` response - header. + Also stamps an `X-Process-Time` response header. """ def __init__(self, app: ASGIApp, *, fastapi_app) -> None: @@ -166,13 +169,12 @@ class AuthTokenMiddleware: token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key) request.state.token = token - request.state.enable_api_keys = await Config.get('auth.enable_api_keys') async def send_with_timing(message: Message) -> None: if message['type'] == 'http.response.start': - process_time = int(time.monotonic() - start_time) + process_time = time.monotonic() - start_time headers = MutableHeaders(scope=message) - headers['X-Process-Time'] = str(process_time) + headers['X-Process-Time'] = f'{process_time:.6f}' await send(message) await self.app(scope, receive, send_with_timing) @@ -231,7 +233,15 @@ class RedirectMiddleware: return path = scope.get('path', '') - query_string = scope.get('query_string', b'').decode('latin-1', errors='replace') + raw_query = scope.get('query_string', b'') + # This middleware only acts on /watch?v= and ?shared= URLs; skip the + # decode + parse_qs work for every other GET. (A false positive on the + # substring check just falls through to the full parse below.) + if not (path.endswith('/watch') or b'shared' in raw_query): + await self.app(scope, receive, send) + return + + query_string = raw_query.decode('latin-1', errors='replace') query_params = parse_qs(query_string) redirect_params: dict[str, str] = {} diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 313f4cf235..890960bc58 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -132,14 +132,27 @@ class AuditLoggingMiddleware: ) -> None: self.app = app self.audit_logger = AuditLogger(logger) - self.excluded_paths = excluded_paths or [] - self.included_paths = included_paths or [] + + def normalize_paths(paths: Optional[list[str]]) -> list[str]: + return [path for path in (path.strip().lstrip('/') for path in paths or []) if path] + + self.excluded_paths = normalize_paths(excluded_paths) + self.included_paths = normalize_paths(included_paths) self.max_body_size = max_body_size self.audited_methods = set(self.DEFAULT_AUDITED_METHODS) if audit_get_requests: self.audited_methods.add('GET') self.audit_level = audit_level + # Paths are fixed for the process lifetime; compile once instead of + # per request. None means the corresponding mode has nothing to match. + self._included_pattern = ( + re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') if self.included_paths else None + ) + self._excluded_pattern = ( + re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') if self.excluded_paths else None + ) + if self.included_paths and self.excluded_paths: logger.warning( 'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. ' @@ -196,6 +209,13 @@ class AuditLoggingMiddleware: await self._log_audit_entry(request, context) async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]: + # get_current_user stashes the resolved user on the scope-backed state; + # reuse it instead of running the full auth pipeline (JWT decode, Redis + # revocation checks, DB fetch, last-active write) a second time. + user = getattr(request.state, 'user', None) + if isinstance(user, UserModel): + return user + auth_header = request.headers.get('Authorization') try: @@ -206,6 +226,12 @@ class AuditLoggingMiddleware: return None + ALWAYS_LOG_ENDPOINTS = ( + '/api/v1/auths/signin', + '/api/v1/auths/signout', + '/api/v1/auths/signup', + ) + def _should_skip_auditing(self, request: Request) -> bool: if AUDIT_LOG_LEVEL == 'NONE': return True @@ -213,13 +239,8 @@ class AuditLoggingMiddleware: if request.method not in self.audited_methods: return True - ALWAYS_LOG_ENDPOINTS = { - '/api/v1/auths/signin', - '/api/v1/auths/signout', - '/api/v1/auths/signup', - } path = request.url.path.lower() - for endpoint in ALWAYS_LOG_ENDPOINTS: + for endpoint in self.ALWAYS_LOG_ENDPOINTS: if path.startswith(endpoint): return False # Do NOT skip logging for auth endpoints @@ -229,15 +250,11 @@ class AuditLoggingMiddleware: return True # Whitelist mode: only log paths that match included_paths - if self.included_paths: - pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') - if not pattern.match(request.url.path): - return True # Skip: path not in whitelist - return False # Do NOT skip: path is in whitelist + if self._included_pattern: + return not self._included_pattern.match(request.url.path) # Blacklist mode: skip paths that match excluded_paths - pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') - if pattern.match(request.url.path): + if self._excluded_pattern and self._excluded_pattern.match(request.url.path): return True return False diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index c95e23bb85..0384d6fcb9 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -96,11 +96,15 @@ def get_license_data(app, key): setattr(app.state, 'LICENSE_METADATA', v) def handler(u): - res = requests.post( - f'{u}/api/v1/license/', - json={'key': key, 'version': '1'}, - timeout=5, - ) + try: + res = requests.post( + f'{u}/api/v1/license/', + json={'key': key, 'version': '1'}, + timeout=5, + ) + except Exception as ex: + log.error(f'License: retrieval issue from {u}: {ex}') + return False if getattr(res, 'ok', False): payload = getattr(res, 'json', lambda: {})() @@ -352,6 +356,8 @@ async def get_current_user( current_span.set_attribute('client.user.role', user.role) current_span.set_attribute('client.auth.type', 'api_key') + # Scope-backed, so outer middleware (audit) can reuse the resolved user + request.state.user = user return user # auth by jwt token @@ -399,9 +405,10 @@ async def get_current_user( # Refresh the user's last active timestamp # Fire-and-forget via asyncio.create_task to avoid blocking - import asyncio - asyncio.create_task(Users.update_last_active_by_id(user.id)) + + # Scope-backed, so outer middleware (audit) can reuse the resolved user + request.state.user = user return user else: raise HTTPException( @@ -433,24 +440,30 @@ async def get_current_user_by_api_key(request, api_key: str): detail=ERROR_MESSAGES.INVALID_TOKEN, ) - user_permissions = await Config.get('user.permissions') - enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions') - allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '') + config_values = await Config.get_many( + 'auth.enable_api_keys', + 'user.permissions', + 'auth.api_key.endpoint_restrictions', + 'auth.api_key.allowed_endpoints', + ) - if not request.state.enable_api_keys or ( - user.role != 'admin' - and not await has_permission( + if not config_values.get('auth.enable_api_keys'): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED) + + if user.role != 'admin': + user_permissions = config_values.get('user.permissions') + if not await has_permission( user.id, 'features.api_keys', user_permissions, - ) - ): - raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED) + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED) # Enforce endpoint restrictions — checked here (not in middleware) # so it applies regardless of how the API key was transported # (Authorization header, cookie, x-api-key header, etc.). - if enable_endpoint_restrictions: + if config_values.get('auth.api_key.endpoint_restrictions'): + allowed_endpoints = config_values.get('auth.api_key.allowed_endpoints', '') allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()] request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710) is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths) @@ -475,8 +488,11 @@ async def get_current_user_by_api_key(request, api_key: str): return user +VERIFIED_USER_ROLES = {'user', 'admin'} + + def get_verified_user(user=Depends(get_current_user)): - if user.role not in {'user', 'admin'}: + if user.role not in VERIFIED_USER_ROLES: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -484,6 +500,19 @@ def get_verified_user(user=Depends(get_current_user)): return user +async def get_verified_user_by_token(token: str, redis=None): + """Resolve a verified user from a raw token, for WebSocket handshakes that run outside the HTTP dependency chain.""" + decoded = decode_token(token) + if decoded is None or 'id' not in decoded or not await is_valid_token(decoded, redis): + return None + + user = await Users.get_user_by_id(decoded['id']) + if user is None or user.role not in VERIFIED_USER_ROLES: + return None + + return user + + def get_admin_user(user=Depends(get_current_user)): if user.role != 'admin': raise HTTPException( diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index d4ac9c5207..50a61909ef 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -7,9 +7,11 @@ Follows the utils/.py pattern (cf. utils/channels.py, utils/task.py). The scheduler_worker_loop handles all time-based background work: - Automation execution (claim_due → execute) - Calendar event alerts (upcoming events → socket + webhook notifications) + - One-shot chat timers Environment: SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10) + TIMER_POLL_INTERVAL – seconds between timer polls (default: 1) CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5) """ @@ -32,15 +34,18 @@ from open_webui.internal.db import get_async_db from open_webui.models.automations import AutomationModel, AutomationRuns, Automations from open_webui.models.chats import ChatForm, Chats from open_webui.models.config import Config +from open_webui.models.folders import Folders from open_webui.models.users import Users from open_webui.utils.auth import create_token from open_webui.utils.misc import parse_duration from open_webui.utils.task import prompt_template +from open_webui.utils.terminals import get_terminal_server_url from starlette.datastructures import Headers log = logging.getLogger(__name__) SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10'))) +TIMER_POLL_INTERVAL = int(os.getenv('TIMER_POLL_INTERVAL', '1')) CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10')) @@ -65,7 +70,7 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]: return None -def _parse_rule(s: str): +def _parse_rule(s: str, now: Optional[datetime] = None): """Parse RRULE with clock-aligned DTSTART for sub-daily frequencies. MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00) @@ -77,6 +82,20 @@ def _parse_rule(s: str): if freq in ('MINUTELY', 'HOURLY'): epoch = datetime(2000, 1, 1, 0, 0, 0) + if ( + now is not None + and s.startswith('RRULE:') + and '\n' not in s + and '\r' not in s + and set(parts) <= {'FREQ', 'INTERVAL', 'BYMINUTE', 'BYSECOND'} + ): + try: + interval = int(parts.get('INTERVAL', '1')) + if interval > 0: + step = timedelta(minutes=interval) if freq == 'MINUTELY' else timedelta(hours=interval) + return rrulestr(s, dtstart=epoch + ((now - epoch) // step) * step, ignoretz=True) + except (TypeError, ValueError): + pass return rrulestr(s, dtstart=epoch, ignoretz=True) return rrulestr(s, ignoretz=True) @@ -88,12 +107,12 @@ def validate_rrule(s: str, tz: str = None) -> None: clock so that near-future schedules are not incorrectly rejected on servers whose system clock is ahead (e.g. UTC vs US timezones). """ - try: - rule = _parse_rule(s) - except Exception as e: - raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) zi = _resolve_tz(tz) now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now() + try: + rule = _parse_rule(s, now) + except Exception as e: + raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) if rule.after(now) is None: raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS) @@ -102,7 +121,8 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]: """Next occurrence as epoch nanoseconds, respecting user timezone.""" zi = _resolve_tz(tz) now = datetime.now(zi) if zi else datetime.now() - dt = _parse_rule(s).after(now.replace(tzinfo=None)) + now_naive = now.replace(tzinfo=None) + dt = _parse_rule(s, now_naive).after(now_naive) if dt is None: return None if zi: @@ -117,9 +137,9 @@ def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]: preview matches the user's local clock (same as next_run_ns). """ zi = _resolve_tz(tz) - rule = _parse_rule(s) result = [] now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now() + rule = _parse_rule(s, now) dt = now for _ in range(n): dt = rule.after(dt) @@ -141,8 +161,8 @@ def rrule_interval_seconds(s: str) -> Optional[int]: """ if 'COUNT=1' in s: return None - rule = _parse_rule(s) now = datetime.now() + rule = _parse_rule(s, now) first = rule.after(now) if first is None: return None @@ -173,9 +193,30 @@ async def scheduler_worker_loop(app) -> None: Runs on every instance. Poll interval is configurable via SCHEDULER_POLL_INTERVAL env var (default: 10 seconds). """ - log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)') + log.info( + f'Scheduler worker started (timer poll interval: {TIMER_POLL_INTERVAL}s, ' + f'scheduler poll interval: {SCHEDULER_POLL_INTERVAL}s)' + ) + next_scheduler_poll = 0.0 + while True: try: + now = time.monotonic() + # ── Timers ── + try: + from open_webui.utils.timers import claim_due_timers, execute_due_timer + + for timer_id, claim_id in await claim_due_timers(int(time.time_ns()), limit=10): + asyncio.create_task(execute_due_timer(app, timer_id, claim_id)) + except Exception: + log.exception('Scheduler: timer error') + + if now < next_scheduler_poll: + await asyncio.sleep(max(1, TIMER_POLL_INTERVAL)) + continue + # Jitter to spread automation/calendar load across instances; timers keep a tight poll. + next_scheduler_poll = now + SCHEDULER_POLL_INTERVAL + random.uniform(0, 2) + # ── Automations ── if await Config.get('automations.enable'): try: @@ -198,8 +239,7 @@ async def scheduler_worker_loop(app) -> None: except Exception: log.exception('Scheduler worker error') - # Jitter to spread load across instances - await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2)) + await asyncio.sleep(max(1, TIMER_POLL_INTERVAL)) ########################## @@ -323,16 +363,11 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) - log.warning(f'Terminal server {server_id} not found for CWD set') return - base_url = (connection.get('url') or '').rstrip('/') + base_url = get_terminal_server_url(connection) if not base_url: return - # Build target URL — route through orchestrator policy if configured - policy_id = connection.get('policy_id') - if connection.get('server_type') == 'orchestrator' and policy_id: - target_url = f'{base_url}/p/{policy_id}/files/cwd' - else: - target_url = f'{base_url}/files/cwd' + target_url = f'{base_url}/files/cwd' headers = {'Content-Type': 'application/json', 'X-User-Id': user.id} if chat_id: @@ -396,7 +431,10 @@ async def execute_automation(app, automation: AutomationModel) -> None: prompt = await prompt_template(automation.data['prompt'], user) model_id = automation.data['model_id'] - terminal_config = automation.data.get('terminal') + folder_id = automation.folder_id + if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id): + await Automations.clear_folder_ids(automation.user_id, [folder_id]) + folder_id = None # Generate proper UUIDs for messages (same as frontend) user_msg_id = str(uuid4()) @@ -407,6 +445,7 @@ async def execute_automation(app, automation: AutomationModel) -> None: chat_id, automation.user_id, ChatForm( + folder_id=folder_id, chat={ 'title': automation.name, 'models': [model_id], @@ -438,7 +477,7 @@ async def execute_automation(app, automation: AutomationModel) -> None: {'role': 'user', 'content': prompt}, ], 'meta': {'automation_id': automation.id}, - } + }, ), ) @@ -613,40 +652,25 @@ async def _check_calendar_alerts(app) -> None: except Exception: log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True) - # Send webhook notification if user has one configured + # Send target notification if user has one configured try: - webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI') - enable_user_webhooks = await Config.get('ui.enable_user_webhooks') - - if enable_user_webhooks: - user = await Users.get_user_by_id(event.user_id) - if user and user.settings: - webhook_url = ( - user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None) - if isinstance(user.settings, dict) - else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)( - 'notifications', {} - ).get('webhook_url', None) - if hasattr(user.settings, 'ui') - else None - ) - if webhook_url: - from open_webui.utils.webhook import post_webhook - - time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now' - await post_webhook( - webui_name, - webhook_url, - f'{event.title} — starting {time_str}', - { - 'action': 'calendar_alert', - 'title': event.title, - 'minutes_until': minutes_until, - 'event_id': event.id, - }, - ) + time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now' + await publish_event( + app, + EVENTS.CALENDAR_ALERT, + subject_id=event.id, + subject_type='calendar.event', + source='scheduler', + data={ + **alert_data, + 'user_id': event.user_id, + 'starts_in': time_str, + 'message': f'{event.title}: starting {time_str}', + }, + message=event.title, + ) except Exception: - log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True) + log.debug(f'Failed to send notification for calendar alert {event.id}', exc_info=True) async def _record_run( diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 196689f61d..2850c92b93 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -179,8 +179,10 @@ async def generate_chat_completion( # Merge the direct connection model into server models so that # task functions (title, tags, etc.) can resolve a server-side # task model while still having the direct model available. + # dict(...items()) is one HGETALL on a Redis-backed pool; ``{**pool}`` + # would issue HKEYS plus one HGET per model. models = { - **request.app.state.MODELS, + **dict(request.app.state.MODELS.items()), request.state.model['id']: request.state.model, } log.debug(f'direct connection to model: {request.state.model["id"]}') @@ -188,11 +190,12 @@ async def generate_chat_completion( models = request.app.state.MODELS model_id = form_data['model'] - if model_id not in models: + # Single lookup — membership check plus getitem would be two Redis + # round trips on a Redis-backed model pool. + model = models.get(model_id) + if model is None: raise Exception('Model not found') - model = models[model_id] - if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'): return await generate_direct_chat_completion(request, form_data, user=user, models=models) else: diff --git a/backend/open_webui/utils/chat_fork.py b/backend/open_webui/utils/chat_fork.py new file mode 100644 index 0000000000..4963af0609 --- /dev/null +++ b/backend/open_webui/utils/chat_fork.py @@ -0,0 +1,41 @@ +from copy import deepcopy + + +def build_fork_history(messages_map: dict, source_message_id: str) -> tuple[dict, list[dict]]: + if not messages_map: + raise ValueError('chat has no messages to fork') + + branch: list[tuple[str, dict]] = [] + seen: set[str] = set() + message_id = source_message_id + + while message_id: + if message_id in seen: + raise ValueError('message branch contains a cycle') + seen.add(message_id) + + message = messages_map.get(message_id) + if not isinstance(message, dict): + raise ValueError('message not found') + + branch.append((message_id, message)) + message_id = message.get('parentId') + + fork_messages: dict[str, dict] = {} + ordered_messages: list[dict] = [] + parent_id = None + + for message_id, message in reversed(branch): + copied = deepcopy(message) + copied['id'] = message_id + copied['parentId'] = parent_id + copied['childrenIds'] = [] + + if parent_id: + fork_messages[parent_id]['childrenIds'] = [message_id] + + fork_messages[message_id] = copied + ordered_messages.append(copied) + parent_id = message_id + + return {'messages': fork_messages, 'currentId': source_message_id}, ordered_messages diff --git a/backend/open_webui/utils/chat_id.py b/backend/open_webui/utils/chat_id.py new file mode 100644 index 0000000000..47b5e61174 --- /dev/null +++ b/backend/open_webui/utils/chat_id.py @@ -0,0 +1,27 @@ +from typing import Optional + + +TEMPORARY_CHAT_ID_PREFIX = 'temporary:' +LEGACY_TEMPORARY_CHAT_ID_PREFIX = 'local:' # Legacy temporary chat prefix. +CHANNEL_CHAT_ID_PREFIX = 'channel:' + +TEMPORARY_CHAT_ID_PREFIXES = ( + TEMPORARY_CHAT_ID_PREFIX, + LEGACY_TEMPORARY_CHAT_ID_PREFIX, +) +NON_SAVED_CHAT_ID_PREFIXES = (*TEMPORARY_CHAT_ID_PREFIXES, CHANNEL_CHAT_ID_PREFIX) + + +def is_saved_chat_id(chat_id: Optional[str]) -> bool: + return bool(chat_id) and not chat_id.startswith(NON_SAVED_CHAT_ID_PREFIXES) + + +def is_temporary_chat_id(chat_id: Optional[str]) -> bool: + return bool(chat_id) and chat_id.startswith(TEMPORARY_CHAT_ID_PREFIXES) + + +def get_temporary_chat_session_id(chat_id: str) -> Optional[str]: + for prefix in TEMPORARY_CHAT_ID_PREFIXES: + if chat_id.startswith(prefix): + return chat_id.removeprefix(prefix) + return None diff --git a/backend/open_webui/utils/chat_variables.py b/backend/open_webui/utils/chat_variables.py new file mode 100644 index 0000000000..37438254aa --- /dev/null +++ b/backend/open_webui/utils/chat_variables.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import json +import re +from typing import Any + + +CHAT_VARIABLE_KEY_RE = re.compile(r'^[a-z][a-z0-9_]*$') +CHAT_VARIABLE_ANY_RE = re.compile(r'{{\s*chat\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}') +USER_VARIABLE_ANY_RE = re.compile(r'{{\s*user\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}') +MAX_VARIABLE_VALUE_LENGTH = 20_000 +MAX_VARIABLES_JSON_LENGTH = 100_000 + + +class ChatVariablesError(ValueError): + pass + + +def split_properties(value: str, delimiter: str) -> list[str]: + result: list[str] = [] + current = '' + depth = 0 + in_string = False + escape_next = False + + for char in value: + if escape_next: + current += char + escape_next = False + continue + + if char == '\\': + current += char + escape_next = True + continue + + if char == '"' and not escape_next: + in_string = not in_string + current += char + continue + + if not in_string: + if char in ('{', '['): + depth += 1 + elif char in ('}', ']'): + depth -= 1 + + if char == delimiter and depth == 0: + result.append(current.strip()) + current = '' + continue + + current += char + + if current.strip(): + result.append(current.strip()) + + return result + + +def parse_json_value(value: str) -> Any: + if value.startswith('"') and value.endswith('"'): + return value[1:-1] + + if re.match(r'^[\[{]', value): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + return value + + +def parse_variable_definition(definition: str) -> dict[str, Any]: + parts = split_properties(definition, ':') + if not parts: + return {'type': 'text'} + + first_part, *property_parts = parts + field_type = first_part[5:] if first_part.startswith('type=') else first_part + field_type = field_type.strip() or 'text' + properties: dict[str, Any] = {} + + for part in property_parts: + trimmed = part.strip() + if not trimmed: + continue + + equals_parts = split_properties(trimmed, '=') + if len(equals_parts) == 1: + properties[equals_parts[0].strip()] = True + continue + + property_name, *value_parts = equals_parts + properties[property_name.strip()] = parse_json_value('='.join(value_parts).strip()) + + return {'type': field_type, **properties} + + +def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]: + allowed_keys = { + 'default', + 'label', + 'max', + 'maxlength', + 'min', + 'minlength', + 'options', + 'placeholder', + 'required', + 'step', + 'type', + } + field = {'key': key} + for field_key in allowed_keys: + if field_key in definition: + field[field_key] = definition[field_key] + + field.setdefault('type', 'text') + if field.get('type') == 'select' and not isinstance(field.get('options'), list): + field['options'] = [] + field['required'] = bool(field.get('required', False)) + + return field + + +def get_chat_variables_schema(system_prompt: str | None) -> dict[str, list[dict[str, Any]]] | None: + if not system_prompt: + return None + + try: + fields_by_key = collect_chat_variable_fields(system_prompt) + except ChatVariablesError: + fields_by_key = {} + + if not fields_by_key: + return None + + return {'fields': list(fields_by_key.values())} + + +def collect_chat_variable_fields(system_prompt: str | None) -> dict[str, dict[str, Any]]: + fields_by_key: dict[str, dict[str, Any]] = {} + if not system_prompt: + return fields_by_key + + typed_fields_by_key: dict[str, dict[str, Any]] = {} + for match in CHAT_VARIABLE_ANY_RE.finditer(system_prompt): + key = match.group(1).strip() + definition = match.group(2) + if not CHAT_VARIABLE_KEY_RE.match(key): + raise ChatVariablesError(f'Invalid chat variable key: {key}') + + if definition is None or not definition.strip(): + fields_by_key.setdefault(key, _safe_field(key, {'type': 'text'})) + continue + + field = _safe_field(key, parse_variable_definition(definition.strip())) + + if field.get('type') == 'select' and not field.get('options'): + raise ChatVariablesError(f'Chat variable {key} select needs options.') + previous = typed_fields_by_key.get(key) + if previous and previous != field: + raise ChatVariablesError(f'Chat variable {key} has conflicting definitions.') + typed_fields_by_key[key] = field + fields_by_key[key] = field + + return fields_by_key + + +def normalize_chat_variables(variables: Any) -> dict[str, Any]: + if not isinstance(variables, dict): + return {} + return variables + + +def normalize_user_variables(variables: Any) -> dict[str, str]: + if not isinstance(variables, dict): + return {} + return {key: value for key, value in variables.items() if isinstance(key, str) and isinstance(value, str)} + + +def validate_user_variables(variables: Any) -> dict[str, str]: + if not isinstance(variables, dict): + raise ChatVariablesError('User variables must be an object.') + + try: + if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: + raise ChatVariablesError('User variables are too large.') + except TypeError: + raise ChatVariablesError('User variables must be JSON serializable.') + + validated: dict[str, str] = {} + for key, value in variables.items(): + if not isinstance(key, str) or not CHAT_VARIABLE_KEY_RE.match(key): + raise ChatVariablesError(f'Invalid user variable key: {key}') + if not isinstance(value, str): + raise ChatVariablesError(f'User variable must be a string: {key}') + value = value.replace('\r\n', '\n') + if len(value) > MAX_VARIABLE_VALUE_LENGTH: + raise ChatVariablesError(f'User variable is too long: {key}') + validated[key] = value + + return validated + + +def validate_chat_variables( + system_prompt: str | None, + variables: Any, + *, + required: bool = True, +) -> dict[str, Any]: + field_map = collect_chat_variable_fields(system_prompt) + variables = normalize_chat_variables(variables) + + try: + if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: + raise ChatVariablesError('Chat variables are too large.') + except TypeError: + raise ChatVariablesError('Chat variables must be JSON serializable.') + + validated: dict[str, Any] = {} + for key, field in field_map.items(): + has_value = key in variables and variables[key] not in (None, '') + value = variables.get(key) + + if not has_value: + if field.get('default') not in (None, ''): + value = field.get('default') + has_value = True + elif required and field.get('required'): + label = field.get('label') or key + raise ChatVariablesError(f'Missing required chat variable: {label}') + else: + value = '' + + if field.get('type') == 'select': + options = field.get('options') or [] + if has_value and value not in options: + label = field.get('label') or key + raise ChatVariablesError(f'Invalid value for chat variable: {label}') + + if isinstance(value, str): + value = value.replace('\r\n', '\n') + if len(value) > MAX_VARIABLE_VALUE_LENGTH: + label = field.get('label') or key + raise ChatVariablesError(f'Chat variable is too long: {label}') + + validated[key] = value + + return validated + + +def render_chat_variables( + system_prompt: str | None, + variables: Any, + *, + required: bool = True, +) -> str | None: + if not system_prompt: + return system_prompt + + try: + validated = validate_chat_variables(system_prompt, variables, required=required) + except ChatVariablesError: + validated = {} + + def replace(match: re.Match) -> str: + key = match.group(1).strip() + value = validated.get(key, '') + return '' if value is None else str(value) + + return CHAT_VARIABLE_ANY_RE.sub(replace, system_prompt) + + +def render_user_variables(system_prompt: str | None, variables: Any) -> str | None: + if not system_prompt: + return system_prompt + + variables = normalize_user_variables(variables) + + def replace(match: re.Match) -> str: + key = match.group(1).strip() + if not CHAT_VARIABLE_KEY_RE.match(key): + return '' + return variables.get(key, '') + + return USER_VARIABLE_ANY_RE.sub(replace, system_prompt) diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index e20f6b9cdc..b38a4eb346 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -5,9 +5,9 @@ import logging from typing import Any from fastapi.responses import JSONResponse - from open_webui.models.chats import Chats from open_webui.models.config import Config +from open_webui.utils.chat_id import is_saved_chat_id from open_webui.utils.misc import get_content_from_message, get_last_user_message, get_message_list from open_webui.utils.task import ( get_task_model_id, @@ -53,11 +53,11 @@ async def compact_messages_for_request( return messages, None, False messages, previous_summary = _apply_latest_summary_checkpoint(messages) - token_threshold = _resolve_token_threshold(config['token_threshold'], metadata) + 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: return messages, previous_summary, False - boundary = _find_compaction_boundary(messages) + boundary = _find_compaction_boundary(messages, config['retention_percentage']) compacted_messages = messages[:boundary] recent_messages = messages[boundary:] if not compacted_messages or not recent_messages: @@ -108,12 +108,15 @@ async def compact_messages_for_request( raise chat_id = metadata.get('chat_id') - checkpoint_message_id = metadata.get('user_message_id') or metadata.get('message_id') - if chat_id and checkpoint_message_id and not chat_id.startswith(('local:', 'channel:')): + checkpoint_message_id = ( + recent_messages[0].get('id') or metadata.get('user_message_id') or metadata.get('message_id') + ) + if is_saved_chat_id(chat_id) and checkpoint_message_id: await Chats.upsert_message_to_chat_by_id_and_message_id( chat_id, checkpoint_message_id, {'contextSummary': summary}, + touch=False, ) log.info( @@ -146,8 +149,13 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d if not config['enable']: return {'ok': True, 'compacted': False, 'reason': 'disabled'} - history = (chat.chat or {}).get('history') or {} - current_id = history.get('currentId') + chat_data = chat.chat or {} + history = chat_data.get('history') or {} + current_id = getattr(chat, 'current_message_id', None) or history.get('currentId') + if not current_id: + current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId') + if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']: + current_id = chat_data['messages'][-1].get('id') if not current_id: return {'ok': True, 'compacted': False, 'reason': 'empty'} @@ -156,11 +164,11 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d messages_map = history.get('messages') or {} messages, previous_summary = _apply_latest_summary_checkpoint(get_message_list(messages_map, current_id)) - if len(messages) <= 2: - return {'ok': True, 'compacted': False, 'reason': 'too_short'} - compacted_messages = messages[:-1] recent_messages = messages[-1:] + if not compacted_messages or not recent_messages: + return {'ok': True, 'compacted': False, 'reason': 'too_short'} + summary = await _generate_summary( request, user, @@ -171,7 +179,9 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d previous_summary, config['prompt_template'], ) - await Chats.upsert_message_to_chat_by_id_and_message_id(chat.id, current_id, {'contextSummary': summary}) + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat.id, current_id, {'contextSummary': summary}, touch=False + ) return { 'ok': True, @@ -186,11 +196,16 @@ async def _load_config() -> dict: values = await Config.get_many( 'chat.context_compaction.enable', 'chat.context_compaction.token_threshold', + 'chat.context_compaction.token_cap', + 'chat.context_compaction.retention_percentage', 'chat.context_compaction.prompt_template', ) + token_threshold = _parse_positive_int(values.get('chat.context_compaction.token_threshold')) or 80000 return { 'enable': bool(values.get('chat.context_compaction.enable', False)), - 'token_threshold': int(values.get('chat.context_compaction.token_threshold', 80000) or 80000), + 'token_threshold': token_threshold, + 'token_cap': _parse_positive_int(values.get('chat.context_compaction.token_cap')) or token_threshold, + 'retention_percentage': _clamp_retention_percentage(values.get('chat.context_compaction.retention_percentage')), 'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '', } @@ -203,11 +218,81 @@ def _parse_positive_int(value: Any) -> int | None: return parsed if parsed > 0 else None -def _resolve_token_threshold(global_threshold: int, metadata: dict) -> int: +def _clamp_retention_percentage(value: Any) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = 40 + return min(50, max(10, parsed)) + + +def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: dict) -> int: configured_threshold = _parse_positive_int((metadata.get('params') or {}).get('compact_token_threshold')) - if configured_threshold is None: - return global_threshold - return min(configured_threshold, global_threshold) + return min(configured_threshold or global_threshold, global_cap) + + +async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None: + chat_data = chat.chat or {} + history = chat_data.get('history') or {} + current_id = getattr(chat, 'current_message_id', None) or history.get('currentId') + if not current_id: + current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId') + if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']: + current_id = chat_data['messages'][-1].get('id') + if not current_id: + return None + + messages_map = await Chats.get_messages_map_by_chat_id(chat.id) + messages = get_message_list(messages_map or history.get('messages') or {}, current_id) + if not messages: + return None + + config = await _load_config() + if not config['enable']: + return None + + params = ((chat.chat or {}).get('params') or {}).copy() + if model_id: + params['model'] = model_id + threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], {'params': params}) + messages, previous_summary = _apply_latest_summary_checkpoint(messages) + + for idx in range(len(messages) - 1, -1, -1): + usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage') + if isinstance(usage, dict) and ( + tokens := ( + int( + usage.get('prompt_tokens') + or usage.get('input_tokens') + or usage.get('prompt_eval_count') + or usage.get('prompt_n') + or 0 + ) + + int( + usage.get('completion_tokens') + or usage.get('output_tokens') + or usage.get('eval_count') + or usage.get('predicted_n') + or 0 + ) + + int(usage.get('cache_n') or 0) + ) + ): + tokens += _estimate_messages_tokens(messages[idx + 1 :]) + return _build_context_usage(tokens, threshold) + + tokens = _estimate_tokens(previous_summary or '') + _estimate_messages_tokens(messages) + return _build_context_usage(tokens, threshold) + + +def _build_context_usage(tokens: int, threshold: int) -> dict: + return { + 'tokens': tokens, + 'estimated_tokens': tokens, + 'threshold': threshold, + 'percent': round((tokens / threshold) * 100) if threshold > 0 else 0, + 'source': 'estimated', + } def _apply_latest_summary_checkpoint(messages: list[dict]) -> tuple[list[dict], str | None]: @@ -231,27 +316,37 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary: for idx in range(len(messages) - 1, -1, -1): usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage') - if isinstance(usage, dict) and usage.get('input_tokens'): - total = int(usage.get('input_tokens') or 0) + int(usage.get('output_tokens') or 0) - return total + _estimate_messages_tokens(messages[idx + 1 :]) > threshold + if isinstance(usage, dict) and ( + tokens := ( + int( + usage.get('prompt_tokens') + or usage.get('input_tokens') + or usage.get('prompt_eval_count') + or usage.get('prompt_n') + or 0 + ) + + int( + usage.get('completion_tokens') + or usage.get('output_tokens') + or usage.get('eval_count') + or usage.get('predicted_n') + or 0 + ) + + int(usage.get('cache_n') or 0) + ) + ): + return tokens + _estimate_messages_tokens(messages[idx + 1 :]) > threshold estimated = _estimate_tokens(system_prompt) + _estimate_tokens(summary or '') + _estimate_messages_tokens(messages) return estimated > threshold -def _find_compaction_boundary(messages: list[dict]) -> int: - keep_count = max(2, len(messages) * 2 // 5) - split = max(1, len(messages) - keep_count) - - while split < len(messages) - 1: - previous = messages[split - 1] if split > 0 else {} - current = messages[split] - if current.get('role') == 'tool' or previous.get('tool_calls') or previous.get('output'): - split += 1 - continue - break - - return min(split, len(messages) - 2) +def _find_compaction_boundary(messages: list[dict], retention_percentage: int = 40) -> int: + retention_percentage = _clamp_retention_percentage(retention_percentage) + keep_count = max(2, len(messages) * retention_percentage // 100) + target = max(1, len(messages) - keep_count) + boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:] + return next((idx for idx in reversed(boundaries) if idx <= target), 0) async def _generate_summary( diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index e43057c5d8..74527a40b6 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -6,6 +6,7 @@ import re from pathlib import Path from typing import Optional +import aiofiles from fastapi import ( APIRouter, Depends, @@ -75,7 +76,7 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]: # file-ID resolver which enforces ownership/access checks. return await get_image_base64_from_file_id(url, user=user) - except Exception as e: + except Exception: return None @@ -200,15 +201,15 @@ async def get_image_base64_from_file_id(id: str, user=None) -> Optional[str]: # Check if the file already exists in the cache if file_path.is_file(): - with open(file_path, 'rb') as image_file: - encoded_string = base64.b64encode(image_file.read()).decode('utf-8') - content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type') - if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK: - content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower()) - if not content_type: - return None - return f'data:{content_type};base64,{encoded_string}' + async with aiofiles.open(file_path, 'rb') as image_file: + encoded_string = base64.b64encode(await image_file.read()).decode('utf-8') + content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type') + if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK: + content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower()) + if not content_type: + return None + return f'data:{content_type};base64,{encoded_string}' else: return None - except Exception as e: + except Exception: return None diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 84aebdaacb..f73278e7e2 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -1,11 +1,9 @@ import inspect import logging +from open_webui.env import ENABLE_PLUGINS from open_webui.models.functions import Functions -from open_webui.utils.plugin import ( - get_function_module_from_cache, - load_function_module_by_id, -) +from open_webui.utils.plugin import get_function_module_from_cache log = logging.getLogger(__name__) @@ -19,22 +17,15 @@ async def get_function_module(request, function_id, load_from_db=True): async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None): - async def get_priority(function_id): - try: - function_module = await get_function_module(request, function_id) - if function_module and hasattr(function_module, 'Valves'): - valves_db = await Functions.get_function_valves_by_id(function_id) - valves = function_module.Valves(**(valves_db if valves_db else {})) - return getattr(valves, 'priority', 0) - except Exception: - pass - return 0 + if not ENABLE_PLUGINS: + return [] - filter_ids = [function.id for function in await Functions.get_global_filter_functions()] + active_filters = await Functions.get_active_filter_ids() + filter_ids = [fid for fid, is_global in active_filters if is_global] if 'info' in model and 'meta' in model['info']: filter_ids.extend(model['info']['meta'].get('filterIds', [])) filter_ids = list(set(filter_ids)) - active_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)} + active_filter_ids = {fid for fid, _ in active_filters} async def get_active_status(filter_id): function_module = await get_function_module(request, filter_id) @@ -51,6 +42,18 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = active_filter_ids = {fid for fid, is_active in resolved_active.items() if is_active} filter_ids = [fid for fid in filter_ids if fid in active_filter_ids] + valves_by_id = await Functions.get_function_valves_by_ids(filter_ids) + + async def get_priority(function_id): + try: + function_module = await get_function_module(request, function_id) + if function_module and hasattr(function_module, 'Valves'): + valves_db = valves_by_id.get(function_id) + valves = function_module.Valves(**(valves_db if valves_db else {})) + return getattr(valves, 'priority', 0) + except Exception: + pass + return 0 # Pre-compute priorities (async functions can't be used in sort keys) priorities = {} @@ -64,13 +67,17 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = # Grant these filters the discernment to pass what serves # and refuse what harms, for every soul in the house. async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params): + if not ENABLE_PLUGINS: + return form_data, {} + skip_files = None + valves_by_id = None + filter_ids = [function.id for function in filter_functions if function] for function in filter_functions: - filter = function - filter_id = function.id - if not filter: + if not function: continue + filter_id = function.id function_module = await get_function_module(request, filter_id, load_from_db=(filter_type != 'stream')) # Prepare handler function @@ -84,7 +91,9 @@ async def process_filter_functions(request, filter_functions, filter_type, form_ # Apply valves to the function if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'): - valves = await Functions.get_function_valves_by_id(filter_id) + if valves_by_id is None: + valves_by_id = await Functions.get_function_valves_by_ids(filter_ids) + valves = valves_by_id.get(filter_id) function_module.valves = function_module.Valves(**(valves if valves else {})) try: diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index f7b7297083..2f23879c8a 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -13,9 +13,12 @@ from open_webui.env import ( FORWARD_USER_INFO_HEADER_USER_NAME, FORWARD_USER_INFO_HEADER_USER_ROLE, ) +from open_webui.models.groups import Groups log = logging.getLogger(__name__) +USER_GROUPS_PLACEHOLDERS = ('{{USER_GROUPS}}', '{{USER_GROUP_IDS}}') + def _mint_forward_user_jwt(user: Any) -> str: now = int(time.time()) @@ -59,7 +62,36 @@ def include_user_info_headers(headers: dict, user: Optional[Any] = None) -> dict } -def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict: +def custom_headers_require_user_groups(custom_headers: Optional[dict]) -> bool: + if not custom_headers or not isinstance(custom_headers, dict): + return False + return any( + placeholder in str(value) for value in custom_headers.values() for placeholder in USER_GROUPS_PLACEHOLDERS + ) + + +async def get_user_groups_for_custom_headers( + custom_headers: Optional[dict], user: Optional[Any] = None +) -> Optional[list]: + """Fetch the user's groups only when a header value actually references a groups placeholder.""" + if user is None or not custom_headers_require_user_groups(custom_headers): + return None + + try: + return await Groups.get_groups_by_member_id(user.id) + except Exception: + log.exception('Failed to resolve user groups for custom headers') + return None + + +async def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, request=None) -> dict: + user_groups = await get_user_groups_for_custom_headers(custom_headers, user) + return parse_custom_headers(custom_headers, user, metadata, request=request, user_groups=user_groups) + + +def parse_custom_headers( + custom_headers: dict, user=None, metadata: dict = None, request=None, user_groups: Optional[list] = None +) -> dict: if not custom_headers or not isinstance(custom_headers, dict): return {} @@ -93,6 +125,8 @@ def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None, r '{{USER_NAME}}': (user.name.strip() if user else '') or '', '{{USER_EMAIL}}': (user.email.strip() if user else '') or '', '{{USER_ROLE}}': (user.role if user else '') or '', + '{{USER_GROUPS}}': ','.join(group.name.strip() for group in user_groups) if user_groups else '', + '{{USER_GROUP_IDS}}': ','.join(group.id for group in user_groups) if user_groups else '', '{{USER_AGENT}}': user_agent, } diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index b507aa66c5..e2973d9a12 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -33,6 +33,7 @@ from open_webui.env import ( CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE, ENABLE_API_OUTLET_FILTERS, ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION, + ENABLE_PLUGINS, ENABLE_QUERIES_CACHE, ENABLE_REALTIME_CHAT_SAVE, ENABLE_RESPONSES_API_STATEFUL, @@ -44,8 +45,10 @@ from open_webui.models.config import Config from open_webui.models.folders import Folders from open_webui.models.functions import Functions from open_webui.models.models import Models +from open_webui.models.notes import Notes from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import UserModel, Users +from open_webui.events import EVENTS, publish_event from open_webui.retrieval.utils import get_sources_from_items from open_webui.routers.images import ( CreateImageForm, @@ -54,6 +57,7 @@ from open_webui.routers.images import ( image_generations, ) from open_webui.routers.pipelines import ( + get_sorted_filters, process_pipeline_inlet_filter, process_pipeline_outlet_filter, ) @@ -73,8 +77,10 @@ from open_webui.socket.main import ( get_event_emitter, ) from open_webui.utils.access_control import has_connection_access, has_permission -from open_webui.utils.access_control.files import get_accessible_folder_files +from open_webui.models.access_grants import AccessGrants +from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.chat import generate_chat_completion +from open_webui.utils.chat_id import is_saved_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 ( @@ -102,6 +108,7 @@ from open_webui.utils.misc import ( get_last_user_message, get_last_user_message_item, get_message_list, + get_output_text, get_system_message, is_string_allowed, merge_system_messages, @@ -121,18 +128,50 @@ from open_webui.utils.task import ( ) from open_webui.utils.tools import ( build_tool_server_headers, + get_attached_knowledge, get_builtin_tools, get_terminal_tools, get_tools, get_updated_tool_function, ) -from open_webui.utils.webhook import post_webhook from starlette.responses import JSONResponse, Response, StreamingResponse logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +async def publish_chat_finished_event( + request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None +): + chat_id = metadata.get('chat_id') + if getattr(request.state, 'internal', False) is True or not is_saved_chat_id(chat_id): + return + + content = content or get_output_text(output) + webui_url = await Config.get('webui.url') + await publish_event( + request, + EVENTS.CHAT_FINISHED, + actor=user, + subject_id=chat_id, + subject_type='chat', + data={ + 'user_id': user.id, + 'chat_id': chat_id, + 'message_id': metadata.get('message_id'), + 'model_id': metadata.get('model_id'), + 'title': title, + 'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}', + 'message': content, + }, + message=title or 'Chat finished', + ) + event_emitter = await get_event_emitter(metadata, update_db=False) + if event_emitter: + folder_id = metadata.get('folder_id') or await Chats.get_chat_folder_id(chat_id, metadata.get('user_id')) + await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id, 'folder_id': folder_id}}) + + # We believe in one maker of all models, seen and unseen, # and in the reasoning which proceeds from the architect. # We look for the resurrection of dead processes and the @@ -240,9 +279,9 @@ def get_citation_source_from_tool_result( - document: list of document contents - metadata: list of metadata objects with source, file_id, name fields - Returns a list of sources (usually one, but query_knowledge_files may return multiple). + Returns a list of sources (usually one, but query_knowledge_files/query_chat_files may return multiple). """ - _EXPECTS_LIST = {'search_web', 'query_knowledge_files'} + _EXPECTS_LIST = {'search_web', 'query_knowledge_files', 'query_chat_files'} _EXPECTS_DICT = {'view_knowledge_file', 'view_file'} try: @@ -331,7 +370,7 @@ def get_citation_source_from_tool_result( } ] - elif tool_name == 'query_knowledge_files': + elif tool_name in ('query_knowledge_files', 'query_chat_files'): chunks = tool_result # Group chunks by source for better citation display @@ -1127,10 +1166,16 @@ async def chat_completion_tools_handler( event_emitter = extra_params['__event_emitter__'] metadata = extra_params['__metadata__'] + # One batched SELECT instead of four sequential round trips. + task_config = await Config.get_many( + 'task.model.default', + 'task.model.external', + 'task.tools.prompt_template', + ) task_model_id = get_task_model_id( body['model'], - await Config.get('task.model.default'), - await Config.get('task.model.external'), + task_config.get('task.model.default'), + task_config.get('task.model.external'), models, ) @@ -1140,8 +1185,9 @@ async def chat_completion_tools_handler( specs = [tool['spec'] for tool in tools.values()] tools_specs = json.dumps(specs, ensure_ascii=False) - if await Config.get('task.tools.prompt_template') != '': - template = await Config.get('task.tools.prompt_template') + tools_prompt_template = task_config.get('task.tools.prompt_template') + if tools_prompt_template != '': + template = tools_prompt_template else: template = DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE @@ -1509,7 +1555,7 @@ async def add_file_context(messages: list, chat_id: str, user) -> list: """ Add file URLs to messages for native function calling. """ - if not chat_id or chat_id.startswith('local:') or chat_id.startswith('channel:'): + if not is_saved_chat_id(chat_id): return messages chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) @@ -1520,7 +1566,11 @@ async def add_file_context(messages: list, chat_id: str, user) -> list: stored_messages = get_message_list(history.get('messages', {}), history.get('currentId')) def format_file_tag(file): - attrs = f'type="{file.get("type", "file")}" url="{file["url"]}"' + file_id = file.get('id') or file.get('url') + attrs = f'type="{file.get("type", "file")}"' + if file_id: + attrs += f' id="{file_id}"' + attrs += f' url="{file["url"]}"' if file.get('content_type'): attrs += f' content_type="{file["content_type"]}"' if file.get('name'): @@ -1565,7 +1615,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra if not chat_id or not isinstance(chat_id, str) or not __event_emitter__: return form_data - if chat_id.startswith('local:') or chat_id.startswith('channel:'): + if not is_saved_chat_id(chat_id): message_list = form_data.get('messages', []) else: chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) @@ -1814,6 +1864,15 @@ async def chat_completion_files_handler( queries = [get_last_user_message(body['messages']) or ''] try: + # One batched SELECT instead of six sequential round trips. + rag_config = await Config.get_many( + 'rag.top_k', + 'rag.top_k_reranker', + 'rag.relevance_threshold', + 'rag.hybrid_bm25_weight', + 'rag.enable_hybrid_search', + 'rag.full_context', + ) # Directly await async get_sources_from_items (no thread needed - fully async now) sources = await get_sources_from_items( request=request, @@ -1822,17 +1881,17 @@ async def chat_completion_files_handler( embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( query, prefix=prefix, user=user ), - k=await Config.get('rag.top_k'), + k=rag_config.get('rag.top_k'), reranking_function=( (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user)) if request.app.state.RERANKING_FUNCTION else None ), - k_reranker=await Config.get('rag.top_k_reranker'), - r=await Config.get('rag.relevance_threshold'), - hybrid_bm25_weight=await Config.get('rag.hybrid_bm25_weight'), - hybrid_search=await Config.get('rag.enable_hybrid_search'), - full_context=all_full_context or await Config.get('rag.full_context'), + k_reranker=rag_config.get('rag.top_k_reranker'), + r=rag_config.get('rag.relevance_threshold'), + hybrid_bm25_weight=rag_config.get('rag.hybrid_bm25_weight'), + hybrid_search=rag_config.get('rag.enable_hybrid_search'), + full_context=all_full_context or rag_config.get('rag.full_context'), user=user, ) except Exception as e: @@ -1880,6 +1939,7 @@ def apply_params_to_form_data(form_data, model): 'reasoning_tags': list, 'compact_token_threshold': int, 'system': str, + 'note_id': str, } for key in list(params.keys()): @@ -1975,7 +2035,7 @@ async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[ return None return [ - {k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files', 'contextSummary')} + {k: v for k, v in msg.items() if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage')} for msg in db_messages ] @@ -2016,6 +2076,7 @@ def process_messages_with_output( message['output'], raw=True, reasoning_format=reasoning_format, + flatten_tool_images=True, ) if output_messages: processed.extend(output_messages) @@ -2034,6 +2095,8 @@ def strip_compaction_fields(messages: list[dict]) -> list[dict]: clean = dict(message) clean.pop('contextSummary', None) clean.pop('context_summary', None) + clean.pop('usage', None) + clean.pop('id', None) stripped.append(clean) return stripped @@ -2072,7 +2135,7 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]: return sanitized -SKILL_MENTION_RE = re.compile(r'<\$([^|>]+)(?:\|[^>]*)?>') +SKILL_MENTION_RE = re.compile(r'<(?:\$([^|>]+)(?:\|[^>]*)?|/([^|>]+)\|[^>]*)>') def _get_text_parts(message: dict) -> list[str]: @@ -2086,27 +2149,33 @@ def _get_text_parts(message: dict) -> list[str]: def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]: - """Extract skill IDs from <$skillId|label> mention tags in messages.""" + """Extract skill IDs from <$skillId|label> and mention tags.""" ids: set[str] = set() for message in messages: for text in _get_text_parts(message): - ids.update(m.group(1) for m in SKILL_MENTION_RE.finditer(text)) + ids.update(m.group(1) or m.group(2) for m in SKILL_MENTION_RE.finditer(text)) return ids +SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>') + + def strip_skill_mentions(messages: list[dict]) -> None: - """Replace <$skillId|label> mention tags with the label in message content in-place.""" - strip_re = re.compile(r'<\$[^|>]+(?:\|([^>]*))?>') + """Replace <$skillId|label> and mention tags with the label in-place.""" + + def label(match): + return match.group(1) or match.group(2) or '' + for message in messages: content = message.get('content') - if isinstance(content, str) and strip_re.search(content): - message['content'] = strip_re.sub(r'\1', content).strip() + if isinstance(content, str) and SKILL_MENTION_STRIP_RE.search(content): + message['content'] = SKILL_MENTION_STRIP_RE.sub(label, content).strip() elif isinstance(content, list): for part in content: if isinstance(part, dict) and part.get('type') == 'text': text = part.get('text', '') - if strip_re.search(text): - part['text'] = strip_re.sub(r'\1', text).strip() + if SKILL_MENTION_STRIP_RE.search(text): + part['text'] = SKILL_MENTION_STRIP_RE.sub(label, text).strip() async def connect_mcp_server( @@ -2209,7 +2278,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): chat_id = metadata.get('chat_id') user_message_id = metadata.get('user_message_id') - if chat_id and user_message_id and not chat_id.startswith('local:') and not chat_id.startswith('channel:'): + if is_saved_chat_id(chat_id) and user_message_id: db_messages = await load_messages_from_db(chat_id, user_message_id) if db_messages: # Continue: frontend sends assistant_message_id when continuing @@ -2222,7 +2291,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): { k: v for k, v in assistant_message.items() - if k in ('role', 'content', 'output', 'files', 'contextSummary') + if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage') } ) @@ -2256,7 +2325,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 chat_id and user_message_id and not chat_id.startswith('local:') and not chat_id.startswith('channel:'): + if is_saved_chat_id(chat_id) and user_message_id: if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): compaction_models = { request.state.model['id']: request.state.model, @@ -2344,7 +2413,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Uses lightweight column query — only fetches folder_id, not the full chat JSON blob chat_id = metadata.get('chat_id', None) folder_id = None - if chat_id and user: + if user and is_saved_chat_id(chat_id): folder_id = await Chats.get_chat_folder_id(chat_id, user.id) # Fallback: use folder_id from metadata (temporary chats have no DB record) @@ -2352,23 +2421,23 @@ async def process_chat_payload(request, form_data, user, metadata, model): folder_id = metadata.get('folder_id', None) if folder_id and user: - folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id) + folder = await Folders.get_folder_by_id(folder_id) + if folder and user.role != 'admin' and not await has_folder_access(user.id, folder, 'read', db=None): + folder = None if folder and folder.data: if 'system_prompt' in folder.data: form_data = await apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user) if 'files' in folder.data: - # Defensive: filter to entries the caller can still read. - allowed_files = await get_accessible_folder_files(folder.data['files'], user) if metadata.get('params', {}).get('function_calling') == 'legacy': form_data['files'] = [ - *allowed_files, + {'type': 'folder', 'id': folder.id}, *form_data.get('files', []), ] else: # Native FC: skip RAG injection, builtin tools # will read folder knowledge from metadata. - metadata['folder_knowledge'] = allowed_files + metadata['folder_knowledge'] = folder.data['files'] # Model "Knowledge" handling user_message = get_last_user_message(form_data['messages']) @@ -2421,28 +2490,28 @@ async def process_chat_payload(request, form_data, user, metadata, model): except Exception as e: raise e - try: - filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) - filter_functions = await Functions.get_functions_by_ids(filter_ids) + if ENABLE_PLUGINS: + try: + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) - form_data, flags = await process_filter_functions( - request=request, - filter_functions=filter_functions, - filter_type='inlet', - form_data=form_data, - extra_params=extra_params, - ) - except Exception as e: - raise Exception(f'{e}') + form_data, flags = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='inlet', + form_data=form_data, + extra_params=extra_params, + ) + except Exception as e: + raise Exception(f'{e}') features = form_data.pop('features', None) or {} extra_params['__features__'] = features if features: if 'voice' in features and features['voice']: if await Config.get('task.voice.prompt.enable'): - if await Config.get('task.voice.prompt_template'): - template = await Config.get('task.voice.prompt_template') - else: + template = await Config.get('task.voice.prompt_template') + if not template: template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE form_data['messages'] = add_or_update_system_message( @@ -2469,11 +2538,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Skip XML-tag prompt injection when native FC is enabled — # execute_code will be injected as a builtin tool instead if metadata.get('params', {}).get('function_calling') == 'legacy': - prompt = ( - await Config.get('code_interpreter.prompt_template') - if await Config.get('code_interpreter.prompt_template') != '' - else DEFAULT_CODE_INTERPRETER_PROMPT - ) + ci_prompt_template = await Config.get('code_interpreter.prompt_template') + prompt = ci_prompt_template if ci_prompt_template != '' else DEFAULT_CODE_INTERPRETER_PROMPT # Append filesystem awareness only for pyodide engine if engine != 'jupyter': @@ -2508,14 +2574,43 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Mentioned skills get full content; selected/default skills can be loaded through view_skill. mentioned_skill_ids = extract_skill_ids_from_messages(form_data.get('messages', [])) - skill_ids = ( + skill_ids = sorted( set(form_data.pop('skill_ids', None) or []) | set(model.get('info', {}).get('meta', {}).get('skillIds', [])) | mentioned_skill_ids ) available_skills = [] view_skill_ids = [] + chat = None + if is_saved_chat_id(metadata.get('chat_id')): + chat = await Chats.get_chat_by_id(metadata['chat_id']) + + if chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note': + note_id = (chat.meta or {}).get('note_id') + note = await Notes.get_note_by_id(note_id) if note_id else None + if note and ( + user.role == 'admin' + or note.user_id == user.id + or await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + ) + ): + note_files = [ + file + for file in ((note.data or {}).get('files') or []) + if isinstance(file, dict) + and file.get('type') != 'image' + and not (file.get('content_type') or '').startswith('image/') + ] + if note_files: + files = [*(files or []), *note_files] + use_builtin_tools = ( + chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note' + ) or ( bool(metadata.get('session_id')) and metadata.get('params', {}).get('function_calling') != 'legacy' and (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('builtin_tools', True) @@ -2524,12 +2619,13 @@ async def process_chat_payload(request, form_data, user, metadata, model): if skill_ids: from open_webui.models.skills import Skills as SkillsModel - accessible_skill_ids = {s.id for s in await SkillsModel.get_skills_by_user_id(user.id, 'read')} + # Reuse the rows from the access query instead of re-fetching each + # skill by id. + accessible_skills = {s.id: s for s in await SkillsModel.get_skills_by_user_id(user.id, 'read')} for sid in skill_ids: - if sid in accessible_skill_ids: - s = await SkillsModel.get_skill_by_id(sid) - if s and s.is_active: - available_skills.append(s) + s = accessible_skills.get(sid) + if s and s.is_active: + available_skills.append(s) skill_manifest = '' for skill in available_skills: @@ -2573,31 +2669,20 @@ async def process_chat_payload(request, form_data, user, metadata, model): # urls = extract_urls(prompt) if files: - if not files: - files = [] - - for file_item in files: - if file_item.get('type', 'file') == 'folder': - # Get folder files - folder_id = file_item.get('id', None) - if folder_id: - folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id) - if folder and folder.data and 'files' in folder.data: - files = [f for f in files if f.get('id', None) != folder_id] - files = [*files, *await get_accessible_folder_files(folder.data['files'], user)] - # files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]] # Remove duplicate files based on their content files = list({json.dumps(f, sort_keys=True): f for f in files}.values()) - metadata = { - **metadata, - 'model_id': form_data.get('model'), - 'tool_ids': tool_ids, - 'terminal_id': terminal_id, - 'files': files, - 'features': features, - } + metadata.update( + { + 'model_id': form_data.get('model'), + 'tool_ids': tool_ids, + 'skill_ids': skill_ids, + 'terminal_id': terminal_id, + 'files': files, + 'features': features, + } + ) form_data['metadata'] = metadata # When the caller provides an explicit `tools` key in the request body, @@ -2618,6 +2703,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): mcp_tools_dict = {} if tool_ids: + db_tool_ids = [] for tool_id in tool_ids: if tool_id.startswith('server:mcp:'): try: @@ -2669,18 +2755,21 @@ async def process_chat_payload(request, form_data, user, metadata, model): } ) continue + elif ENABLE_PLUGINS: + db_tool_ids.append(tool_id) - tools_dict = await get_tools( - request, - tool_ids, - user, - { - **extra_params, - '__model__': models[task_model_id], - '__messages__': form_data['messages'], - '__files__': metadata.get('files', []), - }, - ) + if db_tool_ids: + tools_dict = await get_tools( + request, + db_tool_ids, + user, + { + **extra_params, + '__model__': models[task_model_id], + '__messages__': form_data['messages'], + '__files__': metadata.get('files', []), + }, + ) if mcp_tools_dict: tools_dict = {**tools_dict, **mcp_tools_dict} @@ -2711,6 +2800,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) except Exception as e: log.exception(e) + raise HTTPException(status_code=503, detail=f'Terminal unavailable: {e}') from e if direct_tool_servers: for tool_server in direct_tool_servers: @@ -2741,6 +2831,31 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Add file context to user messages chat_id = metadata.get('chat_id') form_data['messages'] = await add_file_context(form_data.get('messages', []), chat_id, user) + + if (model.get('info', {}).get('meta', {}).get('builtinTools') or {}).get('knowledge', True): + from html import escape + + knowledge_tags = [] + for item in get_attached_knowledge(model, metadata): + if not item.get('id') or not item.get('type'): + continue + attrs = ( + f'type="{escape(str(item["type"]), quote=True)}" ' + f'id="{escape(str(item["id"]), quote=True)}"' + ) + if item.get('name'): + attrs += f' name="{escape(str(item["name"]), quote=True)}"' + if item.get('source'): + attrs += f' source="{escape(str(item["source"]), quote=True)}"' + knowledge_tags.append(f'') + + if knowledge_tags: + form_data['messages'] = add_or_update_system_message( + '\n' + '\n'.join(knowledge_tags) + '\n', + form_data['messages'], + append=True, + ) + builtin_tools = await get_builtin_tools( request, { @@ -3056,11 +3171,7 @@ async def background_tasks_handler(ctx): message = None messages = [] - if ( - 'chat_id' in metadata - and not metadata.get('chat_id', '').startswith('local:') - and not metadata.get('chat_id', '').startswith('channel:') - ): + if is_saved_chat_id(metadata.get('chat_id')): messages_map = await Chats.get_messages_map_by_chat_id(metadata['chat_id']) if not messages_map: # Chat was deleted while the response was streaming — skip background tasks @@ -3143,23 +3254,20 @@ async def background_tasks_handler(ctx): } ) - if not metadata.get('chat_id', '').startswith('local:') and not metadata.get( - 'chat_id', '' - ).startswith('channel:'): + if is_saved_chat_id(metadata.get('chat_id')): await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { 'followUps': follow_ups, }, + touch=False, ) except Exception as e: pass - if not metadata.get('chat_id', '').startswith('local:') and not metadata.get('chat_id', '').startswith( - 'channel:' - ): # Only update titles and tags for non-temp chats + if is_saved_chat_id(metadata.get('chat_id')): # Only update titles and tags for saved chats if TASKS.TITLE_GENERATION in tasks: user_message = get_last_user_message(messages) if user_message and len(user_message) > 100: @@ -3295,18 +3403,18 @@ async def outlet_filter_handler(ctx): if not message_id: message_id = output_id('msg') - is_temp_chat = chat_id.startswith('local:') or chat_id.startswith('channel:') + is_unsaved_chat = not is_saved_chat_id(chat_id) try: messages_map = None - if is_temp_chat or not chat_id: + if is_unsaved_chat: form_messages = ctx.get('form_data', {}).get('messages', []) assistant_message = ctx.get('assistant_message', {}) message_list = [ { 'role': m.get('role'), - 'content': m.get('content', ''), + 'content': m.get('content') or get_output_text(m.get('output')), } for m in form_messages ] @@ -3339,10 +3447,11 @@ async def outlet_filter_handler(ctx): { 'id': m.get('id'), 'role': m.get('role'), - 'content': m.get('content', ''), + 'content': m.get('content') or get_output_text(m.get('output')), 'info': m.get('info'), 'timestamp': m.get('timestamp'), - **({'output': m['output']} if m.get('output') else {}), + # Deepcopy so in-place filter mutations do not alias messages_map's baseline + **({'output': copy.deepcopy(m['output'])} if m.get('output') else {}), **({'usage': m['usage']} if m.get('usage') else {}), **({'sources': m['sources']} if m.get('sources') else {}), } @@ -3371,16 +3480,19 @@ async def outlet_filter_handler(ctx): '__model__': model, } - filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) - filter_functions = await Functions.get_functions_by_ids(filter_ids) + if ENABLE_PLUGINS: + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) - outlet_result, _ = await process_filter_functions( - request=request, - filter_functions=filter_functions, - filter_type='outlet', - form_data=outlet_data, - extra_params=extra_params, - ) + outlet_result, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='outlet', + form_data=outlet_data, + extra_params=extra_params, + ) + else: + outlet_result = outlet_data if outlet_result and outlet_result.get('messages'): if not is_temp_chat and messages_map: @@ -3388,17 +3500,21 @@ async def outlet_filter_handler(ctx): outlet_message_id = message.get('id') if outlet_message_id and outlet_message_id in messages_map: original_message = messages_map[outlet_message_id] - content_changed = original_message.get('content') != message.get('content') + original_content = original_message.get('content') or get_output_text( + original_message.get('output') + ) + message_content = message.get('content') or get_output_text(message.get('output')) + content_changed = original_content != message_content output_changed = message.get('output') and message.get('output') != original_message.get( 'output' ) if content_changed or output_changed: message_update = { - 'originalContent': original_message.get('content'), + 'originalContent': original_content, **({'output': message['output']} if output_changed else {}), } if content_changed: - message_update['content'] = message.get('content', '') + message_update['content'] = message_content or '' await Chats.upsert_message_to_chat_by_id_and_message_id( chat_id, outlet_message_id, @@ -3429,6 +3545,9 @@ async def non_streaming_chat_response_handler(response, ctx): if response_data is None: return response + chat_id = metadata.get('chat_id') or '' + save_to_chat = is_saved_chat_id(chat_id) + if event_emitter: try: if 'error' in response_data: @@ -3441,7 +3560,7 @@ async def non_streaming_chat_response_handler(response, ctx): log.error('Provider returned error (non-streaming): %s', error) - if not metadata.get('chat_id', '').startswith('channel:'): + if save_to_chat: await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], @@ -3457,13 +3576,14 @@ async def non_streaming_chat_response_handler(response, ctx): } ) - if 'selected_model_id' in response_data and not metadata.get('chat_id', '').startswith('channel:'): + if 'selected_model_id' in response_data and save_to_chat: await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { 'selectedModelId': response_data['selected_model_id'], }, + touch=False, ) choices = response_data.get('choices', []) @@ -3479,11 +3599,7 @@ async def non_streaming_chat_response_handler(response, ctx): } ) - title = ( - await Chats.get_chat_title_by_id(metadata['chat_id']) - if not metadata.get('chat_id', '').startswith('channel:') - else '' - ) + title = await Chats.get_chat_title_by_id(metadata['chat_id']) if save_to_chat else '' # Use output from backend if provided (OR-compliant backends), # otherwise generate from response content @@ -3534,7 +3650,7 @@ async def non_streaming_chat_response_handler(response, ctx): # Save message in the database usage = normalize_usage(response_data.get('usage', {}) or {}) - if not metadata.get('chat_id', '').startswith('channel:'): + if save_to_chat: await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], @@ -3546,22 +3662,7 @@ async def non_streaming_chat_response_handler(response, ctx): }, ) - # Send a webhook notification if the user is not active - if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id): - webhook_url = await Users.get_user_webhook_url_by_id(user.id) - if webhook_url: - webui_url = await Config.get('webui.url') - await post_webhook( - request.app.state.WEBUI_NAME, - webhook_url, - f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}', - { - 'action': 'chat', - 'message': content, - 'title': title, - 'url': f'{webui_url}/c/{metadata["chat_id"]}', - }, - ) + await publish_chat_finished_event(request, user, metadata, title, content, response_output) ctx['assistant_message'] = { 'content': content, @@ -3574,6 +3675,25 @@ async def non_streaming_chat_response_handler(response, ctx): response = build_response_object(response, merge_events_into_response(response_data, events)) except Exception as e: log.debug(f'Error occurred while processing request: {e}') + chat_id = metadata.get('chat_id') + if getattr(request.state, 'internal', False) is not True and chat_id and is_saved_chat_id(chat_id): + webui_url = await Config.get('webui.url') + await publish_event( + request, + EVENTS.CHAT_FAILED, + actor=user, + subject_id=chat_id, + subject_type='chat', + data={ + 'user_id': user.id, + 'chat_id': chat_id, + 'message_id': metadata.get('message_id'), + 'model_id': metadata.get('model_id'), + 'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}', + 'message': str(e), + }, + message='Chat failed', + ) pass return response @@ -3609,6 +3729,8 @@ async def streaming_chat_response_handler(response, ctx): event_emitter = ctx['event_emitter'] event_caller = ctx['event_caller'] + chat_id = metadata.get('chat_id') or '' + save_to_chat = is_saved_chat_id(chat_id) extra_params = { '__event_emitter__': event_emitter, @@ -3620,10 +3742,13 @@ async def streaming_chat_response_handler(response, ctx): '__model__': model, } - filter_functions = [ - await Functions.get_function_by_id(filter_id) - for filter_id in await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) - ] + filter_functions = ( + await Functions.get_functions_by_ids( + await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + ) + if ENABLE_PLUGINS + else [] + ) # Standard streaming response handler # event_caller is optional — only needed for direct (client-side) tools @@ -3857,7 +3982,11 @@ async def streaming_chat_response_handler(response, ctx): return output, end_flag - message = await Chats.get_message_by_id_and_message_id(metadata['chat_id'], metadata['message_id']) + message = ( + await Chats.get_message_by_id_and_message_id(metadata['chat_id'], metadata['message_id']) + if save_to_chat + else None + ) tool_calls = [] @@ -3868,9 +3997,10 @@ async def streaming_chat_response_handler(response, ctx): except Exception as e: pass - content = ( + initial_content = ( message.get('content', '') if message else last_assistant_message if last_assistant_message else '' ) + content_parts = [initial_content] if initial_content else [] # Initialize output: use existing from message if continuing, else create new existing_output = message.get('output') if message else None @@ -3878,14 +4008,14 @@ async def streaming_chat_response_handler(response, ctx): output = existing_output else: # Only create an initial message item if there is content to initialize with - if content: + if initial_content: output = [ { 'type': 'message', 'id': output_id('msg'), 'status': 'in_progress', 'role': 'assistant', - 'content': [{'type': 'output_text', 'text': content}], + 'content': [{'type': 'output_text', 'text': initial_content}], } ] else: @@ -3938,16 +4068,17 @@ async def streaming_chat_response_handler(response, ctx): ) # Save message in the database - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - **event, - }, - ) + if save_to_chat: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + **event, + }, + ) async def stream_body_handler(response, form_data): - nonlocal content + nonlocal content_parts nonlocal usage nonlocal output nonlocal prior_output @@ -4011,16 +4142,17 @@ async def streaming_chat_response_handler(response, ctx): raw_obj = json.loads(data) raw_error = raw_obj.get('error') if isinstance(raw_obj, dict) else None if raw_error: - try: - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'error': {'content': raw_error}, - }, - ) - except Exception: - pass + if save_to_chat: + try: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': raw_error}, + }, + ) + except Exception: + pass await event_emitter({'type': 'chat:completion', 'data': {'error': raw_error}}) except Exception: pass @@ -4046,13 +4178,15 @@ async def streaming_chat_response_handler(response, ctx): if 'selected_model_id' in data: model_id = data['selected_model_id'] - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'selectedModelId': 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, + }, + touch=False, + ) await event_emitter( { 'type': 'chat:completion', @@ -4165,16 +4299,17 @@ async def streaming_chat_response_handler(response, ctx): error = data.get('error', {}) if error: log.error('Provider returned error (streaming): %s', error) - try: - await Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'error': {'content': error}, - }, - ) - except Exception: - pass + if save_to_chat: + try: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': error}, + }, + ) + except Exception: + pass await event_emitter( { 'type': 'chat:completion', @@ -4280,13 +4415,15 @@ async def streaming_chat_response_handler(response, ctx): image_urls = await get_image_urls(delta.get('images', []), request, metadata, user) if image_urls: image_file_list = [{'type': 'image', 'url': url} for url in image_urls] - message_files = await Chats.add_message_files_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - image_file_list, - ) - if message_files is None: - message_files = image_file_list + message_files = image_file_list + if save_to_chat: + message_files = await Chats.add_message_files_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + image_file_list, + ) + if message_files is None: + message_files = image_file_list await event_emitter( { @@ -4303,13 +4440,35 @@ async def streaming_chat_response_handler(response, ctx): or delta.get('thinking') ) reasoning_details = delta.get('reasoning_details') - if reasoning_content or reasoning_details: - reasoning_item = ( - next( - (item for item in reversed(output) if item.get('type') == 'reasoning'), - None, + reasoning_detail_items = ( + [item for item in reasoning_details if isinstance(item, dict)] + if isinstance(reasoning_details, list) + else [reasoning_details] + if isinstance(reasoning_details, dict) + else [] + ) + existing_reasoning_item = next( + (item for item in reversed(output) if item.get('type') == 'reasoning'), + None, + ) + message_index = next( + (i for i, item in enumerate(output) if item.get('type') == 'message'), + None, + ) + if reasoning_content or ( + reasoning_detail_items + and ( + existing_reasoning_item + or any( + item.get('text') or item.get('summary') or item.get('data') + for item in reasoning_detail_items ) - if reasoning_details and not reasoning_content + ) + ): + reasoning_item = ( + existing_reasoning_item + if (reasoning_detail_items and not reasoning_content) + or message_index is not None else None ) @@ -4326,7 +4485,13 @@ async def streaming_chat_response_handler(response, ctx): 'summary': None, 'started_at': time.time(), } - output.append(reasoning_item) + if message_index is not None: + reasoning_item['ended_at'] = time.time() + reasoning_item['duration'] = 0 + reasoning_item['status'] = 'completed' + output.insert(message_index, reasoning_item) + else: + output.append(reasoning_item) else: reasoning_item = output[-1] @@ -4348,10 +4513,10 @@ async def streaming_chat_response_handler(response, ctx): } delta_type = 'content' - if reasoning_details: + if reasoning_detail_items: merge_streamed_reasoning_details( reasoning_item.setdefault('reasoning_details', []), - reasoning_details, + reasoning_detail_items, ) data = { 'output': full_output(), @@ -4397,7 +4562,8 @@ async def streaming_chat_response_handler(response, ctx): user, ) - content = f'{content}{value}' + # closure-cell str += recopies per chunk; append + join once at read is O(n) + content_parts.append(value if isinstance(value, str) else f'{value}') # Check if we're inside a tag-based block # (reasoning, code_interpreter, or solution). @@ -4501,19 +4667,18 @@ async def streaming_chat_response_handler(response, ctx): if end: break - if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith( - 'channel:' - ): + if ENABLE_REALTIME_CHAT_SAVE and save_to_chat: + current_output = full_output() # Save message in the database await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { - 'output': full_output(), + 'output': current_output, }, ) data = { - 'output': full_output(), + 'output': current_output, } delta_type = 'content' else: @@ -4611,6 +4776,11 @@ async def streaming_chat_response_handler(response, ctx): await response.background() tool_call_iterations = 0 + max_tool_call_iterations = getattr( + request.state, + 'max_tool_call_iterations', + CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS, + ) tool_call_sources = [] # Track citation sources from tool results all_tool_call_sources = [] # Accumulated sources across all iterations user_message = get_last_user_message(form_data['messages']) @@ -4631,8 +4801,7 @@ async def streaming_chat_response_handler(response, ctx): ) while tool_calls and ( - CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is None - or tool_call_iterations < CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS + max_tool_call_iterations is None or tool_call_iterations < max_tool_call_iterations ): tool_call_iterations += 1 @@ -4669,83 +4838,92 @@ async def streaming_chat_response_handler(response, ctx): results = [] + def parse_tool_params(tool_call): + tool_args = tool_call.get('function', {}).get('arguments', '{}') + params = {} + if tool_args and tool_args.strip(): + try: + params = json.loads(tool_args) + except Exception: + try: + params = ast.literal_eval(tool_args) + except Exception as e: + log.debug(e) + return None + tool_call.setdefault('function', {})['arguments'] = json.dumps(params) + return params + + async def execute_tool_call(tool_call): + name = tool_call.get('function', {}).get('name', '') + params = parse_tool_params(tool_call) + if params is None: + return {}, None, None, None, False + tool = tools.get(name) + if not tool: + return params, f'Error: Tool "{name}" not found.', None, None, False + spec = tool.get('spec', {}) + tool_type = tool.get('type', '') + direct_tool = tool.get('direct', False) + allowed_params = spec.get('parameters', {}).get('properties', {}).keys() + params = {key: value for key, value in params.items() if key in allowed_params} + try: + if direct_tool: + result = await event_caller( + { + 'type': 'execute:tool', + 'data': { + 'id': str(uuid4()), + 'name': name, + 'params': params, + 'server': tool.get('server', {}), + 'session_id': metadata.get('session_id'), + }, + } + ) + else: + function = await get_updated_tool_function( + function=tool['callable'], + extra_params={ + '__messages__': form_data.get('messages', []), + '__files__': metadata.get('files', []), + }, + ) + result = await function(**params) + except Exception as e: + result = str(e) + return params, result, tool, tool_type, direct_tool + + delegate_calls = [ + tool_call + for tool_call in response_tool_calls + if tool_call.get('function', {}).get('name') == 'delegate_task' + ] + tool_results = {} + for tool_call in response_tool_calls: + if tool_call.get('function', {}).get('name') != 'delegate_task': + tool_results[id(tool_call)] = await execute_tool_call(tool_call) + tool_results.update( + zip( + [id(tool_call) for tool_call in delegate_calls], + await asyncio.gather(*(execute_tool_call(tool_call) for tool_call in delegate_calls)), + ) + ) + for tool_call in response_tool_calls: tool_call_id = tool_call.get('id', '') tool_function_name = tool_call.get('function', {}).get('name', '') - tool_args = tool_call.get('function', {}).get('arguments', '{}') - - tool_function_params = {} - if tool_args and tool_args.strip(): - try: - # json.loads cannot be used because some models do not produce valid JSON - tool_function_params = ast.literal_eval(tool_args) - except Exception as e: - log.debug(e) - # Fallback to JSON parsing - try: - tool_function_params = json.loads(tool_args) - except Exception as e: - log.error(f'Error parsing tool call arguments: {tool_args}') - results.append( - { - 'tool_call_id': tool_call_id, - 'content': f'Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.', - } - ) - continue - - # Ensure arguments are valid JSON for downstream LLM integrations - log.debug(f'Parsed args from {tool_args} to {tool_function_params}') - tool_call.setdefault('function', {})['arguments'] = json.dumps(tool_function_params) - - tool_result = None - tool = None - tool_type = None - direct_tool = False - - if tool_function_name in tools: - tool = tools[tool_function_name] - spec = tool.get('spec', {}) - - tool_type = tool.get('type', '') - direct_tool = tool.get('direct', False) - - try: - 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 + tool_function_params, tool_result, tool, tool_type, direct_tool = tool_results[id(tool_call)] + if tool_result is None: + results.append( + { + 'tool_call_id': tool_call_id, + 'content': ( + 'Error: Tool call arguments could not be parsed. The model generated ' + f'malformed or incomplete JSON for `{tool_function_name}`. Please try again.' + ), } - - if direct_tool: - tool_result = await event_caller( - { - 'type': 'execute:tool', - 'data': { - 'id': str(uuid4()), - 'name': tool_function_name, - 'params': tool_function_params, - 'server': tool.get('server', {}), - 'session_id': metadata.get('session_id', None), - }, - } - ) - - else: - tool_function = await get_updated_tool_function( - function=tool['callable'], - extra_params={ - '__messages__': form_data.get('messages', []), - '__files__': metadata.get('files', []), - }, - ) - - tool_result = await tool_function(**tool_function_params) - - except Exception as e: - tool_result = str(e) - else: - tool_result = f'Error: Tool "{tool_function_name}" not found.' + ) + continue tool_result, tool_result_files, tool_result_embeds = await process_tool_result( request, @@ -4774,6 +4952,7 @@ async def streaming_chat_response_handler(response, ctx): 'view_file', 'view_knowledge_file', 'query_knowledge_files', + 'query_chat_files', ] and tool_result ): @@ -4945,7 +5124,10 @@ async def streaming_chat_response_handler(response, ctx): new_form_data['previous_response_id'] = last_response_id else: tool_messages = convert_output_to_messages( - output, raw=True, reasoning_format=get_reasoning_format(model) + output, + raw=True, + reasoning_format=get_reasoning_format(model), + flatten_tool_images=True, ) # Chat Completions providers don't support multimodal @@ -5017,13 +5199,13 @@ async def streaming_chat_response_handler(response, ctx): break if ( - CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is not None + max_tool_call_iterations is not None and tool_calls - and tool_call_iterations >= CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS + and tool_call_iterations >= max_tool_call_iterations ): - log.warning('Tool-call iteration limit reached (%s)', CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS) - error_content = f'Tool-call limit reached ({CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS} iterations).' - if not metadata.get('chat_id', '').startswith('channel:'): + log.warning('Tool-call iteration limit reached (%s)', max_tool_call_iterations) + error_content = f'Tool-call limit reached ({max_tool_call_iterations} iterations).' + if save_to_chat: await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], @@ -5068,7 +5250,7 @@ async def streaming_chat_response_handler(response, ctx): BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES} _real_import = builtins.__import__ - async def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): + def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): if name.split('.')[0] in BLOCKED_MODULES: importer_name = globals.get('__name__') if globals else None if importer_name == '__main__': @@ -5081,7 +5263,8 @@ async def streaming_chat_response_handler(response, ctx): """) code = blocking_code + '\n' + code - if await Config.get('code_interpreter.engine') == 'pyodide': + ci_engine = await Config.get('code_interpreter.engine') + if ci_engine == 'pyodide': ci_output = await event_caller( { 'type': 'execute:python', @@ -5093,7 +5276,7 @@ async def streaming_chat_response_handler(response, ctx): }, } ) - elif await Config.get('code_interpreter.engine') == 'jupyter': + elif ci_engine == 'jupyter': ci_output = await execute_code_jupyter( await Config.get('code_interpreter.jupyter.url'), code, @@ -5185,7 +5368,10 @@ async def streaming_chat_response_handler(response, ctx): 'messages': [ *form_data['messages'], *convert_output_to_messages( - output, raw=True, reasoning_format=get_reasoning_format(model) + output, + raw=True, + reasoning_format=get_reasoning_format(model), + flatten_tool_images=True, ), ], } @@ -5210,11 +5396,7 @@ async def streaming_chat_response_handler(response, ctx): if item.get('status') == 'in_progress': item['status'] = 'completed' - title = ( - await Chats.get_chat_title_by_id(metadata['chat_id']) - if not metadata.get('chat_id', '').startswith('channel:') - else '' - ) + title = await Chats.get_chat_title_by_id(metadata['chat_id']) if save_to_chat else '' data = { 'done': True, 'output': output, @@ -5222,7 +5404,7 @@ async def streaming_chat_response_handler(response, ctx): **({'usage': usage} if usage else {}), } - if not metadata.get('chat_id', '').startswith('channel:'): + if save_to_chat: if not ENABLE_REALTIME_CHAT_SAVE: # Save message in the database await Chats.upsert_message_to_chat_by_id_and_message_id( @@ -5247,22 +5429,7 @@ async def streaming_chat_response_handler(response, ctx): {'done': True}, ) - # Send a webhook notification if the user is not active - if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id): - webhook_url = await Users.get_user_webhook_url_by_id(user.id) - if webhook_url: - webui_url = await Config.get('webui.url') - await post_webhook( - request.app.state.WEBUI_NAME, - webhook_url, - f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}', - { - 'action': 'chat', - 'message': content, - 'title': title, - 'url': f'{webui_url}/c/{metadata["chat_id"]}', - }, - ) + await publish_chat_finished_event(request, user, metadata, title, ''.join(content_parts), output) await event_emitter( { @@ -5292,7 +5459,7 @@ async def streaming_chat_response_handler(response, ctx): async def save_cancelled_state(): await event_emitter({'type': 'chat:tasks:cancel'}) - if not metadata.get('chat_id', '').startswith('channel:'): + if save_to_chat: if not ENABLE_REALTIME_CHAT_SAVE: await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], @@ -5307,6 +5474,7 @@ async def streaming_chat_response_handler(response, ctx): metadata['chat_id'], metadata['message_id'], {'done': True}, + touch=False, ) try: @@ -5327,6 +5495,16 @@ async def streaming_chat_response_handler(response, ctx): return f'data: {item}\n\n' assistant_message = {} + has_api_outlet_filters = ENABLE_API_OUTLET_FILTERS and bool(filter_functions) + if ENABLE_API_OUTLET_FILTERS and not has_api_outlet_filters: + try: + model_id = model.get('id') if isinstance(model, dict) else model + has_api_outlet_filters = bool( + (isinstance(model, dict) and 'pipeline' in model) + or get_sorted_filters(model_id, request.app.state.MODELS) + ) + except Exception: + has_api_outlet_filters = True for event in events: event, _ = await process_filter_functions( @@ -5350,11 +5528,11 @@ async def streaming_chat_response_handler(response, ctx): ) if data: - if ENABLE_API_OUTLET_FILTERS: + if has_api_outlet_filters: update_assistant_message_from_stream(assistant_message, data) yield data - if ENABLE_API_OUTLET_FILTERS and assistant_message: + if has_api_outlet_filters and assistant_message: ctx['assistant_message'] = assistant_message await outlet_filter_handler(ctx) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 509663fcfb..ddef7b91c2 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -17,6 +17,7 @@ import mimeparse from open_webui.env import CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE log = logging.getLogger(__name__) +SURROGATE_RE = re.compile('[\ud800-\udfff]') def deep_update(d, u): @@ -28,18 +29,26 @@ def deep_update(d, u): return d +def _strip_filter_entry(entry): + # Compose list-form env syntax passes surrounding quotes through verbatim + return (entry or '').strip().strip('"\'').strip() + + def get_allow_block_lists(filter_list): allow_list = [] block_list = [] - if filter_list: - for d in filter_list: - if d.startswith('!'): - # Domains starting with "!" → blocked - block_list.append(d[1:].strip()) - else: - # Domains starting without "!" → allowed - allow_list.append(d.strip()) + for raw_entry in filter_list or []: + entry = _strip_filter_entry(raw_entry) + is_blocked = entry.startswith('!') + if is_blocked: + entry = _strip_filter_entry(entry[1:]) + if not entry: + continue + if is_blocked: + block_list.append(entry) + else: + allow_list.append(entry) return allow_list, block_list @@ -159,13 +168,38 @@ def get_last_user_message_item(messages: list[dict]) -> dict | None: def get_content_from_message(message: dict) -> str | None: - if isinstance(message.get('content'), list): - for item in message['content']: - if item['type'] == 'text': - return item['text'] - else: - return message.get('content') - return None + content = message.get('content') + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get('type') == 'text': + return item.get('text') + elif content: + return content + + output_text = get_output_text(message.get('output')) + return output_text or (content if isinstance(content, str) else None) + + +def get_output_text(output: list | None) -> str: + if not isinstance(output, list): + return '' + + texts = [] + for item in output: + if not isinstance(item, dict) or item.get('type') != 'message': + continue + + parts = item.get('content') or [] + if not isinstance(parts, list): + continue + + text = ''.join( + str(part.get('text')) for part in parts if isinstance(part, dict) and part.get('text') is not None + ) + if text.strip(): + texts.append(text) + + return '\n'.join(texts) def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: @@ -212,7 +246,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: # All tool_calls were orphans — keep the message only if it # carries meaningful text or reasoning content. - content = message.get('content', '') + content = get_content_from_message(message) or '' has_meaningful_content = content.strip() if isinstance(content, str) else content if has_meaningful_content or message.get('reasoning_content'): reconciled_messages.append({key: value for key, value in message.items() if key != 'tool_calls'}) @@ -224,6 +258,7 @@ def convert_output_to_messages( output: list, raw: bool = False, reasoning_format: str | None = None, + flatten_tool_images: bool = False, ) -> list[dict]: """ Convert OR-aligned output items to OpenAI Chat Completion-format messages. @@ -242,6 +277,8 @@ def convert_output_to_messages( (for Ollama, which expects reasoning as tagged content). - ``'reasoning_content'``: set as ``reasoning_content`` top-level field (for llama.cpp, which routes it via the chat template). + flatten_tool_images: Move tool output images into a following user + message for Chat Completions providers. """ if not output or not isinstance(output, list): return [] @@ -251,6 +288,10 @@ def convert_output_to_messages( pending_content = [] pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content' pending_reasoning_details = [] + pending_tool_image_urls = [] + function_call_ids = { + item.get('call_id') for item in output if item.get('type') == 'function_call' and item.get('call_id') + } def flush_pending(): nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details @@ -275,8 +316,29 @@ def convert_output_to_messages( pending_reasoning = [] pending_reasoning_details = [] + def flush_tool_images(): + nonlocal pending_tool_image_urls + if not pending_tool_image_urls: + return + + messages.append( + { + 'role': 'user', + 'content': [ + { + 'type': 'text', + 'text': 'Here are the images from the tool results above. Please analyze them.', + }, + *[{'type': 'image_url', 'image_url': {'url': url}} for url in pending_tool_image_urls], + ], + } + ) + pending_tool_image_urls = [] + for item in output: item_type = item.get('type', '') + if item_type != 'function_call_output': + flush_tool_images() if item_type == 'message': # Extract text from output_text content parts @@ -322,8 +384,17 @@ def convert_output_to_messages( if url: image_urls.append(url) - if image_urls: - # Multimodal tool content with image(s) + if flatten_tool_images: + messages.append( + { + 'role': 'tool', + 'tool_call_id': item.get('call_id', ''), + 'content': content, + } + ) + if item.get('call_id') in function_call_ids: + pending_tool_image_urls.extend(image_urls) + elif image_urls: messages.append( { 'role': 'tool', @@ -395,6 +466,7 @@ def convert_output_to_messages( pass # Flush remaining content/tool_calls + flush_tool_images() flush_pending() return reconcile_tool_pairs(messages) @@ -715,18 +787,10 @@ def sanitize_text_for_db(text: str) -> str: """Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage.""" if not isinstance(text, str): return text - # Fast path: skip work when there are no null bytes (the common case) - if '\x00' not in text: + # Fast path: skip work when there are no null bytes or surrogate code points. + if '\x00' not in text and not SURROGATE_RE.search(text): return text - # Remove null bytes - text = text.replace('\x00', '').replace('\u0000', '') - # Remove invalid UTF-8 surrogate characters that can cause encoding errors - # This handles cases where binary data or encoding issues introduced surrogates - try: - text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore') - except (UnicodeEncodeError, UnicodeDecodeError): - pass - return text + return SURROGATE_RE.sub('', text.replace('\x00', '')) def _strip_null_bytes_deep(obj): @@ -734,7 +798,10 @@ def _strip_null_bytes_deep(obj): if isinstance(obj, str): return sanitize_text_for_db(obj) elif isinstance(obj, dict): - return {k: _strip_null_bytes_deep(v) for k, v in obj.items()} + cleaned = {} + for k, v in obj.items(): + cleaned[sanitize_text_for_db(k) if isinstance(k, str) else k] = _strip_null_bytes_deep(v) + return cleaned elif isinstance(obj, list): return [_strip_null_bytes_deep(v) for v in obj] return obj @@ -744,19 +811,21 @@ def sanitize_data_for_db(obj): """Recursively sanitize all strings in a data structure for database storage. Performs a fast pre-check: serializes the structure once and scans for - null bytes. If none are found (the overwhelmingly common case), the + null bytes or invalid UTF-8 surrogates. If none are found, the original object is returned immediately, skipping the expensive recursive walk. """ if isinstance(obj, str): return sanitize_text_for_db(obj) - # Fast path: check for null bytes in the serialized form. + # Fast path: check for null bytes and surrogate code points in the serialized form. # json.dumps is implemented in C and much faster than a Python-level # recursive walk over every leaf string. try: - if '\\u0000' not in json.dumps(obj, ensure_ascii=False): + serialized = json.dumps(obj, ensure_ascii=False) + if '\\u0000' not in serialized: + serialized.encode('utf-8') return obj - except (TypeError, ValueError): + except (TypeError, ValueError, UnicodeEncodeError): pass return _strip_null_bytes_deep(obj) diff --git a/backend/open_webui/utils/model_ids.py b/backend/open_webui/utils/model_ids.py new file mode 100644 index 0000000000..c28c8b532d --- /dev/null +++ b/backend/open_webui/utils/model_ids.py @@ -0,0 +1,4 @@ +def strip_provider_model_prefix(model_id: str, prefix_id: str | None) -> str: + if prefix_id and model_id.startswith(f'{prefix_id}.'): + return model_id[len(f'{prefix_id}.') :] + return model_id diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 39e139b053..a5b2261b14 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -9,13 +9,14 @@ from open_webui.config import ( BYPASS_ADMIN_ACCESS_CONTROL, DEFAULT_ARENA_MODEL, ) -from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL +from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL from open_webui.functions import get_function_models from open_webui.models.access_grants import AccessGrants from open_webui.models.config import Config from open_webui.models.functions import Functions from open_webui.models.groups import Groups from open_webui.models.models import Models +from open_webui.utils.chat_variables import get_chat_variables_schema from open_webui.models.users import UserModel from open_webui.routers import ollama, openai from open_webui.socket.utils import RedisDict @@ -68,6 +69,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) 'models.base_models_cache', 'evaluation.arena.enable', 'evaluation.arena.models', + 'models.default_metadata', ) if ( request.app.state.MODELS @@ -125,11 +127,21 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) ] models = models + arena_models - global_action_ids = {function.id for function in await Functions.get_global_action_functions()} - enabled_action_ids = {function.id for function in await Functions.get_functions_by_type('action', active_only=True)} + # One query per type: the global sets are subsets of the active sets, so + # deriving them from the same rows halves the function-table queries. + if ENABLE_PLUGINS: + active_actions = await Functions.get_active_function_ids_by_type('action') + global_action_ids = {function_id for function_id, is_global in active_actions if is_global} + enabled_action_ids = {function_id for function_id, _ in active_actions} - global_filter_ids = {function.id for function in await Functions.get_global_filter_functions()} - enabled_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)} + active_filters = await Functions.get_active_function_ids_by_type('filter') + global_filter_ids = {function_id for function_id, is_global in active_filters if is_global} + enabled_filter_ids = {function_id for function_id, _ in active_filters} + else: + global_action_ids = set() + enabled_action_ids = set() + global_filter_ids = set() + enabled_filter_ids = set() custom_models = await Models.get_all_models() @@ -151,14 +163,18 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if custom_model.is_active: model['name'] = custom_model.name model['info'] = custom_model.model_dump() + schema = get_chat_variables_schema(custom_model.params.model_dump().get('system')) + if schema: + model['info'].setdefault('meta', {})['chat_variables_schema'] = schema action_ids = [] filter_ids = [] if 'info' in model: if 'meta' in model['info']: - action_ids.extend(model['info']['meta'].get('actionIds', [])) - filter_ids.extend(model['info']['meta'].get('filterIds', [])) + if ENABLE_PLUGINS: + action_ids.extend(model['info']['meta'].get('actionIds', [])) + filter_ids.extend(model['info']['meta'].get('filterIds', [])) if 'params' in model['info']: del model['info']['params'] @@ -199,6 +215,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) } info = custom_model.model_dump() + schema = get_chat_variables_schema(custom_model.params.model_dump().get('system')) + if schema: + info.setdefault('meta', {})['chat_variables_schema'] = schema if 'params' in info: # Remove params to avoid exposing sensitive info del info['params'] @@ -211,10 +230,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if custom_model.meta: meta = custom_model.meta.model_dump() - if 'actionIds' in meta: + if ENABLE_PLUGINS and 'actionIds' in meta: action_ids.extend(meta['actionIds']) - if 'filterIds' in meta: + if ENABLE_PLUGINS and 'filterIds' in meta: filter_ids.extend(meta['filterIds']) model['action_ids'] = action_ids @@ -292,7 +311,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) # Apply global model defaults to all models # Per-model overrides take precedence over global defaults - default_metadata = await Config.get('models.default_metadata', {}) or {} + default_metadata = config.get('models.default_metadata') or {} if default_metadata: for model in models: @@ -316,16 +335,27 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) all_function_valves = await Functions.get_function_valves_by_ids(list(all_function_ids)) functions_cache = get_functions_cache(request) + # Global actions and filters appear in every model, so priorities and item + # lists are memoized across the loop instead of rebuilt per model. + action_priorities = {} + def get_action_priority(action_id): + if action_id in action_priorities: + return action_priorities[action_id] + priority = 0 try: function_module = functions_cache.get(action_id) if function_module and hasattr(function_module, 'Valves'): valves_db = all_function_valves.get(action_id) valves = function_module.Valves(**(valves_db if valves_db else {})) - return getattr(valves, 'priority', 0) + priority = getattr(valves, 'priority', 0) except Exception: - pass - return 0 + priority = 0 + action_priorities[action_id] = priority + return priority + + action_items_by_id = {} + filter_items_by_id = {} for model in models: action_ids = [ @@ -343,30 +373,45 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) model['actions'] = [] for action_id in action_ids: - action_function = functions_by_id.get(action_id) - if action_function is None: - log.info(f'Action not found: {action_id}') - continue + items = action_items_by_id.get(action_id) + if items is None: + action_function = functions_by_id.get(action_id) + if action_function is None: + log.info(f'Action not found: {action_id}') + action_items_by_id[action_id] = [] + continue - function_module = functions_cache.get(action_id) - if function_module is None: - log.info(f'Failed to load action module: {action_id}') - continue - model['actions'].extend(get_action_items_from_module(action_function, function_module)) + function_module = functions_cache.get(action_id) + if function_module is None: + log.info(f'Failed to load action module: {action_id}') + action_items_by_id[action_id] = [] + continue + items = get_action_items_from_module(action_function, function_module) + action_items_by_id[action_id] = items + # Shallow copies keep per-model item dicts independent, as before + model['actions'].extend({**item} for item in items) model['filters'] = [] for filter_id in filter_ids: - filter_function = functions_by_id.get(filter_id) - if filter_function is None: - log.info(f'Filter not found: {filter_id}') - continue + items = filter_items_by_id.get(filter_id) + if items is None: + filter_function = functions_by_id.get(filter_id) + if filter_function is None: + log.info(f'Filter not found: {filter_id}') + filter_items_by_id[filter_id] = [] + continue - function_module = functions_cache.get(filter_id) - if function_module is None: - log.info(f'Failed to load filter module: {filter_id}') - continue - if getattr(function_module, 'toggle', None): - model['filters'].extend(get_filter_items_from_module(filter_function, function_module)) + function_module = functions_cache.get(filter_id) + if function_module is None: + log.info(f'Failed to load filter module: {filter_id}') + filter_items_by_id[filter_id] = [] + continue + if getattr(function_module, 'toggle', None): + items = get_filter_items_from_module(filter_function, function_module) + else: + items = [] + filter_items_by_id[filter_id] = items + model['filters'].extend({**item} for item in items) log.debug(f'get_all_models() returned {len(models)} models') @@ -411,7 +456,7 @@ async def check_model_access(user, model, db=None): raise Exception('Model not found') # Enforce access on chained base models - if not await has_base_model_access(user.id, model_info, db=db): + if not await has_base_model_access(user.id, model_info, user_role=user.role, db=db): raise Exception('Model not found') diff --git a/backend/open_webui/utils/notifications.py b/backend/open_webui/utils/notifications.py new file mode 100644 index 0000000000..0ad94a6377 --- /dev/null +++ b/backend/open_webui/utils/notifications.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import logging +import re +import time +from typing import Any +from urllib.parse import urlparse + +from open_webui.events import EVENT_DEFINITIONS_BY_NAME, NOTIFICATION_EVENTS +from open_webui.models.config import Config +from open_webui.models.users import Users +from open_webui.retrieval.web.utils import validate_url +from open_webui.utils.webhook import post_webhook + + +VALID_EVENTS = set(NOTIFICATION_EVENTS) +LEGACY_EVENTS = {'chat.finished', 'chat.failed'} +VALID_DELIVERY = {'away', 'always'} +CHAT_FINISHED_EVENT = 'chat.finished' +CHAT_FAILED_EVENT = 'chat.failed' +CHANNEL_MESSAGE_EVENT = 'channel.message' +CALENDAR_ALERT_EVENT = 'calendar.alert' + +DEFAULT_TARGET_ID = 'webhook' +DESCRIPTION_DEFAULT = object() +log = logging.getLogger(__name__) + + +def _normalize_target(target: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]: + existing = existing or {} + now = int(time.time()) + + target_type = str(target.get('type') or existing.get('type') or 'webhook').strip() + if target_type != 'webhook': + raise ValueError('Unsupported notification target type') + + config = dict(existing.get('config') or {}) + config.update(target.get('config') or {}) + url = str(config.get('url') or '').strip() + if not url: + raise ValueError('Webhook URL is required') + if '...' in url: + url = str((existing.get('config') or {}).get('url') or '').strip() + validate_url(url) + config['url'] = url + + target_id = str(target.get('id') or existing.get('id') or '').strip() + if not target_id: + hostname = urlparse(url).hostname or 'webhook' + target_id = re.sub(r'[^a-zA-Z0-9_-]+', '-', hostname).strip('-').lower() or 'target' + + events = target['events'] if 'events' in target else existing.get('events', []) + if events is None: + events = [] + if not isinstance(events, list): + raise ValueError('events must be a list') + cleaned_events = [] + for event in events: + event = str(event) + if event not in VALID_EVENTS: + raise ValueError(f'unsupported notification event: {event}') + if event not in cleaned_events: + cleaned_events.append(event) + + delivery = str(target.get('delivery') or existing.get('delivery') or 'away').strip() + if delivery not in VALID_DELIVERY: + raise ValueError('Invalid notification delivery mode') + + return { + 'id': target_id, + 'type': target_type, + 'enabled': bool(target.get('enabled', existing.get('enabled', True))), + 'events': cleaned_events, + 'delivery': delivery, + 'config': config, + 'created_at': int(existing.get('created_at') or now), + 'updated_at': now, + } + + +def _public_target(target: dict[str, Any], default_target_id: str | None = None) -> dict[str, Any]: + config = dict(target.get('config') or {}) + url = str(config.pop('url', '') or '') + if url: + parsed = urlparse(url) + if parsed.hostname: + path = parsed.path or '' + suffix = path[-4:] if len(path) > 4 else path + config['url_masked'] = f'{parsed.scheme}://{parsed.hostname}/...{suffix}' + else: + config['url_masked'] = '****' + else: + config['url_masked'] = '' + return {**target, 'config': config, 'is_default': target.get('id') == default_target_id} + + +async def _load_notifications(user_id: str) -> dict[str, Any]: + user = await Users.get_user_by_id(user_id) + if not user: + raise ValueError('User not found') + + settings = getattr(user, 'settings', None) + settings = settings.model_dump(exclude_none=True) if hasattr(settings, 'model_dump') else dict(settings or {}) + notifications = dict(settings.get('notifications') or {}) + targets = notifications.get('targets') + + legacy_url = str( + notifications.get('webhook_url') or settings.get('ui', {}).get('notifications', {}).get('webhook_url') or '' + ).strip() + + if not isinstance(targets, list) or not targets: + if legacy_url: + target = _normalize_target( + { + 'id': DEFAULT_TARGET_ID, + 'type': 'webhook', + 'enabled': True, + 'events': sorted(VALID_EVENTS), + 'delivery': 'away', + 'config': {'url': legacy_url}, + } + ) + notifications = { + **notifications, + 'targets': [target], + 'default_target_id': DEFAULT_TARGET_ID, + 'legacy_notification_events_migrated': True, + } + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + else: + notifications['targets'] = [target for target in targets if isinstance(target, dict)] + notifications.setdefault( + 'default_target_id', notifications['targets'][0].get('id') if notifications['targets'] else None + ) + if legacy_url and not notifications.get('legacy_notification_events_migrated'): + changed = False + for target in notifications['targets']: + if ( + target.get('id') == DEFAULT_TARGET_ID + and str((target.get('config') or {}).get('url') or '').strip() == legacy_url + and set(target.get('events') or []) == LEGACY_EVENTS + ): + target['events'] = sorted(VALID_EVENTS) + changed = True + notifications['legacy_notification_events_migrated'] = True + if changed: + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + + return notifications + + +async def list_targets(user_id: str) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + default_target_id = notifications.get('default_target_id') + return { + 'targets': [_public_target(target, default_target_id) for target in notifications.get('targets') or []], + } + + +async def create_target(user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + targets = notifications.get('targets') or [] + has_explicit_id = bool(str(payload.get('id') or '').strip()) + target = _normalize_target(payload) + if any(str(existing.get('id', '')).lower() == target['id'].lower() for existing in targets): + if has_explicit_id: + raise ValueError('notification target id already exists') + base = target['id'] + suffix = 2 + while any(str(existing.get('id', '')).lower() == target['id'].lower() for existing in targets): + target['id'] = f'{base}-{suffix}' + suffix += 1 + targets.append(target) + notifications['targets'] = targets + notifications.setdefault('default_target_id', target['id']) + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + return _public_target(target, notifications.get('default_target_id')) + + +async def update_target(user_id: str, target_id: str, payload: dict[str, Any]) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + targets = notifications.get('targets') or [] + for index, existing in enumerate(targets): + if str(existing.get('id', '')).lower() == target_id.lower(): + updated = _normalize_target({'id': target_id, **payload}, existing=existing) + if any( + idx != index and str(target.get('id', '')).lower() == updated['id'].lower() + for idx, target in enumerate(targets) + ): + raise ValueError('notification target id already exists') + targets[index] = updated + notifications['targets'] = targets + if str(notifications.get('default_target_id') or '').lower() == target_id.lower(): + notifications['default_target_id'] = updated['id'] + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + return _public_target(updated, notifications.get('default_target_id')) + raise ValueError('Notification target not found') + + +async def delete_target(user_id: str, target_id: str) -> bool: + notifications = await _load_notifications(user_id) + targets = notifications.get('targets') or [] + next_targets = [target for target in targets if str(target.get('id', '')).lower() != target_id.lower()] + if len(next_targets) == len(targets): + return False + notifications['targets'] = next_targets + if str(notifications.get('default_target_id') or '').lower() == target_id.lower(): + notifications['default_target_id'] = next_targets[0].get('id') if next_targets else None + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + return True + + +async def set_default_target(user_id: str, target_id: str) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + for target in notifications.get('targets') or []: + if str(target.get('id', '')).lower() == target_id.lower(): + notifications['default_target_id'] = target['id'] + await Users.update_user_settings_by_id(user_id, {'notifications': notifications}) + return _public_target(target, target['id']) + raise ValueError('Notification target not found') + + +def get_notification_event_catalog() -> list[dict[str, str]]: + return [ + { + 'event': event_name, + 'label': EVENT_DEFINITIONS_BY_NAME[event_name].message or event_name, + 'description': EVENT_DEFINITIONS_BY_NAME[event_name].description or '', + } + for event_name in NOTIFICATION_EVENTS + ] + + +def _find_target(notifications: dict[str, Any], target: str = '') -> dict[str, Any] | None: + targets = notifications.get('targets') or [] + target = target.strip() + target_id = target or str(notifications.get('default_target_id') or '') + if not target_id: + return None + for item in targets: + if str(item.get('id', '')).lower() == target_id.lower(): + return item + return None + + +async def _send_webhook( + app_name: str, + target: dict[str, Any], + message: str, + data: dict[str, Any], + title: str = '', + description: str | None | object = DESCRIPTION_DEFAULT, +): + url = str((target.get('config') or {}).get('url') or '').strip() + if not url: + raise ValueError('Webhook URL is required') + if description is DESCRIPTION_DEFAULT: + description = message if title else None + ok = await post_webhook(app_name, url, title or message, data, description=description) + if not ok: + raise ValueError('Webhook delivery failed') + + +def _notification_webhook_content(event: Any) -> tuple[str, str, dict[str, Any], str | None]: + data = event.data or {} + + if event.event == CHAT_FINISHED_EVENT: + title = str(data.get('title') or event.message or 'Chat finished') + content = str(data.get('message') or '') + url = str(data.get('url') or '') + chat_id = str(data.get('chat_id') or '') + if chat_id and url.endswith(f'/c/{chat_id}'): + url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}' + body = '\n'.join(part for part in (content, url) if part) + return ( + f'**{title}**', + body, + { + 'action': 'chat', + 'message': content, + 'title': title, + 'url': url, + }, + body, + ) + + if event.event == CHAT_FAILED_EVENT: + title = str(event.message or 'Chat failed') + content = str(data.get('message') or '') + url = str(data.get('url') or '') + chat_id = str(data.get('chat_id') or '') + if chat_id and url.endswith(f'/c/{chat_id}'): + url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}' + body = '\n'.join(part for part in (content, url) if part) + return ( + f'**{title}**', + body, + { + 'action': 'chat_failed', + 'message': content, + 'title': title, + 'url': url, + }, + body, + ) + + if event.event == CHANNEL_MESSAGE_EVENT: + channel_name = str(data.get('title') or event.message or 'Channel') + content = str(data.get('content') or data.get('message') or '') + url = str(data.get('url') or '') + body = '\n'.join(part for part in (content, url) if part) + return ( + f'**#{channel_name}**', + body, + { + 'action': 'channel', + 'message': content, + 'title': channel_name, + 'url': url, + }, + body, + ) + + if event.event == CALENDAR_ALERT_EVENT: + title = str(data.get('title') or event.message or 'Calendar alert') + starts_in = str(data.get('starts_in') or '') + message = f'**{title}**\nstarting {starts_in}'.strip() + return ( + '', + message, + { + 'action': 'calendar_alert', + 'title': title, + 'minutes_until': data.get('minutes_until'), + 'event_id': data.get('event_id') or (event.subject or {}).get('id'), + }, + None, + ) + + definition = EVENT_DEFINITIONS_BY_NAME.get(event.event) + title = event.message or (definition.message if definition else event.event) + message = str(data.get('message') or data.get('preview') or data.get('content_preview') or title) + return str(title), message, event.model_dump(), message if title else None + + +async def test_target(user_id: str, target_id: str, app_name: str = 'Open WebUI') -> dict[str, Any]: + notifications = await _load_notifications(user_id) + target = _find_target(notifications, target_id) + if not target: + raise ValueError('Notification target not found') + await _send_webhook( + app_name, + target, + 'This is a test notification from Open WebUI.', + {'action': 'test', 'user_id': user_id}, + 'Test notification', + ) + return {'ok': True} + + +async def notify_target( + user_id: str, message: str, target: str = '', title: str = '', app_name: str = 'Open WebUI' +) -> dict[str, Any]: + notifications = await _load_notifications(user_id) + item = _find_target(notifications, target) + if not item: + raise ValueError('Notification target not found') + if not item.get('enabled', True): + raise ValueError('Notification target is disabled') + await _send_webhook( + app_name, + item, + message, + {'action': 'notify', 'user_id': user_id, 'message': message, 'title': title}, + title or 'Notification', + ) + return {'ok': True, 'target_id': item.get('id')} + + +async def dispatch_notification_event(app: Any, event: Any) -> None: + if event.event not in VALID_EVENTS or not await Config.get('ui.enable_user_webhooks'): + return + + from open_webui.events import event_user_ids + + app_name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI') + for user_id in event_user_ids(event): + try: + notifications = await _load_notifications(user_id) + is_active = False if event.event == CHANNEL_MESSAGE_EVENT else await Users.is_user_active(user_id) + + for target in notifications.get('targets') or []: + if not target.get('enabled', True): + continue + if event.event not in target.get('events', []): + continue + if target.get('delivery', 'away') == 'away' and is_active: + continue + + title, message, data, description = _notification_webhook_content(event) + await _send_webhook(app_name, target, message, data, title, description=description) + except Exception: + log.exception('Notification delivery failed for user %s and event %s', user_id, event.event) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 0d72a24efe..92cee2da87 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -15,8 +15,8 @@ from types import SimpleNamespace from typing import Literal, Optional import aiohttp +import jwt from authlib.integrations.starlette_client import OAuth -from authlib.jose.errors import BadSignatureError from authlib.oauth2.rfc6749.errors import OAuth2Error from authlib.oidc.core import UserInfo from cryptography.fernet import Fernet @@ -24,6 +24,7 @@ from fastapi import ( HTTPException, status, ) +from joserfc.errors import BadSignatureError from joserfc.jws import JWSRegistry from joserfc.registry import HeaderParameter from mcp.shared.auth import ( @@ -35,6 +36,7 @@ from mcp.shared.auth import ( from open_webui.config import ( DEFAULT_USER_ROLE, ENABLE_OAUTH_GROUP_CREATION, + ENABLE_OAUTH, ENABLE_OAUTH_GROUP_MANAGEMENT, ENABLE_OAUTH_ROLE_MANAGEMENT, ENABLE_OAUTH_SIGNUP, @@ -82,7 +84,7 @@ from open_webui.models.config import Config from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import Users -from open_webui.retrieval.web.utils import validate_url +from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url from open_webui.utils.auth import create_token, get_password_hash from open_webui.utils.groups import apply_default_group_assignment from open_webui.utils.misc import parse_duration @@ -120,6 +122,7 @@ OAUTH_RESOURCE_PARAMETER_MODES = {'auto', 'include', 'omit'} OAUTH_RUNTIME_CONFIG = { 'DEFAULT_USER_ROLE': ('ui.default_user_role', DEFAULT_USER_ROLE), + 'ENABLE_OAUTH': ('oauth.enable', ENABLE_OAUTH), 'ENABLE_OAUTH_SIGNUP': ('oauth.enable_signup', ENABLE_OAUTH_SIGNUP), 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': ( 'oauth.refresh_token.include_scope', @@ -211,19 +214,28 @@ def _normalize_token_expiry(token: dict) -> dict: token['issued_at'] = datetime.now().timestamp() if token.get('expires_at') is not None: - token['expires_at'] = int(token['expires_at']) - return token + expires_at = int(token['expires_at']) + elif token.get('expires_in') is not None: + expires_at = int(datetime.now().timestamp() + token['expires_in']) + else: + # Neither field present — conservative fallback + log.warning( + "OAuth token response missing both 'expires_in' and 'expires_at'; " + f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now' + ) + expires_at = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS) - if token.get('expires_in') is not None: - token['expires_at'] = int(datetime.now().timestamp() + token['expires_in']) - return token + id_token = token.get('id_token') + if id_token: + # Cap at the id_token expiry so pipes and tools never receive an expired JWT + try: + exp = jwt.decode(id_token, options={'verify_signature': False}).get('exp') + if exp is not None: + expires_at = min(expires_at, int(exp)) + except Exception as e: + log.debug(f'Could not read exp from id_token: {e}') - # Neither field present — conservative fallback - log.warning( - "OAuth token response missing both 'expires_in' and 'expires_at'; " - f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now' - ) - token['expires_at'] = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS) + token['expires_at'] = expires_at return token @@ -1641,8 +1653,8 @@ class OAuthManager: get_kwargs['headers'] = { 'Authorization': f'Bearer {access_token}', } - async with aiohttp.ClientSession(trust_env=True) as session: - # allow_redirects=False prevents redirect-based SSRF: validate_url() only vetted the initial URL (CVE-2026-45401 cohort). + # get_ssrf_safe_session pins the connect-time IP (defeats DNS rebinding); allow_redirects=False keeps validate_url's vet authoritative. + async with get_ssrf_safe_session() as session: async with session.get( picture_url, **get_kwargs, @@ -1670,6 +1682,8 @@ class OAuthManager: async def handle_login(self, request, provider): auth_config = await get_oauth_runtime_config() + if not auth_config.ENABLE_OAUTH: + raise HTTPException(404) if provider not in OAUTH_PROVIDERS: raise HTTPException(404) # If the provider has a custom redirect URL, use that, otherwise automatically generate one @@ -1690,6 +1704,8 @@ class OAuthManager: async def handle_callback(self, request, provider, response, db=None): auth_config = await get_oauth_runtime_config() + if not auth_config.ENABLE_OAUTH: + raise HTTPException(404) if provider not in OAUTH_PROVIDERS: raise HTTPException(404) diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index d6242261db..89568c3a63 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -7,6 +7,7 @@ from open_webui.utils.misc import ( deep_update, replace_system_message_content, ) +from open_webui.utils.chat_variables import render_chat_variables, render_user_variables from open_webui.utils.task import prompt_template, prompt_variables_template @@ -18,6 +19,15 @@ async def resolve_system_prompt( if not system: return '' + if metadata: + system = render_chat_variables( + system, + metadata.get('chat_variables', {}), + required=False, + ) + + system = render_user_variables(system, getattr(user, 'variables', {}) if user else {}) + # Metadata (WebUI Usage) if metadata: variables = metadata.get('variables', {}) diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 53531c4350..d6d5ff3388 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -13,6 +13,7 @@ from typing import Any from open_webui.env import ( ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS, + ENABLE_PLUGINS, OFFLINE_MODE, PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, @@ -203,6 +204,10 @@ def replace_imports(content): # May the intent of the one who wrote it survive every # import and transformation, as a deed survives the generations. async def load_tool_module_by_id(tool_id, content=None): + if not ENABLE_PLUGINS: + raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false') + + frontmatter = None if content is None: tool = await Tools.get_tool_by_id(tool_id) if not tool: @@ -234,7 +239,8 @@ async def load_tool_module_by_id(tool_id, content=None): # Executing the modified content in the created module's namespace exec(content, module.__dict__) - frontmatter = extract_frontmatter(content) + if frontmatter is None: + frontmatter = extract_frontmatter(content) log.info(f'Loaded module: {module.__name__}') # Create and return the object if the class 'Tools' is found in the module @@ -251,6 +257,10 @@ async def load_tool_module_by_id(tool_id, content=None): async def load_function_module_by_id(function_id: str, content: str | None = None): + if not ENABLE_PLUGINS: + raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false') + + frontmatter = None if content is None: function = await Functions.get_function_by_id(function_id) if not function: @@ -279,7 +289,8 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non # Execute the modified content in the created module's namespace exec(content, module.__dict__) - frontmatter = extract_frontmatter(content) + if frontmatter is None: + frontmatter = extract_frontmatter(content) log.info(f'Loaded module: {module.__name__}') # Create appropriate object based on available class type in the module @@ -447,6 +458,10 @@ async def install_tool_and_function_dependencies(): and then installing them using pip. Duplicates or similar version specifications are handled by pip as much as possible. """ + if not ENABLE_PLUGINS: + log.info('ENABLE_PLUGINS is disabled, skipping tool and function dependencies.') + return + function_list = await Functions.get_functions(active_only=True) tool_list = await Tools.get_tools() diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index 1417be134c..90d81abb26 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -14,7 +14,6 @@ from typing import Any from urllib.parse import ParseResult, urlparse import redis as _redis_sync - from open_webui.env import ( REDIS_CLUSTER, REDIS_HEALTH_CHECK_INTERVAL, @@ -24,6 +23,7 @@ from open_webui.env import ( REDIS_SENTINEL_PORT, REDIS_SOCKET_CONNECT_TIMEOUT, REDIS_SOCKET_KEEPALIVE, + REDIS_SOCKET_TIMEOUT, REDIS_URL, ) @@ -33,6 +33,7 @@ _ACCEPTED_SCHEMES = frozenset({'redis', 'rediss'}) _SENTINEL_RETRYABLE = ( _redis_sync.exceptions.ConnectionError, _redis_sync.exceptions.ReadOnlyError, + _redis_sync.exceptions.TimeoutError, ) _FACTORY_METHODS = frozenset({'pipeline', 'pubsub', 'monitor', 'client', 'transaction'}) _CONNECTION_POOL: dict[tuple, Any] = {} @@ -126,10 +127,11 @@ class SentinelRedisProxy: self._sentinel = sentinel self._service_name = service_name self._async_mode = async_mode + self._master: Any | None = None def __getattr__(self, name: str) -> Any: """Proxy attribute access with automatic Sentinel failover retry.""" - current_master = self._sentinel.master_for(self._service_name) + current_master = self._resolve_master() original = getattr(current_master, name) # Non-callable or factory attributes pass through without wrapping. @@ -143,7 +145,12 @@ class SentinelRedisProxy: def _resolve_master(self) -> Any: """Ask Sentinel for the current master connection.""" - return self._sentinel.master_for(self._service_name) + if self._master is None: + self._master = self._sentinel.master_for(self._service_name) + return self._master + + def _clear_master(self) -> None: + self._master = None def _should_retry(self, attempt: int) -> bool: return attempt < REDIS_SENTINEL_MAX_RETRY_COUNT - 1 @@ -184,6 +191,7 @@ class SentinelRedisProxy: except _SENTINEL_RETRYABLE as exc: if proxy._should_retry(attempt): proxy._log_retry(exc, attempt) + proxy._clear_master() if REDIS_RECONNECT_DELAY: await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000) continue @@ -208,6 +216,7 @@ class SentinelRedisProxy: except _SENTINEL_RETRYABLE as exc: if proxy._should_retry(attempt): proxy._log_retry(exc, attempt) + proxy._clear_master() if REDIS_RECONNECT_DELAY: await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000) continue @@ -229,6 +238,7 @@ class SentinelRedisProxy: except _SENTINEL_RETRYABLE as exc: if proxy._should_retry(attempt): proxy._log_retry(exc, attempt) + proxy._clear_master() if REDIS_RECONNECT_DELAY: time.sleep(REDIS_RECONNECT_DELAY / 1000) continue @@ -248,6 +258,8 @@ def _socket_options() -> dict[str, Any]: opts: dict[str, Any] = {} if REDIS_SOCKET_CONNECT_TIMEOUT is not None: opts['socket_connect_timeout'] = REDIS_SOCKET_CONNECT_TIMEOUT + if REDIS_SOCKET_TIMEOUT: + opts['socket_timeout'] = REDIS_SOCKET_TIMEOUT if REDIS_SOCKET_KEEPALIVE: opts['socket_keepalive'] = True if REDIS_HEALTH_CHECK_INTERVAL: @@ -294,6 +306,7 @@ def get_redis_connection( cache_key = ( redis_url, tuple(redis_sentinels) if redis_sentinels else (), + redis_cluster, async_mode, decode_responses, ) diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index de3ede0a07..8b0214f6c8 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -55,8 +55,6 @@ USAGE_TOKEN_KEYS = { 'input_tokens', 'output_tokens', 'total_tokens', - 'prompt_tokens', - 'completion_tokens', } USAGE_COST_KEYS = { @@ -105,7 +103,7 @@ def merge_usage(current: dict | None, incoming: dict | None) -> dict: """ Merge usage payloads from multiple model calls into one cumulative usage dict. - Token fields are additive; non-numeric metadata keeps the latest provider value. + Canonical token fields are additive; provider aliases keep the latest value. """ current_usage = normalize_usage(current or {}) if current else {} incoming_usage = normalize_usage(incoming or {}) if incoming else {} @@ -133,6 +131,17 @@ def merge_usage(current: dict | None, incoming: dict | None) -> dict: incoming_usage.get(key) if isinstance(incoming_usage.get(key), dict) else {}, ) + result['prompt_tokens'] = ( + incoming_usage.get('prompt_tokens') + or incoming_usage.get('input_tokens') + or current_usage.get('prompt_tokens', 0) + ) + result['completion_tokens'] = ( + incoming_usage.get('completion_tokens') + or incoming_usage.get('output_tokens') + or current_usage.get('completion_tokens', 0) + ) + return result diff --git a/backend/open_webui/utils/security_headers.py b/backend/open_webui/utils/security_headers.py index 713b33cc6d..c6cbbce2ba 100644 --- a/backend/open_webui/utils/security_headers.py +++ b/backend/open_webui/utils/security_headers.py @@ -2,15 +2,36 @@ import os import re from typing import Dict -from fastapi import Request -from starlette.middleware.base import BaseHTTPMiddleware +from starlette.datastructures import MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send -class SecurityHeadersMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - response = await call_next(request) - response.headers.update(set_security_headers()) - return response +class SecurityHeadersMiddleware: + """Apply configured security headers to every HTTP response. + + Pure ASGI to avoid BaseHTTPMiddleware's response re-buffering. See + open_webui.utils.asgi_middleware for the rationale. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + # Headers derive only from env vars, which are static for the process + # lifetime — compute them once instead of per response. + self._headers = list(set_security_headers().items()) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http' or not self._headers: + await self.app(scope, receive, send) + return + + async def send_with_security_headers(message: Message) -> None: + if message['type'] == 'http.response.start': + headers = MutableHeaders(scope=message) + for key, value in self._headers: + headers[key] = value + await send(message) + + await self.app(scope, receive, send_with_security_headers) def set_security_headers() -> Dict[str, str]: diff --git a/backend/open_webui/utils/session_pool.py b/backend/open_webui/utils/session_pool.py index 90ca728bd9..980435e033 100644 --- a/backend/open_webui/utils/session_pool.py +++ b/backend/open_webui/utils/session_pool.py @@ -28,6 +28,7 @@ from typing import Optional import aiohttp from open_webui.env import ( + AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_POOL_CONNECTIONS, AIOHTTP_POOL_CONNECTIONS_PER_HOST, @@ -39,6 +40,13 @@ log = logging.getLogger(__name__) _session: Optional[aiohttp.ClientSession] = None +def get_client_timeout(stream: bool = False) -> aiohttp.ClientTimeout: + return aiohttp.ClientTimeout( + total=AIOHTTP_CLIENT_TIMEOUT, + sock_read=AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT if stream else None, + ) + + async def get_session() -> aiohttp.ClientSession: """Return the shared aiohttp ClientSession, creating it lazily.""" global _session @@ -56,7 +64,7 @@ async def get_session() -> aiohttp.ClientSession: else: connector_kwargs['limit_per_host'] = 0 # aiohttp: 0 = unlimited connector = aiohttp.TCPConnector(**connector_kwargs) - timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + timeout = get_client_timeout() _session = aiohttp.ClientSession( connector=connector, timeout=timeout, @@ -104,14 +112,23 @@ async def cleanup_response( await result -async def stream_wrapper(response, session=None, content_handler=None): +async def stream_wrapper(response, session=None, content_handler=None, passthrough=False): """Wrap a stream to ensure cleanup happens even if streaming is interrupted. This is more reliable than BackgroundTask which may not run if the client disconnects. When using the shared pool, ``session`` should be ``None``. + + ``passthrough=True`` yields raw network chunks (iter_any) instead of + lines: byte-identical output without a buffer scan, slice and copy per + line. Only for streams no internal consumer parses line-by-line. """ try: - stream = content_handler(response.content) if content_handler else response.content + if content_handler: + stream = content_handler(response.content) + elif passthrough: + stream = response.content.iter_any() + else: + stream = response.content async for chunk in stream: yield chunk finally: diff --git a/backend/open_webui/utils/subagents.py b/backend/open_webui/utils/subagents.py new file mode 100644 index 0000000000..e680068458 --- /dev/null +++ b/backend/open_webui/utils/subagents.py @@ -0,0 +1,672 @@ +from __future__ import annotations + +import asyncio +import copy +import json +import time +from datetime import timedelta +from uuid import uuid4 + +from fastapi import Request +from fastapi.security import HTTPAuthorizationCredentials +from open_webui.internal.db import get_async_db +from open_webui.models.chat_messages import ChatMessages +from open_webui.models.chats import Chat, ChatForm, Chats +from open_webui.models.config import Config +from open_webui.models.users import UserModel, Users +from open_webui.tasks import create_task, has_active_tasks +from open_webui.utils.auth import create_token +from open_webui.utils.misc import get_message_list +from sqlalchemy import select +from starlette.datastructures import Headers + +DEFAULT_SUBAGENT_SYSTEM_PROMPT = """You are a sub-agent working on a specific task assigned by the lead agent. + +You have full access to the workspace — you can read, write, edit files, and run commands. +Focus exclusively on your assigned task. Do NOT work on anything outside your scope. + +When done, end with a clear summary: +- What you did +- What files you changed (if any) +- Any issues or open questions +""" + +MUTATING_MEMORY_TOOLS = { + 'add_memory', + 'delete_memory', + 'replace_memory_content', + 'update_memory', +} + +_background_active: set[str] = set() +_background_lock = asyncio.Lock() +_foreground_semaphore: asyncio.Semaphore | None = None +_parent_locks: dict[str, asyncio.Lock] = {} + + +def _build_request(source: Request, user_id: str, *, internal: bool) -> Request: + scope = { + 'type': 'http', + 'asgi': {'version': '3.0', 'spec_version': '2.0'}, + 'method': 'POST', + 'path': '/api/v1/subagents/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 0), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': source.app, + } + request = Request(scope) + token = create_token( + data={'id': user_id, 'typ': 'subagent'}, + expires_delta=timedelta(hours=1), + ) + request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=token) + request.state.enable_api_keys = False + if internal: + request.state.internal = True + return request + + +async def process_pending_internal_messages( + source_request: Request, + parent_chat_id: str, + user_id: str, + run: dict, +) -> None: + lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock()) + while await has_active_tasks(source_request.app.state.redis, parent_chat_id): + await asyncio.sleep(0.25) + + async with lock: + if await has_active_tasks(source_request.app.state.redis, parent_chat_id): + return + + user = await Users.get_user_by_id(user_id) + if not user: + return + + async with get_async_db() as db: + stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == user_id) + if db.bind.dialect.name == 'postgresql': + stmt = stmt.with_for_update() + result = await db.execute(stmt) + chat = result.scalar_one_or_none() + if not chat: + return + + history = copy.deepcopy((chat.chat or {}).get('history') or {}) + messages = history.get('messages') or {} + pending = [ + message + for message in messages.values() + for meta in [message.get('meta') or {}] + if message.get('role') == 'user' + and not message.get('childrenIds') + and ( + ( + meta.get('internal') is True + and meta.get('type') == 'subagent' + and meta.get('status') in (None, 'pending') + ) + or (meta.get('internal') is True and meta.get('type') == 'timer') + ) + ] + if not pending: + return + + first = pending[0] + first_meta = first.get('meta') or {} + kind = 'timer' if first_meta.get('internal') is True and first_meta.get('type') == 'timer' else 'subagent' + parent_id = first.get('parentId') + if kind == 'timer' and first_meta.get('timer_id'): + timer = await Chats.get_chat_by_id(first_meta['timer_id']) + run = {**run, **(((timer.meta or {}).get('run') if timer else None) or {})} + model_id = first.get('model') or run['model_id'] + if kind == 'timer': + batch = [first] + else: + batch = [ + message + for message in pending + for meta in [message.get('meta') or {}] + if message.get('parentId') == parent_id + and (message.get('model') or model_id) == model_id + and ( + meta.get('internal') is True + and meta.get('type') == 'subagent' + and meta.get('status') in (None, 'pending') + ) + ] + combined_content = '\n\n'.join(message.get('content', '') for message in batch if message.get('content')) + if kind == 'timer': + timer_ids = [ + message['meta']['timer_id'] for message in batch if (message.get('meta') or {}).get('timer_id') + ] + combined_meta = {'internal': True, 'type': 'timer'} + if len(timer_ids) == 1: + combined_meta['timer_id'] = timer_ids[0] + elif timer_ids: + combined_meta['timer_ids'] = timer_ids + else: + delegation_ids = [ + message['meta']['delegation_id'] + for message in batch + if (message.get('meta') or {}).get('delegation_id') + ] + subagent_chat_ids = [ + message['meta']['subagent_chat_id'] + for message in batch + if (message.get('meta') or {}).get('subagent_chat_id') + ] + combined_meta = {'internal': True, 'type': 'subagent'} + if len(delegation_ids) == 1: + combined_meta['delegation_id'] = delegation_ids[0] + elif delegation_ids: + combined_meta['delegation_ids'] = delegation_ids + if len(subagent_chat_ids) == 1: + combined_meta['subagent_chat_id'] = subagent_chat_ids[0] + elif subagent_chat_ids: + combined_meta['subagent_chat_ids'] = subagent_chat_ids + + reuse_message = len(batch) == 1 and (first.get('meta') or {}).get('status') != 'pending' + user_message_id = first['id'] if reuse_message else str(uuid4()) + removed_ids = set() + if not reuse_message: + removed_ids = {message['id'] for message in batch} + for message_id in removed_ids: + messages.pop(message_id, None) + if parent_id and parent_id in messages: + messages[parent_id]['childrenIds'] = [ + child_id + for child_id in messages[parent_id].get('childrenIds', []) + if child_id not in removed_ids + ] + + assistant_message_id = str(uuid4()) + message_list = get_message_list(messages, parent_id) + system_prompt = run.get('system_prompt') + user_message = { + 'id': user_message_id, + 'parentId': parent_id, + 'childrenIds': [assistant_message_id], + 'role': 'user', + 'content': combined_content, + 'model': model_id, + 'meta': combined_meta, + 'timestamp': int(time.time()), + } + assistant_message = { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': model_id, + 'timestamp': int(time.time()), + } + + if parent_id and parent_id in messages: + parent_children = [ + child_id for child_id in messages[parent_id].get('childrenIds', []) if child_id != user_message_id + ] + parent_children.append(user_message_id) + messages[parent_id]['childrenIds'] = parent_children + messages[user_message_id] = {**messages.get(user_message_id, {}), **user_message} + messages[assistant_message_id] = assistant_message + history['messages'] = messages + history['currentId'] = assistant_message_id + chat.chat = {**(chat.chat or {}), 'history': history} + chat.updated_at = int(time.time()) + await db.commit() + + if removed_ids: + await ChatMessages.delete_message_ids_by_chat_id(parent_chat_id, removed_ids) + await ChatMessages.upsert_message(user_message_id, parent_chat_id, user_id, user_message) + await ChatMessages.upsert_message(assistant_message_id, parent_chat_id, user_id, assistant_message) + + from open_webui.socket.main import sio + + await sio.emit( + 'events', + { + 'chat_id': parent_chat_id, + 'message_id': assistant_message_id, + 'data': {'type': 'chat:reload'}, + }, + room=f'user:{user.id}', + ) + + form_data = { + 'model': model_id, + 'messages': [ + *([{'role': 'system', 'content': system_prompt}] if system_prompt else []), + *message_list, + {'role': 'user', 'content': combined_content}, + ], + 'stream': True, + 'chat_id': parent_chat_id, + 'id': assistant_message_id, + 'parent_id': parent_id, + 'user_message': user_message, + 'session_id': run.get('session_id') or f'{kind}-result:{parent_chat_id}', + 'background_tasks': {}, + 'tool_ids': run.get('tool_ids') or [], + 'skill_ids': run.get('skill_ids') or [], + 'filter_ids': run.get('filter_ids') or [], + 'features': run.get('features') or {}, + 'files': run.get('files') or [], + 'variables': run.get('variables') or {}, + } + if run.get('terminal_id'): + form_data['terminal_id'] = run['terminal_id'] + + request = _build_request(source_request, user.id, internal=False) + await source_request.app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + + +async def delegate( + task: str, + context: str, + background: bool, + *, + request: Request, + user_data: dict, + metadata: dict, + parent_chat_id: str, + parent_message_id: str | None, +) -> str: + global _foreground_semaphore + + task = task.strip() + if not task: + return 'Error: task must not be empty.' + if not parent_chat_id or not user_data.get('id'): + return 'Error: chat and user context are required.' + + config = await Config.get_many( + 'subagents.background_enabled', + 'subagents.max_concurrent', + 'subagents.max_async', + 'subagents.max_iterations', + 'subagents.max_output', + 'subagents.system_prompt', + ) + max_concurrent = int(config.get('subagents.max_concurrent') or 20) + max_async = int(config.get('subagents.max_async') or 20) + max_iterations = int(config.get('subagents.max_iterations') or 30) + max_output = int(config.get('subagents.max_output') or 30_000) + if max_concurrent != -1: + max_concurrent = max(1, max_concurrent) + if max_async != -1: + max_async = max(1, max_async) + + if background and not config.get('subagents.background_enabled'): + return 'Error: background sub-agents are disabled in settings.' + + features = copy.deepcopy(metadata.get('features') or {}) + if ( + background + and features.get('code_interpreter') + and await Config.get('code_interpreter.engine', 'pyodide') != 'jupyter' + ): + features.pop('code_interpreter') + run = { + 'model_id': metadata.get('model_id') or (metadata.get('model') or {}).get('id'), + 'session_id': metadata.get('session_id'), + 'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []), + 'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []), + 'system_prompt': metadata.get('system_prompt'), + 'tool_servers': [] if background else copy.deepcopy(metadata.get('tool_servers') or []), + 'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []), + 'terminal_id': metadata.get('terminal_id'), + 'features': features, + 'files': copy.deepcopy(metadata.get('files') or []), + 'variables': copy.deepcopy(metadata.get('variables') or {}), + 'direct': bool(metadata.get('direct')), + } + if not run.get('model_id'): + return 'Error: model context is required.' + if run.get('direct'): + return 'Error: sub-agents are unavailable for direct connections.' + + delegation_id = f'deleg_{uuid4().hex[:8]}' + foreground_semaphore = None + if background: + async with _background_lock: + if max_async != -1 and len(_background_active) >= max_async: + return ( + f'Error: Async subagent capacity reached ({max_async} running). ' + 'Wait for one to finish or increase subagents.max_async.' + ) + _background_active.add(delegation_id) + elif max_concurrent != -1: + if _foreground_semaphore is None: + _foreground_semaphore = asyncio.Semaphore(max_concurrent) + foreground_semaphore = _foreground_semaphore + await foreground_semaphore.acquire() + + mode = 'background' if background else 'foreground' + try: + user = UserModel(**user_data) + chat_id = str(uuid4()) + user_message_id = str(uuid4()) + assistant_message_id = str(uuid4()) + prompt = f'{task}\n\n## Context\n{context}' if context else task + chat = await Chats.insert_new_chat( + chat_id, + user.id, + ChatForm( + chat={ + 'id': chat_id, + 'title': f'Sub-agent: {task[:60]}', + 'models': [run['model_id']], + 'history': { + 'currentId': assistant_message_id, + 'messages': { + user_message_id: { + 'id': user_message_id, + 'parentId': None, + 'childrenIds': [assistant_message_id], + 'role': 'user', + 'content': prompt, + 'timestamp': int(time.time()), + 'models': [run['model_id']], + }, + assistant_message_id: { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': run['model_id'], + 'timestamp': int(time.time()), + }, + }, + }, + 'messages': [{'role': 'user', 'content': prompt}], + } + ), + internal_meta={ + 'internal': True, + 'type': 'subagent', + 'parent_chat_id': parent_chat_id, + 'parent_message_id': parent_message_id, + 'delegation_id': delegation_id, + 'mode': mode, + }, + ) + if not chat: + raise RuntimeError('Failed to create sub-agent chat') + except Exception as exc: + if background: + async with _background_lock: + _background_active.discard(delegation_id) + elif foreground_semaphore: + foreground_semaphore.release() + prefix = 'background ' if background else '' + return f'Error: failed to create {prefix}sub-agent: {exc}' + + async def run_reserved() -> dict: + try: + child_request = _build_request(request, user.id, internal=True) + child_request.state.max_tool_call_iterations = max_iterations + parent_system_prompt = run.get('system_prompt') or '' + subagent_system_prompt = ( + str(config.get('subagents.system_prompt') or '').strip() or DEFAULT_SUBAGENT_SYSTEM_PROMPT + ) + form_data = { + 'model': run['model_id'], + 'messages': [ + { + 'role': 'system', + 'content': ( + f'{parent_system_prompt}\n\n{subagent_system_prompt}' + if parent_system_prompt + else subagent_system_prompt + ), + }, + {'role': 'user', 'content': prompt}, + ], + 'stream': True, + 'chat_id': chat_id, + 'id': assistant_message_id, + 'parent_id': None, + 'user_message': { + 'id': user_message_id, + 'parentId': None, + 'role': 'user', + 'content': prompt, + }, + 'session_id': run.get('session_id') or f'subagent:{chat_id}', + 'background_tasks': {}, + 'tool_ids': run.get('tool_ids') or [], + 'skill_ids': run.get('skill_ids') or [], + 'filter_ids': run.get('filter_ids') or [], + 'features': run.get('features') or {}, + 'files': run.get('files') or [], + 'variables': run.get('variables') or {}, + } + if run.get('terminal_id'): + form_data['terminal_id'] = run['terminal_id'] + if run.get('tool_servers'): + form_data['tool_servers'] = run['tool_servers'] + await request.app.state.CHAT_COMPLETION_HANDLER(child_request, form_data, user=user) + message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id) + if not message: + return { + 'status': 'error', + 'summary': '', + 'error': 'Sub-agent chat or completion message no longer exists.', + } + + summary = message.get('content') or '' + if isinstance(summary, list): + summary = ''.join( + str(item.get('text', '')) + for item in summary + if isinstance(item, dict) and item.get('type') == 'text' + ) + if not summary: + summary = ''.join( + str(part.get('text', '')) + for item in message.get('output') or [] + if item.get('type') == 'message' + for part in item.get('content') or [] + if part.get('type') == 'output_text' + ) + if len(summary) > max_output: + summary = f'{summary[:max_output]}\n\n[output truncated]' + error = message.get('error') + return { + 'status': 'error' if error else 'completed', + 'summary': summary or ('Sub-agent produced no output.' if not error else ''), + 'error': error, + } + except asyncio.CancelledError: + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + assistant_message_id, + {'done': True, 'error': {'content': 'Sub-agent cancelled.'}}, + ) + raise + except Exception as exc: + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + assistant_message_id, + {'done': True, 'error': {'content': str(exc)}}, + ) + raise + finally: + if background: + async with _background_lock: + _background_active.discard(delegation_id) + elif foreground_semaphore: + foreground_semaphore.release() + + async def run_background() -> dict: + started_at = time.time() + cancelled = False + try: + result = await run_reserved() + except asyncio.CancelledError: + result = {'status': 'interrupted', 'summary': '', 'error': 'cancelled'} + cancelled = True + except Exception as exc: + result = {'status': 'error', 'summary': '', 'error': str(exc)} + + duration = f'{time.time() - started_at:.1f}s' + lines = [ + f'[ASYNC SUBAGENT COMPLETE - {delegation_id}]', + ( + 'A background subagent you dispatched earlier has finished. ' + 'The original task source is included so you can decide whether ' + 'to use the result or continue without it.' + ), + '', + f'Original task: {task}', + ] + if context: + lines.append(f'Context provided: {context}') + lines.extend( + [ + f'Subagent chat: {chat_id}', + f'Status: {result.get("status", "completed")} Duration: {duration}', + '--- RESULT ---', + ] + ) + if result.get('status') == 'completed': + lines.append(result.get('summary') or 'Subagent completed without a final summary.') + elif result.get('status') == 'interrupted': + lines.append('The subagent was interrupted before completing.') + if result.get('summary'): + lines.extend(['Partial output:', result['summary']]) + else: + detail = f' {result.get("error")}' if result.get('error') else '' + lines.append(f'The subagent did not complete successfully.{detail}') + if result.get('summary'): + lines.extend(['Partial output:', result['summary']]) + + pending_message_id = str(uuid4()) + pending_meta = { + 'internal': True, + 'type': 'subagent', + 'delegation_id': delegation_id, + 'subagent_chat_id': chat_id, + } + pending_message = { + 'id': pending_message_id, + 'parentId': None, + 'childrenIds': [], + 'role': 'user', + 'content': '\n'.join(lines), + 'model': run['model_id'], + 'meta': pending_meta, + 'timestamp': int(time.time()), + } + + lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock()) + async with lock: + async with get_async_db() as db: + stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == user.id) + if db.bind.dialect.name == 'postgresql': + stmt = stmt.with_for_update() + result_row = await db.execute(stmt) + parent = result_row.scalar_one_or_none() + if not parent: + if cancelled: + raise asyncio.CancelledError + return result + + updated_chat = copy.deepcopy(parent.chat or {}) + updated_history = updated_chat.setdefault('history', {}) + updated_messages = updated_history.setdefault('messages', {}) + done_assistants = [ + message + for message in updated_messages.values() + if message.get('role') == 'assistant' and message.get('done') is not False + ] + result_parent_id = ( + max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id') + if done_assistants + else parent_message_id + ) + pending_message['parentId'] = result_parent_id + if await has_active_tasks(request.app.state.redis, parent_chat_id): + pending_message['meta']['status'] = 'pending' + updated_messages[pending_message_id] = pending_message + if result_parent_id and result_parent_id in updated_messages: + children = updated_messages[result_parent_id].setdefault('childrenIds', []) + if pending_message_id not in children: + children.append(pending_message_id) + updated_history['messages'] = updated_messages + parent.chat = {**(parent.chat or {}), **updated_chat, 'history': updated_history} + parent.updated_at = int(time.time()) + await db.commit() + + await ChatMessages.upsert_message( + message_id=pending_message_id, + chat_id=parent_chat_id, + user_id=user.id, + data=pending_message, + ) + + if pending_message['meta'].get('status') == 'pending': + from open_webui.socket.main import sio + + await sio.emit( + 'events', + { + 'chat_id': parent_chat_id, + 'message_id': pending_message_id, + 'data': {'type': 'chat:reload'}, + }, + room=f'user:{user.id}', + ) + if not await has_active_tasks(request.app.state.redis, parent_chat_id): + await process_pending_internal_messages(request, parent_chat_id, user.id, run) + if cancelled: + raise asyncio.CancelledError + return result + + try: + _, child_task = await create_task( + request.app.state.redis, + run_background() if background else run_reserved(), + id=chat_id, + ) + except Exception as exc: + if background: + async with _background_lock: + _background_active.discard(delegation_id) + elif foreground_semaphore: + foreground_semaphore.release() + return f'Error: {exc}' + + if background: + return json.dumps( + { + 'status': 'dispatched', + 'delegation_id': delegation_id, + 'subagent_chat_id': chat_id, + 'mode': 'background', + 'task': task, + }, + ensure_ascii=False, + ) + + try: + result = await child_task + except asyncio.CancelledError: + if asyncio.current_task() and asyncio.current_task().cancelling(): + raise + return 'Error: sub-agent was cancelled.' + except Exception as exc: + return f'Error: {exc}' + + if result.get('status') != 'completed': + return f'Error: {result.get("error") or "sub-agent failed."}' + return result.get('summary') or 'Sub-agent produced no output.' diff --git a/backend/open_webui/utils/terminals.py b/backend/open_webui/utils/terminals.py new file mode 100644 index 0000000000..e97348a8d5 --- /dev/null +++ b/backend/open_webui/utils/terminals.py @@ -0,0 +1,16 @@ +"""Shared routing helpers for admin-configured terminal servers.""" + +from urllib.parse import quote + + +def get_terminal_server_url(connection: dict) -> str: + """Return the upstream base URL for a terminal connection. + + An explicit policy uses the named-policy route. Connections without one + keep their existing root route. + """ + base_url = str(connection.get('url') or '').rstrip('/') + policy_id = str(connection.get('policy_id') or '').strip() + if policy_id: + return f'{base_url}/p/{quote(policy_id, safe="")}' + return base_url diff --git a/backend/open_webui/utils/timers.py b/backend/open_webui/utils/timers.py new file mode 100644 index 0000000000..5500daa21a --- /dev/null +++ b/backend/open_webui/utils/timers.py @@ -0,0 +1,418 @@ +"""Durable one-shot timers backed by internal child chats.""" + +from __future__ import annotations + +import asyncio +import copy +import json +import logging +import re +import time +from datetime import datetime, timezone +from typing import Literal +from uuid import uuid4 + +from fastapi import Request +from sqlalchemy import select +from starlette.datastructures import Headers + +from open_webui.internal.db import get_async_db +from open_webui.models.chat_messages import ChatMessages +from open_webui.models.chats import Chat, ChatForm, Chats +from open_webui.models.users import UserModel, Users +from open_webui.tasks import has_active_tasks +from open_webui.utils.misc import get_message_list + +log = logging.getLogger(__name__) + +_RELATIVE_TIME = re.compile(r'^(?:\+|in\s+)?(\d+)\s*(s|sec(?:onds?)?|m|min(?:utes?)?|h|hours?|d|days?)$') +_RFC3339_TIME = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$') +_TIME_UNITS_NS = { + 's': 1_000_000_000, + 'm': 60 * 1_000_000_000, + 'h': 60 * 60 * 1_000_000_000, + 'd': 24 * 60 * 60 * 1_000_000_000, +} +_timer_locks: dict[str, asyncio.Lock] = {} + + +def parse_timer_at(value: str) -> int: + """Normalize a relative offset or timezone-aware RFC 3339 timestamp.""" + raw = value.strip() + now = time.time_ns() + relative = _RELATIVE_TIME.fullmatch(raw.lower()) + if relative: + count = int(relative.group(1)) + if count <= 0: + raise ValueError('at must be in the future.') + return now + count * _TIME_UNITS_NS[relative.group(2)[0]] + + if not _RFC3339_TIME.fullmatch(raw): + raise ValueError( + 'at must be a relative time such as 10s or in 10 seconds, or an RFC 3339 timestamp with a timezone.' + ) + try: + parsed = datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError as exc: + raise ValueError( + 'at must be a relative time such as 10s or in 10 seconds, or an RFC 3339 timestamp with a timezone.' + ) from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError('absolute at values must include an explicit timezone.') + + due_at = int(parsed.timestamp() * 1_000_000_000) + if due_at <= now: + raise ValueError('at must be in the future.') + return due_at + + +async def create_timer( + *, + prompt: str, + at: str, + cancel_on: list[Literal['chat.read', 'chat.user_message']] | None, + request: Request, + user_data: dict, + metadata: dict, + parent_chat_id: str, + parent_message_id: str | None, +) -> str: + prompt = prompt.strip() + if not prompt: + return 'Error: prompt must not be empty.' + if not parent_chat_id or not user_data.get('id'): + return 'Error: chat and user context are required.' + + try: + due_at = parse_timer_at(at) + except ValueError as exc: + return f'Error: {exc}' + + selected_events = cancel_on or [] + allowed_events = {'chat.read', 'chat.user_message'} + if any(event not in allowed_events for event in selected_events): + return 'Error: cancel_on accepts only chat.read and chat.user_message.' + selected_events = list(dict.fromkeys(selected_events)) + + model_id = metadata.get('model_id') or (metadata.get('model') or {}).get('id') + if not model_id: + return 'Error: model context is required.' + if metadata.get('direct'): + return 'Error: timers are unavailable for direct connections.' + + chat_id = str(uuid4()) + user_message_id = str(uuid4()) + user = UserModel(**user_data) + run = { + 'model_id': model_id, + 'session_id': metadata.get('session_id'), + 'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []), + 'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []), + 'system_prompt': metadata.get('system_prompt'), + 'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []), + 'terminal_id': metadata.get('terminal_id'), + 'features': copy.deepcopy(metadata.get('features') or {}), + 'files': copy.deepcopy(metadata.get('files') or []), + 'variables': copy.deepcopy(metadata.get('variables') or {}), + } + + chat = await Chats.insert_new_chat( + chat_id, + user.id, + ChatForm( + chat={ + 'id': chat_id, + 'title': f'Timer: {prompt[:60]}', + 'models': [model_id], + 'history': { + 'currentId': user_message_id, + 'messages': { + user_message_id: { + 'id': user_message_id, + 'parentId': None, + 'childrenIds': [], + 'role': 'user', + 'content': prompt, + 'timestamp': int(time.time()), + 'models': [model_id], + }, + }, + }, + 'messages': [{'role': 'user', 'content': prompt}], + } + ), + internal_meta={ + 'internal': True, + 'type': 'timer', + 'parent_chat_id': parent_chat_id, + 'parent_message_id': parent_message_id, + 'timer_at': due_at, + 'status': 'pending', + 'timer_model_id': model_id, + 'timer_task_message_id': user_message_id, + 'cancel_on': selected_events, + 'run': run, + }, + ) + if not chat: + return 'Error: failed to create timer.' + + return json.dumps( + { + 'status': 'set', + 'at': datetime.fromtimestamp(due_at / 1_000_000_000, timezone.utc).isoformat().replace('+00:00', 'Z'), + 'cancel_on': selected_events, + }, + ensure_ascii=False, + ) + + +async def claim_due_timers(now_ns: int, limit: int = 10) -> list[tuple[str, str]]: + """Claim due timers by moving them from pending to running.""" + async with get_async_db() as db: + stmt = ( + select(Chat) + .where(Chat.meta['internal'].as_boolean().is_(True)) + .where(Chat.meta['type'].as_string() == 'timer') + .where(Chat.meta['status'].as_string() == 'pending') + ) + if db.bind.dialect.name == 'postgresql': + stmt = stmt.with_for_update(skip_locked=True) + + result = await db.execute(stmt) + rows = [row for row in result.scalars().all() if int((row.meta or {}).get('timer_at') or 0) <= now_ns] + rows.sort(key=lambda row: int((row.meta or {}).get('timer_at') or 0)) + rows = rows[:limit] + + claimed = [] + for row in rows: + claim_id = str(uuid4()) + row.meta = { + **(row.meta or {}), + 'status': 'running', + 'timer_started_at': now_ns, + 'timer_claim_id': claim_id, + } + row.updated_at = int(time.time()) + claimed.append((row.id, claim_id)) + await db.commit() + return claimed + + +async def cancel_timers_for_chat( + parent_chat_id: str, event: Literal['chat.read', 'chat.user_message'], user_id: str +) -> None: + """Owner-scoped: without the user filter, reading a chat cancels every user's timers on it.""" + async with get_async_db() as db: + result = await db.execute( + select(Chat) + .where(Chat.user_id == user_id) + .where(Chat.meta['internal'].as_boolean().is_(True)) + .where(Chat.meta['type'].as_string() == 'timer') + .where(Chat.meta['parent_chat_id'].as_string() == parent_chat_id) + .where(Chat.meta['status'].as_string() == 'pending') + ) + now_ns = int(time.time_ns()) + for row in result.scalars().all(): + meta = row.meta or {} + if event not in (meta.get('cancel_on') or []): + continue + row.meta = { + **meta, + 'status': 'cancelled', + 'timer_cancelled_at': now_ns, + 'timer_cancelled_by': event, + } + row.updated_at = int(time.time()) + await db.commit() + + +async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> None: + lock = _timer_locks.setdefault(timer_id, asyncio.Lock()) + async with lock: + from open_webui.socket.main import sio + from open_webui.utils.subagents import _parent_locks + + timer = await Chats.get_chat_by_id(timer_id) + if not timer: + return + meta = timer.meta or {} + if meta.get('status') != 'running': + return + if claim_id is not None and meta.get('timer_claim_id') != claim_id: + return + + parent_chat_id = meta.get('parent_chat_id') or '' + parent = await Chats.get_chat_by_id_and_user_id(parent_chat_id, timer.user_id) + if not parent: + await _set_timer_state(timer_id, 'error', timer_error='parent chat no longer exists') + return + + prompt_message_id = meta.get('timer_task_message_id') + prompt_message = await Chats.get_message_by_id_and_message_id(timer_id, prompt_message_id) + if not prompt_message: + await _set_timer_state(timer_id, 'error', timer_error='timer task message is missing') + return + + user = await Users.get_user_by_id(timer.user_id) + if not user: + await _set_timer_state(timer_id, 'error', timer_error='timer user no longer exists') + return + + run = meta.get('run') or {} + model_id = run.get('model_id') or meta.get('timer_model_id') + if not model_id: + await _set_timer_state(timer_id, 'error', timer_error='model context is missing') + return + + prompt = prompt_message.get('content') or '' + if isinstance(prompt, list): + prompt = ''.join( + str(part.get('text', '')) for part in prompt if isinstance(part, dict) and part.get('type') == 'text' + ) + + user_message_id = str(uuid4()) + assistant_message_id = str(uuid4()) + user_message = None + assistant_message = None + message_list = [] + parent_lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock()) + async with parent_lock: + async with get_async_db() as db: + stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == timer.user_id) + if db.bind.dialect.name == 'postgresql': + stmt = stmt.with_for_update() + result = await db.execute(stmt) + parent = result.scalar_one_or_none() + if not parent: + await _set_timer_state(timer_id, 'error', timer_error='parent chat no longer exists') + return + if await has_active_tasks(app.state.redis, parent_chat_id): + timer_row = await db.get(Chat, timer_id) + if timer_row: + timer_row.meta = { + **(timer_row.meta or {}), + 'status': 'pending', + 'timer_claim_id': None, + 'timer_started_at': None, + } + timer_row.updated_at = int(time.time()) + await db.commit() + return + + parent_chat = copy.deepcopy(parent.chat or {}) + history = parent_chat.setdefault('history', {}) + messages = history.setdefault('messages', {}) + done_assistants = [ + message + for message in messages.values() + if message.get('role') == 'assistant' and message.get('done') is not False + ] + parent_id = ( + max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id') + if done_assistants + else meta.get('parent_message_id') + ) + message_list = get_message_list(messages, parent_id) + + user_message = { + 'id': user_message_id, + 'parentId': parent_id, + 'childrenIds': [assistant_message_id], + 'role': 'user', + 'content': prompt, + 'model': model_id, + 'meta': {'internal': True, 'type': 'timer', 'timer_id': timer_id}, + 'timestamp': int(time.time()), + } + assistant_message = { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': model_id, + 'timestamp': int(time.time()), + } + messages[user_message_id] = user_message + messages[assistant_message_id] = assistant_message + if parent_id and parent_id in messages: + children = messages[parent_id].setdefault('childrenIds', []) + if user_message_id not in children: + children.append(user_message_id) + + parent.chat = parent_chat + history['currentId'] = assistant_message_id + parent.updated_at = int(time.time()) + timer_row = await db.get(Chat, timer_id) + if timer_row: + timer_row.meta = { + **(timer_row.meta or {}), + 'status': 'completed', + 'timer_completed_at': int(time.time_ns()), + } + timer_row.updated_at = int(time.time()) + await db.commit() + await ChatMessages.upsert_message(user_message_id, parent_chat_id, timer.user_id, user_message) + await ChatMessages.upsert_message(assistant_message_id, parent_chat_id, timer.user_id, assistant_message) + + await sio.emit( + 'events', + { + 'chat_id': parent_chat_id, + 'message_id': assistant_message_id, + 'data': {'type': 'chat:reload'}, + }, + room=f'user:{timer.user_id}', + ) + form_data = { + 'model': model_id, + 'messages': [ + *([{'role': 'system', 'content': run.get('system_prompt')}] if run.get('system_prompt') else []), + *message_list, + {'role': 'user', 'content': prompt}, + ], + 'stream': True, + 'chat_id': parent_chat_id, + 'id': assistant_message_id, + 'parent_id': user_message.get('parentId'), + 'user_message': user_message, + 'session_id': run.get('session_id') or f'timer:{parent_chat_id}', + 'background_tasks': {}, + 'tool_ids': run.get('tool_ids') or [], + 'skill_ids': run.get('skill_ids') or [], + 'filter_ids': run.get('filter_ids') or [], + 'features': run.get('features') or {}, + 'files': run.get('files') or [], + 'variables': run.get('variables') or {}, + } + if run.get('terminal_id'): + form_data['terminal_id'] = run['terminal_id'] + request = Request( + { + 'type': 'http', + 'asgi': {'version': '3.0', 'spec_version': '2.0'}, + 'method': 'POST', + 'path': '/api/v1/timers/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 0), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': app, + } + ) + request.state.token = None + request.state.enable_api_keys = False + await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + + +async def _set_timer_state(timer_id: str, status: str, **fields) -> None: + async with get_async_db() as db: + row = await db.get(Chat, timer_id) + if not row: + return + row.meta = {**(row.meta or {}), 'status': status, **fields} + row.updated_at = int(time.time()) + await db.commit() diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 6c33882c50..1930837af8 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -35,21 +35,25 @@ from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, ENABLE_FORWARD_USER_INFO_HEADERS, + ENABLE_PLUGINS, FORWARD_SESSION_INFO_HEADER_CHAT_ID, FORWARD_SESSION_INFO_HEADER_MESSAGE_ID, REDIS_KEY_PREFIX, ) from open_webui.models.access_grants import AccessGrants +from open_webui.models.chats import Chats from open_webui.models.config import Config from open_webui.models.groups import Groups from open_webui.models.tools import Tools from open_webui.models.users import UserModel +from open_webui.utils.chat_id import is_saved_chat_id from open_webui.tools.builtin import ( add_memory, calculate_timestamp, create_automation, create_calendar_event, create_tasks, + delegate_task, delete_automation, delete_calendar_event, delete_memory, @@ -58,13 +62,17 @@ from open_webui.tools.builtin import ( fetch_url, generate_image, get_current_timestamp, + grep_chat_files, grep_knowledge_files, kb_exec, + list_chat_files, list_automations, list_knowledge, list_knowledge_bases, list_memories, list_memory_paths, + notify, + query_chat_files, query_knowledge_bases, query_knowledge_files, read_memory_path, @@ -79,6 +87,7 @@ from open_webui.tools.builtin import ( search_memories, search_notes, search_web, + timer, toggle_automation, update_automation, update_calendar_event, @@ -97,6 +106,7 @@ from open_webui.utils.access_control import has_access, has_connection_access, h from open_webui.utils.headers import get_custom_headers, include_user_info_headers from open_webui.utils.misc import is_string_allowed from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id +from open_webui.utils.terminals import get_terminal_server_url from pydantic import BaseModel, Field, create_model from pydantic.fields import FieldInfo @@ -161,7 +171,7 @@ async def build_tool_server_headers( # Interpolate template vars in custom connection headers connection_headers = connection.get('headers', None) if connection_headers and isinstance(connection_headers, dict): - headers.update(get_custom_headers(connection_headers, user, metadata)) + headers.update(await get_custom_headers(connection_headers, user, metadata)) # Add user info headers if enabled if ENABLE_FORWARD_USER_INFO_HEADERS and user: @@ -253,6 +263,9 @@ async def get_updated_tool_function(function: Callable, extra_params: dict): async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extra_params: dict) -> dict[str, dict]: """Load tools for the given tool_ids, checking access control.""" + if not ENABLE_PLUGINS: + return {} + if not tool_ids: return {} @@ -462,6 +475,45 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr return tools_dict +def get_attached_knowledge(model: dict, metadata: dict) -> list[dict]: + model_meta = model.get('info', {}).get('meta', {}) + knowledge = [] + seen = set() + + for source, items in ( + ('model', model_meta.get('knowledge') or []), + ('folder', metadata.get('folder_knowledge') or []), + ): + for item in items: + if not isinstance(item, dict): + continue + key = (item.get('type'), item.get('id')) + if not all(key) or key in seen: + continue + knowledge.append({**item, 'source': source}) + seen.add(key) + + file_context_enabled = (model_meta.get('capabilities') or {}).get('file_context', True) + if not file_context_enabled: + for item in metadata.get('files') or []: + if not isinstance(item, dict) or item.get('type') not in ('collection', 'note'): + continue + key = (item.get('type'), item.get('id')) + if not all(key) or key in seen: + continue + knowledge.append( + { + 'type': item.get('type'), + 'id': item.get('id'), + 'name': item.get('name'), + 'source': 'chat', + } + ) + seen.add(key) + + return knowledge + + async def get_builtin_tools( request: Request, extra_params: dict, features: dict = None, model: dict = None ) -> dict[str, dict]: @@ -495,6 +547,9 @@ async def get_builtin_tools( 'channels.enable', 'automations.enable', 'calendar.enable', + 'ui.enable_user_webhooks', + 'subagents.enable', + 'subagents.background_enabled', ) async def has_user_permission(feature_key: str) -> bool: @@ -506,18 +561,42 @@ async def get_builtin_tools( await Config.get('user.permissions'), ) + async def has_user_chat_permission(permission_key: str) -> bool: + if user.get('role') == 'admin': + return True + return await has_permission( + user.get('id', ''), + f'chat.{permission_key}', + await Config.get('user.permissions'), + ) + # Time utilities - available for date calculations if is_builtin_tool_enabled('time'): builtin_functions.extend([get_current_timestamp, calculate_timestamp]) + metadata = extra_params.get('__metadata__') or {} + chat_files = metadata.get('files') or extra_params.get('__files__') or [] + has_chat_files = any( + isinstance(item, dict) + and item.get('type', 'file') == 'file' + and (item.get('id') or item.get('url')) + and not str(item.get('id') or item.get('url')).startswith(('http://', 'https://', 'data:')) + for item in chat_files + ) + + if ( + is_builtin_tool_enabled('files') + and get_model_capability('file_upload') + and not get_model_capability('file_context') + and has_chat_files + and await has_user_chat_permission('file_upload') + ): + builtin_functions.extend([list_chat_files, query_chat_files, grep_chat_files, view_file]) + # Knowledge base tools - conditional injection based on model knowledge # If model has attached knowledge (any type), only provide query_knowledge_files # Otherwise, provide all KB browsing tools - model_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', []) - # Merge folder-attached knowledge so builtin tools can search it - folder_knowledge = extra_params.get('__metadata__', {}).get('folder_knowledge') - if folder_knowledge: - model_knowledge = list(model_knowledge or []) + list(folder_knowledge) + model_knowledge = get_attached_knowledge(model, metadata) if is_builtin_tool_enabled('knowledge'): from open_webui.env import ENABLE_KB_EXEC @@ -559,6 +638,14 @@ async def get_builtin_tools( if is_builtin_tool_enabled('chats'): builtin_functions.extend([search_chats, view_chat]) + if ( + is_builtin_tool_enabled('subagents') + and config.get('subagents.enable') + and getattr(request.state, 'internal', False) is not True + and getattr(request.state, 'direct', False) is not True + ): + builtin_functions.extend([delegate_task, timer]) + # Add memory tools when memory is enabled and the model allows this builtin category. if ( is_builtin_tool_enabled('memory') @@ -589,7 +676,8 @@ async def get_builtin_tools( ): builtin_functions.extend([search_web, fetch_url]) - # Add image generation/edit tools if builtin category enabled AND enabled globally AND model has image_generation capability + # Add image generation/edit tools if builtin category enabled, + # globally enabled, and allowed by model capability. if ( is_builtin_tool_enabled('image_generation') and config.get('image_generation.enable') @@ -607,7 +695,8 @@ async def get_builtin_tools( ): builtin_functions.append(edit_image) - # Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability + # Add code interpreter tool if builtin category enabled, + # globally enabled, and allowed by model capability. if ( is_builtin_tool_enabled('code_interpreter') and config.get('code_interpreter.enable') @@ -617,8 +706,15 @@ async def get_builtin_tools( ): builtin_functions.append(execute_code) + chat_id = metadata.get('chat_id') or '' + chat = None + if is_saved_chat_id(chat_id): + chat = await Chats.get_chat_by_id(chat_id) + # Notes tools - search, view, create, and update user's notes - if is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes'): + if (chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') or ( + is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes') + ): builtin_functions.extend([search_notes, view_note, write_note, replace_note_content]) # Channels tools - search channels and messages @@ -637,7 +733,8 @@ async def get_builtin_tools( builtin_functions.append(view_skill) # Task management - break down complex work into trackable steps - if is_builtin_tool_enabled('tasks'): + # Task state is stored on the chats row; local/channel IDs do not have one. + if is_builtin_tool_enabled('tasks') and is_saved_chat_id(chat_id): builtin_functions.extend([create_tasks, update_task]) # Automation tools - create and manage scheduled automations from chat @@ -656,6 +753,18 @@ async def get_builtin_tools( [search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event] ) + if ( + is_builtin_tool_enabled('notifications') + and config.get('ui.enable_user_webhooks') + and await has_user_permission('webhooks') + ): + builtin_functions.append(notify) + + if getattr(request.state, 'internal', False) is True: + from open_webui.utils.subagents import MUTATING_MEMORY_TOOLS + + builtin_functions = [func for func in builtin_functions if func.__name__ not in MUTATING_MEMORY_TOOLS] + for func in builtin_functions: callable = await get_async_tool_function_and_apply_extra_params( func, @@ -665,6 +774,7 @@ async def get_builtin_tools( '__event_emitter__': extra_params.get('__event_emitter__'), '__event_call__': extra_params.get('__event_call__'), '__metadata__': extra_params.get('__metadata__'), + '__files__': chat_files, '__chat_id__': extra_params.get('__chat_id__'), '__message_id__': extra_params.get('__message_id__'), '__model_knowledge__': model_knowledge, @@ -675,6 +785,11 @@ async def get_builtin_tools( pydantic_model = convert_function_to_pydantic_model(func) spec = convert_pydantic_model_to_openai_function_spec(pydantic_model) spec = clean_openai_tool_schema(spec) + if func.__name__ == 'delegate_task' and not config.get('subagents.background_enabled'): + parameters = spec.get('parameters', {}) + parameters.get('properties', {}).pop('background', None) + if isinstance(parameters.get('required'), list): + parameters['required'] = [name for name in parameters['required'] if name != 'background'] tools_dict[func.__name__] = { 'tool_id': f'builtin:{func.__name__}', @@ -866,23 +981,22 @@ def resolve_schema(schema, components, resolved_schemas=None): # Avoid infinite recursion on circular references return {} - resolved_schemas.add(schema_name) - ref_parts = ref_path.strip('#/').split('/') resolved = components for part in ref_parts[1:]: # Skip the initial 'components' resolved = resolved.get(part, {}) - return resolve_schema(resolved, components, resolved_schemas) + # Per-path visited set so sibling refs to the same schema still resolve + return resolve_schema(resolved, components, resolved_schemas | {schema_name}) resolved_schema = copy.deepcopy(schema) # Recursively resolve inner schemas if 'properties' in resolved_schema: for prop, prop_schema in resolved_schema['properties'].items(): - resolved_schema['properties'][prop] = resolve_schema(prop_schema, components) + resolved_schema['properties'][prop] = resolve_schema(prop_schema, components, resolved_schemas) if 'items' in resolved_schema: - resolved_schema['items'] = resolve_schema(resolved_schema['items'], components) + resolved_schema['items'] = resolve_schema(resolved_schema['items'], components, resolved_schemas) # Resolve composition keywords (oneOf, anyOf, allOf) which may contain $ref for keyword in ('oneOf', 'anyOf', 'allOf'): @@ -1071,7 +1185,9 @@ async def get_terminal_system_prompt( trust_env=True, ) as session: # 1. Check feature flag - async with session.get(f'{base}/api/config', ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp: + async with session.get( + f'{base}/api/config', headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: if resp.status != 200: return None config = await resp.json() @@ -1103,13 +1219,7 @@ async def set_terminal_servers(request: Request): enabled = connection.get('enabled', True) - base_url = connection.get('url', '').rstrip('/') - policy_id = connection.get('policy_id', '') - - # Orchestrator connections route through /p/{policy_id}/ — the - # OpenAPI spec lives on the proxied terminal, not the orchestrator. - if connection.get('server_type') == 'orchestrator' and policy_id: - base_url = f'{base_url}/p/{policy_id}' + base_url = get_terminal_server_url(connection) server_configs.append( { @@ -1139,6 +1249,8 @@ async def set_terminal_servers(request: Request): headers = {} if connection.get('auth_type', 'bearer') == 'bearer': headers.update(bearer_auth_header(connection.get('key', ''))) + if connection.get('policy_id'): + headers['X-User-Id'] = 'system' prompt = await get_terminal_system_prompt(server['url'], headers) if prompt: server['system_prompt'] = prompt @@ -1188,24 +1300,21 @@ async def get_terminal_tools( connections = await Config.get('terminal_server.connections', []) or [] connection = next((c for c in connections if c.get('id') == terminal_id), None) if connection is None: - log.warning(f'Terminal server not found: {terminal_id}') - return {} + raise RuntimeError(f"Terminal server '{terminal_id}' not found") user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} if not await has_connection_access(user, connection, user_group_ids): - log.warning(f'Access denied to terminal {terminal_id} for user {user.id}') - return {} + raise RuntimeError(f'Access denied to terminal {terminal_id}') # Find the cached spec data for this terminal terminal_servers = await get_terminal_servers(request) server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None) if server_data is None: - log.warning(f'Terminal server spec not found for {terminal_id}') - return {} + raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable") specs = server_data.get('specs', []) if not specs: - return {} + raise RuntimeError(f"Terminal server '{terminal_id}' has no available tools") # Build auth headers auth_type = connection.get('auth_type', 'bearer') @@ -1232,7 +1341,7 @@ async def get_terminal_tools( if session_id: headers['X-Session-Id'] = session_id - terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies) + terminal_cwd = await get_terminal_cwd(server_data['url'], headers, cookies) tools_dict = {} for spec in specs: diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index d3026ffac6..9430c6ff67 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -34,8 +34,12 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict, desc # caller-controlled (user notification settings under # ENABLE_USER_WEBHOOKS, automation notification triggers). await asyncio.to_thread(validate_url, url) - payload = {} + except Exception as e: + log.warning('Webhook skipped, URL invalid or not publicly resolvable: %s', e) + return False + try: + payload = {} # Slack and Google Chat Webhooks if 'https://hooks.slack.com' in url or 'https://chat.googleapis.com' in url: payload['text'] = _event_text(message, description, event_data) diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index d5646443e6..a3cb5e2d13 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -2,18 +2,18 @@ # WIP: use this as a reference to build a minimal docker image fastapi==0.136.3 -uvicorn[standard]==0.41.0 +uvicorn[standard]==0.51.0 pydantic==2.13.4 -python-multipart==0.0.27 +python-multipart==0.0.32 itsdangerous==2.2.0 python-socketio==5.16.2 -python-jose==3.5.0 cryptography bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.13.0 authlib==1.7.2 +joserfc==1.7.4 requests==2.34.2 aiohttp==3.13.5 # do not update to 3.13.3 - broken @@ -33,6 +33,7 @@ alembic==1.18.4 pycrdt==0.13.1 redis +hiredis APScheduler==3.11.2 RestrictedPython==8.2 diff --git a/backend/requirements.txt b/backend/requirements.txt index e33bf131ea..16bdef9994 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,19 +1,20 @@ fastapi==0.136.3 -uvicorn[standard]==0.41.0 +uvicorn[standard]==0.51.0 pydantic==2.13.4 -python-multipart==0.0.27 +python-multipart==0.0.32 itsdangerous==2.2.0 python-socketio==5.16.2 -python-jose==3.5.0 cryptography==48.0.0 bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.13.0 authlib==1.7.2 +joserfc==1.7.4 requests==2.34.2 aiohttp==3.13.5 # do not update to 3.13.3 - broken +aiodns==4.0.4 # makes aiohttp resolve DNS on the event loop instead of the threadpool async-timeout==5.0.1 aiocache==0.12.3 aiofiles==25.1.0 @@ -30,7 +31,8 @@ psycopg[binary]==3.3.4 alembic==1.18.4 pycrdt==0.13.1 -redis==8.0.0 +redis==8.0.1 +hiredis==3.4.0 APScheduler==3.11.2 RestrictedPython==8.2 @@ -76,6 +78,7 @@ unstructured==0.22.31 nltk==3.9.4 Markdown==3.10.2 beautifulsoup4==4.14.3 +lxml==6.1.1 pypandoc==1.17 pandas==3.0.3 openpyxl==3.1.5 @@ -88,7 +91,7 @@ soundfile==0.13.1 pillow==12.2.0 opencv-python-headless==4.13.0.92 -rapidocr-onnxruntime==1.4.4 +rapidocr==3.9.2 rank-bm25==0.2.2 onnxruntime==1.26.0 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index aced4883c9..16536236da 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -44,7 +44,7 @@ If your report describes a real vulnerability under this policy, here's what you - **Coordinated disclosure.** We won't publish out from under you while you're still working the issue with us. Status moves visibly on the advisory itself — including the CVE request — and GitHub notifies you of those updates, so you can follow it through to publication. - **A real fix, handled responsibly.** For findings with broad or severe real-world impact, we may hold publication for up to ~2 weeks after the patched release so administrators can update before details are public. -We're a small volunteer team, so what we _can't_ offer is a bounty or a guaranteed turnaround. What you get is a serious fix, honest credit, and a process that treats your work as the contribution it is. +What we _can't_ offer is a bounty or a guaranteed turnaround. What you get is a serious fix, honest credit, and a process that treats your work as the contribution it is. ## Alignment with the CVE Program @@ -54,7 +54,7 @@ The **CVE Program rules** (and CNA operational rules) are the **baseline** for a We accept vulnerability reports **only** through [GitHub Security Advisories](https://github.com/open-webui/open-webui/security/advisories/new). Reports submitted through **any** other platform — including but not limited to third-party vulnerability reporting platforms, vulnerability brokers, social media, email, Discord, or Reddit — will not be processed. -This is not a procedural preference. As a volunteer- and community-driven project, our security process is built around the same transparency and direct community interaction as the rest of our work, and GitHub Security Advisories is where that process lives. We do not and cannot monitor or engage with external reporting platforms, and reports arriving through them will be closed without review. +This is not a procedural preference. Our security process is built around the same transparency as the rest of our work, and GitHub Security Advisories is the single authoritative channel where that process lives. We do not and cannot monitor or engage with external reporting platforms, and reports arriving through them will be closed without review. A report filed on another platform has no standing here: it confers no priority, establishes no filing date, and creates no obligation for us to triage, publish, or otherwise consider it. Only the GitHub Security Advisory record exists for the purposes of this policy — including determining who filed first. @@ -130,7 +130,7 @@ Your remediation guidance can include, for example: > Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions, > **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research. -10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also 'Threat Model Understanding'). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. **Granting a user the ability to create Tools is equivalent to giving them shell access to the server**. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by 'Admin Actions Are Out of Scope'. More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`). +10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also 'Threat Model Understanding'). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. **Granting a user the ability to create Tools is equivalent to giving them shell access to the server**. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by 'Admin Actions Are Out of Scope'. Deployments that do not need `workspace.tools` or Functions plugin execution can set `ENABLE_PLUGINS=false`. More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`). > [!IMPORTANT] > **For administrators:** Treat the `workspace.tools` permission as **root-equivalent access**. Only grant it to users you would trust with direct access to your server. If you enable this permission for untrusted users, you are accepting the risk of arbitrary code execution on your host. For more details, see our [Plugin Security documentation](https://docs.openwebui.com/features/extensibility/plugin/). @@ -159,11 +159,11 @@ If you want to report something that does not fulfill our rules and guidelines l ## Expected Timeframe -We aim to triage new reports, ship fixes, and publish advisories promptly. However, due to the very high volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by a high number of (AI-generated) reports — our capacity to respond is limited. Open WebUI is a community-driven project maintained by a small team, and security reports are handled alongside all other project responsibilities. +We aim to triage new reports, ship fixes, and publish advisories promptly. However, due to the very high volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by a high number of (AI-generated) reports — not every report can be handled immediately. Open WebUI is led and maintained by a small core team, and security reports are handled alongside all other project responsibilities. -**Please expect several weeks** for your report to be triaged, investigated, fixed, and published. While we aim to respond to every report as quickly as possible, it is normal to experience periods of silence lasting up to several weeks. **This does not mean your report has been ignored** — it means we have not yet had the capacity to address it. Feel free to post a follow-up comment on your advisory for visibility if you feel your report may have been lost; we'll get to you as soon as our capacity allows. The entire process can realistically take multiple weeks from initial submission to final publication. We appreciate your patience and understanding. +**Please expect several weeks** for your report to be triaged, investigated, fixed, and published. While we aim to respond to every report as quickly as possible, it is normal to experience periods of silence lasting up to several weeks. **This does not mean your report has been ignored** — it means it has not yet been picked up. Feel free to post a follow-up comment on your advisory for visibility if you feel your report may have been lost; we'll get to you as we work through the reports. The entire process can realistically take multiple weeks from initial submission to final publication. We appreciate your patience and understanding. -**We do not accept reporter-imposed publishing deadlines.** We coordinate disclosure on our own schedule, and we will triage, fix, and publish as fast as we reasonably can. Externally-imposed hard timelines do not speed this up — they do the opposite: they pull our limited time away from actually fixing issues and toward managing a clock, **at the expense of every other report (even ones that might be more serious)** in the queue and the project as a whole. A deadline attached to your report will not change when or how fast it is handled. +**We do not accept reporter-imposed publishing deadlines.** We coordinate disclosure on our own schedule, and we will triage, fix, and publish as fast as we reasonably can. Externally-imposed hard timelines do not speed this up — they do the opposite: they pull our time away from actually fixing issues and toward managing a clock, **at the expense of every other report (even ones that might be more serious)** in the queue and the project as a whole. A deadline attached to your report will not change when or how fast it is handled. For findings we judge to have **broad or severe real-world impact** — regardless of CVSS score — we may hold off on publishing for a couple of days, max ~2 weeks after the patched version is released, to give administrators time to update their instances. @@ -206,4 +206,4 @@ For any other immediate concerns and questions, please create an issue in our [i --- -_Last updated on **2026-06-13**._ +_Last updated on **2026-07-24**._ diff --git a/package-lock.json b/package-lock.json index 7c572b1bf5..53d8177484 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.10.2", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.10.2", + "version": "0.11.0", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index aeca4abd10..bdae394001 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.10.2", + "version": "0.11.0", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/pyproject.toml b/pyproject.toml index 468543e71c..2ad206ea08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,21 +7,22 @@ authors = [ license = { file = "LICENSE" } dependencies = [ "fastapi==0.136.3", - "uvicorn[standard]==0.41.0", + "uvicorn[standard]==0.51.0", "pydantic==2.13.4", - "python-multipart==0.0.27", + "python-multipart==0.0.32", "itsdangerous==2.2.0", "python-socketio==5.16.2", - "python-jose==3.5.0", "cryptography==48.0.0", "bcrypt==5.0.0", "argon2-cffi==25.1.0", "PyJWT[crypto]==2.13.0", "authlib==1.7.2", + "joserfc==1.7.4", "requests==2.34.2", "aiohttp==3.13.5", # do not update to 3.13.3 - broken + "aiodns==4.0.4", # makes aiohttp resolve DNS on the event loop instead of the threadpool "async-timeout==5.0.1", "aiocache==0.12.3", "aiofiles==25.1.0", @@ -38,7 +39,8 @@ dependencies = [ "alembic==1.18.4", "pycrdt==0.13.1", - "redis==8.0.0", + "redis==8.0.1", + "hiredis==3.4.0", # "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey "pytz==2026.2", @@ -83,6 +85,7 @@ dependencies = [ "nltk==3.9.4", "Markdown==3.10.2", "beautifulsoup4==4.14.3", + "lxml==6.1.1", "pypandoc==1.17", "pandas==3.0.3", "openpyxl==3.1.5", @@ -96,7 +99,7 @@ dependencies = [ "pillow==12.2.0", "opencv-python-headless==4.13.0.92", - "rapidocr-onnxruntime==1.4.4", + "rapidocr==3.9.2", "rank-bm25==0.2.2", "onnxruntime==1.26.0", @@ -151,7 +154,6 @@ all = [ "psycopg2-binary==2.9.12", "pgvector==0.4.2", "moto[s3]>=5.0.26", - "gcp-storage-emulator>=2024.8.3", "docker~=7.1.0", "pytest~=8.3.2", "pytest-docker~=3.2.5", diff --git a/src/app.css b/src/app.css index 9352177bd8..1d9c74770e 100644 --- a/src/app.css +++ b/src/app.css @@ -6,24 +6,6 @@ font-display: swap; } -@font-face { - font-family: 'Archivo'; - src: url('/assets/fonts/Archivo-Variable.ttf'); - font-display: swap; -} - -@font-face { - font-family: 'Mona Sans'; - src: url('/assets/fonts/Mona-Sans.woff2'); - font-display: swap; -} - -@font-face { - font-family: 'InstrumentSerif'; - src: url('/assets/fonts/InstrumentSerif-Regular.ttf'); - font-display: swap; -} - @font-face { font-family: 'Vazirmatn'; src: url('/assets/fonts/Vazirmatn-Variable.ttf'); @@ -70,8 +52,16 @@ code { border-radius: 2px; } -.font-secondary { - font-family: 'InstrumentSerif', sans-serif; +.app-muted { + @apply text-gray-500 dark:text-gray-400; +} + +.app-icon-muted { + @apply text-gray-500 dark:text-gray-400; +} + +.app-interactive-active { + @apply bg-gray-50/40 dark:bg-gray-800/40; } .marked a { @@ -94,34 +84,43 @@ textarea::placeholder { direction: auto; } +textarea { + color-scheme: light; +} + +.dark textarea { + color-scheme: dark; +} + +textarea::-webkit-resizer, +textarea::-webkit-scrollbar-corner { + background: transparent; +} + .input-prose { - @apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line; + @apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line; } .input-prose-sm { - @apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm; + @apply prose dark:prose-invert prose-headings:font-normal prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm; } .markdown-prose { - @apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0; } .markdown-prose-sm { - @apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .markdown-prose-xs { - @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .markdown a { @apply underline; } -.font-primary { - font-family: 'Archivo', 'Vazirmatn', sans-serif; -} - .drag-region { -webkit-app-region: drag; } @@ -411,6 +410,11 @@ input[type='number'] { @apply line-clamp-1 absolute; } +/* #676767 is 3.17:1 on the dark canvas; the light value already passes. */ +html.high-contrast.dark .ProseMirror p.is-editor-empty:first-child::before { + color: var(--color-gray-500); +} + .tiptap ul[data-type='taskList'] { list-style: none; margin-left: 0; @@ -673,7 +677,7 @@ input[type='number'] { } .tiptap th { - @apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850; + @apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-normal uppercase bg-gray-50 dark:bg-gray-850; } .tiptap td { @@ -681,7 +685,7 @@ input[type='number'] { } .tiptap tr { - @apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs; + @apply dark:border-gray-850 text-xs; } .tippy-box[data-theme~='transparent'] { @@ -839,3 +843,78 @@ body { #note-content-container .ProseMirror { padding-bottom: 2rem; /* space for the bottom toolbar */ } + +/* High Contrast Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */ +@layer utilities { + html.high-contrast:not(.dark) :is(input, textarea)::placeholder { + color: var(--color-gray-600); + } + + html.high-contrast.dark :is(input, textarea)::placeholder { + color: var(--color-gray-500); + } +} + +/* High Contrast Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark). + :where() keeps these at low specificity so hover variants still win. */ +@layer utilities { + html:where(.high-contrast):not(:where(.dark)) .text-gray-400 { + color: var(--color-gray-600); + } + + html:where(.high-contrast).dark .dark\:text-gray-600 { + color: var(--color-gray-400); + } + + /* Elements that hover to a lighter grey than the new resting colour. */ + html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover { + color: var(--color-gray-800); + } +} + +/* High Contrast Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1). + The utility stays in the layer so hover variants still outrank it; the classes below + are unlayered, so their override has to be too. */ +@layer utilities { + html:where(.high-contrast):not(:where(.dark)) .text-gray-500 { + color: var(--color-gray-600); + } +} + +html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) { + color: var(--color-gray-600); +} + +/* High Contrast Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark. + text-gray-400/70 is an icon colour, so 1.4.11 governs it, and 2.07:1 misses that too. */ +@layer utilities { + html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-300, .text-gray-400\/70) { + color: var(--color-gray-600); + } + + html:where(.high-contrast).dark .dark\:text-gray-700 { + color: var(--color-gray-400); + } + + /* Hover states that land lighter than the new resting colour. */ + html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover { + color: var(--color-gray-800); + } + + html:where(.high-contrast):not(:where(.dark)) .group:hover .group-hover\:text-gray-500 { + color: var(--color-gray-800); + } +} + +/* Autocompletion ghost text is 2.65:1 in light; the dark canvas already passes. */ +html.high-contrast:not(.dark) .ai-autocompletion::after { + color: var(--color-gray-600); +} + +/* The shimmer gradient is 2.10:1 in light, and clipping it to the text leaves no + solid colour to raise; render it as flat text instead. */ +html.high-contrast:not(.dark) .shimmer { + background: none; + color: var(--color-gray-700); + -webkit-text-fill-color: var(--color-gray-700); +} diff --git a/src/lib/apis/automations/index.ts b/src/lib/apis/automations/index.ts index a79fe1ddc8..593a56cab1 100644 --- a/src/lib/apis/automations/index.ts +++ b/src/lib/apis/automations/index.ts @@ -14,6 +14,7 @@ export type AutomationData = { export type AutomationForm = { name: string; + folder_id?: string | null; data: AutomationData; meta?: { system_prompt?: string; @@ -36,6 +37,7 @@ export type AutomationRunModel = { export type AutomationResponse = { id: string; user_id: string; + folder_id: string | null; name: string; data: AutomationData; meta: Record | null; @@ -53,7 +55,8 @@ export const getAutomationItems = async ( token: string, query: string | null, status: string | null, - page: number + page: number, + folder_id?: string | null ): Promise<{ items: AutomationResponse[]; total: number }> => { let error = null; @@ -67,6 +70,9 @@ export const getAutomationItems = async ( if (page) { searchParams.append('page', page.toString()); } + if (folder_id !== undefined && folder_id !== null) { + searchParams.append('folder_id', folder_id); + } const res = await fetch(`${WEBUI_API_BASE_URL}/automations/list?${searchParams.toString()}`, { method: 'GET', diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 577b31cf49..b55df4b72d 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -58,7 +58,12 @@ export const updateChatConfig = async (token: string, config: object) => { return res; }; -export const createNewChat = async (token: string, chat: object, folderId: string | null) => { +export const createNewChat = async ( + token: string, + chat: object, + folderId: string | null, + variables: object | null = null +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, { @@ -70,6 +75,7 @@ export const createNewChat = async (token: string, chat: object, folderId: strin }, body: JSON.stringify({ chat: chat, + ...(variables !== null ? { variables } : {}), folder_id: folderId ?? null }) }) @@ -879,6 +885,62 @@ export const toggleChatPinnedStatusById = async (token: string, id: string) => { return res; }; +export const markChatUnreadById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/unread`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = 'detail' in err ? err.detail : err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const markChatsRead = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/read`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = 'detail' in err ? err.detail : err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const cloneChatById = async (token: string, id: string, title?: string) => { let error = null; @@ -920,6 +982,47 @@ export const cloneChatById = async (token: string, id: string, title?: string) = return res; }; +export const forkChatById = async (token: string, id: string, messageId?: string | null) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/fork`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + message_id: messageId ?? null + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const cloneSharedChatById = async (token: string, id: string) => { let error = null; @@ -1156,7 +1259,12 @@ export const getChatAccessGrants = async (token: string, id: string) => { return res; }; -export const updateChatById = async (token: string, id: string, chat: object) => { +export const updateChatById = async ( + token: string, + id: string, + chat: object, + variables: object | null = null +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, { @@ -1167,7 +1275,8 @@ export const updateChatById = async (token: string, id: string, chat: object) => ...(token && { authorization: `Bearer ${token}` }) }, body: JSON.stringify({ - chat: chat + chat: chat, + ...(variables !== null ? { variables } : {}) }) }) .then(async (res) => { @@ -1191,6 +1300,39 @@ export const updateChatById = async (token: string, id: string, chat: object) => return res; }; +export const compactChatById = async (token: string, id: string, model?: string | null) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/compact`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ model }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteChatMessageById = async (token: string, id: string, messageId: string) => { let error = null; diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 93df4464dc..edb80028ed 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -311,6 +311,33 @@ export const putOrchestratorPolicy = async ( return res; }; +export const getOrchestratorPolicy = async ( + token: string, + url: string, + key: string, + policyId: string, + authType: string = 'bearer' +): Promise => { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/policy`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + url: url.replace(/\/$/, ''), + key, + auth_type: authType, + policy_id: policyId + }) + }); + if (!res.ok) { + const body = await res.json(); + throw Object.assign(new Error(body.detail || 'Failed to read policy'), { status: res.status }); + } + return res.json(); +}; + export const putOrchestratorLifecycle = async ( token: string, url: string, @@ -352,6 +379,35 @@ export const putOrchestratorLifecycle = async ( return res; }; +export const getOrchestratorLifecycle = async ( + token: string, + url: string, + key: string, + policyId: string, + authType: string = 'bearer' +): Promise => { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/lifecycle`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + url: url.replace(/\/$/, ''), + key, + auth_type: authType, + policy_id: policyId + }) + }); + if (!res.ok) { + const body = await res.json(); + throw Object.assign(new Error(body.detail || 'Failed to read lifecycle'), { + status: res.status + }); + } + return res.json(); +}; + export const refreshOrchestratorTerminals = async ( token: string, url: string, @@ -661,6 +717,31 @@ export const setModelsConfig = async (token: string, config: object) => { return res; }; +export const getSubagentsConfig = async (token: string) => { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/subagents`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }); + if (!res.ok) throw await res.json(); + return res.json(); +}; + +export const setSubagentsConfig = async (token: string, config: object) => { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/subagents`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(config) + }); + if (!res.ok) throw await res.json(); + return res.json(); +}; + export const setDefaultPromptSuggestions = async (token: string, promptSuggestions: string) => { let error = null; diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index b79e588947..4e50b6a7f9 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -235,6 +235,34 @@ export const deleteFolderById = async (token: string, id: string, deleteContents return res; }; +export const markFolderChatsReadById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/read`, { + method: 'POST', + 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 null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateFolderAccessById = async (token: string, id: string, accessGrants: any[]) => { let error = null; @@ -290,17 +318,40 @@ export const getSharedFolders = async (token: string) => { return res; }; -export const getSharedFolderChats = async (token: string, folderId: string) => { +export const getSharedFolderChats = async ( + token: string, + folderId: string, + params: { + page?: number | null; + sortBy?: 'title' | 'updated_at'; + sortDir?: 'asc' | 'desc'; + } = {} +) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` + const searchParams = new URLSearchParams(); + if (params.page !== undefined && params.page !== null) { + searchParams.append('page', `${params.page}`); + } + if (params.sortBy) { + searchParams.append('sort_by', params.sortBy); + } + if (params.sortDir) { + searchParams.append('sort_dir', params.sortDir); + } + const query = searchParams.toString(); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats${query ? `?${query}` : ''}`, + { + 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(); diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 70bd660e9a..4f55ab14be 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -163,7 +163,14 @@ export const getModels = async ( // Remove duplicates const modelsMap = {}; for (const model of models) { - modelsMap[model.id] = model; + const existing = modelsMap[model.id]; + modelsMap[model.id] = existing + ? { + ...existing, + ...model, + info: existing.info ?? model.info + } + : model; } models = Object.values(modelsMap); diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index feb63ccdd8..f0a6e4d140 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -375,7 +375,9 @@ export const searchKnowledgeBases = async ( query: string | null = null, viewOption: string | null = null, page: number | null = null, - source: string | null = null + source: string | null = null, + orderBy: string | null = null, + direction: string | null = null ) => { let error = null; @@ -384,6 +386,8 @@ export const searchKnowledgeBases = async ( if (viewOption) searchParams.append('view_option', viewOption); if (source) searchParams.append('source', source); if (page) searchParams.append('page', page.toString()); + if (orderBy) searchParams.append('order_by', orderBy); + if (direction) searchParams.append('direction', direction); const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/search?${searchParams.toString()}`, { method: 'GET', diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 80f0413bbc..e03d935f9c 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -97,7 +97,8 @@ export const searchNotes = async ( viewOption: string | null = null, permission: string | null = null, sortKey: string | null = null, - page: number | null = null + page: number | null = null, + direction: string | null = null ) => { let error = null; const searchParams = new URLSearchParams(); @@ -118,6 +119,10 @@ export const searchNotes = async ( searchParams.append('order_by', sortKey); } + if (direction !== null) { + searchParams.append('direction', direction); + } + if (page !== null) { searchParams.append('page', `${page}`); } @@ -218,6 +223,98 @@ export const getNoteById = async (token: string, id: string) => { return res; }; +export const getNoteChatById = async (token: string, id: string) => { + let error = null; + const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`; + + console.info('[note-chat] fetching linked chat', { noteId: id, url }); + + const res = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + console.info('[note-chat] linked chat response', { + noteId: id, + status: res.status, + ok: res.ok + }); + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error('[note-chat] linked chat request failed', { noteId: id, error: err }); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getNoteChatsById = async (token: string, id: string) => { + let error = null; + const url = `${WEBUI_API_BASE_URL}/notes/${id}/chats`; + + const res = await fetch(url, { + 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; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createNoteChatById = async (token: string, id: string) => { + let error = null; + const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`; + + const res = await fetch(url, { + method: 'POST', + 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; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateNoteById = async (token: string, id: string, note: NoteItem) => { let error = null; diff --git a/src/lib/apis/notifications/index.ts b/src/lib/apis/notifications/index.ts new file mode 100644 index 0000000000..3b427b33fe --- /dev/null +++ b/src/lib/apis/notifications/index.ts @@ -0,0 +1,81 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type NotificationTarget = { + id: string; + type: 'webhook'; + is_default?: boolean; + enabled: boolean; + events: string[]; + delivery: 'away' | 'always'; + config: { + url?: string; + url_masked?: string; + }; + created_at?: number; + updated_at?: number; +}; + +export type NotificationEvent = { + event: string; + label: string; + description?: string; +}; + +const jsonRequest = async (url: string, token: string, method = 'GET', body?: object) => { + let error = null; + + const res = await fetch(url, { + method, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + ...(body ? { body: JSON.stringify(body) } : {}) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getNotificationEvents = async (token: string): Promise => { + const data = await jsonRequest(`${WEBUI_API_BASE_URL}/notifications/events`, token); + return data?.events ?? data ?? []; +}; + +export const getNotificationTargets = async ( + token: string +): Promise<{ targets: NotificationTarget[] }> => + jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token); + +export const createNotificationTarget = async ( + token: string, + target: Partial +) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token, 'POST', target); + +export const updateNotificationTarget = async ( + token: string, + targetId: string, + target: Partial +) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}`, token, 'PUT', target); + +export const deleteNotificationTarget = async (token: string, targetId: string) => + jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}`, token, 'DELETE'); + +export const setDefaultNotificationTarget = async (token: string, targetId: string) => + jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}/default`, token, 'PUT'); + +export const testNotificationTarget = async (token: string, targetId: string) => + jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}/test`, token, 'POST'); diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts index 24139fa042..fc1dc24ce1 100644 --- a/src/lib/apis/skills/index.ts +++ b/src/lib/apis/skills/index.ts @@ -97,7 +97,9 @@ export const getSkillItems = async ( token: string = '', query: string | null = null, viewOption: string | null = null, - page: number | null = null + page: number | null = null, + orderBy: string | null = null, + direction: string | null = null ) => { let error = null; @@ -105,6 +107,8 @@ export const getSkillItems = async ( if (query) searchParams.append('query', query); if (viewOption) searchParams.append('view_option', viewOption); if (page) searchParams.append('page', page.toString()); + if (orderBy) searchParams.append('order_by', orderBy); + if (direction) searchParams.append('direction', direction); const res = await fetch(`${WEBUI_API_BASE_URL}/skills/list?${searchParams.toString()}`, { method: 'GET', diff --git a/src/lib/apis/tasks/index.ts b/src/lib/apis/tasks/index.ts deleted file mode 100644 index dab6090fde..0000000000 --- a/src/lib/apis/tasks/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { WEBUI_API_BASE_URL } from '$lib/constants'; - -export const checkActiveChats = async (token: string, chatIds: string[]) => { - const res = await fetch(`${WEBUI_API_BASE_URL}/tasks/active/chats`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ chat_ids: chatIds }) - }); - if (!res.ok) throw await res.json(); - return res.json(); -}; diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index ee38a9a8fa..eedbcc3eed 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -143,7 +143,8 @@ export const downloadFileBlob = async ( if (!res || !res.ok) return null; const filename = path.split('/').pop() ?? 'file'; - const blob = await res.blob(); + const blob = await res.blob().catch(() => null); + if (!blob) return null; return { blob, filename }; }; @@ -170,7 +171,8 @@ export const archiveFromTerminal = async ( const disposition = res.headers.get('content-disposition') ?? ''; const match = disposition.match(/filename="?([^"]+)"?/); const filename = match?.[1] ?? 'download.zip'; - const blob = await res.blob(); + const blob = await res.blob().catch(() => null); + if (!blob) return null; return { blob, filename }; }; diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index fb68ab8f61..82556e7822 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -440,6 +440,62 @@ export const updateUserInfo = async (token: string, info: object) => { return res; }; +export const getUserVariables = async (token: string) => { + let error = null; + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateUserVariables = async (token: string, variables: Record) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + variables + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getAndUpdateUserLocation = async (token: string) => { const location = await getUserPosition().catch((err) => { console.error(err); @@ -604,3 +660,87 @@ export const getUserPreview = async (token: string, userId: string) => { return res; }; + +export type UserUsageHeatmapEntry = { + date: string; + messages: number; + chats: number; + tokens: number; + models: Record; +}; + +export type UserUsageResponse = { + totals: { + lifetime_tokens: number; + input_tokens: number; + output_tokens: number; + peak_daily_tokens: number; + longest_chat_seconds: number; + current_streak: number; + longest_streak: number; + total_chats: number; + active_days: number; + models_used: number; + messages: number; + user_messages: number; + assistant_messages: number; + }; + heatmap: UserUsageHeatmapEntry[]; + weekly_heatmap: UserUsageHeatmapEntry[]; + cumulative_heatmap: UserUsageHeatmapEntry[]; + insights: { + most_used_model: string | null; + average_tokens_per_chat: number; + average_messages_per_active_day: number; + user_message_share: number; + assistant_message_share: number; + }; + top_models: Array<{ + model_id: string; + messages: number; + input_tokens: number; + output_tokens: number; + total_tokens: number; + }>; + top_tools: Array<{ name: string; count: number }>; + period: { + start_date: number; + end_date: number; + days: number; + }; +}; + +export const getUserUsage = async ( + token: string, + options: { days?: number; startDate?: number | null; endDate?: number | null } = {} +): Promise => { + let error = null; + const searchParams = new URLSearchParams(); + + if (options.days) searchParams.append('days', options.days.toString()); + if (options.startDate) searchParams.append('start_date', options.startDate.toString()); + if (options.endDate) searchParams.append('end_date', options.endDate.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/usage?${searchParams.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 26b1190465..16616fc247 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -3,7 +3,6 @@ import { getContext, onMount } from 'svelte'; const i18n = getContext('i18n'); - import { settings } from '$lib/stores'; import { verifyOpenAIConnection } from '$lib/apis/openai'; import { verifyOllamaConnection } from '$lib/apis/ollama'; @@ -50,6 +49,7 @@ let apiType = ''; // '' = chat completions (default), 'responses' = Responses API let headers = ''; + let passthroughParams = ''; let tags = []; @@ -58,6 +58,18 @@ let loading = false; let showDeleteConfirmDialog = false; + let showAdvanced = false; + + const inputClass = + 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'; + const selectClass = + 'bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'; + + const parsePassthroughParams = (value: string) => + value + .split(',') + .map((param) => param.trim()) + .filter(Boolean); const verifyOllamaHandler = async () => { // remove trailing slash from url @@ -105,6 +117,7 @@ ...(provider ? { provider } : {}), ...(azure ? { azure: true } : {}), api_version: apiVersion, + passthrough_params: parsePassthroughParams(passthroughParams), ...(_headers ? { headers: _headers } : {}) } }, @@ -145,6 +158,7 @@ if (azure) { if (!apiVersion) { loading = false; + showAdvanced = true; toast.error($i18n.t('API Version is required')); return; @@ -159,6 +173,7 @@ if (modelIds.length === 0) { loading = false; + showAdvanced = true; toast.error($i18n.t('Deployment names are required for Azure OpenAI')); return; } @@ -191,6 +206,7 @@ connection_type: connectionType, auth_type, headers: headers ? JSON.parse(headers) : undefined, + passthrough_params: parsePassthroughParams(passthroughParams), ...(provider ? { provider } : {}), ...(!ollama && azure ? { azure: true } : {}), ...(azure ? { api_version: apiVersion } : {}), @@ -207,6 +223,8 @@ key = ''; auth_type = 'bearer'; prefixId = ''; + passthroughParams = ''; + showAdvanced = false; tags = []; modelIds = []; }; @@ -224,6 +242,9 @@ enable = connection.config?.enable ?? true; tags = connection.config?.tags ?? []; prefixId = connection.config?.prefix_id ?? ''; + passthroughParams = Array.isArray(connection.config?.passthrough_params) + ? connection.config.passthrough_params.join(', ') + : (connection.config?.passthrough_params ?? ''); modelIds = connection.config?.model_ids ?? []; if (ollama) { @@ -306,14 +327,13 @@ {$i18n.t('URL')}
- @@ -383,7 +401,7 @@
+

+ {$i18n.t('Leave empty for the built-in default.')} +

+
+ {/if} +
+ {/if} +
+ + {#if !loading} +
+ +
+ {/if} + diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index c92572c3a0..7b69b3aeb8 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -8,8 +8,12 @@ import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Textarea from '$lib/components/common/Textarea.svelte'; + import SettingsSelect from '$lib/components/common/SettingsSelect.svelte'; + import AdminSettingField from './AdminSettingField.svelte'; + import AdminSettingRow from './AdminSettingRow.svelte'; + import AdminSettingSection from './AdminSettingSection.svelte'; - const i18n = getContext('i18n'); + const i18n: any = getContext('i18n'); export let saveHandler: Function; @@ -46,7 +50,11 @@ ]; let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'microsoft_web_iq', 'external']; - let webConfig = null; + let webConfig: any = null; + const inputClass = + 'w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; + const textareaClass = + 'w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; const submitHandler = async () => { // Convert domain filter string to array before sending @@ -55,8 +63,8 @@ webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST ) { webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST.split(',') - .map((domain) => domain.trim()) - .filter((domain) => domain.length > 0); + .map((domain: string) => domain.trim()) + .filter((domain: string) => domain.length > 0); } else if (!Array.isArray(webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST)) { webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = []; } @@ -67,8 +75,8 @@ webConfig.YOUTUBE_LOADER_LANGUAGE ) { webConfig.YOUTUBE_LOADER_LANGUAGE = webConfig.YOUTUBE_LOADER_LANGUAGE.split(',') - .map((lang) => lang.trim()) - .filter((lang) => lang.length > 0); + .map((lang: string) => lang.trim()) + .filter((lang: string) => lang.length > 0); } else if (!Array.isArray(webConfig.YOUTUBE_LOADER_LANGUAGE)) { webConfig.YOUTUBE_LOADER_LANGUAGE = []; } @@ -144,1057 +152,501 @@
{ await submitHandler(); saveHandler(); }} > -
+

{$i18n.t('Web Search')}

+ +
{#if webConfig} -
-
-
{$i18n.t('General')}
- -
- -
-
- {$i18n.t('Web Search')} -
-
- -
-
- -
-
- {$i18n.t('Web Search Confirmation')} -
-
- - - -
-
- - {#if webConfig.ENABLE_WEB_SEARCH_CONFIRMATION} -
-
- {$i18n.t('Web Search Confirmation Content')} -
- {/if} -
+
- {#if !edit} + {#if compactPreview && message.timestamp} +
+ + + +
+ {:else if !edit}
{#if message.done || siblings.length > 1} {#if siblings.length > 1} @@ -939,7 +945,7 @@ {#if messageIndexEdit}
/{siblings.length}
{:else}
{ messageIndexEdit = true; @@ -1069,6 +1075,22 @@ + {#if onInsertToNote && visibleResponseContent} + + + + {/if} + {#if !readOnly && ($user?.role === 'admin' || ($user?.permissions?.chat?.tts ?? true))} + + {/if} + {#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? true)} {#if $settings?.regenerateMenu ?? true} {:else} @@ -1429,7 +1483,7 @@ {/if} {#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete_message ?? true)} - {#if siblings.length > 1} + {#if allowDelete && siblings.length > 1} -
+
diff --git a/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte b/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte index bb65bf331b..80b25567f4 100644 --- a/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage/StatusHistory.svelte @@ -29,7 +29,7 @@ {#if history && history.length > 0} {#if status?.hidden !== true} -
+
+ {#if expanded} +
+ {content} +
+ {/if} +
diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index 8d540828e7..47a7e90f98 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -1,11 +1,14 @@ -
- {#each selectedModels as selectedModel, selectedModelIdx} -
-
-
- ({ - value: model.id, - label: model.name, - model: model - }))} - {pinModelHandler} - bind:value={selectedModel} - /> -
+
+
+
+
+ ({ + value: model.id, + label: model.name, + model: model + }))} + {pinModelHandler} + {className} + {triggerClassName} + {placement} + {align} + {showSetDefault} + onSetDefault={saveDefaultModel} + multipleEnabled={$user?.role === 'admin' || + ($user?.permissions?.chat?.multiple_models ?? true)} + {disabled} + bind:compareEnabled={compareModels} + bind:values={selectedModels} + />
- - {#if $user?.role === 'admin' || ($user?.permissions?.chat?.multiple_models ?? true)} - {#if selectedModelIdx === 0} -
- - - -
- {:else} -
- - - -
- {/if} - {/if}
- {/each} -
- -{#if showSetDefault} -
-
-{/if} +
diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte index f143ebc38a..e19163df4c 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -23,6 +23,8 @@ export let item: any = {}; export let index: number = -1; export let value: string | null = ''; + export let selectedValues: string[] = []; + export let compareEnabled = false; export let unloadModelHandler: (modelValue: string) => void = () => {}; export let pinModelHandler: (modelId: string) => void = () => {}; @@ -43,23 +45,24 @@ }; let showMenu = false; + $: isSelected = compareEnabled ? selectedValues.includes(item.value) : value === item.value; @@ -70,7 +69,7 @@ {#if $user?.role === 'admin' && model?.owned_by === 'ollama'} {#if $config?.features.enable_community_sharing} -
+
{/if} -
+
diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index d7645cc2b6..28654d6897 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -11,19 +11,19 @@ import { flyAndScale } from '$lib/utils/transitions'; import { createEventDispatcher, onMount, getContext, tick } from 'svelte'; - import { goto } from '$app/navigation'; import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama'; + import { deleteModelById } from '$lib/apis/models'; import { unloadModel } from '$lib/apis'; import { user, MODEL_DOWNLOAD_POOL, models, - mobile, temporaryChatEnabled, settings, - config + config, + showSettings } from '$lib/stores'; import { toast } from 'svelte-sonner'; import { capitalizeFirstLetter, sanitizeResponseContent, splitStream } from '$lib/utils'; @@ -35,6 +35,8 @@ import Tooltip from '$lib/components/common/Tooltip.svelte'; import Switch from '$lib/components/common/Switch.svelte'; import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte'; + import Keyframes from '$lib/components/icons/Keyframes.svelte'; + import TagSelector from '$lib/components/workspace/common/TagSelector.svelte'; import ModelItem from './ModelItem.svelte'; @@ -43,6 +45,10 @@ export let id = ''; export let value: string | null = ''; + export let values: string[] | null = null; + export let compareEnabled = false; + export let multipleEnabled = false; + export let disabled = false; export let placeholder = $i18n.t('Select a model'); export let searchEnabled = true; export let searchPlaceholder = $i18n.t('Search a model'); @@ -57,17 +63,22 @@ [key: string]: any; }[] = []; - export let className = 'w-[32rem]'; + export let className = 'w-[20rem]'; export let triggerClassName = 'text-lg'; + export let placement: 'top' | 'bottom' | 'auto' = 'bottom'; + export let align: 'start' | 'end' = 'start'; + export let showSetDefault = false; + export let onSetDefault: () => Promise | void = () => {}; export let pinModelHandler: (modelId: string) => void = () => {}; - let tagsContainerElement; - let show = false; let triggerElement: HTMLElement | null = null; let contentElement: HTMLElement | null = null; - let dropdownPosition = { top: 0, left: 0, width: 0 }; + let panelElement: HTMLElement | null = null; + let dropdownPosition = { top: 0, left: 0, maxHeight: undefined as number | undefined }; + let positionFrame: number | undefined; + let settleTimers: number[] = []; const portal = (node: HTMLElement) => { document.body.appendChild(node); @@ -78,23 +89,99 @@ }; }; - const updatePosition = () => { - if (!show || !triggerElement) return; - const rect = triggerElement.getBoundingClientRect(); - dropdownPosition = { - top: rect.bottom + 2, - left: $mobile ? 8 : rect.left, - width: $mobile ? window.innerWidth - 16 : 0 + const measureContent = () => { + if (!contentElement) return { width: 0, height: 0 }; + + const previousMaxHeight = panelElement?.style.maxHeight; + if (panelElement) panelElement.style.maxHeight = ''; + const rect = contentElement.getBoundingClientRect(); + if (panelElement && previousMaxHeight !== undefined) { + panelElement.style.maxHeight = previousMaxHeight; + } + + return { width: rect.width, height: rect.height }; + }; + + const visualViewportRect = () => { + const viewport = window.visualViewport; + return { + left: viewport?.offsetLeft ?? 0, + top: viewport?.offsetTop ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight }; }; - const toggleOpen = () => { + const updatePosition = () => { + if (!show || !triggerElement) return; + const rect = triggerElement.getBoundingClientRect(); + const { width: contentWidth, height: contentHeight } = measureContent(); + const viewport = visualViewportRect(); + const viewportRight = viewport.left + viewport.width; + const viewportBottom = viewport.top + viewport.height; + const pad = 8; + const gap = 2; + const spaceBelow = viewportBottom - rect.bottom - gap - pad; + const spaceAbove = rect.top - viewport.top - gap - pad; + const preferredLeft = align === 'end' && contentWidth ? rect.right - contentWidth : rect.left; + const maxLeft = contentWidth ? viewportRight - contentWidth - pad : preferredLeft; + const resolvedPlacement = + placement === 'auto' + ? contentHeight && spaceBelow < contentHeight && spaceAbove > spaceBelow + ? 'top' + : 'bottom' + : placement; + const availableHeight = resolvedPlacement === 'top' ? spaceAbove : spaceBelow; + const constrainedHeight = + contentHeight && availableHeight >= 0 + ? Math.min(contentHeight, availableHeight) + : contentHeight; + const top = + resolvedPlacement === 'top' && contentHeight + ? rect.top - constrainedHeight - gap + : rect.bottom + gap; + + dropdownPosition = { + top: Math.max(viewport.top + pad, Math.min(top, viewportBottom - pad - constrainedHeight)), + left: Math.max(viewport.left + pad, Math.min(preferredLeft, maxLeft)), + maxHeight: + contentHeight && availableHeight >= 0 && contentHeight > availableHeight + ? Math.max(0, availableHeight) + : undefined + }; + }; + + const schedulePositionUpdate = () => { + if (positionFrame != null) cancelAnimationFrame(positionFrame); + positionFrame = requestAnimationFrame(() => { + positionFrame = undefined; + updatePosition(); + }); + }; + + const scheduleSettledPositionUpdates = () => { + for (const timer of settleTimers) window.clearTimeout(timer); + settleTimers = []; + schedulePositionUpdate(); + for (const delay of [50, 150, 300]) { + settleTimers.push(window.setTimeout(schedulePositionUpdate, delay)); + } + }; + + const handleScroll = (event: Event) => { + if (event.target instanceof Node && contentElement?.contains(event.target)) return; + schedulePositionUpdate(); + }; + + const toggleOpen = async () => { show = !show; if (show) { searchValue = ''; listScrollTop = 0; resetView(); updatePosition(); + await tick(); + updatePosition(); window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0); } else { document.getElementById(`model-selector-${id}-button`)?.blur(); @@ -106,7 +193,8 @@ const target = e.target as Node; if ( (triggerElement && triggerElement.contains(target)) || - (contentElement && contentElement.contains(target)) + (contentElement && contentElement.contains(target)) || + ((target as HTMLElement).closest?.('.model-selector-child-menu') ?? false) ) { return; } @@ -126,12 +214,22 @@ let tags = []; let selectedModel = ''; - $: selectedModel = items.find((item) => item.value === value) ?? ''; + $: selectedValues = values ?? (value ? [value] : []); + $: primaryValue = selectedValues[0] ?? value ?? ''; + $: selectedModel = items.find((item) => item.value === primaryValue) ?? ''; + $: selectedCount = selectedValues.filter(Boolean).length; + $: triggerLabel = selectedModel + ? compareEnabled && selectedCount > 1 + ? `${selectedModel.label} +${selectedCount - 1}` + : selectedModel.label + : placeholder; let searchValue = ''; let selectedTag = ''; let selectedConnectionType = ''; + let selectedFilter = ''; + let modelFilterItems = []; let ollamaVersion = null; let selectedModelIdx = 0; @@ -229,10 +327,42 @@ resetView(); } + $: modelFilterItems = [ + ...(items.find((item) => item.model?.connection_type === 'local') + ? [{ value: 'connection:local', label: $i18n.t('Local') }] + : []), + ...(items.find((item) => item.model?.connection_type === 'external') + ? [{ value: 'connection:external', label: $i18n.t('External') }] + : []), + ...(items.find((item) => item.model?.direct) + ? [{ value: 'connection:direct', label: $i18n.t('Direct') }] + : []), + ...tags.map((tag) => ({ value: `tag:${tag}`, label: tag })) + ]; + + $: selectedFilter = selectedConnectionType + ? `connection:${selectedConnectionType}` + : selectedTag + ? `tag:${selectedTag}` + : ''; + + const setModelFilter = (filterValue: string) => { + if (!filterValue) { + selectedConnectionType = ''; + selectedTag = ''; + } else if (filterValue.startsWith('connection:')) { + selectedConnectionType = filterValue.replace('connection:', ''); + selectedTag = ''; + } else if (filterValue.startsWith('tag:')) { + selectedConnectionType = ''; + selectedTag = filterValue.replace('tag:', ''); + } + }; + const resetView = async () => { await tick(); - const selectedInFiltered = filteredItems.findIndex((item) => item.value === value); + const selectedInFiltered = filteredItems.findIndex((item) => item.value === primaryValue); if (selectedInFiltered >= 0) { // The selected model is visible in the current filter @@ -255,6 +385,46 @@ await tick(); const item = document.querySelector(`[data-arrow-selected="true"]`); item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); + schedulePositionUpdate(); + }; + + const setCompareEnabled = (enabled: boolean) => { + compareEnabled = enabled; + + if (!enabled && values) { + values = [primaryValue || selectedValues[0] || '']; + value = values[0]; + } + }; + + const selectItem = (item, index: number) => { + selectedModelIdx = index; + + if (values) { + if (compareEnabled) { + const nextValues = selectedValues.includes(item.value) + ? selectedValues.length > 1 + ? selectedValues.filter((selectedValue) => selectedValue !== item.value) + : selectedValues + : [...selectedValues.filter(Boolean), item.value]; + + values = nextValues.length ? nextValues : [item.value]; + value = values[0]; + return; + } + + values = [item.value]; + value = item.value; + show = false; + return; + } + + value = item.value; + show = false; + }; + + const setDefaultHandler = async () => { + await onSetDefault(); }; const pullModelHandler = async () => { @@ -389,7 +559,7 @@ ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false); }; - onMount(async () => { + onMount(() => { if (items) { tags = items .filter((item) => includeHidden || !(item.model?.info?.meta?.hidden ?? false)) @@ -398,6 +568,18 @@ // Remove duplicates and sort tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b)); } + + window.addEventListener('scroll', handleScroll, true); + window.visualViewport?.addEventListener('resize', scheduleSettledPositionUpdates); + window.visualViewport?.addEventListener('scroll', schedulePositionUpdate); + + return () => { + if (positionFrame != null) cancelAnimationFrame(positionFrame); + for (const timer of settleTimers) window.clearTimeout(timer); + window.removeEventListener('scroll', handleScroll, true); + window.visualViewport?.removeEventListener('resize', scheduleSettledPositionUpdates); + window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate); + }; }); $: if (show && !selectionOnly) { @@ -448,12 +630,25 @@ const model = deleteModelTarget; if (!model) return; - const res = await deleteModel(localStorage.token, model.id).catch((error) => { - toast.error($i18n.t('Error deleting model: {{error}}', { error })); - }); + let success = false; - if (res) { - // $i18n.t('Model {{modelId}} not found') + if (model?.info?.base_model_id) { + // Workspace model: only delete the workspace model record, not the underlying base model + const res = await deleteModelById(localStorage.token, model.id).catch((error) => { + toast.error($i18n.t('Error deleting model: {{error}}', { error })); + return null; + }); + success = !!res; + } else { + // Base Ollama model: delete from Ollama directly + const res = await deleteModel(localStorage.token, model.id).catch((error) => { + toast.error($i18n.t('Error deleting model: {{error}}', { error })); + return null; + }); + success = !!res; + } + + if (success) { toast.success( $i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id }) ); @@ -474,16 +669,38 @@ deleteModelTarget = null; }; - const ITEM_HEIGHT = 42; + const ITEM_HEIGHT = 32; const OVERSCAN = 10; let listScrollTop = 0; let listContainer; + let listViewportHeight = 288; + + const trackListViewport = (node: HTMLElement) => { + const updateHeight = () => { + listViewportHeight = node.clientHeight || 288; + }; + + updateHeight(); + + if (!('ResizeObserver' in window)) { + return { destroy() {} }; + } + + const observer = new ResizeObserver(updateHeight); + observer.observe(node); + + return { + destroy() { + observer.disconnect(); + } + }; + }; $: visibleStart = Math.max(0, Math.floor(listScrollTop / ITEM_HEIGHT) - OVERSCAN); $: visibleEnd = Math.min( filteredItems.length, - Math.ceil((listScrollTop + 256) / ITEM_HEIGHT) + OVERSCAN + Math.ceil((listScrollTop + listViewportHeight) / ITEM_HEIGHT) + OVERSCAN ); @@ -501,7 +718,7 @@
@@ -511,16 +728,17 @@ ? '' : 'outline-hidden focus:outline-hidden'}" aria-label={selectedModel - ? $i18n.t('Selected model: {{modelName}}', { modelName: selectedModel.label }) + ? $i18n.t('Selected model: {{modelName}}', { modelName: triggerLabel }) : placeholder} aria-haspopup="listbox" aria-expanded={show} id="model-selector-{id}-button" type="button" + {disabled} on:click={toggleOpen} >
- {#if selectedModel} - {selectedModel.label} - {:else} - {placeholder} - {/if} - + {triggerLabel} +
@@ -546,32 +760,30 @@
{#if searchEnabled} -
- +
+ { if (e.code === 'Enter' && filteredItems.length > 0) { - value = filteredItems[selectedModelIdx].value; - show = false; + selectItem(filteredItems[selectedModelIdx], selectedModelIdx); return; // dont need to scroll on selection } else if (e.code === 'ArrowDown') { e.stopPropagation(); @@ -592,132 +804,72 @@ }); }} /> + + {#if modelFilterItems.length > 0 || (multipleEnabled && items.length > 0)} +
+ {#if multipleEnabled && items.length > 0} + + + + {/if} + + {#if modelFilterItems.length > 0} + + {/if} +
+ {/if}
{/if} -
- {#if tags && items.filter((item) => includeHidden || !(item.model?.info?.meta?.hidden ?? false)).length > 0} -
{ - if (e.deltaY !== 0) { - e.preventDefault(); - e.currentTarget.scrollLeft += e.deltaY; - } - }} - > -
- {#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0} - - {/if} - - {#if items.find((item) => item.model?.connection_type === 'local')} - - {/if} - - {#if items.find((item) => item.model?.connection_type === 'external')} - - {/if} - - {#if items.find((item) => item.model?.direct)} - - {/if} - - {#each tags as tag} - - - - {/each} -
-
- {/if} -
- -
+
{#if filteredItems.length === 0} {#if items.length === 0 && $user?.role === 'admin'} -
-
+
+
{$i18n.t('No models available')}
- {:else}
-
+
{$i18n.t('No results found')}
@@ -725,10 +877,12 @@ {:else}
{ listScrollTop = listContainer.scrollTop; }} @@ -740,16 +894,15 @@ {selectedModelIdx} {item} {index} - {value} + value={primaryValue} {pinModelHandler} {unloadModelHandler} {deleteModelHandler} {selectionOnly} + {compareEnabled} + {selectedValues} onClick={() => { - value = item.value; - selectedModelIdx = index; - - show = false; + selectItem(item, index); }} /> {/each} @@ -765,7 +918,7 @@ placement="top-start" > +
+ {:else} +
+ {/if}